- N1: never call Lines() on inline nodes; locate empty ATX headings after the end of all preceding blocks (no panic, correct line after `***`) - N2: take reference definition lines from ast.LinkReferenceDefinition and drop refCtx/defRe - N3: reject docs/adr and docs/plans that resolve outside the repository Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
131 lines
4.7 KiB
Go
131 lines
4.7 KiB
Go
package docs
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestLinkDestinations covers the section 7 rules through one body link.
|
|
func TestLinkDestinations(t *testing.T) {
|
|
tests := []struct{ dest, want string }{ // want "" means valid
|
|
{"/docs/spec.txt", ""},
|
|
{"/docs/spec.txt#frag", ""},
|
|
{"/docs/sp%20ace.md", ""},
|
|
{"#anchor", ""},
|
|
{"mailto:a@example.com", ""},
|
|
{"https://example.com/a?b=1#c", ""},
|
|
{`/docs/spec\.txt`, ""},
|
|
{"/docs/spec.txt", ""},
|
|
{"/docs/spec.txt", ""},
|
|
{`/docs\\spec.txt`, "backslash"},
|
|
{"/docs/SPEC.txt", "does not exist"},
|
|
{"/Docs/spec.txt", "does not exist"},
|
|
{"foo.md", "relative"},
|
|
{"./foo.md", "relative"},
|
|
{"../docs/spec.txt", "relative"},
|
|
{"/docs/../docs/spec.txt", `".."`},
|
|
{"/docs/%2e%2e/docs/spec.txt", `".."`},
|
|
{`/docs\spec.txt`, "backslash"},
|
|
{`C:\spec.txt`, "backslash"},
|
|
{"//example.com/x", "protocol-relative"},
|
|
{"/docs/spec.txt?x=1", "queries"},
|
|
{"/docs/%zz", "percent-encoding"},
|
|
{"/docs/missing.txt", "does not exist"},
|
|
{"/Users/me/spec.txt", "does not exist"},
|
|
{"/docs", "not a regular file"},
|
|
{"/docs/out/f.md", "outside the repository"},
|
|
{"file:///etc/passwd", "scheme"},
|
|
{"ftp://example.com/x", "scheme"},
|
|
{"javascript:alert(1)", "scheme"},
|
|
{"https://u:p@example.com/x", "credentials"},
|
|
{"https:///nohost", "host"},
|
|
{"<>", "empty"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.dest, func(t *testing.T) {
|
|
dest := tt.dest
|
|
if strings.ContainsAny(dest, " ") {
|
|
dest = "<" + dest + ">"
|
|
}
|
|
doc := adrBase + "\nSee [x](" + dest + ").\n"
|
|
res, err := Check(tree(t, withSupport(map[string]string{aPath: doc}), map[string]string{"docs/out": "@outside"}))
|
|
must(t, err)
|
|
var want []string
|
|
if tt.want != "" {
|
|
want = []string{aPath + ":35: " + tt.want}
|
|
}
|
|
expect(t, res.Problems, want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLinkForms covers which Markdown constructs are link destinations.
|
|
func TestLinkForms(t *testing.T) {
|
|
tests := []struct {
|
|
name, body string
|
|
want []string
|
|
}{
|
|
{"image", "", []string{":35: relative"}},
|
|
{"autolink", "<ftp://example.com/x>", []string{":35: scheme"}},
|
|
{"reference link and definition", "[x][r]\n\n[r]: foo.md", []string{":35: relative", ":37: relative"}},
|
|
{"duplicate definition is checked", "[a]: /docs/spec.txt\n\n[a]: ../bad.md", []string{":37: relative"}},
|
|
{"definition in a blockquote", "> [Zed]: ../bad.md", []string{":35: relative"}},
|
|
{"definition-like line in code", "```\n[a]: x\n```\n\n[a]: ../bad.md", []string{":39: relative"}},
|
|
{"definition-like paragraph continuation", "Some text\n[a]: ../x.md\n\n[a]: ../bad.md", []string{":36: relative", ":38: relative"}}, // [a] in the text is a reference link
|
|
{"definition-like line in an HTML comment", "<!--\n[a]: x\n-->\n\n[a]: ../bad.md", []string{":39: relative"}},
|
|
{"autolink on a later line", "Para one\nline two\n<ftp://example.com/x>", []string{":37: scheme"}},
|
|
{"image without alt on a later line", "Para one\nline two\n", []string{":37: relative"}},
|
|
{"unused definition", "[r]: foo.md", []string{":35: relative"}},
|
|
{"link in a list", "* [x](foo.md)", []string{":35: relative"}},
|
|
{"bare URL and raw HTML are not checked", "http://u:p@example.com and @user #123 abc1234\n\n<a href=\"foo.md\">x</a>", nil},
|
|
{"link in code is not a link", "`[x](foo.md)`\n\n```\n[x](foo.md)\n```", nil},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
res, err := Check(tree(t, withSupport(map[string]string{aPath: adrBase + "\n" + tt.body + "\n"}), nil))
|
|
must(t, err)
|
|
var want []string
|
|
for _, w := range tt.want {
|
|
want = append(want, aPath+w)
|
|
}
|
|
expect(t, res.Problems, want)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSpecExamples keeps the minimal examples in docs/docs-format.md valid.
|
|
func TestSpecExamples(t *testing.T) {
|
|
spec, err := os.ReadFile("../../docs/docs-format.md")
|
|
must(t, err)
|
|
root := t.TempDir()
|
|
parts := strings.Split(string(spec[strings.Index(string(spec), "## 9."):]), "```markdown\n")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("expected two examples, got %d", len(parts)-1)
|
|
}
|
|
// The path of each example is the backticked name on the line before its fence.
|
|
heads := strings.Split(string(spec[strings.Index(string(spec), "## 9."):]), "\n")
|
|
var paths []string
|
|
for _, l := range heads {
|
|
if strings.HasPrefix(l, "`docs/") {
|
|
paths = append(paths, strings.Trim(l, "`:"))
|
|
}
|
|
}
|
|
if len(paths) != 2 {
|
|
t.Fatalf("expected two example paths, got %v", paths)
|
|
}
|
|
for i, part := range parts[1:] {
|
|
body, _, _ := strings.Cut(part, "```")
|
|
full := filepath.Join(root, paths[i])
|
|
must(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
|
must(t, os.WriteFile(full, []byte(body), 0o644))
|
|
}
|
|
res, err := Check(root)
|
|
must(t, err)
|
|
expect(t, res.Problems, nil)
|
|
if res.Records != 2 {
|
|
t.Fatalf("records = %d", res.Records)
|
|
}
|
|
}
|