@@ -220,12 +220,12 @@ func handleLeftoverHeadings(
220220 // path absorbed. Surface it specifically as a max-exceeded
221221 // diagnostic so the user sees which scope was over-filled
222222 // rather than a generic "unexpected".
223- if idx := claimedScopeMatches (scopes , dh , claimed , docFM ); idx >= 0 {
223+ if idx := claimedScopeExceeded (
224+ scopes , dh , claimed , docFM , docHeads , docIdx , expectedLevel ); idx >= 0 {
224225 sc := scopes [idx ]
225226 diags = append (diags , mkDiag (f .Path , dh .Line ,
226227 fmt .Sprintf (
227- "section %q exceeds scope %q's allowed occurrences " +
228- "(out-of-order recovery only counts one match)" ,
228+ "section %q exceeds scope %q's allowed occurrences" ,
229229 formatHeading (dh .Level , dh .Text ),
230230 formatHeading (expectedLevel , displayHeading (sc )))))
231231 docIdx ++
@@ -241,23 +241,21 @@ func handleLeftoverHeadings(
241241 return docIdx , diags
242242}
243243
244- // claimedScopeMatches returns the index of the first already-claimed
245- // non-slot, non-preamble scope with `max == 1` that accepts dh, or
246- // -1 when no such scope is a candidate. Used by
247- // handleLeftoverHeadings to distinguish "max exceeded" from
248- // generic "unexpected" .
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 .
249249//
250- // Only `max == 1` scopes are flagged because the recovery state
251- // is a boolean claim and doesn't track how many occurrences were
252- // already counted. For `max > 1` we can't tell whether the new
253- // heading is the second of three allowed or a true excess
254- // without re-running the run-matcher — false positives there
255- // would be worse than the loss of specificity, so those cases
256- // fall through to the generic unexpected handling. Unbounded
257- // matchers (`max == 0`) are likewise excluded — they accept any
258- // number of occurrences.
259- func claimedScopeMatches (
260- scopes []Scope , dh DocHeading , claimed map [int ]bool , docFM map [string ]any ,
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.
256+ func claimedScopeExceeded (
257+ scopes []Scope , dh DocHeading , claimed map [int ]bool ,
258+ docFM map [string ]any , docHeads []DocHeading , dhIdx , expectedLevel int ,
261259) int {
262260 for i , sc := range scopes {
263261 if ! claimed [i ] {
@@ -270,16 +268,40 @@ func claimedScopeMatches(
270268 continue
271269 }
272270 _ , max := sc .Matcher .Repeat .Bounds ()
273- if max != 1 {
271+ if max == 0 {
274272 continue
275273 }
276- if scopeMatchesHeading (sc , dh , docFM ) {
274+ if ! scopeMatchesHeading (sc , dh , docFM ) {
275+ continue
276+ }
277+ count := countSameLevelMatches (sc , docHeads , expectedLevel , dhIdx , docFM )
278+ if count > max {
277279 return i
278280 }
279281 }
280282 return - 1
281283}
282284
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+
283305// claimLateScope marks a late-arriving listed scope as claimed,
284306// emits its out-of-order diagnostic, and recurses into the scope's
285307// nested children so missing-required-section diagnostics still
@@ -567,12 +589,12 @@ func (s *matchRun) handleNonMatch(docHeads []DocHeading, docIdx int) (bool, int,
567589 // was over-filled. Without this, a sequence like [A, B] with
568590 // doc [B, B, A] silently consumes the second B because
569591 // findOutOfOrderIdx ignores claimed scopes.
570- if idx := claimedScopeMatches (s .scopes , dh , s .claimed , s .docFM ); idx >= 0 {
592+ if idx := claimedScopeExceeded (
593+ s .scopes , dh , s .claimed , s .docFM , docHeads , docIdx , s .expectedLevel ); idx >= 0 {
571594 sc := s .scopes [idx ]
572595 s .diags = append (s .diags , s .mkDiag (s .f .Path , dh .Line ,
573596 fmt .Sprintf (
574- "section %q exceeds scope %q's allowed occurrences " +
575- "(out-of-order recovery only counts one match)" ,
597+ "section %q exceeds scope %q's allowed occurrences" ,
576598 formatHeading (dh .Level , dh .Text ),
577599 formatHeading (s .expectedLevel , displayHeading (sc )))))
578600 return false , docIdx + 1 , false
0 commit comments