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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user