diff --git a/PLAN.md b/PLAN.md index bb864ad02..5a4f9b996 100644 --- a/PLAN.md +++ b/PLAN.md @@ -17,7 +17,7 @@ footer: | | ID | Status | Title | |-----|--------|------------------------------------------------------------------------------------------------------| | 52 | ✅ | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) | -| 61 | 🔳 | [Required Structure Rule Hardening](plan/61_required-structure-hardening.md) | +| 61 | ✅ | [Required Structure Rule Hardening](plan/61_required-structure-hardening.md) | | 65 | ✅ | [Spike WASM-Embedded Weasel Inference](plan/65_spike-wasm-embedded-inference.md) | | 78 | ✅ | [Query subcommand for front-matter filtering](plan/78_query-command.md) | | 83 | ✅ | [Security hardening batch](plan/83_security-hardening-batch.md) | diff --git a/internal/rules/MDS020-required-structure/README.md b/internal/rules/MDS020-required-structure/README.md index c3f4efe01..09f30613e 100644 --- a/internal/rules/MDS020-required-structure/README.md +++ b/internal/rules/MDS020-required-structure/README.md @@ -221,7 +221,7 @@ Describe the goal here. | extra section | unexpected section "## Extra" (expected "## Settings") | | out of order | section "## Tasks" out of order: expected after "## Goal" | | heading sync | heading does not match frontmatter: expected "MDS001" (from id), got "MDS002" | -| body sync | body does not match frontmatter field "description" | +| body sync | body does not match frontmatter field "description": expected "..." | | front matter schema | front matter does not satisfy schema CUE constraints: ... | | filename mismatch | filename "foo.md" does not match required pattern "[0-9]*_*.md" | | misplaced require | is only recognized in schema files; this directive has no effect | diff --git a/internal/rules/MDS020-required-structure/bad/data/wildcard-tmpl.md b/internal/rules/MDS020-required-structure/bad/data/wildcard-tmpl.md new file mode 100644 index 000000000..46d8c5136 --- /dev/null +++ b/internal/rules/MDS020-required-structure/bad/data/wildcard-tmpl.md @@ -0,0 +1 @@ +# ? diff --git a/internal/rules/MDS020-required-structure/bad/multi-missing.md b/internal/rules/MDS020-required-structure/bad/multi-missing.md new file mode 100644 index 000000000..7dd96aef2 --- /dev/null +++ b/internal/rules/MDS020-required-structure/bad/multi-missing.md @@ -0,0 +1,12 @@ +--- +settings: + schema: "../../internal/rules/MDS020-required-structure/bad/data/tmpl.md" +diagnostics: + - line: 1 + column: 1 + message: 'missing required section "## Goal"' + - line: 1 + column: 1 + message: 'missing required section "## Tasks"' +--- +# Title Only diff --git a/internal/rules/MDS020-required-structure/bad/wildcard-level.md b/internal/rules/MDS020-required-structure/bad/wildcard-level.md new file mode 100644 index 000000000..6b92bf1f1 --- /dev/null +++ b/internal/rules/MDS020-required-structure/bad/wildcard-level.md @@ -0,0 +1,9 @@ +--- +settings: + schema: "../../internal/rules/MDS020-required-structure/bad/data/wildcard-tmpl.md" +diagnostics: + - line: 1 + column: 1 + message: 'heading level mismatch for "Title": expected h1, got h2' +--- +## Title diff --git a/internal/rules/requiredstructure/rule.go b/internal/rules/requiredstructure/rule.go index 14ae95f17..4b2c8a716 100644 --- a/internal/rules/requiredstructure/rule.go +++ b/internal/rules/requiredstructure/rule.go @@ -1131,7 +1131,7 @@ func checkBodySync( } return []lint.Diagnostic{makeDiag(f.Path, dh.Line, - fmt.Sprintf("body does not match frontmatter field %q", field))} + fmt.Sprintf("body does not match frontmatter field %q: expected %q", field, expected))} } func validateCUESchemaSyntax(schema string) error { diff --git a/internal/rules/requiredstructure/rule_test.go b/internal/rules/requiredstructure/rule_test.go index ac2561bab..4c64f3c44 100644 --- a/internal/rules/requiredstructure/rule_test.go +++ b/internal/rules/requiredstructure/rule_test.go @@ -1450,3 +1450,93 @@ func TestExtractPIFileParam_MultiLine(t *testing.T) { require.NoError(t, err) assert.Equal(t, "other.md", result) } + +// ===================================================================== +// Plan 61 hardening: additional edge-case tests +// ===================================================================== + +// Wildcard heading (`# ?`) must still enforce the correct level. +// A document h2 where h1 is required produces a level-mismatch diagnostic. +func TestCheck_WildcardHeadingLevelMismatch(t *testing.T) { + schemaPath := writeSchema(t, "# ?\n") + r := &Rule{Schema: schemaPath} + f := newTestFile(t, "doc.md", "## Title\n") + diags := r.Check(f) + expectDiagMsg(t, diags, `heading level mismatch for "Title": expected h1, got h2`) +} + +// Soft-wrapped body paragraph (multiple lines joined by space) must +// match the front matter field value when concatenated. +func TestCheck_BodySyncSoftWrapped(t *testing.T) { + schemaPath := writeSchema(t, "# ?\n\n{description}\n") + r := &Rule{Schema: schemaPath} + f := newTestFile(t, "doc.md", + "---\ndescription: Line exceeds maximum length.\n---\n# My Rule\n\n"+ + "Line exceeds\nmaximum length.\n") + diags := r.Check(f) + expectDiags(t, diags, 0) +} + +// The improved body sync diagnostic must include the expected value so +// authors know what text to write. +func TestCheck_BodySyncDiagnosticIncludesExpected(t *testing.T) { + schemaPath := writeSchema(t, "# ?\n\n{description}\n") + r := &Rule{Schema: schemaPath} + f := newTestFile(t, "doc.md", + "---\ndescription: Correct description.\n---\n# My Rule\n\nWrong text.\n") + diags := r.Check(f) + expectDiagMsg(t, diags, `expected "Correct description."`) +} + +// Integer front matter values are stringified for heading sync. +func TestCheck_SyncIntegerFrontMatterValue(t *testing.T) { + schemaPath := writeSchema(t, "# {id}: {name}\n") + r := &Rule{Schema: schemaPath} + f := newTestFile(t, "doc.md", + "---\nid: 42\nname: line-length\n---\n# 42: line-length\n") + diags := r.Check(f) + expectDiags(t, diags, 0) +} + +// When a synced heading is absent from the document, checkSync must +// not emit a spurious diagnostic; only checkStructure reports it. +func TestCheck_SyncNotFiredForMissingHeading(t *testing.T) { + schemaPath := writeSchema(t, "# ?\n\n## {title}\n") + r := &Rule{Schema: schemaPath} + f := newTestFile(t, "doc.md", + "---\ntitle: My Section\n---\n# Title\n") + diags := r.Check(f) + // Exactly one diagnostic: missing required section, no sync error. + require.Len(t, diags, 1) + expectDiagMsg(t, diags, "missing required section") + for _, d := range diags { + assert.NotContains(t, d.Message, "sync") + } +} + +// When several required sections are all absent, each gets its own +// "missing required section" diagnostic. +func TestCheck_MultipleMissingSections(t *testing.T) { + schemaPath := writeSchema(t, + "# ?\n\n## Goal\n\n## Tasks\n\n## Acceptance Criteria\n") + r := &Rule{Schema: schemaPath} + f := newTestFile(t, "doc.md", "# Title\n") + diags := r.Check(f) + expectDiagMsg(t, diags, `missing required section "## Goal"`) + expectDiagMsg(t, diags, `missing required section "## Tasks"`) + expectDiagMsg(t, diags, `missing required section "## Acceptance Criteria"`) +} + +// A section that is both out of order AND at the wrong level must +// produce both the out-of-order and the level-mismatch diagnostic. +func TestCheck_OutOfOrderAlsoReportsLevelMismatch(t *testing.T) { + schemaPath := writeSchema(t, + "# ?\n\n## Goal\n\n## Tasks\n") + r := &Rule{Schema: schemaPath} + // Tasks (h2) appears before Goal; Goal appears at h3 (wrong level). + f := newTestFile(t, "doc.md", + "# Title\n\n## Tasks\n\n### Goal\n") + diags := r.Check(f) + expectDiagMsg(t, diags, `out of order`) + expectDiagMsg(t, diags, `heading level mismatch`) +} diff --git a/plan/61_required-structure-hardening.md b/plan/61_required-structure-hardening.md index 9aa483640..5fe75e222 100644 --- a/plan/61_required-structure-hardening.md +++ b/plan/61_required-structure-hardening.md @@ -1,7 +1,7 @@ --- id: 61 title: Required Structure Rule Hardening -status: 🔳 +status: ✅ --- # Required Structure Rule Hardening @@ -13,28 +13,28 @@ with clear, actionable messages. ## Tasks -1. Audit current MDS020 behavior against `rules/proto.md` +1. [x] Audit current MDS020 behavior against `rules/proto.md` and identify mismatch classes not covered by tests (heading order/level, optional sections, sync fields). -2. Expand `requiredstructure` unit tests with focused fixtures +2. [x] Expand `requiredstructure` unit tests with focused fixtures for false positives and false negatives, including front matter/body sync scenarios. -3. Refine matching logic for required headings and sync points +3. [x] Refine matching logic for required headings and sync points to reduce ambiguous comparisons and improve determinism. -4. Improve diagnostic messages to include expected vs actual +4. [x] Improve diagnostic messages to include expected vs actual structure details and precise heading context. -5. Update `rules/MDS020-required-structure/README.md` +5. [x] Update `rules/MDS020-required-structure/README.md` with clarified settings, examples, and diagnostics. ## Acceptance Criteria -- [ ] MDS020 correctly detects missing, reordered, +- [x] MDS020 correctly detects missing, reordered, and wrong-level required headings from template input. -- [ ] Sync checks correctly validate heading/body placeholders +- [x] Sync checks correctly validate heading/body placeholders against front matter fields without spurious matches. -- [ ] Diagnostics include actionable expected vs actual detail +- [x] Diagnostics include actionable expected vs actual detail at stable line locations. -- [ ] Tests cover representative success/failure cases, +- [x] Tests cover representative success/failure cases, including template config edge cases. -- [ ] All tests pass: `go test ./...` -- [ ] `golangci-lint run` reports no issues +- [x] All tests pass: `go test ./...` +- [x] `golangci-lint run` reports no issues