@@ -2,10 +2,15 @@ import { expect, test } from 'vitest';
22import fs from 'node:fs' ;
33import path from 'node:path' ;
44import { eagerClosureGraphOf } from './eager-import-closure.fixtures.ts' ;
5+ import { mkdtempForTestSync } from './test-utils/tmp-dir.ts' ;
56import {
7+ classifyBudget ,
8+ describeClosurePressure ,
69 discoverFacadeEntryFiles ,
710 EAGER_CLOSURE_BUDGETS ,
11+ FACADE_BUDGETS ,
812 formatImportChain ,
13+ HUB_BUDGETS ,
914 PLATFORM_IMPLEMENTATION_PATTERNS ,
1015 type EagerClosureBudget ,
1116} from './eager-closure-budgets.ts' ;
@@ -18,18 +23,20 @@ import {
1823 * issue owns the exact probe and planted-red procedure." #1950 built the walker this file reuses
1924 * (`eager-import-closure.fixtures.ts`, AST-level: static value edges plus top-level dynamic
2025 * imports, type-only erased) and proved the planted-red procedure on one file. This is that
21- * probe, generalized to every workspace-package entry surface plus designated hub modules, driven
22- * by the table in `eager-closure-budgets.ts` instead of one-off pins.
26+ * probe, generalized to every workspace-package entry surface plus designated hub modules.
2327 *
2428 * - Catches: an entry surface or vocabulary module silently going eager -- the regression class
2529 * #1950 fixed once and #1959/#1969 fixed at five more sites. Nothing else prevents the next
2630 * instance: layering R3/R13 govern import DIRECTION (may this file reach that one at all),
2731 * never evaluation WEIGHT (how much of the repo an importer drags along).
2832 * - Evidence: planted red re-verified against this gate itself, not merely cited from #1950 --
29- * see the PR description for the observed failure, including the printed edge chain.
33+ * see the PR description. Every rule the real-tree assertions rest on (the equality ratchet,
34+ * the bounded attribution, recursive discovery, row uniqueness) additionally has its own
35+ * failing-direction test below, because a real tree that happens to satisfy its pins cannot
36+ * distinguish a correct rule from a vacuous one.
3037 * - Cost: one unit-lane test file plus one data module; no subprocess, no device. The walker
3138 * memoizes per-file edges, so the ~100 entries parse each reachable file once in total.
32- * - Kill criterion: if two consecutive quarters show no budget ever tightening or firing, or
39+ * - Kill criterion: if two consecutive quarters show no pin ever tightening or firing, or
3340 * ADR-0019 composition lands a stronger structural proof of the loading shape, delete this gate
3441 * in favor of that proof.
3542 */
@@ -40,37 +47,123 @@ function relList(files: readonly string[]): string[] {
4047 return [ ...files ] . map ( ( file ) => path . relative ( repoRoot , file ) ) . sort ( ) ;
4148}
4249
43- test ( 'every discovered entry surface has exactly one budget entry, and none is stale' , ( ) => {
50+ // --- the rules, tested in their failing direction -------------------------------------------
51+ // Each of these covers a hole that the real-tree assertions below cannot see: while the tree
52+ // matches its pins, an `<=` comparison, a one-level discovery scan, and a duplicate-swallowing
53+ // `Set` all look exactly like correct implementations.
54+
55+ test ( 'the ratchet fails an entry that SHRANK, not only one that grew' , ( ) => {
56+ // The hole: `actual <= budget` passes every shrink, silently converting the gain into headroom
57+ // that a later regression grows back into unnoticed.
58+ expect ( classifyBudget ( 'x.ts' , 42 , 42 ) ) . toBeNull ( ) ;
59+ expect ( classifyBudget ( 'x.ts' , 43 , 42 ) ) . toMatch ( / e v a l u a t e s 4 3 .* p i n n e d a t 4 2 / ) ;
60+ const shrank = classifyBudget ( 'x.ts' , 40 , 42 ) ;
61+ expect ( shrank ) . toMatch ( / s h r a n k / ) ;
62+ expect ( shrank , 'a shrink finding must tell the author the new number to pin' ) . toMatch (
63+ / l o w e r i t s p i n t o 4 0 / ,
64+ ) ;
65+ } ) ;
66+
67+ test ( 'closure pressure is attributed to the heaviest direct edges and is bounded' , ( ) => {
68+ // The hole: printing a chain per evaluated module is unusable at src/cli.ts scale (361), so a
69+ // failure that "names the chain" can still be unreadable. This pins both halves: the offending
70+ // edge ranks first, and the output stays bounded however wide the entry is.
71+ const entry = '/repo/entry.ts' ;
72+ const graph = new Map < string , string | null > ( [ [ entry , null ] ] ) ;
73+ // One heavy edge with a deep chain, one trivial edge, plus many shallow ones to force capping.
74+ graph . set ( '/repo/heavy.ts' , entry ) ;
75+ graph . set ( '/repo/heavy-2.ts' , '/repo/heavy.ts' ) ;
76+ graph . set ( '/repo/heavy-3.ts' , '/repo/heavy-2.ts' ) ;
77+ graph . set ( '/repo/light.ts' , entry ) ;
78+ for ( let index = 0 ; index < 10 ; index += 1 ) graph . set ( `/repo/filler-${ index } .ts` , entry ) ;
79+
80+ const described = describeClosurePressure ( graph , entry , '/repo' ) ;
81+ expect ( described ) . toContain ( 'heavy.ts -- 3 module(s) enter through this edge' ) ;
82+ expect ( described . indexOf ( 'heavy.ts' ) ) . toBeLessThan ( described . indexOf ( 'light.ts' ) ) ;
83+ expect ( described , 'the deep route must be shown, not just the edge name' ) . toContain ( 'heavy-3.ts' ) ;
84+ expect ( described , 'output must be capped and say how much it omitted' ) . toMatch (
85+ / \( \+ \d + m o r e d i r e c t e d g e \( s \) , s m a l l e r \) / ,
86+ ) ;
87+ expect ( described . split ( '\n' ) . length ) . toBeLessThan ( 20 ) ;
88+ } ) ;
89+
90+ test ( 'an entry that evaluates only itself is described without pretending to an edge' , ( ) => {
91+ // The six platform façades are exactly this shape, so the message they would print matters.
92+ const graph = new Map < string , string | null > ( [ [ '/repo/solo.ts' , null ] ] ) ;
93+ expect ( describeClosurePressure ( graph , '/repo/solo.ts' , '/repo' ) ) . toContain ( 'only itself' ) ;
94+ } ) ;
95+
96+ test ( 'discovery is recursive, so a NESTED facade file cannot hide from the gate' , ( ) => {
97+ // The hole: a one-level `readdir` of `src/facades` omits `src/facades/nested/x.ts`, which R11's
98+ // recursive scan covers -- the two gates would disagree about what a façade is, and this one
99+ // would be the lenient half. Exercised against a fixture tree so it holds even while the real
100+ // repo happens to have no nested façade.
101+ const fixtureRoot = mkdtempForTestSync ( 'eager-closure-discovery-' ) ;
102+ const pkgDir = path . join ( fixtureRoot , 'packages/demo' ) ;
103+ fs . mkdirSync ( path . join ( pkgDir , 'src/facades/nested' ) , { recursive : true } ) ;
104+ fs . writeFileSync (
105+ path . join ( pkgDir , 'package.json' ) ,
106+ JSON . stringify ( { name : '@agent-device/demo' , exports : { '.' : './src/entry.ts' } } ) ,
107+ ) ;
108+ fs . writeFileSync ( path . join ( pkgDir , 'src/entry.ts' ) , 'export const a = 1;\n' ) ;
109+ fs . writeFileSync ( path . join ( pkgDir , 'src/facades/top.ts' ) , 'export const b = 2;\n' ) ;
110+ fs . writeFileSync ( path . join ( pkgDir , 'src/facades/nested/deep.ts' ) , 'export const c = 3;\n' ) ;
111+ fs . writeFileSync ( path . join ( pkgDir , 'src/facades/skip.test.ts' ) , 'export const d = 4;\n' ) ;
112+
113+ const discovered = discoverFacadeEntryFiles ( fixtureRoot ) ;
114+ expect ( discovered ) . toContain ( 'packages/demo/src/entry.ts' ) ; // manifest-declared
115+ expect ( discovered ) . toContain ( 'packages/demo/src/facades/top.ts' ) ;
116+ expect ( discovered ) . toContain ( 'packages/demo/src/facades/nested/deep.ts' ) ; // the regression
117+ expect ( discovered , 'test sources are not entry surfaces' ) . not . toContain (
118+ 'packages/demo/src/facades/skip.test.ts' ,
119+ ) ;
120+ } ) ;
121+
122+ test ( 'no entry path is budgeted twice, checked before any Set could absorb it' , ( ) => {
123+ // Uniqueness within each record is a TypeScript error (ts1117, duplicate object literal key),
124+ // so the only duplicate still expressible is one path appearing in both records. Asserted on
125+ // the ARRAY: converting to a Set first is what made the original "exactly one row" claim
126+ // unfalsifiable.
127+ const ids = EAGER_CLOSURE_BUDGETS . map ( ( entry ) => entry . entryFile ) ;
128+ const seen = new Set < string > ( ) ;
129+ const duplicated : string [ ] = [ ] ;
130+ for ( const id of ids ) {
131+ if ( seen . has ( id ) ) duplicated . push ( id ) ;
132+ seen . add ( id ) ;
133+ }
134+ expect (
135+ duplicated ,
136+ 'These paths are budgeted twice (a path in both FACADE_BUDGETS and HUB_BUDGETS). One row per ' +
137+ 'entry: pick the record that describes it.' ,
138+ ) . toEqual ( [ ] ) ;
139+ expect ( ids . length ) . toBe ( Object . keys ( FACADE_BUDGETS ) . length + Object . keys ( HUB_BUDGETS ) . length ) ;
140+ } ) ;
141+
142+ // --- the real tree --------------------------------------------------------------------------
143+
144+ test ( 'every discovered entry surface has exactly one row, and none is stale' , ( ) => {
44145 // Bidirectional, mirroring the repo's other exhaustiveness gates (R7/R10 field checklists, the
45- // R11 exhaustive re-export check): an entry surface with no budget lets this whole mechanism go
146+ // R11 exhaustive re-export check): an entry surface with no row lets this whole mechanism go
46147 // silently vacuous for it -- which is exactly how the first version of this gate missed all six
47- // platform-package façades -- and a budget naming a file that is no longer an entry surface lets
148+ // platform-package façades -- and a row naming a file that is no longer an entry surface lets
48149 // the table drift from what it claims to police.
49150 const discovered = new Set ( discoverFacadeEntryFiles ( repoRoot ) ) ;
50- const budgeted = new Set (
51- EAGER_CLOSURE_BUDGETS . filter ( ( entry ) => entry . kind === 'facade' ) . map (
52- ( entry ) => entry . entryFile ,
53- ) ,
54- ) ;
55-
56- const missingBudget = [ ...discovered ] . filter ( ( file ) => ! budgeted . has ( file ) ) . sort ( ) ;
57- const staleBudget = [ ...budgeted ] . filter ( ( file ) => ! discovered . has ( file ) ) . sort ( ) ;
151+ const budgeted = new Set ( Object . keys ( FACADE_BUDGETS ) ) ;
58152
59153 expect (
60- missingBudget ,
61- 'These package entry surfaces (a package.json `exports` target, or a file under a ' +
62- '`src/facades/` directory) have no entry in eager-closure-budgets.ts. Measure the current ' +
63- 'closure size with eagerClosureOf and add a row, or the loading-shape probe does not ' +
64- 'actually cover them.' ,
154+ [ ...discovered ] . filter ( ( file ) => ! budgeted . has ( file ) ) . sort ( ) ,
155+ 'These package entry surfaces (a package.json `exports` target, or a production file under a ' +
156+ '`src/facades/` directory) have no row in eager-closure-budgets.ts. Measure the current ' +
157+ 'closure size and add one, or the loading-shape probe does not actually cover them.' ,
65158 ) . toEqual ( [ ] ) ;
66159 expect (
67- staleBudget ,
68- " These eager-closure-budgets.ts rows are marked kind: 'facade' but are no longer a package " +
69- 'entry surface. Remove the stale row or fix its path.' ,
160+ [ ... budgeted ] . filter ( ( file ) => ! discovered . has ( file ) ) . sort ( ) ,
161+ ' These FACADE_BUDGETS rows are no longer a package entry surface. Remove the stale row or ' +
162+ 'fix its path.' ,
70163 ) . toEqual ( [ ] ) ;
71164} ) ;
72165
73- test ( 'discovery is manifest-derived, so it reaches entries with no facades/ directory' , ( ) => {
166+ test ( 'discovery reaches manifest-only façades with no facades/ directory' , ( ) => {
74167 // Non-vacuity with a specific target. The platform packages publish `./src/index.ts` and have no
75168 // `facades/` directory at all, so a directory-only scan omits precisely the files ADR-0019's
76169 // implementation-laziness rule is about while every assertion above stays green.
@@ -94,27 +187,19 @@ test('every budgeted entry file exists on disk', () => {
94187 ) ;
95188} ) ;
96189
97- test . for ( EAGER_CLOSURE_BUDGETS ) (
98- '$id evaluates at most $budget modules' ,
99- ( entry : EagerClosureBudget ) => {
100- const entryPath = path . resolve ( repoRoot , entry . entryFile ) ;
101- const graph = eagerClosureGraphOf ( entryPath ) ;
102- // Report the newest arrivals by their import chain rather than dumping a sorted set: the
103- // chain is what turns "this is over budget" into "this import is why" (#1960).
104- const chains = [ ...graph . keys ( ) ]
105- . filter ( ( file ) => file !== entryPath )
106- . map ( ( file ) => formatImportChain ( graph , file , repoRoot ) )
107- . sort ( ) ;
108- expect (
109- graph . size ,
110- `${ entry . id } evaluates ${ graph . size } modules on import, over its budget of ${ entry . budget } .` +
111- '\nBudgets here are exact ratchets, not ceilings with slack: either something that used ' +
112- 'to load on demand now loads eagerly (fix the import), or the growth is deliberate and ' +
113- 'this row moves to the new number in the same PR.\nEvery evaluated module, as the import ' +
114- `chain that pulled it in:\n\n${ chains . join ( '\n\n' ) } ` ,
115- ) . toBeLessThanOrEqual ( entry . budget ) ;
116- } ,
117- ) ;
190+ test . for ( EAGER_CLOSURE_BUDGETS ) ( '$id evaluates exactly $budget modules' , ( entry ) => {
191+ const entryPath = path . resolve ( repoRoot , entry . entryFile ) ;
192+ const graph = eagerClosureGraphOf ( entryPath ) ;
193+ const finding = classifyBudget ( entry . id , graph . size , entry . budget ) ;
194+ expect (
195+ finding ,
196+ finding === null
197+ ? ''
198+ : `${ finding } \n\nWhere the weight comes from (heaviest direct edges, capped -- this ` +
199+ 'attributes by shortest import route, it does not diff against a recorded baseline):\n' +
200+ describeClosurePressure ( graph , entryPath , repoRoot ) ,
201+ ) . toBeNull ( ) ;
202+ } ) ;
118203
119204test . for ( EAGER_CLOSURE_BUDGETS . filter ( ( entry ) => entry . denyPlatformImplementations ) ) (
120205 '$id never evaluates a concrete platform implementation' ,
0 commit comments