test(structure): per-package eager-closure budgets — the ADR-0019 loading-shape probe (#1739) - #1965
test(structure): per-package eager-closure budgets — the ADR-0019 loading-shape probe (#1739)#1965thymikee wants to merge 3 commits into
Conversation
Size Report
npm unpacked components
Startup median (7 runs, lower is better):
Top changed chunks: no changes in the largest emitted chunks. Top changed packed filesNo changed packed files. |
|
Not ready at
Please derive façade roots from manifest ownership, add predecessor-chain diagnostics, and reseed post-#1969 with minimal headroom. |
… reseed tight Review findings on #1965: 1. Discovery scanned only `packages/*/src/facades/*.ts`, which omits every package that publishes its entry surface straight from the manifest — including all six `packages/platform-*/src/index.ts` façades, the exact subject of ADR-0019's implementation-laziness rule. Discovery now derives from `readWorkspacePackages(...).exportTargets` and then adds `/src/facades/` files, reusing the R11 helper rather than reimplementing it so the two gates cannot disagree about what an entry surface is. The table grows from 14 façades + 2 hubs to 95 entry surfaces + 8 hubs. 2. Budgets carried a few files of slack each. They are now exact ratchets with no headroom, matching how the repo pins R9/R10 and test-file size: growth is allowed, it just has to be a visible number change in the diff of the PR that causes it. Every budget is reseeded from post-#1969 measurement. 3. Violations printed a flat sorted set, which named the offender but not the route. `eagerClosureGraphOf` records each file's discoverer, so failures now print the transitive chain entry -> ... -> offender. `eagerClosureOf` keeps its contract and is expressed in terms of the new walk; per-file edges are memoized, which also cuts the existing pins' runtime (cli closure test 2336ms -> ~550ms). The platform-package façades evaluate exactly one module each — themselves — so their budget of 1 is the tightest statement of "metadata-eager, implementation-lazy" the walker can make.
0352dba to
55d4846
Compare
|
All three addressed in 1. Discovery now derives from manifest ownershipYou were right that this was a correctness gap and not just a wording problem. The omission was worse than "incomplete": all six What they measure is the payoff — each platform façade evaluates exactly one module: itself. Metadata inline, contract imports all Table grew 14 façades + 2 hubs → 95 entry surfaces + 8 hubs. 2. Budgets reseeded post-#1969, with zero headroomReseeded from measurement on Your point about slack not being neutral is the reason: the regression class is a single static import dragging a subtree in, #1969 measured it at 5–12% of suite import work per instance, and "a few files" of spare room silently absorbs the small end of exactly that. Growth is still fine — it just has to show up as a number change in the diff of the PR causing it.
I also added the five hubs #1969 moved off the wide façades ( 3. Edge chains, not offender setsAdded Breadth-first, so each chain is the shortest route to that file. I also memoized per-file edges while I was in there, since ~100 entries share most of their subtrees. Side benefit: Planted red, re-runReplaced the lazy The third is the composition root picking up the blast radius through its own chain — the cascade a flat offender set wouldn't have explained, which is a fair argument for your finding 3. Sabotage reverted; diff is three files. One thing I did not doLeft CI green (27/27). |
|
Not ready at |
|
The primary static-import planted red is credible and CI is green, but the generalized gate still has four correctness holes:
Please add planted-red/non-vacuity tests for these failure modes and correct the PR text that currently claims exactness and shared R11 discovery. |
ADR-0019 requires platform-package façades to stay implementation-lazy and is explicit that a startup threshold alone is not a substitute for preserving the loading shape. #1950 built the AST-level walker (eager-import-closure.fixtures.ts) and proved the planted-red procedure on one file (session-teardown.ts's android-helper denylist); this generalizes it into a data-driven budget table so any workspace-package façade -- or a designated hub module -- can get an eager-closure ceiling without a bespoke test. Seeds a budget for every packages/*/src/facades/*.ts file (discovered the same way package-boundaries.test.ts discovers façades, not hand-listed) from its measured current closure size, plus a platform-implementation denylist for façades whose contract is implementation-neutral vocabulary. Also demonstrates the mechanism on two designated hub modules (cli.ts, session-teardown.ts) alongside their existing, more specific ad hoc pins. Closes #1960
… reseed tight Review findings on #1965: 1. Discovery scanned only `packages/*/src/facades/*.ts`, which omits every package that publishes its entry surface straight from the manifest — including all six `packages/platform-*/src/index.ts` façades, the exact subject of ADR-0019's implementation-laziness rule. Discovery now derives from `readWorkspacePackages(...).exportTargets` and then adds `/src/facades/` files, reusing the R11 helper rather than reimplementing it so the two gates cannot disagree about what an entry surface is. The table grows from 14 façades + 2 hubs to 95 entry surfaces + 8 hubs. 2. Budgets carried a few files of slack each. They are now exact ratchets with no headroom, matching how the repo pins R9/R10 and test-file size: growth is allowed, it just has to be a visible number change in the diff of the PR that causes it. Every budget is reseeded from post-#1969 measurement. 3. Violations printed a flat sorted set, which named the offender but not the route. `eagerClosureGraphOf` records each file's discoverer, so failures now print the transitive chain entry -> ... -> offender. `eagerClosureOf` keeps its contract and is expressed in terms of the new walk; per-file edges are memoized, which also cuts the existing pins' runtime (cli closure test 2336ms -> ~550ms). The platform-package façades evaluate exactly one module each — themselves — so their budget of 1 is the tightest statement of "metadata-eager, implementation-lazy" the walker can make.
…le-owner Second review pass on #1965 found four holes, two of which were places the PR text claimed a property the code did not have. 1. Rows were documented as exact ratchets but asserted with `<=`, so a shrink silently became headroom a later regression could grow back into. The comparison is now equality, in a pure `classifyBudget` with a separate message for each direction ("lower its pin to N in this PR so the ratchet keeps the gain"), matching test-file-size-ratchet.ts and the R9/R10 pins. 2. An over-pin failure printed a chain per evaluated module — 361 of them for src/cli.ts. It now prints a bounded attribution: the entry's heaviest direct edges (capped at 4) with a couple of representative deep routes each, ranked so a newly added import sorts first. The comment states plainly that this attributes by shortest import route and does NOT diff against a recorded baseline; naming a true delta would mean checking in ~1,500 module paths and rewriting them on every contracts refactor. 3. Discovery reimplemented a one-level `src/facades` scan while canonical R11 discovery is recursive, so a nested façade file could be covered by R11 and silently missing here. `facadeEntryFiles` is now a single exported owner in package-boundaries.ts that both R11's façade gate and this table consume. 4. Rows were converted to Sets before any uniqueness check, so a duplicate was unobservable. The table is now two `Record<string, number>` literals keyed by path, making an in-record duplicate a TypeScript error (ts1117); the only remaining case — one path in both records — is asserted on the array. Each of the four holes gets a test that fails when the rule is broken, since a tree that happens to satisfy its pins cannot distinguish a correct rule from a vacuous one. Writing those found a real bug in the duplicate check itself (`Set.add` returns the Set, so the filter never matched). Pins reseeded on 04e4c23.
55d4846 to
6d337d2
Compare
|
All four fixed in 1. "Exact ratchet" is now actually equalityIt was Now Pulling the rule into a pure function is what makes it testable in its failing direction, which matters here: over the real tree, 2. Bounded attribution instead of a chain dumpYou're right that 361 chains for One place I did not follow the direction, and want your call. You asked me to persist per-row module identity so the failure can name the newly added modules. I didn't, and the docstring says so in those words rather than implying a delta it can't compute. The reason: naming a true delta means checking in ~1,500 module paths (that's the sum of all 103 closures) and rewriting them on every contracts refactor — a second source of truth with real churn, right after #1969 moved 259 imports. What I shipped attributes by shortest import route instead, which lands on the same answer for the common case (a new import on the entry is a new edge, so its whole subtree is attributed to it and it sorts first) and degrades to "this top-level edge contains the growth" for a regression deep inside an existing subtree. If you'd rather have the exact delta and accept the baseline file, say so and I'll add it — the walker already produces the data, it's a 3. One discovery owner, and it's the recursive one
R11's façade test is 13/13 green on the shared function, and the full layering suite is 181/181. 4. Duplicates are now unwritable, not merely unassertedThe table is two The one duplicate still expressible is the same path in both records, and that's asserted on the array before any Planted red for each of the fourAdded a failing-direction test per hole, since "a hole no test can fail" was your objection:
Each was observed red, then reverted. The nested-façade one runs against a fixture tree in a temp dir, so it holds even though the real repo has no nested façade today. Worth flagging: writing #4's sabotage found a bug in my own check — I had Also
Validation
CI: one |
Summary
ADR-0019's "Implementation-laziness" section requires platform-package façades to stay implementation-lazy and is explicit that a startup threshold alone is not a substitute for preserving the loading shape: "the tracking issue owns the exact probe and planted-red procedure." #1950 built the AST-level walker (
eager-import-closure.fixtures.ts); #1959/#1969 fixed two more instances of the regression class by hand. This PR generalizes the proof into a data-driven budget table so every workspace-package entry surface — and any designated hub module — gets an eager-closure ceiling without a bespoke test file.Three files:
src/__tests__/eager-closure-budgets.ts— the data module: the budget table, the platform-implementation patterns, manifest-derived entry discovery, and the import-chain formatter.src/__tests__/eager-closure-budgets.test.ts— iterates the table and asserts.src/__tests__/eager-import-closure.fixtures.ts— gainseagerClosureGraphOf(predecessor-recording BFS) and per-file edge memoization;eagerClosureOfkeeps its contract and is expressed in terms of it.Discovery has one owner, shared with R11
facadeEntryFiles(repoRoot)lives inscripts/layering/package-boundaries.ts, and both R11's façade gate and this budget table call it — it is one function, not two implementations of the same rule. It takes each package'spackage.jsonexportstargets, then adds every production source under asrc/facades/directory, walking recursively so a nested façade file cannot be covered by one gate and missed by the other.(The first version of this PR claimed to share R11's discovery while actually carrying its own one-level
readdir. That scan missed nested façade files, and missed every manifest-only entry surface — including all six platform façades. It is now genuinely one owner, and R11's façade test is green on it.)This matters concretely: the six
packages/platform-*/src/index.tsfaçades — the exact subject of ADR-0019's implementation-laziness rule — have nofacades/directory at all. A directory-only scan omits them, and the gate would claim to prove the loading shape while never looking at it. A dedicated non-vacuity test asserts all six are discovered.Each platform façade evaluates exactly one module: itself. Its metadata is inline, its contract imports are
import type(erased), and every implementation loads behind a function-scopedawait import. A budget of1is the tightest possible statement of "metadata-eager and implementation-lazy", and a single static value import would destroy it while every other gate stayed green — R3/R13 govern import direction, never evaluation weight.Pins are exact ratchets, asserted with equality
Every pin equals the measured closure size on
04e4c23b9, asserted with equality, not<=. Growing fails, and shrinking also fails until the row is lowered in the same PR — which is what "only ever ratchets down" requires, and what<=does not give: under<=, the moment an entry legitimately shrinks the unchanged row silently becomes headroom for the next regression. No slack in either direction. This matches how the repo pins R9 type-cycle size, R10 writer/owner counts, and test-file line counts — "existing pins only shrink; a new pin requires measured justification" (docs/agents/testing.md). Slack is not neutral for this gate: the regression it catches is a single static import dragging a subtree in, measured by #1969 at 5–12% of suite import work each, and a ceiling carrying "a few files" of spare room silently absorbs the small end of exactly that. Growth stays allowed — it just has to be a visible number change in the diff of the PR that causes it.103 rows: 95 package entry surfaces + 8 designated hubs. The table is two
Record<string, number>literals keyed by entry path, so a duplicate row inside either is a TypeScript error (TS1117) rather than something a laterSetconversion would swallow. Notable values — platform-{apple,android,harmonyos,vega,linux,web}1each;contracts/facades/platform.ts42;contracts/facades/interaction.ts25; most granularized contracts entries1–5;maestro104;src/cli.ts361;src/platform-runtime.ts31(the ADR-0019 composition root);src/daemon/session-teardown.ts89. The hub set includes the five #1969 moved off the wide façades (dispatch,capabilities,registry,platform-execution-entry,register-builtins).Violations name a bounded offending route
eagerClosureGraphOfrecords which file first pulled each module in. A failure prints the entry's heaviest direct edges — ranked by how many modules enter through each, capped at 4 edges and 2 representative routes apiece — so a newly added import is the first thing on screen instead of one chain per evaluated module (which would be 361 lines atsrc/cli.tsscale):What it does not do is diff against a recorded baseline: this gate persists each entry's module count, not its module identity, so it attributes by shortest import route rather than naming which modules are new. For the common case — a new import on the entry itself — that edge is new, its whole subtree is attributed to it, and it sorts first. For a regression deep inside an already-large subtree it names the top-level edge containing the growth. Storing a true delta would mean checking in ~1,500 module paths and rewriting them on every contracts refactor; the docstring states this trade explicitly rather than implying precision the data does not support.
The walk is breadth-first, so every printed chain is the shortest route to that file. Memoizing per-file edges also cut the existing pins' runtime (
cli-startup-import-closure2336ms → ~550ms).Every rule has a failing-direction test
A real tree that satisfies its pins cannot distinguish a correct rule from a vacuous one, so each rule the gate rests on carries its own planted-red test: the equality ratchet (sabotage:
===→<=), the bounded attribution (cap 4 → 999), recursive discovery (recursive → one-level, against a temp fixture tree with a nested façade), and row uniqueness (same path in both records; plus TS1117 for a duplicate key within one). All four were observed red and reverted.Per the lane rule
Planted-red proof (reverted before commit)
Replaced the lazy
await import('./runtime.ts')inpackages/platform-apple/src/index.tswith a static namespace import — the flagship regression this gate exists to catch. Three tests fired:The third is the composition root picking up the blast radius through its own chain — the cascade a flat offender set would not have explained. Sabotage reverted; the diff contains only the three intended files.
On #1969
#1969 is merged and this branch is rebased on it. Its
contracts-entry-closure.test.tsis left in place: both of its tests state properties this table cannot (a specific hub must not reach two named façades; no source file value-imports them — a whole-tree scan, not an entry walk). The five hubs it names are now also budgeted here, so they carry both the specific pin and the general ceiling.Validation
pnpm vitest run --project unit-coreon the new file — 206/206 passing in ~700ms.pnpm typecheck— clean.pnpm check:layering— 181/181 passing (R11's façade gate now runs on the sharedfacadeEntryFiles); R11 reports 95 exported subpaths.pnpm check:fallow— no issues in the changed files.pnpm check:affected --run— all runnable checks passed.Closes #1960