test(11.1-07): acceptance scanner sees blockquoted and aliased fences; gate plants the holes

- scanDocFences strips blockquote markers and indentation and cross-checks captions
- the phase gate plants callout src=, golang fences, env-prefix and flag-first commands, and wrong-case identifiers
This commit is contained in:
Jakub Zych
2026-10-01 08:51:58 +02:00
parent 9ba5530ca7
commit 550fa06f91
2 changed files with 198 additions and 15 deletions

View File

@@ -15,6 +15,8 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/alecthomas/chroma/v2/lexers"
"git.golem15.com/golem15/summercms/internal/docsite" "git.golem15.com/golem15/summercms/internal/docsite"
"git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/bonfire"
) )
@@ -240,6 +242,71 @@ func TestPhase11_1Acceptance(t *testing.T) {
t.Errorf("%s is not a package of the root module", dir) t.Errorf("%s is not a package of the root module", dir)
} }
} }
// The acceptance scanner is independent of docsite: a src= fence
// inside a blockquote or list is nested, and a module README has none.
err := filepath.WalkDir(filepath.Join(repoRoot, "docs"), func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && d.Name() == "examples" {
return filepath.SkipDir
}
if d.IsDir() || !strings.HasSuffix(p, ".md") {
return nil
}
rel, _ := filepath.Rel(repoRoot, p)
for _, f := range scanDocFences(filepath.ToSlash(rel), readFile(t, p)) {
if f.src != "" && f.nested {
t.Errorf("%s:%d: src= fence is nested (%s)", f.file, f.line, f.src)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
err = filepath.WalkDir(filepath.Join(repoRoot, "modules"), func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || d.Name() != "README.md" {
return nil
}
rel, _ := filepath.Rel(repoRoot, p)
for _, f := range scanDocFences(filepath.ToSlash(rel), readFile(t, p)) {
if f.src != "" {
t.Errorf("%s:%d: module README has a src= fence", f.file, f.line)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
for _, p := range pages {
if p.Module != "" {
continue
}
n := 0
for _, f := range pageFences(t, p.Source) {
if f.src != "" && !f.nested {
n++
}
}
html := readFile(t, filepath.Join(out, filepath.FromSlash(p.URL)+".html"))
if got := strings.Count(html, "<figcaption>"); got != n {
t.Errorf("%s has %d figcaptions, want %d top-level src= fences", p.URL, got, n)
}
}
api, err := filepath.Glob(filepath.Join(out, "api", "*.html"))
if err != nil {
t.Fatal(err)
}
for _, p := range api {
if strings.Contains(readFile(t, p), "<figcaption>") {
t.Errorf("%s has a figcaption", p)
}
}
}) })
// SC5: the concept map and the acme/blog walkthrough exist, and the // SC5: the concept map and the acme/blog walkthrough exist, and the
@@ -409,31 +476,68 @@ func execPrograms(t *testing.T, file string) []string {
} }
// docFence is one fenced code block of a docs page, found by an // docFence is one fenced code block of a docs page, found by an
// independent scanner (not docsite's). // independent scanner (not docsite's). nested is true when the opening
// line was indented or a blockquote marker was stripped from it.
type docFence struct { type docFence struct {
file, info, src, body string file, info, src, body string
line int line int
nested bool
} }
func pageFences(t *testing.T, rel string) []docFence { // stripMarkers removes a run of leading spaces and blockquote markers
t.Helper() // (each ">" optionally followed by one space). nested is true when the
var fences []docFence // line was indented or a marker was removed.
lines := strings.Split(readFile(t, filepath.Join(repoRoot, filepath.FromSlash(rel))), "\n") func stripMarkers(line string) (string, bool) {
for i := 0; i < len(lines); i++ { nested := false
trimmed := strings.TrimLeft(lines[i], " ") for {
if !strings.HasPrefix(trimmed, "```") && !strings.HasPrefix(trimmed, "~~~") { if strings.HasPrefix(line, " ") {
nested = true
line = strings.TrimLeft(line, " ")
continue continue
} }
marker := trimmed[:3] if strings.HasPrefix(line, ">") {
f := docFence{file: rel, info: strings.TrimSpace(strings.TrimLeft(trimmed, marker[:1])), line: i + 1} nested = true
line = line[1:]
if strings.HasPrefix(line, " ") {
line = line[1:]
}
continue
}
return line, nested
}
}
// scanDocFences lists the fences in content. Container markers are
// stripped before detection, so a fence inside a blockquote or list is
// visible and marked nested. The closing fence is detected on stripped
// lines.
func scanDocFences(rel, content string) []docFence {
var fences []docFence
lines := strings.Split(content, "\n")
for i := 0; i < len(lines); i++ {
stripped, nested := stripMarkers(lines[i])
if !strings.HasPrefix(stripped, "```") && !strings.HasPrefix(stripped, "~~~") {
continue
}
marker := stripped[:3]
f := docFence{
file: rel,
info: strings.TrimSpace(strings.TrimLeft(stripped, marker[:1])),
line: i + 1,
nested: nested,
}
for _, field := range strings.Fields(f.info) { for _, field := range strings.Fields(f.info) {
if v, ok := strings.CutPrefix(field, "src="); ok { if v, ok := strings.CutPrefix(field, "src="); ok {
f.src = v f.src = v
} }
} }
var body []string var body []string
for i++; i < len(lines) && !strings.HasPrefix(strings.TrimLeft(lines[i], " "), marker); i++ { for i++; i < len(lines); i++ {
body = append(body, lines[i]) s, _ := stripMarkers(lines[i])
if strings.HasPrefix(s, marker) {
break
}
body = append(body, s)
} }
f.body = strings.Join(body, "\n") f.body = strings.Join(body, "\n")
fences = append(fences, f) fences = append(fences, f)
@@ -441,6 +545,62 @@ func pageFences(t *testing.T, rel string) []docFence {
return fences return fences
} }
func pageFences(t *testing.T, rel string) []docFence {
t.Helper()
return scanDocFences(rel, readFile(t, filepath.Join(repoRoot, filepath.FromSlash(rel))))
}
// goFenceLang reports a Go fence the way the highlighter's chroma
// registry does: names, aliases and file names. It does not call docsite.
func goFenceLang(lang string) bool {
lexer := lexers.Get(lang)
return lexer != nil && lexer.Config().Name == "Go"
}
func TestAcceptanceFenceScanner(t *testing.T) {
body := strings.Join([]string{
"> [!TIP]",
"> ```go src=pkg/a.go#A",
"> BOGUS",
"> ```",
"",
"> ```go",
"> x := 1",
"> ```",
"",
"```golang",
"x := 1",
"```",
"",
"```go src=pkg/b.go",
"package b",
"```",
"",
}, "\n")
fences := scanDocFences("docs/x.md", body)
if len(fences) != 4 {
t.Fatalf("fences = %d, want 4", len(fences))
}
if !fences[0].nested || fences[0].src != "pkg/a.go#A" {
t.Errorf("callout fence = %+v, want nested src= pkg/a.go#A", fences[0])
}
if !fences[1].nested || fences[1].src != "" || !strings.HasPrefix(fences[1].info, "go") {
t.Errorf("blockquote fence = %+v, want nested go", fences[1])
}
if fences[2].nested || !strings.HasPrefix(fences[2].info, "golang") {
t.Errorf("golang fence = %+v, want a top-level golang fence", fences[2])
}
if fences[3].nested || fences[3].src != "pkg/b.go" {
t.Errorf("top-level fence = %+v, want src= pkg/b.go", fences[3])
}
if !goFenceLang("go") || !goFenceLang("golang") {
t.Error("goFenceLang rejected go or golang")
}
if goFenceLang("go-html-template") || goFenceLang("text") {
t.Error("goFenceLang accepted go-html-template or text")
}
}
// docsGoFences lists every go fence of every page under docs/ (the // docsGoFences lists every go fence of every page under docs/ (the
// docs/examples tree is code, not pages). // docs/examples tree is code, not pages).
func docsGoFences(t *testing.T) []docFence { func docsGoFences(t *testing.T) []docFence {
@@ -458,7 +618,8 @@ func docsGoFences(t *testing.T) []docFence {
} }
rel, _ := filepath.Rel(repoRoot, p) rel, _ := filepath.Rel(repoRoot, p)
for _, f := range pageFences(t, filepath.ToSlash(rel)) { for _, f := range pageFences(t, filepath.ToSlash(rel)) {
if lang, _, _ := strings.Cut(f.info, " "); lang == "go" { lang, _, _ := strings.Cut(f.info, " ")
if goFenceLang(lang) {
out = append(out, f) out = append(out, f)
} }
} }

View File

@@ -311,6 +311,26 @@ run_self_test() {
expect_refusal 'unknown callout' 'callout: unknown type DANGER (use NOTE, TIP or WARNING)' "$summer" "$scratch" expect_refusal 'unknown callout' 'callout: unknown type DANGER (use NOTE, TIP or WARNING)' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md restore "$pristine" "$scratch" docs/index.md
printf '\n> [!TIP]\n> ```go src=modules/bonfire/example_test.go#ExampleCall\n> BOGUS\n> ```\n' >>"$scratch/docs/index.md"
expect_refusal 'callout src=' 'snippet: modules/bonfire/example_test.go#ExampleCall: src= code block must be a top-level block' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
printf '\n```golang\nx := 1\n```\n' >>"$scratch/docs/index.md"
expect_refusal 'golang fence without src=' 'snippet: golang code block has no src= reference' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
printf '\n```sh\nFOO=1 summer no:such\n```\n' >>"$scratch/docs/index.md"
expect_refusal 'env-prefixed command' '"no:such" is not a summer or application command' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
printf '\n```sh\nsummer --root . no:such\n```\n' >>"$scratch/docs/index.md"
expect_refusal 'flag-first command' '"no:such" is not a summer or application command' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
printf '\nSee `lagoon.Openfromapp`.\n' >>"$scratch/docs/index.md"
expect_refusal 'wrong-case identifier' 'identifier: lagoon.Openfromapp does not exist in modules/lagoon' "$summer" "$scratch"
restore "$pristine" "$scratch" docs/index.md
# The planted-violation corpus: one fixture per checker rule, each of # The planted-violation corpus: one fixture per checker rule, each of
# which must fail for its own rule, and the clean fixture. Skips, zero # which must fail for its own rule, and the clean fixture. Skips, zero
# matches and "no tests to run" refuse. # matches and "no tests to run" refuse.
@@ -352,8 +372,10 @@ DOCSITE_TESTS=(TestPlantedViolations TestCleanFixture TestBuildOutputGuard TestS
TestMarkdownSiblings TestSearchIndexSchema TestBaseURLPrefixing TestTOCThreshold TestLinkRewriting TestMarkdownSiblings TestSearchIndexSchema TestBaseURLPrefixing TestTOCThreshold TestLinkRewriting
TestCalloutRendering TestHighlightGo TestHighlightYAML TestHighlightShell TestHighlightFallback TestCalloutRendering TestHighlightGo TestHighlightYAML TestHighlightShell TestHighlightFallback
TestBuildSiteMarkers TestThemeAssetsAndPager TestServeHandler TestServeHandlerBranches TestServeAddrPolicy TestBuildSiteMarkers TestThemeAssetsAndPager TestServeHandler TestServeHandlerBranches TestServeAddrPolicy
TestServeRefusesNonLoopback TestServeRebuildKeepsLastGoodBuild TestServeWatchRebuilds) TestServeRefusesNonLoopback TestServeRebuildKeepsLastGoodBuild TestServeWatchRebuilds
SUMMER_TESTS=(TestPhase11_1Acceptance TestDocsTree TestDocsBuildRealTree TestEveryModuleInSidebar TestCollectFences TestFenceCaptionsOnlyVerified TestGoLang TestNestedFenceChecks
TestIdentifierGoDocCaseSensitive TestCommandWord TestSnippetRootsAndBuild TestSyncParsedFences)
SUMMER_TESTS=(TestPhase11_1Acceptance TestAcceptanceFenceScanner TestDocsTree TestDocsBuildRealTree TestEveryModuleInSidebar
TestDocsAIOutputsInSync TestDocsRequiredPages TestDocsCommandNames TestDocsCommandsMirrorGeneratedMain TestDocsAIOutputsInSync TestDocsRequiredPages TestDocsCommandNames TestDocsCommandsMirrorGeneratedMain
TestDocsBuildCheckOutput TestDocsSyncOutput TestDocsServeRefusal TestToolCommandNames) TestDocsBuildCheckOutput TestDocsSyncOutput TestDocsServeRefusal TestToolCommandNames)