Skip to content

Commit dd08a22

Browse files
committed
schema: address fourth Copilot review round (plan 156)
Five new review findings (plus the optional-matcher case from the third round): 1. matchScope::step — an optional matcher (`repeat.min == 0`) used to terminate at the first non-matching same-level heading even when consumed == 0. That made an in-order optional section after a tolerated extra get flagged as out-of-order by the leftover pass. The step path now distinguishes "active run terminated" (consumed > 0) from "optional not started" (consumed == 0) and, for the latter, yields to a matching later scope or advances past the tolerated heading. Test: TestPlan156_OptionalMatcherSkipsTolerated. 2. matcher.go::resolvePatternForCheck — `fmvar(name)` no longer passes parse-time validation with a non-CUE-path argument. `fmvar(my-key)` (unquoted hyphen) now errors at parse with a pointer at the canonical `fmvar("my-key")` quoting. Test: TestPlan156_RejectsInvalidFmvarPath. 3. schema_test.go — delete the permanently-skipped TestParseInline_ContentRejectedOnQuestionMarkHeading. The `heading: "?"` shape is gone; the test was dead code. 4. acronyms.go::acronymRanges — refresh the function comment so it matches the repeated-scope behavior the prior round added (one range per occurrence, not first-match-only). 5. docs/reference/section-schema.md — the "regex: is a CUE expression" wording oversold what mdsmith actually does. The resolver handles only `\#(digits)` and `\#(fmvar(...))`; rewrite the page to describe the helper-only surface. Known-limitation comment on claimLateScope: out-of-order recovery still claims one heading and skips repeat/sequential checks. The in-order matchScope path remains the primary enforcement surface; non-contiguous run enforcement is tracked as a separate plan. https://claude.ai/code/session_012GGH62fZUzLuzP8T4ocGkJ
1 parent 5ff2840 commit dd08a22

6 files changed

Lines changed: 107 additions & 42 deletions

File tree

docs/reference/section-schema.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -108,20 +108,19 @@ value is a mapping.
108108

109109
## The regex matcher
110110

111-
`regex:` is a CUE expression evaluating to a
112-
string. The string is compiled as Go RE2.
113-
114-
The YAML value is the body of a CUE
115-
raw-interpolation string. mdsmith wraps it in
116-
`#"..."#` before evaluating. Two consequences:
111+
`regex:` is a Go RE2 pattern body. mdsmith
112+
recognizes two interpolation references in the
113+
pattern surface — borrowed from CUE's
114+
raw-interpolation syntax (`\#(expr)`) — but it
115+
does not actually evaluate the body as CUE. Two
116+
consequences:
117117

118118
- **Backslash is literal.** Write `\d`, `\w`,
119119
`\.`, `\(` directly — no doubling. Plain
120120
RE2 patterns work as-is.
121-
- **Interpolation is `\#(expr)`.** Inside the
122-
string, `\#(x)` evaluates `x` in the CUE
123-
scope (frontmatter fields plus mdsmith
124-
helpers) and substitutes the result.
121+
- **Interpolation is `\#(expr)`.** Only the two
122+
helpers below are accepted; any other `expr`
123+
parse-errors with "unknown helper".
125124

126125
**Anchoring.** Whole-string. `regex: 'Overview'`
127126
matches a heading whose text is exactly

internal/schema/acronyms.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,11 @@ type lineRange struct {
7777

7878
// acronymRanges returns the line windows the acronym check should
7979
// scan. An empty scope list applies to the whole document.
80-
// Otherwise the schema scope tree is walked and each schema scope
81-
// claims the first matching document heading at its level; if the
82-
// document repeats a heading text under the same schema scope,
83-
// only the first occurrence is included today. This matches the
84-
// validator's claim semantics; widening to multi-match would
85-
// require schema repeats support (currently rejected by the
86-
// parser) and is tracked as a follow-up.
80+
// Otherwise the schema scope tree is walked and one line range is
81+
// emitted per occurrence: a repeated scope (`repeat.max > 1` or
82+
// unbounded) contributes a range for each matched heading, so a
83+
// scope name like "Diagnosis" applied to two `## Diagnosis`
84+
// sections scans both bodies for first-use acronyms.
8785
func acronymRanges(f *lint.File, sch *Schema, scope []string, docFM map[string]any) []lineRange {
8886
if len(scope) == 0 {
8987
return []lineRange{{Start: 1, End: len(f.Lines) + 1}}

internal/schema/matcher.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,24 @@ func resolvePattern(pattern string, fm map[string]any) (string, error) {
9797
// frontmatter lookups so the result is syntactically valid even
9898
// when no document is in hand. Used at parse time to catch invalid
9999
// RE2 in `regex:` early. Returns an error when the pattern uses an
100-
// unsupported helper or an unterminated `\#(` reference; the
101-
// validator never has a chance to compile a pattern that didn't
102-
// pass this check.
100+
// unsupported helper, an unterminated `\#(` reference, or an
101+
// `fmvar(name)` whose argument is not a valid CUE path — turning
102+
// a schema typo into a parse-time diagnostic instead of a
103+
// confusing missing-section diagnostic at validate-time.
103104
func resolvePatternForCheck(pattern string) (string, error) {
104105
return rewriteInterps(pattern, func(expr string) (string, error) {
105106
expr = strings.TrimSpace(expr)
106107
switch {
107108
case expr == "digits":
108109
return digitsCaptureExpr, nil
109110
case fmvarCallRe.MatchString(expr):
111+
name := strings.TrimSpace(fmvarCallRe.FindStringSubmatch(expr)[1])
112+
if fieldinterp.ParseCUEPath(name) == nil {
113+
return "", fmt.Errorf(
114+
"`fmvar(%s)`: invalid frontmatter path "+
115+
"(non-identifier keys must be quoted, "+
116+
"e.g. `fmvar(\"my-key\")`)", name)
117+
}
110118
// Use a literal placeholder so the compiled regex is
111119
// syntactically valid. The validator will re-resolve
112120
// against the real frontmatter at validate-time.

internal/schema/plan156_acceptance_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,57 @@ func TestPlan156_OptionalSpecificClaimsOwnSlot(t *testing.T) {
389389
"a broad `.+` matcher must not absorb it")
390390
}
391391

392+
// TestPlan156_RejectsInvalidFmvarPath regresses a Copilot
393+
// finding: `fmvar(name)` accepted any argument string at parse
394+
// time, so a typo like `fmvar(my-key)` (unquoted hyphenated key)
395+
// only surfaced as a confusing missing-section diagnostic at
396+
// validate-time. Parse-time validation now applies the same CUE
397+
// path rules as the runtime lookup.
398+
func TestPlan156_RejectsInvalidFmvarPath(t *testing.T) {
399+
raw := map[string]any{
400+
"sections": []any{
401+
map[string]any{"heading": map[string]any{
402+
"regex": `\#(fmvar(my-key))`,
403+
}},
404+
},
405+
}
406+
_, err := ParseInline(raw, "kind x")
407+
require.Error(t, err)
408+
assert.Contains(t, err.Error(), "fmvar(my-key)")
409+
assert.Contains(t, err.Error(), "non-identifier keys must be quoted")
410+
}
411+
412+
// TestPlan156_OptionalMatcherSkipsTolerated regresses a Copilot
413+
// low-confidence finding: an optional matcher (`repeat.min == 0`)
414+
// stopped at the first non-matching heading and left later
415+
// in-order occurrences to be flagged as out-of-order by the
416+
// leftover pass. The matcher should scan past tolerated extras
417+
// in open schemas the same way a required matcher does, only
418+
// terminating once it has actually started a run.
419+
func TestPlan156_OptionalMatcherSkipsTolerated(t *testing.T) {
420+
raw := map[string]any{
421+
"sections": []any{
422+
map[string]any{
423+
"heading": map[string]any{
424+
"regex": "X",
425+
"repeat": map[string]any{"min": 0, "max": 1},
426+
},
427+
},
428+
},
429+
}
430+
sch, err := ParseInline(raw, "kind x")
431+
require.NoError(t, err)
432+
// Doc has a tolerated extra before X. The open-schema walk
433+
// should silently consume Other and then claim X normally.
434+
doc := newDocFile(t, "doc.md",
435+
"# T\n\n## Other\n\nx\n\n## X\n\ny\n")
436+
diags := Validate(doc, sch, nil, false, makeDiagForTest)
437+
for _, d := range diags {
438+
assert.NotContains(t, d.Message, "out of order",
439+
"optional X must claim itself even after a tolerated extra")
440+
}
441+
}
442+
392443
// TestPlan156_BroadRepeatYieldsAcronymScope covers the acronym
393444
// walker's broad-matcher yield: an acronym-scoped named section
394445
// after a broad repeated matcher must still get its body

internal/schema/schema_test.go

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -427,19 +427,6 @@ func TestParseInline_ContentRejectedOnWildcard(t *testing.T) {
427427
"not allowed on a slot")
428428
}
429429

430-
func TestParseInline_ContentRejectedOnQuestionMarkHeading(t *testing.T) {
431-
t.Skip("plan 156 dropped the inline `heading: \"?\"` shape — use `heading: { regex: '.+' }`")
432-
_, err := ParseInline(map[string]any{
433-
"sections": []any{map[string]any{
434-
"heading": "?",
435-
"content": []any{map[string]any{"kind": "paragraph"}},
436-
}},
437-
}, "kind x")
438-
require.Error(t, err)
439-
assert.Contains(t, err.Error(),
440-
"not allowed on a `?` wildcard heading")
441-
}
442-
443430
// ---- Validate (content:) ----
444431

445432
func TestValidate_Content_MissingCodeBlock(t *testing.T) {

internal/schema/validate.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,13 @@ func handleLeftoverHeadings(
229229
// emits its out-of-order diagnostic, and recurses into the scope's
230230
// nested children so missing-required-section diagnostics still
231231
// surface beneath a late parent.
232+
// Known limitation: claimLateScope (and claimOutOfOrder below)
233+
// claim a single heading and mark the scope fully claimed. Repeat
234+
// cardinality (`repeat.min/max`) and `sequential:` checks are
235+
// skipped for the late/out-of-order recovery path. Enforcing run
236+
// semantics for non-contiguous occurrences is a separate plan;
237+
// the in-order matchScope path is the primary enforcement
238+
// surface.
232239
func claimLateScope(
233240
f *lint.File, scopes []Scope, idx, expectedLevel int,
234241
docHeads []DocHeading, docIdx int, claimed map[int]bool,
@@ -414,17 +421,32 @@ func (s *matchRun) step(docHeads []DocHeading, docIdx int) (bool, int, bool) {
414421
}
415422
return false, s.claimMatch(docHeads, docIdx, captured), false
416423
}
424+
// A deeper-than-expected heading is part of an earlier claimed
425+
// scope's body, not a sibling boundary — skip it so the run
426+
// keeps scanning for the next same-level match.
427+
if dh.Level > s.expectedLevel {
428+
return false, docIdx + 1, false
429+
}
430+
if s.consumed > 0 {
431+
// An active run is contiguous; the first same-level
432+
// non-match closes it.
433+
return true, docIdx, true
434+
}
417435
if s.consumed >= s.min {
418-
// A deeper-than-expected heading is part of an earlier
419-
// claimed scope's body, not a sibling boundary — skip it
420-
// so the run keeps scanning for the next same-level match.
421-
// Without this, a repeated `## Step` whose first occurrence
422-
// contains a `### Detail` would terminate after Step 1 and
423-
// leave later steps unclaimed.
424-
if dh.Level > s.expectedLevel {
425-
return false, docIdx + 1, false
436+
// Optional matcher (min=0) hasn't started yet. Yield to a
437+
// later listed scope when the heading would claim it;
438+
// otherwise the heading is a tolerated extra: skip it in
439+
// open schemas, flag it in closed.
440+
if claimsLaterLiteral(s.scopes, s.idx+1, dh, s.claimed, s.docFM) {
441+
return true, docIdx, false
426442
}
427-
return true, docIdx, s.consumed > 0
443+
if !s.allowExtra && s.closed {
444+
s.diags = append(s.diags, s.mkDiag(s.f.Path, dh.Line,
445+
fmt.Sprintf("unexpected section %q (expected %q)",
446+
formatHeading(dh.Level, dh.Text),
447+
formatHeading(s.expectedLevel, displayHeading(sc)))))
448+
}
449+
return false, docIdx + 1, false
428450
}
429451
return s.handleNonMatch(docHeads, docIdx)
430452
}

0 commit comments

Comments
 (0)