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"
"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, "<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
@@ -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)
}
}

View File

@@ -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)