Skip to content

Commit ac29c52

Browse files
committed
test(structure): make the eager-closure pins exact, bounded, and single-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.
1 parent 469257b commit ac29c52

4 files changed

Lines changed: 498 additions & 253 deletions

File tree

scripts/layering/package-boundaries.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { listSourceFiles } from './check.ts';
1010
import { readDirectNamedExports, readNamedExports, readReExportSources } from './facade-exports.ts';
1111
import {
1212
checkPackageBoundaries,
13+
facadeEntryFiles,
1314
checkPackageInternalSites,
1415
checkRootSites,
1516
readWorkspacePackages,
@@ -138,17 +139,17 @@ test('every workspace package façade names its exports explicitly (no bare `exp
138139
// the façade file itself IS the pin — a widening shows up in the diff of the file that grew,
139140
// not in a table two files away that only a gate failure would surface. This structural gate is
140141
// what keeps that property true: every façade a package manifest declares (`exportTargets`),
141-
// plus every file under a `packages/*/src/facades/` directory, must parse through
142+
// plus every production file under a `src/facades/` directory, must parse through
142143
// `readNamedExports` without hitting the bare-`export *`/`export default` rejection it already
143144
// implements — reusing that check rather than writing a second, regex-based one that would have
144145
// to independently rediscover every export form to be trustworthy.
145-
const packages = readWorkspacePackages(repoRoot);
146-
const facadeFiles = new Set<string>(packages.flatMap((pkg) => [...pkg.exportTargets.values()]));
147-
for (const file of listSourceFiles()) {
148-
if (file.includes('/src/facades/')) facadeFiles.add(file);
149-
}
150-
assert.ok(facadeFiles.size > 0, 'expected at least one workspace package façade to check');
151-
for (const file of [...facadeFiles].sort()) {
146+
//
147+
// The façade set comes from `facadeEntryFiles`, the single owner of "what is an entry surface".
148+
// The ADR-0019 eager-closure budget table consumes the same function, so a file this gate holds
149+
// to an explicit export list is necessarily a file that gate holds to a loading-shape budget.
150+
const facadeFiles = facadeEntryFiles(repoRoot);
151+
assert.ok(facadeFiles.length > 0, 'expected at least one workspace package façade to check');
152+
for (const file of facadeFiles) {
152153
const source = fs.readFileSync(path.join(repoRoot, file), 'utf8');
153154
try {
154155
readNamedExports(source);

scripts/layering/package-boundaries.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,36 @@ export function rootExternalDependencyRanges(repoRoot: string): Map<string, stri
255255
return new Map(Object.entries(manifest.dependencies ?? {}));
256256
}
257257

258+
/**
259+
* Every workspace-package entry surface, repo-root-relative and sorted: whatever a package
260+
* manifest's `exports` map points at, plus every production source file under a `src/facades/`
261+
* directory.
262+
*
263+
* The single owner of that question. R11's façade gates and the ADR-0019 eager-closure budget
264+
* table (`src/__tests__/eager-closure-budgets.ts`) both consume this, so the two cannot drift
265+
* into disagreeing about what counts as a façade — a gate that scanned a narrower set would
266+
* silently exempt files the other one covers, which is exactly the hole #1960 review found (a
267+
* one-level `readdir` missed both nested façade files and the six `packages/platform-*`
268+
* manifest façades, which have no `facades/` directory at all).
269+
*
270+
* The `src/facades/` walk is recursive and skips test sources, matching the production-file
271+
* scope every other layering scan uses.
272+
*/
273+
export function facadeEntryFiles(repoRoot: string): string[] {
274+
const found = new Set<string>();
275+
for (const pkg of readWorkspacePackages(repoRoot)) {
276+
for (const target of pkg.exportTargets.values()) found.add(target);
277+
}
278+
for (const root of ['src', 'packages']) {
279+
for (const file of walkTsFiles(repoRoot, root)) {
280+
if (!file.includes('/src/facades/')) continue;
281+
if (/(?:^|\/)__tests__\//.test(file) || file.endsWith('.test.ts')) continue;
282+
found.add(file);
283+
}
284+
}
285+
return [...found].filter((file) => fs.existsSync(path.join(repoRoot, file))).sort();
286+
}
287+
258288
function walkTsFiles(repoRoot: string, relativeDir: string): string[] {
259289
const absolute = path.join(repoRoot, relativeDir);
260290
if (!fs.existsSync(absolute)) return [];

src/__tests__/eager-closure-budgets.test.ts

Lines changed: 130 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ import { expect, test } from 'vitest';
22
import fs from 'node:fs';
33
import path from 'node:path';
44
import { eagerClosureGraphOf } from './eager-import-closure.fixtures.ts';
5+
import { mkdtempForTestSync } from './test-utils/tmp-dir.ts';
56
import {
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(/evaluates 43 .*pinned at 42/);
60+
const shrank = classifyBudget('x.ts', 40, 42);
61+
expect(shrank).toMatch(/shrank/);
62+
expect(shrank, 'a shrink finding must tell the author the new number to pin').toMatch(
63+
/lower its pin to 40/,
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+ more direct edge\(s\), smaller\)/,
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

119204
test.for(EAGER_CLOSURE_BUDGETS.filter((entry) => entry.denyPlatformImplementations))(
120205
'$id never evaluates a concrete platform implementation',

0 commit comments

Comments
 (0)