Skip to content

Commit 411850c

Browse files
committed
schema: address third Copilot review round (plan 156)
Five new review findings: 1. claimsLaterLiteral was too permissive after the previous fix: an optional specific scope followed by a broad `.+` matcher yielded its heading to the broad scope, skipping its own content / rules. Add `isBroadMatcher` and skip broad-regex later scopes in the yield check. Test: TestPlan156_OptionalSpecificClaimsOwnSlot. 2-4. The per-scope walkers (rules, content, acronyms) were greedy: a broad repeated matcher claimed every matching heading before later named scopes got a chance, so the named scopes' content / rules / acronyms-scope checks silently skipped those headings. Add a walker-side yield helper (`laterScopeMatches` / `laterScopeClaimsHead`) and wire it into walkContentScopes, walkRanges, and the per-scope rule walker. Test: TestPlan156_BroadRepeatYieldsToLaterScopeInPerScopeWalkers. 5. The matcher cache was a process-global sync.Map that could grow unboundedly in a long-running LSP session — each unique `fmvar(...)` value adds an entry. Replace with a bounded map (cap 1024); reset the map when it fills. Mutex-guarded so parallel validator passes stay race-free. Test: TestMatcherCache_BoundsGrowth. https://claude.ai/code/session_012GGH62fZUzLuzP8T4ocGkJ
1 parent 185a603 commit 411850c

8 files changed

Lines changed: 262 additions & 17 deletions

File tree

internal/rules/requiredstructure/scope_rules.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ func walkScopes(
7373
claimed map[int]bool, docFM map[string]any,
7474
visit func(sc schema.Scope, startLine, endLine int),
7575
) {
76-
for _, sc := range scopes {
76+
for i, sc := range scopes {
7777
if sc.Preamble {
7878
// Preamble range: [parentStart, first heading at this
7979
// level in the window). Empty if the very first doc
@@ -97,8 +97,15 @@ func walkScopes(
9797
if matched < 0 {
9898
break
9999
}
100-
claimed[matched] = true
101100
dh := heads[matched]
101+
// Yield broad matchers to later named scopes so a
102+
// `regex: '.+'` repeat does not absorb a heading the
103+
// user named separately.
104+
if isBroadScopeMatcher(sc) &&
105+
laterScopeClaimsHead(scopes, i+1, dh, docFM) {
106+
break
107+
}
108+
claimed[matched] = true
102109
// The section's end boundary follows the doc heading's
103110
// real level, not the schema's expectedLevel. When the
104111
// two differ (level-mismatch fallback), basing the end
@@ -217,6 +224,35 @@ func isSlotScope(sc schema.Scope) bool {
217224
return true
218225
}
219226

227+
// isBroadScopeMatcher reports whether sc's matcher is the `.+`
228+
// catch-all, regardless of repeat bounds. Slot matchers are a
229+
// subset. The yield helper uses this to keep a broad repeated
230+
// matcher from absorbing a heading that a later named scope
231+
// would otherwise claim.
232+
func isBroadScopeMatcher(sc schema.Scope) bool {
233+
return sc.Matcher != nil && sc.Matcher.Regex == ".+"
234+
}
235+
236+
// laterScopeClaimsHead reports whether any scope at index >=
237+
// startIdx (in the same parent window) matches dh AND is more
238+
// specific than a broad matcher. Mirrors schema.laterScopeMatches
239+
// but stays local to this package so the import surface is small.
240+
func laterScopeClaimsHead(
241+
scopes []schema.Scope, startIdx int, dh schema.DocHeading,
242+
docFM map[string]any,
243+
) bool {
244+
for i := startIdx; i < len(scopes); i++ {
245+
sc := scopes[i]
246+
if sc.Preamble || isSlotScope(sc) || isBroadScopeMatcher(sc) {
247+
continue
248+
}
249+
if schema.MatchesHeading(sc, dh, docFM) {
250+
return true
251+
}
252+
}
253+
return false
254+
}
255+
220256
// runScopeRules executes each rule named in sc.Rules and returns
221257
// diagnostics that fall within the scope's line range. Each rule is
222258
// cloned with its DefaultSettings and then has the scope's override

internal/schema/acronyms.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ func walkRanges(
132132
visit func(sc Scope, start, end int),
133133
) {
134134
claimed := make(map[int]bool, len(heads))
135-
for _, sc := range scopes {
135+
for i, sc := range scopes {
136136
if sc.Preamble || isSlotMatcher(sc.Matcher) {
137137
continue
138138
}
@@ -142,6 +142,12 @@ func walkRanges(
142142
if idx < 0 {
143143
break
144144
}
145+
// Yield broad matchers to later named scopes so a `.+`
146+
// repeat does not consume a heading the user named.
147+
if isBroadMatcher(sc.Matcher) &&
148+
laterScopeMatches(scopes, i+1, heads[idx], docFM) {
149+
break
150+
}
145151
claimed[idx] = true
146152
start := heads[idx].Line
147153
end := nextSectionLine(heads, idx, heads[idx].Level, parentEnd)

internal/schema/matcher.go

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -210,11 +210,25 @@ func compileMatcher(m *Matcher, fm map[string]any) (*compiledMatcher, error) {
210210
return &compiledMatcher{re: re, digitsIdx: digitsIdx}, nil
211211
}
212212

213+
// matcherCacheCap bounds the per-process compiled-matcher cache.
214+
// A long-running LSP session edits front matter often, and each
215+
// distinct `fmvar(...)` value produces a fresh cache key — without
216+
// a bound the cache grows unboundedly. 1024 entries covers a busy
217+
// workspace while keeping the working-set predictable; once full,
218+
// the cache resets so old entries do not pin compiled regexps for
219+
// the lifetime of the process.
220+
const matcherCacheCap = 1024
221+
213222
// matcherCache memoises compiled matchers keyed on the raw pattern
214223
// plus a serialised frontmatter fingerprint. Hot loops (one
215224
// validator pass walks every heading × every scope) re-use the
216-
// compiled regexp instead of recompiling per heading.
217-
var matcherCache sync.Map
225+
// compiled regexp instead of recompiling per heading. The cache
226+
// is sized-bounded by matcherCacheCap; see matcherCacheReset.
227+
var (
228+
matcherCacheMu sync.Mutex
229+
matcherCache map[matcherCacheKey]*compiledMatcher = make(map[matcherCacheKey]*compiledMatcher, matcherCacheCap)
230+
matcherCacheLen int
231+
)
218232

219233
type matcherCacheKey struct {
220234
regex string
@@ -224,19 +238,36 @@ type matcherCacheKey struct {
224238
// cachedMatcher returns the compiled matcher for m under fm,
225239
// compiling on cache miss. nil + err on a malformed pattern; the
226240
// caller decides whether to surface that as a diagnostic.
241+
//
242+
// When the cache reaches matcherCacheCap, the entire map is
243+
// dropped before inserting the new entry. A simple reset beats
244+
// per-entry LRU bookkeeping for workloads where hot patterns are
245+
// re-encountered on the next validator pass anyway.
227246
func cachedMatcher(m *Matcher, fm map[string]any) (*compiledMatcher, error) {
228247
if m == nil {
229248
return nil, fmt.Errorf("nil matcher")
230249
}
231250
key := matcherCacheKey{regex: m.Regex, fmKey: fmFingerprint(m.Regex, fm)}
232-
if v, ok := matcherCache.Load(key); ok {
233-
return v.(*compiledMatcher), nil
251+
matcherCacheMu.Lock()
252+
if v, ok := matcherCache[key]; ok {
253+
matcherCacheMu.Unlock()
254+
return v, nil
234255
}
256+
matcherCacheMu.Unlock()
235257
cm, err := compileMatcher(m, fm)
236258
if err != nil {
237259
return nil, err
238260
}
239-
matcherCache.Store(key, cm)
261+
matcherCacheMu.Lock()
262+
if matcherCacheLen >= matcherCacheCap {
263+
matcherCache = make(map[matcherCacheKey]*compiledMatcher, matcherCacheCap)
264+
matcherCacheLen = 0
265+
}
266+
if _, exists := matcherCache[key]; !exists {
267+
matcherCacheLen++
268+
}
269+
matcherCache[key] = cm
270+
matcherCacheMu.Unlock()
240271
return cm, nil
241272
}
242273

internal/schema/parse_inline.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,15 @@ func isSlotMatcher(m *Matcher) bool {
326326
return true
327327
}
328328

329+
// isBroadMatcher reports whether m's regex matches "anything" —
330+
// the `.+` body, regardless of repeat bounds. Used by the
331+
// yield-to-later helpers so a broad scope never claims a heading
332+
// that a more-specific later scope would have matched. Slot
333+
// matchers are a subset of broad matchers.
334+
func isBroadMatcher(m *Matcher) bool {
335+
return m != nil && m.Regex == ".+"
336+
}
337+
329338
// rejectKeys errors if any forbidden key is present in m. The
330339
// shape label and key list go into the error so the user sees
331340
// which field is incompatible and why. Forbidden keys are checked

internal/schema/plan156_acceptance_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,96 @@ func TestPlan156_RejectsMultipleDigitsInline(t *testing.T) {
340340
assert.Contains(t, err.Error(), "once")
341341
}
342342

343+
// TestPlan156_OptionalSpecificClaimsOwnSlot regresses a Copilot
344+
// finding: a heading that matches an earlier optional specific
345+
// matcher must claim that scope, not yield to a later broad
346+
// (`.+`) matcher. The previous fix made claimsLaterLiteral
347+
// include optional scopes — necessary for the wildcard-slot
348+
// case — but the yield should only fire when the later
349+
// matcher is *narrower* than the current one (i.e. not a
350+
// broad `.+` regex).
351+
func TestPlan156_OptionalSpecificClaimsOwnSlot(t *testing.T) {
352+
raw := map[string]any{
353+
"sections": []any{
354+
map[string]any{
355+
"heading": map[string]any{
356+
"regex": "Overview",
357+
"repeat": map[string]any{"min": 0, "max": 1},
358+
},
359+
"content": []any{
360+
map[string]any{"kind": "code-block", "lang": "yaml"},
361+
},
362+
},
363+
map[string]any{
364+
"heading": map[string]any{
365+
"regex": ".+",
366+
"repeat": map[string]any{"min": 1},
367+
},
368+
},
369+
},
370+
}
371+
sch, err := ParseInline(raw, "kind x")
372+
require.NoError(t, err)
373+
// Overview is present but missing its required code block.
374+
// If the broad `.+` matcher claims the heading first, the
375+
// content check is silenced. We want the missing-content
376+
// diagnostic to fire.
377+
doc := newDocFile(t, "doc.md",
378+
"# T\n\n## Overview\n\nNo code block here.\n\n## Body\n\nx\n")
379+
diags := Validate(doc, sch, nil, false, makeDiagForTest)
380+
var contentMissing bool
381+
for _, d := range diags {
382+
if strings.Contains(d.Message, "missing required content") &&
383+
strings.Contains(d.Message, "Overview") {
384+
contentMissing = true
385+
}
386+
}
387+
assert.True(t, contentMissing,
388+
"the optional specific scope must claim its own heading; "+
389+
"a broad `.+` matcher must not absorb it")
390+
}
391+
392+
// TestPlan156_BroadRepeatYieldsToLaterScopeInPerScopeWalkers
393+
// regresses a Copilot finding: the per-scope walkers (rules,
394+
// content, acronyms) must yield to later named scopes the same
395+
// way the structural validator does, otherwise a broad repeated
396+
// matcher silently consumes headings the user named separately.
397+
func TestPlan156_BroadRepeatYieldsToLaterScopeInPerScopeWalkers(t *testing.T) {
398+
raw := map[string]any{
399+
"sections": []any{
400+
map[string]any{
401+
"heading": map[string]any{
402+
"regex": ".+",
403+
"repeat": map[string]any{"min": 1},
404+
},
405+
},
406+
map[string]any{
407+
"heading": "Diagnosis",
408+
"content": []any{
409+
map[string]any{"kind": "code-block", "lang": "yaml"},
410+
},
411+
},
412+
},
413+
}
414+
sch, err := ParseInline(raw, "kind x")
415+
require.NoError(t, err)
416+
// Doc has Body + Diagnosis. The broad matcher must yield
417+
// "Diagnosis" so the content check fires there.
418+
doc := newDocFile(t, "doc.md",
419+
"# T\n\n## Body\n\nx\n\n## Diagnosis\n\nNo code block.\n")
420+
diags := Validate(doc, sch, nil, false, makeDiagForTest)
421+
var missing bool
422+
for _, d := range diags {
423+
if strings.Contains(d.Message, "missing required content") &&
424+
strings.Contains(d.Message, "Diagnosis") {
425+
missing = true
426+
}
427+
}
428+
assert.True(t, missing,
429+
"named Diagnosis scope must claim its heading; "+
430+
"a broad repeated matcher must not absorb it")
431+
}
432+
343433
// TestPlan156_RejectsMultipleNTokensProto regresses the same
344434
// constraint for proto.md heading rows. `## Step {n} of {n}`
345435
// would expand to two `\#(digits)` helpers; the file parser

internal/schema/plan156_coverage_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package schema
22

33
import (
4+
"strconv"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
@@ -283,6 +284,46 @@ func TestScopeMatchesHeading_NilScope(t *testing.T) {
283284
assert.False(t, scopeMatchesHeading(Scope{}, DocHeading{Text: "x"}, nil))
284285
}
285286

287+
// TestMatcherCache_BoundsGrowth regresses a Copilot review
288+
// concern: the global matcher cache must not grow without bound
289+
// when an LSP session emits a stream of unique `fmvar(...)`
290+
// values. After matcherCacheCap inserts the cache resets so
291+
// memory stays predictable.
292+
func TestMatcherCache_BoundsGrowth(t *testing.T) {
293+
matcherCacheMu.Lock()
294+
matcherCache = make(map[matcherCacheKey]*compiledMatcher, matcherCacheCap)
295+
matcherCacheLen = 0
296+
matcherCacheMu.Unlock()
297+
298+
m := &Matcher{Regex: `\#(fmvar(id))`}
299+
// Fill past the cap with distinct fmvar values so each entry
300+
// is a fresh cache key.
301+
for i := 0; i < matcherCacheCap+5; i++ {
302+
_, err := cachedMatcher(m, map[string]any{
303+
"id": "id-" + strconv.Itoa(i),
304+
})
305+
require.NoError(t, err)
306+
}
307+
matcherCacheMu.Lock()
308+
size := matcherCacheLen
309+
matcherCacheMu.Unlock()
310+
assert.LessOrEqual(t, size, matcherCacheCap,
311+
"matcher cache must stay within the configured cap")
312+
}
313+
314+
// TestIsBroadMatcher covers the helper's three branches.
315+
func TestIsBroadMatcher(t *testing.T) {
316+
assert.False(t, isBroadMatcher(nil))
317+
assert.False(t, isBroadMatcher(&Matcher{Regex: "Specific"}))
318+
assert.True(t, isBroadMatcher(&Matcher{Regex: ".+"}))
319+
// A bounded-max `.+` is still broad; the helper is
320+
// intentionally repeat-agnostic.
321+
assert.True(t, isBroadMatcher(&Matcher{
322+
Regex: ".+",
323+
Repeat: Repeat{Set: true, Min: 1, Max: 2},
324+
}))
325+
}
326+
286327
// TestScope_Required covers the three branches of Scope.Required.
287328
func TestScope_Required(t *testing.T) {
288329
// Preamble is never required.

internal/schema/validate.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -494,20 +494,46 @@ func claimRun(
494494
return docIdx, diags
495495
}
496496

497+
// laterScopeMatches reports whether dh matches any later scope in
498+
// the same parent window that is more specific than a broad
499+
// matcher. Used by the per-scope walkers (rules, content,
500+
// acronyms) so a broad repeated matcher does not consume a
501+
// heading that a later named scope would claim. The walker
502+
// version does not need a scope-index `claimed` map because each
503+
// walker iterates scopes in declared order and acts immediately.
504+
func laterScopeMatches(
505+
scopes []Scope, startIdx int, dh DocHeading,
506+
docFM map[string]any,
507+
) bool {
508+
for i := startIdx; i < len(scopes); i++ {
509+
sc := scopes[i]
510+
if sc.Preamble ||
511+
isSlotMatcher(sc.Matcher) || isBroadMatcher(sc.Matcher) {
512+
continue
513+
}
514+
if scopeMatchesHeading(sc, dh, docFM) {
515+
return true
516+
}
517+
}
518+
return false
519+
}
520+
497521
// claimsLaterLiteral reports whether dh matches any unclaimed
498-
// non-slot, non-preamble scope at index >= startIdx — meaning the
499-
// heading should not be absorbed by a slot or a repeat-run at the
500-
// current position. Optional scopes participate: a `regex: 'A'`
501-
// scope with `repeat: { min: 0, max: 1 }` still wants the heading
502-
// to claim it (not the greedy predecessor) so its own content,
503-
// rules, and placement apply when present.
522+
// later scope that is more specific than a broad/slot matcher.
523+
// Used so a slot or repeat-run does not absorb a heading the
524+
// user named separately further down the list. Optional scopes
525+
// participate (a `regex: 'A'` with `repeat: { min: 0, max: 1 }`
526+
// still wants the heading), but later scopes with a broad `.+`
527+
// regex are skipped — yielding to them would let a generic
528+
// catch-all steal a heading from a specific predecessor.
504529
func claimsLaterLiteral(
505530
scopes []Scope, startIdx int, dh DocHeading,
506531
claimed map[int]bool, docFM map[string]any,
507532
) bool {
508533
for i := startIdx; i < len(scopes); i++ {
509534
sc := scopes[i]
510-
if claimed[i] || sc.Preamble || isSlotMatcher(sc.Matcher) {
535+
if claimed[i] || sc.Preamble ||
536+
isSlotMatcher(sc.Matcher) || isBroadMatcher(sc.Matcher) {
511537
continue
512538
}
513539
if scopeMatchesHeading(sc, dh, docFM) {

internal/schema/validate_content.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ func walkContentScopes(
223223
claimed map[int]bool, blocks []contentBlock,
224224
docFM map[string]any, mkDiag MakeDiag, diags *[]lint.Diagnostic,
225225
) {
226-
for _, sc := range scopes {
226+
for i, sc := range scopes {
227227
if isSlotMatcher(sc.Matcher) {
228228
continue
229229
}
@@ -248,8 +248,14 @@ func walkContentScopes(
248248
if matched < 0 {
249249
break
250250
}
251-
claimed[matched] = true
252251
dh := heads[matched]
252+
// Yield broad matchers to later named scopes so a `.+`
253+
// repeat does not absorb a heading the user named.
254+
if isBroadMatcher(sc.Matcher) &&
255+
laterScopeMatches(scopes, i+1, dh, docFM) {
256+
break
257+
}
258+
claimed[matched] = true
253259
end := contentScopeEndLine(heads, matched, dh.Level, parentEnd)
254260
runContent(f, sc, dh.Line, dh.Level, dh.Line+1, end, blocks, mkDiag, diags)
255261
if len(sc.Sections) > 0 {

0 commit comments

Comments
 (0)