@@ -146,6 +146,7 @@ func validateScopes(
146146) (int , []lint.Diagnostic ) {
147147 var diags []lint.Diagnostic
148148 claimed := make (map [int ]bool )
149+ claimCounts := make (map [int ]int )
149150 allowExtra := false
150151
151152 for i , sc := range scopes {
@@ -166,7 +167,7 @@ func validateScopes(
166167 }
167168 newIdx , scDiags , claimedThis := matchScope (
168169 f , scopes , i , expectedLevel , docHeads , docIdx ,
169- claimed , allowExtra , closed , docFM , mkDiag )
170+ claimed , claimCounts , allowExtra , closed , docFM , mkDiag )
170171 diags = append (diags , scDiags ... )
171172 docIdx = newIdx
172173 if claimedThis {
@@ -179,7 +180,7 @@ func validateScopes(
179180 }
180181
181182 newIdx , leftoverDiags := handleLeftoverHeadings (
182- f , scopes , claimed , docHeads , docIdx , expectedLevel ,
183+ f , scopes , claimed , claimCounts , docHeads , docIdx , expectedLevel ,
183184 closed , allowExtra , docFM , mkDiag )
184185 diags = append (diags , leftoverDiags ... )
185186 return newIdx , diags
@@ -194,7 +195,7 @@ func validateScopes(
194195// closed: flagged as unexpected in closed scopes, silently
195196// consumed in open ones.
196197func handleLeftoverHeadings (
197- f * lint.File , scopes []Scope , claimed map [int ]bool ,
198+ f * lint.File , scopes []Scope , claimed map [int ]bool , claimCounts map [ int ] int ,
198199 docHeads []DocHeading , docIdx , expectedLevel int ,
199200 closed , allowExtra bool , docFM map [string ]any , mkDiag MakeDiag ,
200201) (int , []lint.Diagnostic ) {
@@ -210,7 +211,8 @@ func handleLeftoverHeadings(
210211 }
211212 if idx := unclaimedListedScope (scopes , dh , claimed , docFM ); idx >= 0 {
212213 newIdx , claimDiags := claimLateScope (
213- f , scopes , idx , expectedLevel , docHeads , docIdx , claimed , docFM , mkDiag )
214+ f , scopes , idx , expectedLevel , docHeads , docIdx ,
215+ claimed , claimCounts , docFM , mkDiag )
214216 diags = append (diags , claimDiags ... )
215217 docIdx = newIdx
216218 continue
@@ -221,7 +223,7 @@ func handleLeftoverHeadings(
221223 // diagnostic so the user sees which scope was over-filled
222224 // rather than a generic "unexpected".
223225 if idx := claimedScopeExceeded (
224- scopes , dh , claimed , docFM , docHeads , docIdx , expectedLevel ); idx >= 0 {
226+ scopes , dh , claimed , claimCounts , docFM ); idx >= 0 {
225227 sc := scopes [idx ]
226228 diags = append (diags , mkDiag (f .Path , dh .Line ,
227229 fmt .Sprintf (
@@ -241,21 +243,23 @@ func handleLeftoverHeadings(
241243 return docIdx , diags
242244}
243245
244- // claimedScopeExceeded returns the index of the first already-
245- // claimed non-slot, non-preamble scope whose matcher accepts dh
246- // AND whose bounded `max` has been exceeded by the count of
247- // same-level matching headings up to and including dhIdx — or
248- // -1 when no such scope is over-filled.
246+ // claimedScopeExceeded reports whether dh would push an
247+ // already-claimed non-slot, non-preamble scope past its `max`.
248+ // It also increments `claimCounts[i]` for the first claimed
249+ // scope whose matcher accepts dh — so consecutive same-scope
250+ // extras are counted accurately. Returns the matched scope's
251+ // index when the new count exceeds max, or -1 otherwise.
249252//
250- // Counting matches in the parent window (rather than tracking a
251- // per-scope counter through the validator) lets the late/out-of-
252- // order recovery path enforce `repeat.max > 1` without
253- // false-positives at count <= max. Unbounded matchers
254- // (`max == 0`) are excluded — they accept any number of
255- // occurrences.
253+ // Claim counts are tracked via the parallel `claimCounts` map,
254+ // so the check stays accurate even when overlapping matchers
255+ // yield specific headings to later named scopes: only headings
256+ // actually claimed by THIS scope count toward its max.
257+ //
258+ // Unbounded matchers (`max == 0`) are excluded — they accept
259+ // any number of occurrences.
256260func claimedScopeExceeded (
257261 scopes []Scope , dh DocHeading , claimed map [int ]bool ,
258- docFM map [string ] any , docHeads [] DocHeading , dhIdx , expectedLevel int ,
262+ claimCounts map [int ] int , docFM map [ string ] any ,
259263) int {
260264 for i , sc := range scopes {
261265 if ! claimed [i ] {
@@ -267,41 +271,24 @@ func claimedScopeExceeded(
267271 if sc .Matcher == nil {
268272 continue
269273 }
270- _ , max := sc .Matcher .Repeat .Bounds ()
271- if max == 0 {
272- continue
273- }
274274 if ! scopeMatchesHeading (sc , dh , docFM ) {
275275 continue
276276 }
277- count := countSameLevelMatches (sc , docHeads , expectedLevel , dhIdx , docFM )
278- if count > max {
277+ _ , max := sc .Matcher .Repeat .Bounds ()
278+ if max == 0 {
279+ // Unbounded — count it but never flag.
280+ claimCounts [i ]++
281+ return - 1
282+ }
283+ claimCounts [i ]++
284+ if claimCounts [i ] > max {
279285 return i
280286 }
287+ return - 1
281288 }
282289 return - 1
283290}
284291
285- // countSameLevelMatches returns the number of headings in
286- // docHeads[0..upToIdx] (inclusive) at expectedLevel that sc's
287- // matcher accepts. Used to compute the occurrence count for the
288- // max-exceeded check in the trailing-leftover pass.
289- func countSameLevelMatches (
290- sc Scope , docHeads []DocHeading , expectedLevel , upToIdx int ,
291- docFM map [string ]any ,
292- ) int {
293- count := 0
294- for j := 0 ; j <= upToIdx && j < len (docHeads ); j ++ {
295- if docHeads [j ].Level != expectedLevel {
296- continue
297- }
298- if scopeMatchesHeading (sc , docHeads [j ], docFM ) {
299- count ++
300- }
301- }
302- return count
303- }
304-
305292// claimLateScope marks a late-arriving listed scope as claimed,
306293// emits its out-of-order diagnostic, and recurses into the scope's
307294// nested children so missing-required-section diagnostics still
@@ -320,7 +307,8 @@ func countSameLevelMatches(
320307// surface.
321308func claimLateScope (
322309 f * lint.File , scopes []Scope , idx , expectedLevel int ,
323- docHeads []DocHeading , docIdx int , claimed map [int ]bool ,
310+ docHeads []DocHeading , docIdx int ,
311+ claimed map [int ]bool , claimCounts map [int ]int ,
324312 docFM map [string ]any , mkDiag MakeDiag ,
325313) (int , []lint.Diagnostic ) {
326314 sc := scopes [idx ]
@@ -330,6 +318,7 @@ func claimLateScope(
330318 "section %q out of order: expected before this position" ,
331319 formatHeading (dh .Level , dh .Text )))}
332320 claimed [idx ] = true
321+ claimCounts [idx ]++
333322 docIdx ++
334323 if len (sc .Sections ) > 0 {
335324 newIdx , childDiags := validateScopes (
@@ -368,12 +357,14 @@ func unclaimedListedScope(
368357func matchScope (
369358 f * lint.File , scopes []Scope , idx , expectedLevel int ,
370359 docHeads []DocHeading , docIdx int ,
371- claimed map [int ]bool , allowExtra , closed bool ,
360+ claimed map [int ]bool , claimCounts map [int ]int ,
361+ allowExtra , closed bool ,
372362 docFM map [string ]any , mkDiag MakeDiag ,
373363) (int , []lint.Diagnostic , bool ) {
374364 state := matchRun {
375365 f : f , scopes : scopes , idx : idx , expectedLevel : expectedLevel ,
376- claimed : claimed , allowExtra : allowExtra , closed : closed ,
366+ claimed : claimed , claimCounts : claimCounts ,
367+ allowExtra : allowExtra , closed : closed ,
377368 docFM : docFM , mkDiag : mkDiag ,
378369 }
379370 state .min , state .max = scopes [idx ].Matcher .Repeat .Bounds ()
@@ -471,6 +462,7 @@ type matchRun struct {
471462 idx int
472463 expectedLevel int
473464 claimed map [int ]bool
465+ claimCounts map [int ]int
474466 allowExtra bool
475467 closed bool
476468 docFM map [string ]any
@@ -554,6 +546,7 @@ func (s *matchRun) claimMatch(docHeads []DocHeading, docIdx int, captured string
554546 dh := docHeads [docIdx ]
555547 s .diags = append (s .diags , levelDiagIfNeeded (s .f , dh , s .expectedLevel , s .mkDiag )... )
556548 s .claimed [s .idx ] = true
549+ s .claimCounts [s .idx ]++
557550 if len (sc .Sections ) > 0 {
558551 newIdx , childDiags := validateScopes (
559552 s .f , sc .Sections , sc .Closed , docHeads , docIdx + 1 ,
@@ -579,7 +572,7 @@ func (s *matchRun) handleNonMatch(docHeads []DocHeading, docIdx int) (bool, int,
579572 if ooIdx := findOutOfOrderIdx (s .scopes , dh , s .claimed , s .idx + 1 , s .docFM ); ooIdx >= 0 {
580573 newIdx , ooDiags := claimOutOfOrder (
581574 s .f , s .scopes , s .idx , ooIdx , s .expectedLevel , docHeads , docIdx ,
582- s .claimed , s .docFM , s .mkDiag )
575+ s .claimed , s .claimCounts , s . docFM , s .mkDiag )
583576 s .diags = append (s .diags , ooDiags ... )
584577 return false , newIdx , false
585578 }
@@ -590,7 +583,7 @@ func (s *matchRun) handleNonMatch(docHeads []DocHeading, docIdx int) (bool, int,
590583 // doc [B, B, A] silently consumes the second B because
591584 // findOutOfOrderIdx ignores claimed scopes.
592585 if idx := claimedScopeExceeded (
593- s .scopes , dh , s .claimed , s .docFM , docHeads , docIdx , s . expectedLevel ); idx >= 0 {
586+ s .scopes , dh , s .claimed , s .claimCounts , s . docFM ); idx >= 0 {
594587 sc := s .scopes [idx ]
595588 s .diags = append (s .diags , s .mkDiag (s .f .Path , dh .Line ,
596589 fmt .Sprintf (
@@ -723,7 +716,8 @@ func levelDiagIfNeeded(
723716// recurses into the matched scope's child sections.
724717func claimOutOfOrder (
725718 f * lint.File , scopes []Scope , idx , ooIdx , expectedLevel int ,
726- docHeads []DocHeading , docIdx int , claimed map [int ]bool ,
719+ docHeads []DocHeading , docIdx int ,
720+ claimed map [int ]bool , claimCounts map [int ]int ,
727721 docFM map [string ]any , mkDiag MakeDiag ,
728722) (int , []lint.Diagnostic ) {
729723 sc := scopes [idx ]
@@ -763,6 +757,7 @@ func claimOutOfOrder(
763757 formatHeading (expectedLevel , displayHeading (ooSc )))))
764758 }
765759 claimed [ooIdx ] = true
760+ claimCounts [ooIdx ]++
766761 docIdx ++
767762 if len (ooSc .Sections ) > 0 {
768763 newIdx , childDiags := validateScopes (
0 commit comments