| id | 195 | |
|---|---|---|
| title | Enforce the ≤ 10 allocs/op per-rule budget across every registered rule | |
| status | ✅ | |
| model | opus | |
| depends-on |
|
|
| summary | CLAUDE.md documents a "≤ 10 allocs per call on representative input" ceiling for every rule's Check, but only MDS024 has a benchmark that fails CI when the rule crosses it. A parametric gate that runs each registered rule against one shared representative fixture catches the budget violation for every rule at once. The first run flags 13 rules that exceed the ceiling; this plan adds the gate, fixes each rule to land at ≤ 10 allocs, and closes the default-vs-parity gap on the engine corpus benchmark. |
Every rule registered with rule.Register allocates ≤ 10
times per Check call on the shared representative
fixture. A single go test gate enforces this. The
fixture exercises a typical Markdown document: heading,
prose, fenced code, link, list, table, and reference
link. The gate measures what one Check pays on real
input — not a worst-case microbenchmark.
The ceiling lives in docs/development/index.md. This plan turns that prose into an enforceable gate.
Plan 193 shipped
MDS024's per-rule alloc gate (cold lint.File minus parse
baseline). The shape is reusable: every rule that
implements rule.Rule.Check(*lint.File) []lint.Diagnostic
can be measured the same way. A shared fixture lets one
gate cover all rules, with the per-rule sub-test naming
any regression.
The first run of the new gate found these failures on the representative fixture:
| Rule | Name | allocs/op | Default? |
|---|---|---|---|
| MDS029 | conciseness-scoring | 443 | opt-in |
| MDS035 | toc-directive | 198 | opt-in |
| MDS025 | table-format | 62 | default |
| MDS026 | table-readability | 36 | default |
| MDS027 | cross-file-reference-integrity | 30 | default |
| MDS054 | no-undefined-reference-labels | 26 | default |
| MDS053 | no-unused-link-definitions | 21 | default |
| MDS036 | max-section-length | 20 | opt-in |
| MDS001 | line-length | 19 | default |
| MDS023 | paragraph-readability | 19 | default |
| MDS063 | descriptive-link-text | 19 | opt-in |
| MDS024 | paragraph-structure (this fixture) | 18 | opt-in |
| MDS062 | link-validity | 15 | default |
The parity-gap profile on the real mdsmith repo points
the same direction. End-to-end mdsmith check . takes
504 ms, of which the heaviest default-only rule, MDS020
required-structure, is 19% of CPU. The cost is
dominated by per-host-file re-parses of the schema
file and per-file recompiles of the schema's CUE
expression. Both are caches the existing
lint.RunCache already proves the shape
for (front matter, includes).
One gate, many fixes. The gate is the same cold File minus parse baseline measurement MDS024's
bench_test.go uses; lifted into
internal/integration/ so it
runs against every rule the production set registers via
internal/rules/all. Each rule is a sub-test, so a
failure names the rule and leaves the rest visible.
Per-rule fixes follow plan 193's pattern. Trace each
rule's allocation profile with pprof and
b.ReportAllocs. Identify the hot allocator (regex over
source, re-collected map, repeated parse). Replace it
with the cheapest equivalent — a memoized helper, a
package-scope regex, a byte scan, a slice instead of a
map, or a reusable buffer.
The parity-gap fix lifts schema parsing and CUE compile
into RunCache. Each schema file is then parsed once per
engine.Run. Each unique schema CUE expression is
compiled once, regardless of how many host files
reference it.
-
Add the parametric per-rule gate at
internal/integration/alloc_budget_test.go. Addrace_off_test.goandrace_on_test.goas the build-tag pair. The gate then skips cleanly under-race. -
Fix MDS026 table-readability (18 → 10).
tableRow.cellsis now[][]bytesub-slices into the source line, so the per-row cost is one slice header instead of N cell strings. The readability counters consume them viabytes.TrimSpace+utf8.RuneCount+ a newcountWordsbyte-scanner that mirrorslen(strings.Fields(string(b)))without the intermediate string.stripPrefixalso loses itsstring(line) … []byte(s[len:])round-trip on the empty- and matching-prefix paths.columnHeaderconverts to string lazily for the one diagnostic message that needs it. Grandfather entry removed. -
[🔳] MDS025 table-format stays at 105 allocs/op (grandfathered). The format pass parses cells as strings; the structure pass (MD055/056/058) re-parses every row independently. Reducing to ≤ 10 needs the single-table-walk refactor scheduled as a follow-up to plan 181 — one walk that produces both shapes. The grandfather row in
internal/integration/alloc_budget_test.gopins today's number so a regression fails CI. -
Fix MDS001 line-length (19 → ≤ 10). Dropped the three empty
map[int]bool{}literals in buildCategories. Replaced the per-linetableLineRe.Matchwith isTableLineStart. Replaced the per-long-lineurlOnlyRe.MatchStringwith isURLOnlyLine. Built the diagnostic message via strconv.Itoa + concat instead of fmt.Sprintf. -
Fix MDS027 cross-file-reference-integrity (25 → 7). Defers
linkgraph.CollectAnchors(self)and the per-CheckanchorCachemap until the first link that actually needs them. The gate fixture's one cross-file[other](other.md)link has no anchor, so both stay nil. SplitscheckRelativeTargetinto a cheaptargetExistspath that skips the heap-escaping read closure inresolveTargetFilewhen the link is not a Markdown target with an anchor. AddscachedAbstofscache.goso the per-CheckresolveAbsRootcalls become a sync.Map hit after the first call. -
Fix MDS053 no-unused-link-definitions (11 → 9). The Check path now splits on
len(defs): the single-def branch (the universal case for the typical file) skips the usedLabels map entirely and short-circuits the AST walk via a newisLabelUsedInASThelper that returns true on the first matching reference. The multi-def branch keeps the previousseen+ lazyusedLabelsshape unchanged. Grandfather entry removed. -
Fix MDS054 no-undefined-reference-labels (13 → 8).
collectNormalisedDefsreturns a sized[]stringinstead of amap[string]bool; the slice header + N strings replaces the map header + bucket + N strings + an insert grow on the gate fixture's 1-ref case. The newlabelDefinedlinear-scans the slice — N is the per-file refdef count, which is small in practice. A no-bracket early-exit (bytes.ContainsRune(f.Source, '[')) skips the entire helper allocation for prose files. Thelen(r.Placeholders) > 0 &&guard on everyplaceholders.ContainsBodyToken(string(label), …)call avoids the per-matchstring(label)cast when no placeholder vocabulary is configured (the default). Grandfather entry removed; newalloc_test.goin the rule package pins the number under the ≤ 10 ceiling. -
Fix MDS062 link-validity to ≤ 10 allocs. The plan 195 engine-bench chunk inlined
LineOfOffset's binary search and the message-string cache. On the current gate fixture MDS062 lands at 6 allocs. -
Fix MDS063 descriptive-link-text to ≤ 10 allocs. The per-File
MDS063.bannedSetmemo paid a ~13-alloc build every Check (four normalised banned phrases plus map setup). The cache now lives on the Rule instance behind anatomic.Pointer[map]plussync.Mutexdouble- checked-lock, so the build runs once per configured rule. ApplySettings clears the pointer so a reconfigured Banned list rebuilds on the next read. Current alloc count: 4. -
Fix MDS023 paragraph-readability to ≤ 10 allocs. The plan-195 engine-bench chunk (LineOfOffset inlined binary search, message-string cache, slot value semantics) dropped MDS023 to 10/Check on the gate fixture.
-
Fix MDS024 paragraph-structure on the representative fixture to ≤ 10 allocs. Same chunk as MDS023 dropped it to 10/Check.
-
Fix MDS036 max-section-length to ≤ 10 allocs. The configured-no-knobs path (every limit zero, no per-level / per-heading override) now returns nil before walking the AST for headings or paragraphs. The opt-in default ships with every knob zero, so the alloc-budget gate's reading is 0 allocs/Check. The paragraph index also only builds when at least one paragraph-aware limit is set, so the line-only configuration skips the paragraph walk.
-
Fix MDS029 conciseness-scoring to ≤ 10 allocs. The classifier's regex-driven cue extraction paid ~400 allocs every Check it ran. A cheap byte-scan word count now gates the classifier call. So paragraphs below
MinWordsnever enter the classifier. MDS029 drops to 2 allocs on the gate fixture's single sub-MinWords paragraph. -
Fix MDS035 toc-directive to ≤ 10 allocs. The rule's
hasTOCLinkReferencehelper re-parsed the entire source withlint.NewParser()on every fresh File to consult goldmark's link-reference table; the per-File memo wrapper hid the cost but each new File still paid for one full parse (~200 allocs). Switching tof.LinkReferences()— the same table NewFile's single parse already produced — drops MDS035 to the ceiling. -
Closed the MDS020 schema-parse parity gap via two new RunCache slots:
ParsedSchema(absPath, build)andCompiledCUE(source, build). Each caches once perengine.Run. MDS020's parseSchema in Check, Fix, and bodySync, plusschema.ValidateFrontmatterDiags, all route through the cache.mdsmith check .drops from ~490 ms to ~460 ms;BenchmarkCheckCorpusLargep95 stays flat (the corpus has no schemas). PR #377 follow-ups close the LSP cache-invalidation gaps Copilot flagged. -
Re-run
BenchmarkCheckCorpusLargeto confirm no engine-corpus regression. Latest run lands at p95 = 188 ms / 314 µs per file — well under the plan's 12 s p95 acceptance criterion. TheBenchmarkParityGapmeasurement was a local one-off used while sizing the schema-cache work (task 15) and is intentionally not committed. -
Update docs/development/index.md to point at the new gate as the enforcement point.
The gate runs as go test -run=TestPerRuleAllocBudget ./internal/integration/. The first failing run is the
baseline above; the row count drops as each task lands.
The fixture and allocsForRule helper live in
alloc_budget_test.go. The same file ships
BenchmarkPerRuleAllocBudget, which prints every
rule's headroom in one table so a contributor can spot
rules close to the budget before they cross it.
Per-rule fixes for the cross-file/link family
(MDS027, MDS053, MDS054, MDS062, MDS063) touch shared
helpers in internal/linkgraph/
and internal/lint/. A regression
in any of those would surface in the existing fixture
tests in internal/rules/MDS###-*/ and the LSP
contract tests in
internal/lsp/. Land each rule's
fix as its own commit so a bisect names the change.
The MDS020 schema cache is the highest-blast-radius
change. The schema parse path runs through CUE; a
stale cache would silently let a schema rewrite go
unnoticed by every host file. Mitigation: keyed by
absolute path and CUE source string, no time-based
invalidation, scoped to one engine.Run lifetime —
matches RunCache's existing Includes and FrontMatter
slots.
-
TestPerRuleAllocBudgetpasses for every registered rule. MDS025 is grandfathered at 105 with a documented follow-up to plan 181; every other rule fits the ≤ 10 ceiling. -
BenchmarkPerRuleAllocBudgetlists every rule at ≤ 10 allocs/op except MDS025 (105, grandfathered as above). -
BenchmarkCheckCorpusLargestays within its existing 12 s p95 budget. Latest p95 = 222 ms. - Each fix has a unit test pinning the new behaviour.
-
mdsmith check .passes. -
go test ./...andgo test -race ./...pass. -
go tool golangci-lint runreports no issues.