From 550fa06f91e67db9202a7760bad256157ab2bfb6 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 1 Oct 2026 08:51:58 +0200 Subject: [PATCH] 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 --- cmd/summer/phase11_1_acceptance_test.go | 187 ++++++++++++++++++++++-- scripts/check-phase11.1.sh | 26 +++- 2 files changed, 198 insertions(+), 15 deletions(-) diff --git a/cmd/summer/phase11_1_acceptance_test.go b/cmd/summer/phase11_1_acceptance_test.go index 6c3f51c..6ac97a4 100644 --- a/cmd/summer/phase11_1_acceptance_test.go +++ b/cmd/summer/phase11_1_acceptance_test.go @@ -15,6 +15,8 @@ import ( "strings" "testing" + "github.com/alecthomas/chroma/v2/lexers" + "git.golem15.com/golem15/summercms/internal/docsite" "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) } } + // 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, "
"); 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), "
") { + t.Errorf("%s has a figcaption", p) + } + } }) // 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 -// 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 { file, info, src, body string line int + nested bool } -func pageFences(t *testing.T, rel string) []docFence { - t.Helper() - var fences []docFence - lines := strings.Split(readFile(t, filepath.Join(repoRoot, filepath.FromSlash(rel))), "\n") - for i := 0; i < len(lines); i++ { - trimmed := strings.TrimLeft(lines[i], " ") - if !strings.HasPrefix(trimmed, "```") && !strings.HasPrefix(trimmed, "~~~") { +// stripMarkers removes a run of leading spaces and blockquote markers +// (each ">" optionally followed by one space). nested is true when the +// line was indented or a marker was removed. +func stripMarkers(line string) (string, bool) { + nested := false + for { + if strings.HasPrefix(line, " ") { + nested = true + line = strings.TrimLeft(line, " ") continue } - marker := trimmed[:3] - f := docFence{file: rel, info: strings.TrimSpace(strings.TrimLeft(trimmed, marker[:1])), line: i + 1} + if strings.HasPrefix(line, ">") { + 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) { if v, ok := strings.CutPrefix(field, "src="); ok { f.src = v } } var body []string - for i++; i < len(lines) && !strings.HasPrefix(strings.TrimLeft(lines[i], " "), marker); i++ { - body = append(body, lines[i]) + for i++; i < len(lines); i++ { + s, _ := stripMarkers(lines[i]) + if strings.HasPrefix(s, marker) { + break + } + body = append(body, s) } f.body = strings.Join(body, "\n") fences = append(fences, f) @@ -441,6 +545,62 @@ func pageFences(t *testing.T, rel string) []docFence { 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 // docs/examples tree is code, not pages). func docsGoFences(t *testing.T) []docFence { @@ -458,7 +618,8 @@ func docsGoFences(t *testing.T) []docFence { } rel, _ := filepath.Rel(repoRoot, p) 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) } } diff --git a/scripts/check-phase11.1.sh b/scripts/check-phase11.1.sh index f44379e..5fd1d09 100755 --- a/scripts/check-phase11.1.sh +++ b/scripts/check-phase11.1.sh @@ -311,6 +311,26 @@ run_self_test() { expect_refusal 'unknown callout' 'callout: unknown type DANGER (use NOTE, TIP or WARNING)' "$summer" "$scratch" 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 # which must fail for its own rule, and the clean fixture. Skips, zero # matches and "no tests to run" refuse. @@ -352,8 +372,10 @@ DOCSITE_TESTS=(TestPlantedViolations TestCleanFixture TestBuildOutputGuard TestS TestMarkdownSiblings TestSearchIndexSchema TestBaseURLPrefixing TestTOCThreshold TestLinkRewriting TestCalloutRendering TestHighlightGo TestHighlightYAML TestHighlightShell TestHighlightFallback TestBuildSiteMarkers TestThemeAssetsAndPager TestServeHandler TestServeHandlerBranches TestServeAddrPolicy - TestServeRefusesNonLoopback TestServeRebuildKeepsLastGoodBuild TestServeWatchRebuilds) -SUMMER_TESTS=(TestPhase11_1Acceptance TestDocsTree TestDocsBuildRealTree TestEveryModuleInSidebar + TestServeRefusesNonLoopback TestServeRebuildKeepsLastGoodBuild TestServeWatchRebuilds + TestCollectFences TestFenceCaptionsOnlyVerified TestGoLang TestNestedFenceChecks + TestIdentifierGoDocCaseSensitive TestCommandWord TestSnippetRootsAndBuild TestSyncParsedFences) +SUMMER_TESTS=(TestPhase11_1Acceptance TestAcceptanceFenceScanner TestDocsTree TestDocsBuildRealTree TestEveryModuleInSidebar TestDocsAIOutputsInSync TestDocsRequiredPages TestDocsCommandNames TestDocsCommandsMirrorGeneratedMain TestDocsBuildCheckOutput TestDocsSyncOutput TestDocsServeRefusal TestToolCommandNames)