diff --git a/scripts/__tests__/committed-source-tree.ts b/scripts/__tests__/committed-source-tree.ts new file mode 100644 index 000000000..ed87b15e1 --- /dev/null +++ b/scripts/__tests__/committed-source-tree.ts @@ -0,0 +1,115 @@ +// A committed git tree as the closure walker's source of truth: the merge-base with origin/main, +// read without checking it out, so a ratchet compares against what actually landed. + +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import type { SourceTreeReader } from '../../src/__tests__/eager-import-closure.fixtures.ts'; +import { isProductionSourceFile } from '../layering/tracked-sources.ts'; + +const WALKED_SOURCE = /^(?:src|packages\/[^/]+\/src)\/.*\.ts$/; +const WALKED_MANIFEST = /^packages\/[^/]+\/package\.json$/; + +function git(repoRoot: string, args: readonly string[], input = ''): Buffer { + return execFileSync('git', [...args], { + cwd: repoRoot, + input, + maxBuffer: 256 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + }); +} + +/** The commit a branch's closures ratchet against: `git merge-base origin/main HEAD`. */ +export function mergeBaseWithMain(repoRoot: string): string { + try { + return git(repoRoot, ['merge-base', 'origin/main', 'HEAD']).toString('utf8').trim(); + } catch (error) { + const stderr = (error as { stderr?: Buffer }).stderr?.toString('utf8').trim() ?? ''; + throw new Error( + 'The eager-closure ratchet needs origin/main to find its merge-base (git merge-base ' + + `origin/main HEAD failed: ${stderr}). Fetch origin/main; the gate does not skip.`, + { cause: error }, + ); + } +} + +/** Files renamed since `base`, current path -> path at `base`, so a rename is not a new entry. */ +export function renamedSince(repoRoot: string, base: string): ReadonlyMap { + const renamed = new Map(); + const status = git(repoRoot, ['diff', '--name-status', '-M', '--diff-filter=R', '-z', base]); + const fields = status.toString('utf8').split('\0'); + for (let index = 0; index + 2 < fields.length; index += 3) { + const [from, to] = [fields[index + 1], fields[index + 2]]; + if (from && to) renamed.set(to, from); + } + return renamed; +} + +/** + * ` blob \n\n` per hit and ` missing\n` per miss, in request + * order. Sizes are bytes, so this walks the raw buffer rather than a string offset. + */ +function parseCatFileBatch(output: Buffer, files: readonly string[]): Map { + const contents = new Map(); + let offset = 0; + for (const file of files) { + const headerEnd = output.indexOf(0x0a, offset); + const blob = /^\S+ blob (\d+)$/.exec(output.toString('utf8', offset, headerEnd)); + offset = headerEnd + 1; + if (!blob) continue; + const size = Number(blob[1]); + contents.set(file, output.toString('utf8', offset, offset + size)); + offset += size + 1; + } + return contents; +} + +/** Every ancestor directory of the tracked paths, so `exists` answers for directories too. */ +function directoriesOf(files: ReadonlySet): Set { + const directories = new Set(); + for (const file of files) { + for (let dir = path.posix.dirname(file); dir !== '.'; dir = path.posix.dirname(dir)) { + if (directories.has(dir)) break; + directories.add(dir); + } + } + return directories; +} + +/** + * The walker's view of `treeish`: tracked paths from one `git ls-tree`, and every source the + * walker can reach (production TypeScript under `src/` and `packages//src/`, package + * manifests) from ONE `git cat-file --batch` fed those paths up front -- two processes for the + * whole tree, never one per file. A read outside that set is a widening request, not a fallback. + */ +export function createCommittedSourceTree(repoRoot: string, treeish: string): SourceTreeReader { + const listing = git(repoRoot, ['ls-tree', '-r', '--name-only', '-z', treeish]).toString('utf8'); + const tracked = new Set(listing.split('\0').filter(Boolean)); + const directories = directoriesOf(tracked); + const walked = [...tracked].filter( + (file) => + WALKED_MANIFEST.test(file) || (WALKED_SOURCE.test(file) && isProductionSourceFile(file)), + ); + const requests = walked.map((file) => `${treeish}:${file}\n`).join(''); + const contents = parseCatFileBatch(git(repoRoot, ['cat-file', '--batch'], requests), walked); + const relative = (file: string) => path.relative(repoRoot, file).split(path.sep).join('/'); + return { + exists: (file) => tracked.has(relative(file)) || directories.has(relative(file)), + isFile: (file) => tracked.has(relative(file)), + readdir: (dir) => { + const prefix = `${relative(dir)}/`; + const names = new Set(); + for (const entry of [...tracked, ...directories]) { + if (entry.startsWith(prefix)) names.add(entry.slice(prefix.length).split('/')[0] ?? ''); + } + names.delete(''); + return [...names].sort(); + }, + readFile: (file) => { + const source = contents.get(relative(file)); + if (source === undefined) { + throw new Error(`${relative(file)} is not a source the closure walker reads at ${treeish}`); + } + return source; + }, + }; +} diff --git a/scripts/__tests__/eager-closure-budgets.test.ts b/scripts/__tests__/eager-closure-budgets.test.ts index ddcb54d80..dbe97275a 100644 --- a/scripts/__tests__/eager-closure-budgets.test.ts +++ b/scripts/__tests__/eager-closure-budgets.test.ts @@ -5,15 +5,24 @@ import os from 'node:os'; import path from 'node:path'; import { eagerClosureGraphOf } from '../../src/__tests__/eager-import-closure.fixtures.ts'; import { - classifyBudget, + createCommittedSourceTree, + mergeBaseWithMain, + renamedSince, +} from './committed-source-tree.ts'; +import { + APPROVED_OVER_CEILING, + classifyGrowth, + classifyNewEntry, + describeClosureGrowth, describeClosurePressure, describePlatformOffenders, discoverFacadeEntryFiles, - EAGER_CLOSURE_BUDGETS, - FACADE_BUDGETS, - HUB_BUDGETS, + eagerClosureEntries, + entryCategoryOf, + HUB_ENTRY_FILES, + NEW_ENTRY_CEILINGS, + PLATFORM_FACADE_CLOSURE, PLATFORM_IMPLEMENTATION_PATTERNS, - type EagerClosureBudget, } from './eager-closure-budgets.ts'; /** @@ -25,41 +34,55 @@ import { * (`src/__tests__/eager-import-closure.fixtures.ts`, AST-level: static value edges plus top-level * dynamic * imports, type-only erased) and proved the planted-red procedure on one file. This is that - * probe, generalized to every workspace-package entry surface plus designated hub modules. + * probe, generalized to every workspace-package entry surface plus designated hub modules, and + * ratcheted against the committed merge-base rather than against a table of numbers. * * - Catches: an entry surface or vocabulary module silently going eager -- the regression class * #1950 fixed once and #1959/#1969 fixed at five more sites. Nothing else prevents the next * instance: layering R13 governs import DIRECTION (may this file reach that one at all), * never evaluation WEIGHT (how much of the repo an importer drags along). * - Evidence: planted red re-verified against this gate itself, not merely cited from #1950 -- - * see the PR description. Every rule the real-tree assertions rest on (the equality ratchet, - * the bounded attribution, recursive discovery, row uniqueness) additionally has its own - * failing-direction test below, because a real tree that happens to satisfy its pins cannot - * distinguish a correct rule from a vacuous one. - * - Cost: one unit-lane test file plus one data module; no subprocess, no device. The walker - * memoizes per-file edges, so the ~100 entries parse each reachable file once in total. - * - Kill criterion: if two consecutive quarters show no pin ever tightening or firing, or + * see the PR description. Every rule the real-tree assertions rest on (no growth, the + * ceilings, the committed-tree reader, rename following, the bounded attribution, recursive + * discovery) additionally has its own failing-direction test below, because a real tree that + * happens to satisfy its rules cannot distinguish a correct rule from a vacuous one. + * - Cost: one unit-lane test file plus two data/reader modules; four git processes for the + * merge-base side (merge-base, ls-tree, one cat-file batch, one rename diff), no device. The + * walker memoizes per-file edges per tree and parses each file once per distinct content, so + * the base tree pays only for the files the branch changed. + * - Kill criterion: if two consecutive quarters show no rule ever firing, or * ADR-0019 composition lands a stronger structural proof of the loading shape, delete this gate * in favor of that proof. */ const repoRoot = path.resolve(import.meta.dirname, '../..'); +const absolute = (file: string) => path.resolve(repoRoot, file); // --- the rules, tested in their failing direction ------------------------------------------- // Each of these covers a hole that the real-tree assertions below cannot see: while the tree -// matches its pins, an `<=` comparison, a one-level discovery scan, and a duplicate-swallowing -// `Set` all look exactly like correct implementations. - -test('the ratchet fails an entry that SHRANK, not only one that grew', () => { - // The hole: `actual <= budget` passes every shrink, silently converting the gain into headroom - // that a later regression grows back into unnoticed. - expect(classifyBudget('x.ts', 42, 42)).toBeNull(); - expect(classifyBudget('x.ts', 43, 42)).toMatch(/evaluates 43 .*pinned at 42/); - const shrank = classifyBudget('x.ts', 40, 42); - expect(shrank).toMatch(/shrank/); - expect(shrank, 'a shrink finding must tell the author the new number to pin').toMatch( - /lower its pin to 40/, +// satisfies its rules, a wrong comparison, an unfollowed rename, a reader that quietly falls +// back to the working directory, and a one-level discovery scan all look like correct rules. + +test('no-growth fails growth with both counts and passes an equal or smaller closure', () => { + expect(classifyGrowth('x.ts', 42, 42)).toBeNull(); + expect(classifyGrowth('x.ts', 42, 40)).toBeNull(); + expect(classifyGrowth('x.ts', 42, 43)).toMatch(/evaluates 43 modules.*merge-base evaluated 42/); +}); + +test('a first-introduced entry fits its category ceiling or carries an approval', () => { + expect(classifyNewEntry('x.ts', 'vocabulary-facade', 4, false)).toBeNull(); + expect(classifyNewEntry('x.ts', 'vocabulary-facade', 5, false)).toMatch( + /new vocabulary-facade entry evaluating 5 modules.*ceiling of 4/, ); + expect(classifyNewEntry('x.ts', 'vocabulary-facade', 5, true)).toBeNull(); +}); + +test('the category is derived from the path, never hand-listed', () => { + expect(entryCategoryOf('packages/platform-vega/src/index.ts')).toBe('platform-facade'); + expect(entryCategoryOf('packages/contracts/src/facades/device.ts')).toBe('vocabulary-facade'); + expect(entryCategoryOf('packages/kernel/src/rect.ts')).toBe('domain-facade'); + expect(entryCategoryOf('src/cli.ts')).toBe('mechanics-surface'); + expect(() => entryCategoryOf('scripts/gate/check.ts')).toThrow(/neither/); }); test('closure pressure is attributed to the heaviest direct edges and is bounded', () => { @@ -142,7 +165,10 @@ function mkGitFixtureRepo(prefix: string): string { fs.mkdirSync(path.join(pkgDir, 'src/facades/nested'), { recursive: true }); fs.writeFileSync( path.join(pkgDir, 'package.json'), - JSON.stringify({ name: '@agent-device/demo', exports: { '.': './src/entry.ts' } }), + JSON.stringify({ + name: '@agent-device/demo', + exports: { '.': './src/entry.ts' }, + }), ); fs.writeFileSync(path.join(pkgDir, 'src/entry.ts'), 'export const a = 1;\n'); fs.writeFileSync(path.join(pkgDir, 'src/facades/top.ts'), 'export const b = 2;\n'); @@ -158,6 +184,32 @@ function mkGitFixtureRepo(prefix: string): string { return repo; } +test('the committed-tree reader walks what was committed, not the working directory', () => { + // The hole: a reader that falls back to `fs` for anything it cannot answer from git turns the + // merge-base side into a second copy of the head side, and no-growth passes every growth. + const repo = mkGitFixtureRepo('eager-closure-committed-tree-'); + const entry = path.join(repo, 'packages/demo/src/entry.ts'); + fs.writeFileSync(entry, "export * from './facades/top.ts';\n"); + fs.writeFileSync(path.join(repo, 'packages/demo/src/scratch.ts'), 'export const s = 1;\n'); + + const committed = createCommittedSourceTree(repo, 'HEAD'); + expect(committed.isFile(path.join(repo, 'packages/demo/src/scratch.ts'))).toBe(false); + expect(committed.readdir(path.join(repo, 'packages'))).toEqual(['demo']); + expect(committed.readFile(entry)).toBe('export const a = 1;\n'); + expect(eagerClosureGraphOf(entry, committed).size, 'committed: no edges').toBe(1); + expect(eagerClosureGraphOf(entry).size, 'working tree: one edge').toBe(2); +}); + +test('a renamed entry is followed to its path at the base, not treated as first-introduced', () => { + const repo = mkGitFixtureRepo('eager-closure-renamed-entry-'); + execFileSync('git', ['mv', 'packages/demo/src/entry.ts', 'packages/demo/src/moved.ts'], { + cwd: repo, + }); + expect(renamedSince(repo, 'HEAD').get('packages/demo/src/moved.ts')).toBe( + 'packages/demo/src/entry.ts', + ); +}); + test('discovery is recursive and reads TRACKED files only', () => { // Two holes in one fixture, because both are about discovery seeing the wrong set of files. // @@ -209,7 +261,10 @@ test('an untracked PACKAGE contributes no entry surface, however its manifest re fs.mkdirSync(path.join(scratchPkg, 'src/facades'), { recursive: true }); fs.writeFileSync( path.join(scratchPkg, 'package.json'), - JSON.stringify({ name: '@agent-device/scratch', exports: { '.': './src/index.ts' } }), + JSON.stringify({ + name: '@agent-device/scratch', + exports: { '.': './src/index.ts' }, + }), ); fs.writeFileSync(path.join(scratchPkg, 'src/index.ts'), 'export const z = 0;\n'); fs.writeFileSync(path.join(scratchPkg, 'src/facades/thing.ts'), 'export const y = 0;\n'); @@ -254,47 +309,43 @@ test('a dirty manifest naming an UNTRACKED target contributes no entry surface', ).not.toContain('packages/demo/src/draft.ts'); }); -test('no entry path is budgeted twice, checked before any Set could absorb it', () => { - // Uniqueness within each record is a TypeScript error (ts1117, duplicate object literal key), - // so the only duplicate still expressible is one path appearing in both records. Asserted on - // the ARRAY: converting to a Set first is what made the original "exactly one row" claim - // unfalsifiable. - const ids = EAGER_CLOSURE_BUDGETS.map((entry) => entry.entryFile); - const seen = new Set(); - const duplicated: string[] = []; - for (const id of ids) { - if (seen.has(id)) duplicated.push(id); - seen.add(id); +// --- the real tree -------------------------------------------------------------------------- + +const mergeBase = mergeBaseWithMain(repoRoot); +const baseTree = createCommittedSourceTree(repoRoot, mergeBase); +const renamedFrom = renamedSince(repoRoot, mergeBase); +const entries = eagerClosureEntries(repoRoot); + +/** The entry's path in the merge-base tree (renames followed), or null when it was not there. */ +function basePathOf(entryFile: string): string | null { + const file = renamedFrom.get(entryFile) ?? entryFile; + return baseTree.isFile(absolute(file)) ? file : null; +} + +const baseClosures = new Map>(); +function baseClosureOf(baseFile: string): ReadonlySet { + let closure = baseClosures.get(baseFile); + if (!closure) { + closure = new Set(eagerClosureGraphOf(absolute(baseFile), baseTree).keys()); + baseClosures.set(baseFile, closure); } - expect( - duplicated, - 'These paths are budgeted twice (a path in both FACADE_BUDGETS and HUB_BUDGETS). One row per ' + - 'entry: pick the record that describes it.', - ).toEqual([]); - expect(ids.length).toBe(Object.keys(FACADE_BUDGETS).length + Object.keys(HUB_BUDGETS).length); -}); + return closure; +} -// --- the real tree -------------------------------------------------------------------------- +const platformFacades = entries.filter((entry) => entry.category === 'platform-facade'); +const others = entries.filter((entry) => entry.category !== 'platform-facade'); +const carried = others.flatMap((entry) => { + const baseFile = basePathOf(entry.entryFile); + return baseFile === null ? [] : [{ ...entry, baseFile }]; +}); +const introduced = others.filter((entry) => basePathOf(entry.entryFile) === null); -test('every discovered entry surface has exactly one row, and none is stale', () => { - // Bidirectional, mirroring the repo's other exhaustiveness gates (R7/R10 field checklists, the - // R11 exhaustive re-export check): an entry surface with no row lets this whole mechanism go - // silently vacuous for it -- which is exactly how the first version of this gate missed all six - // platform-package façades -- and a row naming a file that is no longer an entry surface lets - // the table drift from what it claims to police. +test('every hub exists and is not also a discovered façade', () => { const discovered = new Set(discoverFacadeEntryFiles(repoRoot)); - const budgeted = new Set(Object.keys(FACADE_BUDGETS)); - + expect(HUB_ENTRY_FILES.filter((file) => !fs.existsSync(absolute(file)))).toEqual([]); expect( - [...discovered].filter((file) => !budgeted.has(file)).sort(), - 'These package entry surfaces (a package.json `exports` target, or a production file under a ' + - '`src/facades/` directory) have no row in eager-closure-budgets.ts. Measure the current ' + - 'closure size and add one, or the loading-shape probe does not actually cover them.', - ).toEqual([]); - expect( - [...budgeted].filter((file) => !discovered.has(file)).sort(), - 'These FACADE_BUDGETS rows are no longer a package entry surface. Remove the stale row or ' + - 'fix its path.', + HUB_ENTRY_FILES.filter((file) => discovered.has(file)), + 'one entry, one rule: a façade is measured as a façade', ).toEqual([]); }); @@ -313,33 +364,67 @@ test('discovery reaches manifest-only façades with no facades/ directory', () = } }); -test('every budgeted entry file exists on disk', () => { - const missing = EAGER_CLOSURE_BUDGETS.filter( - (entry) => !fs.existsSync(path.resolve(repoRoot, entry.entryFile)), - ).map((entry) => entry.entryFile); - expect(missing, 'These eager-closure-budgets.ts rows name a file that does not exist.').toEqual( - [], - ); +test.for(platformFacades)('$id evaluates exactly one module: itself', (entry) => { + const entryPath = absolute(entry.entryFile); + const graph = eagerClosureGraphOf(entryPath); + expect( + graph.size, + `${entry.id} evaluates ${graph.size} modules on import; a platform façade is metadata-eager ` + + `and implementation-lazy (ADR-0019).\n${describeClosurePressure(graph, entryPath, repoRoot)}`, + ).toBe(PLATFORM_FACADE_CLOSURE); }); -test.for(EAGER_CLOSURE_BUDGETS)('$id evaluates exactly $budget modules', (entry) => { - const entryPath = path.resolve(repoRoot, entry.entryFile); +test.for(carried)('$id evaluates no more modules than at the merge-base', (entry) => { + const entryPath = absolute(entry.entryFile); const graph = eagerClosureGraphOf(entryPath); - const finding = classifyBudget(entry.id, graph.size, entry.budget); + const base = baseClosureOf(entry.baseFile); + const finding = classifyGrowth(entry.id, base.size, graph.size); expect( finding, finding === null ? '' - : `${finding}\n\nWhere the weight comes from (heaviest direct edges, capped -- this ` + - 'attributes by shortest import route, it does not diff against a recorded baseline):\n' + - describeClosurePressure(graph, entryPath, repoRoot), + : `${finding}\n\nFirst newly evaluated module, by shortest import route:\n` + + describeClosureGrowth(graph, base, entryPath, repoRoot), ).toBeNull(); }); -test.for(EAGER_CLOSURE_BUDGETS.filter((entry) => entry.denyPlatformImplementations))( +test.for(introduced)( + '$id is first-introduced and fits the $category ceiling or carries an approval', + (entry) => { + const entryPath = absolute(entry.entryFile); + const graph = eagerClosureGraphOf(entryPath); + const approved = Object.hasOwn(APPROVED_OVER_CEILING, entry.entryFile); + const finding = classifyNewEntry(entry.id, entry.category, graph.size, approved); + expect( + finding, + finding === null + ? '' + : `${finding}\n\nWhere the weight comes from (heaviest direct edges, capped):\n` + + describeClosurePressure(graph, entryPath, repoRoot), + ).toBeNull(); + }, +); + +test('no APPROVED_OVER_CEILING row is stale', () => { + // Only a first-introduced entry consults a ceiling. Once the merge-base carries the entry, the + // no-growth rule governs it and nothing reads the row again, so a carried entry's row is stale + // for the same reason a shrunk one is: it can no longer change any verdict. + const introducedById = new Map(introduced.map((entry) => [entry.entryFile, entry])); + const stale = Object.keys(APPROVED_OVER_CEILING).filter((id) => { + const entry = introducedById.get(id); + return !entry || eagerClosureGraphOf(absolute(id)).size <= NEW_ENTRY_CEILINGS[entry.category]; + }); + expect( + stale, + 'These approvals name an entry that no longer exists, that the merge-base now carries, or ' + + 'that now fits its ceiling: remove the rows.', + ).toEqual([]); +}); + +test.for(entries.filter((entry) => entry.denyPlatformImplementations))( '$id never evaluates a concrete platform implementation', - (entry: EagerClosureBudget) => { - const entryPath = path.resolve(repoRoot, entry.entryFile); + (entry) => { + const entryPath = absolute(entry.entryFile); const graph = eagerClosureGraphOf(entryPath); // The entry itself is excluded: a platform package's own façade necessarily matches the // pattern, and the property worth asserting there is that it evaluates none of its OWN diff --git a/scripts/__tests__/eager-closure-budgets.ts b/scripts/__tests__/eager-closure-budgets.ts index 1890c2de9..f2d2488b0 100644 --- a/scripts/__tests__/eager-closure-budgets.ts +++ b/scripts/__tests__/eager-closure-budgets.ts @@ -1,60 +1,54 @@ // Per-entry eager-closure budgets -- the ADR-0019 loading-shape probe (#1739, #1960). // // ADR-0019's "Implementation-laziness" section requires platform-package façades to stay -// implementation-lazy and is explicit that a startup-time threshold alone is not a substitute for -// preserving the loading shape (`docs/adr/0019-request-bound-platform-runtime.md`): "the tracking -// issue owns the exact probe and planted-red procedure." #1950 built the AST-level walker -// (`src/__tests__/eager-import-closure.fixtures.ts`); #1959/#1969 fixed two more instances of the -// regression -// class by hand. This table generalizes the proof: every package entry surface gets an exact pin -// on how many repo modules importing it evaluates, plus a standing assertion that the closure -// never reaches a concrete platform implementation before discovery/binding selects one. +// implementation-lazy and says a startup-time threshold alone does not preserve the loading +// shape (`docs/adr/0019-request-bound-platform-runtime.md`). The AST walker in +// `src/__tests__/eager-import-closure.fixtures.ts` counts the repo modules that importing an +// entry evaluates; this module says what count each entry may have. R13 governs import +// DIRECTION (may this file reach that one at all), never evaluation WEIGHT. // -// The six `packages/platform-*/src/index.ts` façades are the reason this gate exists. Each one -// evaluates exactly ONE module today -- itself -- because its metadata is inline, its contract -// imports are `import type` (erased), and every implementation loads through a function-scoped -// `await import`. That is precisely ADR-0019's "metadata-eager and implementation-lazy" property, -// and a single static value import would silently destroy it while every other gate stayed green: -// R13 governs import DIRECTION (may this file reach that one at all), never evaluation WEIGHT. +// Entries are every package entry surface `facadeEntryFiles` discovers plus the hand-listed hubs +// below. Each falls under one rule, chosen by its category, which is derived from its path: // -// Entry files are repo-root-relative, and are the KEYS of the two records below. Keying by path -// is what makes a duplicate row unwritable rather than merely discouraged: a repeated key in an -// object literal is a TypeScript error (ts1117), so the "exactly one row per entry" claim is -// enforced by the compiler instead of by a runtime check that a `Set` conversion would hide. +// - `platform-facade` (`packages/platform-/src/index.ts`): EXACT. It evaluates one +// module, itself -- metadata inline, contract imports type-only, every implementation behind a +// function-scoped `await import`. A single static value import destroys the property. +// - Every other entry that exists at the merge-base with origin/main: NO GROWTH. Its closure may +// not be larger than the closure of the same file (renames followed) in the committed +// merge-base tree, read through `committed-source-tree.ts`. Shrinking needs no edit: there is +// no number to keep in step, and the next merge-base keeps the gain. +// - An entry absent at the merge-base: a per-category CEILING (`NEW_ENTRY_CEILINGS`). At or +// under it, nothing to write. Over it, one `APPROVED_OVER_CEILING` row naming the issue, the +// reason, and an owner; the row records no number, and the merge-base carries the entry from +// the next PR on. A row is stale once nothing can read it -- the entry is gone, the merge-base +// now carries it, or its closure fits the ceiling -- and a stale row fails. +// +// Independent of size, a façade entry's closure must never reach a concrete platform +// implementation (`PLATFORM_IMPLEMENTATION_PATTERNS`) before discovery or binding selects an +// owner -- the ADR-0019 property itself, and the reason the exceptions below are named. import path from 'node:path'; import { facadeEntryFiles } from '../layering/package-boundaries.ts'; -export type EagerClosureBudget = { +export type EntryCategory = + | 'platform-facade' + | 'vocabulary-facade' + | 'domain-facade' + | 'mechanics-surface'; + +export type EagerClosureEntry = { /** Stable label for test names and failure messages -- the entry's repo-relative path. */ id: string; /** Repo-root-relative path to the module a consumer imports. */ entryFile: string; /** - * The EXACT number of repo modules `eagerClosureOf(entryFile)` evaluates, asserted with - * equality rather than `<=`. - * - * A `<=` ceiling looks stricter than it is: the moment an entry legitimately shrinks, the - * unchanged row silently becomes headroom, and the next regression up to the old number passes - * unnoticed. Equality is what "only ever ratchets down" actually requires -- the same shape - * R9/R10 use for cycle size and writer counts: growing fails, and shrinking ALSO fails until - * the row is lowered in the same PR, so the gain is kept rather than banked as slack. - * - * Seeded from measurement, never rounded up. The regression this catches is a single static - * import dragging a subtree in, measured by #1969 at 5-12% of the whole suite's import work - * each; a row carrying "a few files" of spare room silently absorbs the small end of exactly - * that. - */ - budget: number; - /** - * 'facade' rows are the package entry surfaces discovered by `facadeEntryFiles`, and the - * exhaustiveness test requires every discovered file to have exactly one row. 'hub' rows are - * hand-designated, high-fan-in modules that value-import an entry surface for only a slice of - * it (ADR-0019's other named case, and the shape #1969 fixed at five sites). There is no - * mechanical way to enumerate "every hub" the way a manifest enumerates every entry surface, so - * hub membership is a reviewed judgment call. + * 'facade' entries are the package entry surfaces `facadeEntryFiles` discovers. 'hub' entries + * are hand-designated, high-fan-in modules that value-import an entry surface for only a slice + * of it (ADR-0019's other named case, the shape #1969 fixed at five sites). Nothing enumerates + * "every hub" the way a manifest enumerates every entry surface, so membership is reviewed. */ kind: 'facade' | 'hub'; + category: EntryCategory; /** * When true, the closure must not evaluate any concrete platform implementation * (`PLATFORM_IMPLEMENTATION_PATTERNS`) OTHER than the entry file itself -- ADR-0019's rule that @@ -100,336 +94,55 @@ export function discoverFacadeEntryFiles(repoRoot: string): string[] { } /** - * Measured 2026-08-22 on `04e4c23b9` (post-#1969, which granularized the contracts entry surface - * from 15 subpaths to 70 and moved the hubs off the wide façades). Exact pins; see the `budget` - * field doc for why there is no headroom. + * Designated hubs: entry points whose closure the whole suite or every CLI run pays for. + * `src/platform-runtime.ts` is the ADR-0019 composition root, the one production module allowed + * to value-import a concrete platform package; its no-growth rule is also the assertion that + * composing the registry stays metadata-eager. */ -export const FACADE_BUDGETS: Readonly> = Object.freeze({ - // --- @agent-device/ad-replay --- - 'packages/ad-replay/src/index.ts': 62, - - // --- @agent-device/ad-script --- - 'packages/ad-script/src/index.ts': 41, - - // --- @agent-device/platform-apple/runner --- - // #2040 extraction: the façade stays types/pure-helpers/bundle-ids; the whole - // client implementation is package-internal and loads only behind consumers' dynamic imports. - 'packages/platform-apple/src/runner/index.ts': 13, - 'packages/platform-apple/src/runner/test-host.ts': 2, - - // --- @agent-device/capture-kit --- - // R60 review: audio-probe split into descriptor/status/recovery/live-process modules (+3 files). - 'packages/capture-kit/src/index.ts': 32, - 'packages/capture-kit/src/ios-snapshot-acquisition.ts': 9, - // #2190 keeps iOS snapshot planning behind its dedicated subpath instead of the broad root. - 'packages/capture-kit/src/ios-snapshot-planning.ts': 1, - // #2191 keeps the iOS snapshot engine behind its dedicated subpath instead of the broad root. - 'packages/capture-kit/src/ios-snapshot-engine/index.ts': 36, - 'packages/capture-kit/src/png-resize.ts': 18, - 'packages/capture-kit/src/png-rgb-difference.ts': 1, - 'packages/capture-kit/src/png-size.ts': 3, - 'packages/capture-kit/src/png-worker-client.ts': 10, - 'packages/capture-kit/src/png.ts': 3, - 'packages/capture-kit/src/screenshot-density.ts': 6, - 'packages/capture-kit/src/screenshot-diff-pixels.ts': 1, - 'packages/capture-kit/src/mobile-snapshot-semantics.ts': 10, - 'packages/capture-kit/src/snapshot-desktop-projection.ts': 2, - 'packages/capture-kit/src/snapshot-occlusion.ts': 10, - 'packages/capture-kit/src/snapshot-quality-backend-capabilities.ts': 1, - 'packages/capture-kit/src/snapshot-quality-verdict.ts': 2, - - // --- @agent-device/host-kit --- - 'packages/host-kit/src/archive.ts': 9, - 'packages/host-kit/src/command.ts': 7, - 'packages/host-kit/src/diagnostics.ts': 3, - // #2136 adds one intentionally eager synchronous module: file.ts must value-re-export the - // moved verified-file operations from the existing capability surface. The same module is - // already on these static closure paths, so each exact ratchet moves by one: host-kit/file, - // provision-kit/install-source, platform-android/mechanics, the Apple app-lifecycle, doctor, - // install-artifact, and runner-operations facades, and src/cli. This records deliberate - // ownership growth, not budget headroom. - 'packages/host-kit/src/file.ts': 13, - 'packages/host-kit/src/host-file.ts': 2, - 'packages/host-kit/src/process.ts': 12, - 'packages/host-kit/src/request.ts': 5, - 'packages/host-kit/src/retry.ts': 6, - // #2139 keeps framing, lazy HTTP/body mechanics, and secret comparison behind one - // transport port without growing the CLI's eager closure. - 'packages/host-kit/src/transport.ts': 4, - 'packages/host-kit/src/version.ts': 4, - - // --- @agent-device/provision-kit --- - 'packages/provision-kit/src/app-resolution-cache.ts': 1, - 'packages/provision-kit/src/boot-diagnostics.ts': 3, - 'packages/provision-kit/src/install-artifact-archive-context.ts': 10, - 'packages/provision-kit/src/install-source.ts': 26, - 'packages/provision-kit/src/install-source-config.ts': 3, - 'packages/provision-kit/src/install-source-network.ts': 3, - 'packages/provision-kit/src/install-source-network-transport.ts': 1, - 'packages/provision-kit/src/toolchain-probe.ts': 8, - - // --- @agent-device/contracts --- - 'packages/contracts/src/alert-contract.ts': 1, - 'packages/contracts/src/android-clipboard-support.ts': 1, - // Added by #2041 (adb/IME cluster extraction): shared helper-artifact and touch-plan - // vocabulary moved into the Android platform package. - 'packages/contracts/src/android-helper-artifacts.ts': 3, - 'packages/contracts/src/android-touch-plan.ts': 13, - 'packages/contracts/src/android-input-ownership.ts': 1, - 'packages/contracts/src/android-observation.ts': 1, - 'packages/contracts/src/android-snapshot-quality.ts': 1, - 'packages/contracts/src/android-system-chrome.ts': 1, - 'packages/contracts/src/app-deployment-runtime-plan.ts': 3, - 'packages/contracts/src/app-deployment-runtime.ts': 1, - 'packages/contracts/src/app-inventory-runtime.ts': 1, - 'packages/contracts/src/app-log-runtime.ts': 1, - 'packages/contracts/src/app-state-runtime.ts': 1, - 'packages/contracts/src/apple-runner-request.ts': 1, - 'packages/contracts/src/apple-multitouch-support.ts': 6, - 'packages/contracts/src/application-lifecycle-interaction.ts': 7, - 'packages/contracts/src/application-lifecycle-runtime-plan.ts': 3, - 'packages/contracts/src/application-lifecycle-runtime.ts': 1, - 'packages/contracts/src/async-lifecycle.ts': 1, - 'packages/contracts/src/audio-probe-result.ts': 1, - 'packages/contracts/src/audio-probe-runtime.ts': 1, - 'packages/contracts/src/audio-probe-runtime-host.ts': 1, - 'packages/contracts/src/audio-probe-support.ts': 5, - 'packages/contracts/src/audio-runtime-plan.ts': 5, - 'packages/contracts/src/back-mode.ts': 1, - 'packages/contracts/src/backend-diagnostics.ts': 1, - 'packages/contracts/src/boot-failure.ts': 1, - 'packages/contracts/src/click-button.ts': 3, - 'packages/contracts/src/clipboard.ts': 1, - 'packages/contracts/src/command-platform-execution.ts': 2, - 'packages/contracts/src/daemon-owner-cleanup.ts': 1, - 'packages/contracts/src/device-readiness-runtime.ts': 1, - 'packages/contracts/src/device-shutdown-runtime.ts': 1, - 'packages/contracts/src/durable-resource-envelope.ts': 1, - 'packages/contracts/src/durable-resource.ts': 1, - 'packages/contracts/src/element-text-runtime.ts': 4, - 'packages/contracts/src/facades/capture.ts': 9, - 'packages/contracts/src/facades/client.ts': 2, - 'packages/contracts/src/facades/command.ts': 9, - 'packages/contracts/src/facades/device.ts': 8, - 'packages/contracts/src/facades/divergence.ts': 3, - 'packages/contracts/src/facades/observability.ts': 7, - 'packages/contracts/src/facades/progress.ts': 1, - 'packages/contracts/src/facades/recording.ts': 3, - 'packages/contracts/src/facades/remote.ts': 2, - 'packages/contracts/src/facades/replay.ts': 3, - 'packages/contracts/src/facades/session.ts': 5, - 'packages/contracts/src/facades/snapshot.ts': 8, - 'packages/contracts/src/focus-runtime.ts': 4, - 'packages/contracts/src/gesture-input.ts': 13, - 'packages/contracts/src/gesture-normalization.ts': 14, - 'packages/contracts/src/gesture-plan-types.ts': 1, - 'packages/contracts/src/gesture-admission.ts': 6, - 'packages/contracts/src/gesture-runtime.ts': 5, - 'packages/contracts/src/gesture-plan.ts': 12, - 'packages/contracts/src/host-diagnostics.ts': 1, - 'packages/contracts/src/interaction.ts': 1, - 'packages/contracts/src/interaction-error.ts': 1, - 'packages/contracts/src/interaction-guarantees.ts': 1, - 'packages/contracts/src/interactor-types.ts': 1, - // #2190's iOS snapshot vocabulary has type-only imports and remains a one-module entry. - 'packages/contracts/src/ios-snapshot.ts': 1, - 'packages/contracts/src/is-predicate.ts': 1, - 'packages/contracts/src/keyboard.ts': 1, - 'packages/contracts/src/logs-runtime-plan.ts': 5, - 'packages/contracts/src/managed-web-backend.ts': 1, - 'packages/contracts/src/navigation.ts': 1, - 'packages/contracts/src/network-runtime-plan.ts': 5, - 'packages/contracts/src/network-runtime.ts': 1, - 'packages/contracts/src/network-traffic.ts': 1, - 'packages/contracts/src/platform-module.ts': 5, - 'packages/contracts/src/platform-plugin.ts': 1, - 'packages/contracts/src/platform-providers.ts': 1, - 'packages/contracts/src/platform-resource-cleanup.ts': 1, - 'packages/contracts/src/platform-runtime-host.ts': 1, - 'packages/contracts/src/platform-runtime-operations.ts': 2, - 'packages/contracts/src/platform-runtime-unavailable.ts': 30, - 'packages/contracts/src/platform-runtime.ts': 6, - 'packages/contracts/src/perf-runtime-host.ts': 1, - 'packages/contracts/src/perf-runtime-operation-builder.ts': 3, - 'packages/contracts/src/perf-runtime-plan.ts': 7, - 'packages/contracts/src/perf-runtime.ts': 1, - 'packages/contracts/src/record-runtime-execution.ts': 7, - 'packages/contracts/src/react-native-overlay.ts': 1, - 'packages/contracts/src/runner-lease-context.ts': 1, - 'packages/contracts/src/screen-recording-runtime-plan.ts': 5, - 'packages/contracts/src/screen-recording-runtime.ts': 1, - 'packages/contracts/src/screen-recording-runtime-host.ts': 1, - 'packages/contracts/src/screenshot-runtime.ts': 4, - 'packages/contracts/src/scroll-command.ts': 3, - 'packages/contracts/src/scroll-gesture.ts': 10, - 'packages/contracts/src/scroll-runtime.ts': 4, - 'packages/contracts/src/selector-observation-runtime.ts': 1, - 'packages/contracts/src/settings.ts': 3, - 'packages/contracts/src/snapshot-presentation.ts': 2, - 'packages/contracts/src/snapshot-runtime.ts': 3, - 'packages/contracts/src/snapshot-scope.ts': 1, - 'packages/contracts/src/snapshot-timeout-evidence.ts': 1, - 'packages/contracts/src/startup-recovery-fence.ts': 1, - 'packages/contracts/src/tv-remote.ts': 3, - 'packages/contracts/src/type-text-runtime.ts': 4, - 'packages/contracts/src/touch-runtime.ts': 4, - 'packages/contracts/src/viewport-runtime.ts': 1, - 'packages/contracts/src/wait-runtime-plan.ts': 1, - 'packages/contracts/src/wait.ts': 1, - - // --- @agent-device/kernel --- - 'packages/kernel/src/bounds.ts': 1, - 'packages/kernel/src/collections.ts': 1, - 'packages/kernel/src/contracts.ts': 4, - 'packages/kernel/src/device.ts': 4, - 'packages/kernel/src/errors.ts': 2, - // Added by #2041: keyed async lock moved from src/utils for the extracted IME lifecycle. - 'packages/kernel/src/keyed-lock.ts': 1, - 'packages/kernel/src/numeric.ts': 1, - 'packages/kernel/src/rect-center.ts': 2, - 'packages/kernel/src/rect.ts': 1, - 'packages/kernel/src/device-isolation.ts': 1, - 'packages/kernel/src/location-coordinates.ts': 3, - 'packages/kernel/src/record.ts': 3, - 'packages/kernel/src/scoped-provider.ts': 1, - 'packages/kernel/src/screenshot-geometry.ts': 1, - 'packages/kernel/src/source-value.ts': 3, - 'packages/kernel/src/success-text.ts': 1, - 'packages/kernel/src/ttl-memo.ts': 1, - 'packages/kernel/src/redaction.ts': 1, - 'packages/kernel/src/scroll-indicator.ts': 1, - 'packages/kernel/src/snapshot.ts': 1, - - // --- @agent-device/maestro --- - 'packages/maestro/src/index.ts': 111, - - // --- @agent-device/platform-*: ADR-0019's metadata-eager/implementation-lazy façades. Each - // evaluates only itself; every implementation sits behind a function-scoped `await import`. - // A pin of 1 is the tightest statement of that property the walker can make. - 'packages/platform-android/src/index.ts': 1, - 'packages/platform-android/src/adb-host.ts': 1, - // The named mechanics facet is intentionally implementation-eager once selected. Its exact - // closure is pinned so a future facade expansion is visible in review. - 'packages/platform-android/src/mechanics.ts': 178, - - // --- @agent-device/platform-apple --- - 'packages/platform-apple/src/index.ts': 1, - 'packages/platform-apple/src/app-lifecycle-facade.ts': 120, - 'packages/platform-apple/src/app-resolution-facade.ts': 61, - 'packages/platform-apple/src/debug-symbols-facade.ts': 24, - 'packages/platform-apple/src/doctor-facade.ts': 101, - 'packages/platform-apple/src/install-artifact-facade.ts': 42, - 'packages/platform-apple/src/macos-facade.ts': 25, - 'packages/platform-apple/src/perf-facade.ts': 60, - 'packages/platform-apple/src/physical-device-facade.ts': 47, - 'packages/platform-apple/src/runner-operations-facade.ts': 100, - 'packages/platform-apple/src/runner-owner-facade.ts': 2, - 'packages/platform-apple/src/simctl-facade.ts': 18, - 'packages/platform-apple/src/simulator-facade.ts': 25, - 'packages/platform-apple/src/tool-provider-facade.ts': 14, - - // --- @agent-device/platform-harmonyos --- - 'packages/platform-harmonyos/src/index.ts': 1, - - // --- @agent-device/platform-linux --- - 'packages/platform-linux/src/index.ts': 1, - - // --- @agent-device/platform-vega --- - 'packages/platform-vega/src/index.ts': 1, - - // --- @agent-device/platform-web --- - 'packages/platform-web/src/index.ts': 1, - - // --- @agent-device/provider-limrun --- - 'packages/provider-limrun/src/index.ts': 29, - - // --- @agent-device/provider-webdriver --- - 'packages/provider-webdriver/src/index.ts': 49, - - // --- @agent-device/replay-test --- - 'packages/replay-test/src/index.ts': 20, - - // --- @agent-device/selectors --- - 'packages/selectors/src/ast.ts': 16, - 'packages/selectors/src/engine.ts': 19, - 'packages/selectors/src/index.ts': 55, +export const HUB_ENTRY_FILES: readonly string[] = [ + 'src/cli.ts', + 'src/platform-runtime.ts', + 'src/core/command-descriptor/registry.ts', + 'src/core/command-descriptor/platform-execution-entry.ts', + 'src/core/interactors/register-builtins.ts', + 'src/daemon/session-teardown.ts', +]; - // --- @agent-device/xml --- - 'packages/xml/src/index.ts': 3, +/** A platform façade evaluates exactly this many modules: itself. */ +export const PLATFORM_FACADE_CLOSURE = 1; - // Added by #1993 (device-inventory context moved out of core). - 'packages/contracts/src/back-runtime.ts': 1, - // Added by Wave 6 R55/R56/R57/R58/R59: the clipboard, app-switcher, app-event, settings and - // alert facets, - // plus the local interaction set Android and Linux used to hold a byte-identical copy of each. - // The set is its own module rather than part of the interactor catalog so the catalog's closure - // stays leaf-thin -- every module the set pulls in is one its two consumers already evaluate. - 'packages/contracts/src/alert-runtime.ts': 1, - 'packages/contracts/src/app-event-runtime.ts': 1, - 'packages/contracts/src/app-switcher-runtime.ts': 1, - 'packages/contracts/src/clipboard-runtime.ts': 1, - 'packages/contracts/src/settings-runtime.ts': 1, - 'packages/contracts/src/local-interactor-operation-set.ts': 26, - 'packages/contracts/src/home-runtime.ts': 1, - 'packages/contracts/src/interactor-operation-catalog.ts': 14, - 'packages/contracts/src/keyboard-runtime.ts': 3, - 'packages/contracts/src/orientation-runtime.ts': 1, - 'packages/contracts/src/tv-remote-runtime.ts': 1, +/** + * Ceilings for entries that do not exist at the merge-base, per category. + * Provisional: per-category p75 at e624ef9d3f (2026-09-02); Day-0 maintainer decision pending. + */ +export const NEW_ENTRY_CEILINGS: Readonly> = Object.freeze({ + 'platform-facade': 1, + 'vocabulary-facade': 4, + 'domain-facade': 20, + 'mechanics-surface': 71, }); /** - * Designated hub modules: high-fan-in entry points whose closure the whole suite (or every CLI - * run) pays for. - * - * `cli.ts` and `session-teardown.ts` already carry ad hoc pins naming individual expensive - * modules (`cli-startup-import-closure.test.ts`, `session-teardown-import-closure.test.ts`); the - * five after them are the hubs #1969 moved off the wide contracts façades, pinned there by name - * (`contracts-entry-closure.test.ts`). Those tests state a STRONGER property for the one module - * each names; these pins add the general layer -- any unexpected growth, not only the shape - * someone already thought to forbid. - * - * `src/platform-runtime.ts` is the ADR-0019 composition root, the one production module allowed - * to value-import a concrete platform package. It evaluates all six family façades (metadata - * only), so its pin is also the assertion that composing the registry stays metadata-eager. + * First-introduced entries allowed over their category ceiling, keyed by repo-relative entry + * path. No measured value: the merge-base carries the entry from the next PR on. */ -export const HUB_BUDGETS: Readonly> = Object.freeze({ - // 363 -> 365 in #2004, which cuts per-invocation work and pays two modules for it: - // `@agent-device/kernel/ttl-memo` (version.ts now resolves the package version and the project root - // once per process instead of re-reading package.json several times an invocation) and - // `src/daemon/client/daemon-launch-spec.ts` (the launch-entry probe, split out of the 726-line - // daemon-client-lifecycle.ts). Both run on the path every local command already takes, so - // neither has a lazy seam to hide behind -- unlike `src/daemon/code-signature-cache.ts`, which - // the same PR added and only a source checkout reaches, and which therefore loads on demand - // (`resolveLocalDaemonCodeSignature`) rather than appearing here. - // #2054 splits daemon cleanup and managed web backend into separate neutral contract entries; - // the CLI already loads both command modules, so the second one-module contract is deliberate. - // #2027 splits the 705-line `commands/command-input.ts` into the three leaf modules the - // common-field table needs to exist without an import cycle: `input-readers.ts` (record - // readers), `input-audience.ts` (who may write a key), and `common-input-fields.ts` (the table - // itself). Every command schema already evaluated all three concerns; the growth is three more - // module records for the same code, with no new subtree behind any of them. - // #2148 moves output-only CLI dependencies behind call-time imports and reduces the entry - // closure by two modules. - // #2146 splits one eagerly reached URL utility into its client and Metro owners. - // #2236 adds the typed pre-admission --scope/--depth refusal for `wait absent` to the existing - // wait command reader. That deliberately keeps the shared absence option contract and error - // modules on the CLI path; the measured two-module growth is the contract being loaded, not - // implementation or platform machinery being pulled in eagerly. - 'src/cli.ts': 382, - 'src/platform-runtime.ts': 47, - 'src/core/command-descriptor/registry.ts': 72, - 'src/core/command-descriptor/platform-execution-entry.ts': 3, - 'src/core/interactors/register-builtins.ts': 6, - // R64 removes the perf plugin facet and keeps collector binding behind the selected runtime - // operation. Teardown now owns only neutral durable-resource cleanup; platform collectors load - // through the perf host when an admitted operation actually runs. - 'src/daemon/session-teardown.ts': 68, -}); +export const APPROVED_OVER_CEILING: Readonly< + Record +> = Object.freeze({}); + +/** The category is a function of the path, never a hand-written column. */ +export function entryCategoryOf(entryFile: string): EntryCategory { + if (/^packages\/platform-[^/]+\/src\/index\.ts$/.test(entryFile)) return 'platform-facade'; + if (entryFile.startsWith('packages/contracts/')) return 'vocabulary-facade'; + if (/^packages\/[^/]+\/src\//.test(entryFile)) return 'domain-facade'; + if (entryFile.startsWith('src/')) return 'mechanics-surface'; + throw new Error(`${entryFile} is neither a package entry surface nor a src/ hub`); +} /** * Mechanics facets inside platform packages. Their entry surfaces ARE platform implementation, * so the deny-platform assertion is meaningless for them: the whole closure is the mechanics - * being exported. Their weight stays pinned by the exact budgets. + * being exported. Their weight stays under the no-growth rule. */ const PLATFORM_MECHANICS_ENTRY_PREFIXES = [ 'packages/platform-apple/src/runner/', @@ -452,55 +165,52 @@ const APPLE_DOMAIN_MECHANICS_ENTRY_FILES: ReadonlySet = new Set([ 'packages/platform-apple/src/tool-provider-facade.ts', ]); -function toRows( - budgets: Readonly>, - kind: 'facade' | 'hub', -): EagerClosureBudget[] { - return Object.entries(budgets).map(([entryFile, budget]) => ({ +function toEntry(entryFile: string, kind: 'facade' | 'hub'): EagerClosureEntry { + return { id: entryFile, entryFile, - budget, kind, + category: entryCategoryOf(entryFile), denyPlatformImplementations: kind === 'facade' && !PLATFORM_MECHANICS_ENTRY_PREFIXES.some((prefix) => prefix.endsWith('/') ? entryFile.startsWith(prefix) : entryFile === prefix, ) && !APPLE_DOMAIN_MECHANICS_ENTRY_FILES.has(entryFile), - })); + }; } -/** - * The two records as one list. Uniqueness WITHIN each record is a compile error; the only - * duplicate still expressible is the same path appearing in both, which - * `eager-closure-budgets.test.ts` asserts against on this array, before any `Set` conversion - * could absorb it. - */ -export const EAGER_CLOSURE_BUDGETS: EagerClosureBudget[] = [ - ...toRows(FACADE_BUDGETS, 'facade'), - ...toRows(HUB_BUDGETS, 'hub'), -]; +/** Every entry the gate measures: the discovered façades, then the hubs. */ +export function eagerClosureEntries(repoRoot: string): EagerClosureEntry[] { + return [ + ...discoverFacadeEntryFiles(repoRoot).map((file) => toEntry(file, 'facade')), + ...HUB_ENTRY_FILES.map((file) => toEntry(file, 'hub')), + ]; +} -/** - * The ratchet verdict for one row: `null` when the pin is exact, otherwise the finding to report. - * - * Pure and separately tested, so both directions have a test that fails when the rule is wrong -- - * an `<=` comparison passes every under-budget case, and no assertion over the real tree can - * distinguish that from a correct rule while the tree happens to match its pins. - */ -export function classifyBudget(id: string, actual: number, budget: number): string | null { - if (actual === budget) return null; - if (actual > budget) { - return ( - `${id} evaluates ${actual} modules on import, pinned at ${budget}. Either something that ` + - 'used to load on demand now loads eagerly (fix the import), or the growth is deliberate ' + - 'and this row moves to the new number in the same PR.' - ); - } +/** The no-growth verdict: `null` unless the head closure is larger than the merge-base one. */ +export function classifyGrowth(id: string, base: number, head: number): string | null { + if (head <= base) return null; return ( - `${id} evaluates ${actual} modules on import, pinned at ${budget}. It shrank -- lower its ` + - `pin to ${actual} in this PR so the ratchet keeps the gain instead of leaving headroom a ` + - 'later regression could grow back into.' + `${id} evaluates ${head} modules on import; the merge-base evaluated ${base}. Something ` + + 'that used to load on demand now loads eagerly, or a new static edge was added: move it ' + + 'behind a function-scoped `await import`.' + ); +} + +/** The ceiling verdict for a first-introduced entry: `null` when it fits or is approved. */ +export function classifyNewEntry( + id: string, + category: EntryCategory, + head: number, + approved: boolean, +): string | null { + const ceiling = NEW_ENTRY_CEILINGS[category]; + if (head <= ceiling || approved) return null; + return ( + `${id} is a new ${category} entry evaluating ${head} modules on import, over the ` + + `${category} ceiling of ${ceiling}. Make its heavy edges lazy, or add an ` + + 'APPROVED_OVER_CEILING row naming the issue, the reason, and an owner.' ); } @@ -605,13 +315,8 @@ function renderOwningEdges( * common case, that edge is new and everything under it is attributed to it, so it sorts to the * top and the offending route is the first thing printed. * - * What it does NOT show: a diff against a recorded baseline. This gate persists each entry's - * module COUNT, not its module identity, so it cannot say "these three modules are new" -- only - * "these edges account for the weight". A regression added deep inside an already-large subtree - * is therefore attributed to the top-level edge containing it, not to the exact file that changed. - * Naming the true delta would mean checking in ~1,500 module paths and rewriting them on every - * contracts refactor; the count plus this attribution was judged the better trade. Reconstruct an - * exact delta when you need one by running the walker on the merge base. + * This is the diagnostic for an entry with no merge-base closure to diff against; + * `describeClosureGrowth` names the exact delta for one that has it. */ export function describeClosurePressure( graph: ReadonlyMap, @@ -628,6 +333,24 @@ export function describeClosurePressure( ); } +/** + * Where an existing entry grew: the shortest import route to the first module the merge-base did + * not evaluate, plus how many more there are. The walk is breadth-first, so the first one in + * closure order is the shallowest, which is where the new edge almost always is. + */ +export function describeClosureGrowth( + graph: ReadonlyMap, + baseClosure: ReadonlySet, + entryPath: string, + repoRoot: string, +): string { + const added = [...graph.keys()].filter((file) => file !== entryPath && !baseClosure.has(file)); + const first = added[0]; + if (first === undefined) return ' (no module is new against the merge-base)'; + const more = added.length > 1 ? `\n (+${added.length - 1} more newly evaluated module(s))` : ''; + return ` ${formatImportChain(graph, first, repoRoot)}${more}`; +} + /** * The same bounded shape for the platform-implementation assertion. * diff --git a/src/__tests__/eager-import-closure.fixtures.ts b/src/__tests__/eager-import-closure.fixtures.ts index 27bd53bd6..19a9a6455 100644 --- a/src/__tests__/eager-import-closure.fixtures.ts +++ b/src/__tests__/eager-import-closure.fixtures.ts @@ -106,23 +106,50 @@ export function eagerlyEvaluatedModules(fileName: string, source: string): strin return [...new Set([...staticEvaluatedRefs(parsed.module), ...dynamic])]; } -function resolveRelative(fromFile: string, specifier: string): string | null { - const candidate = path.resolve(path.dirname(fromFile), specifier); - if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate; - for (const suffix of ['.ts', '.tsx', '/index.ts']) { - if (fs.existsSync(`${candidate}${suffix}`)) return `${candidate}${suffix}`; +/** + * How the walker sees a source tree, by absolute path. The working tree is the default; a + * committed git tree (`scripts/__tests__/committed-source-tree.ts`) answers the same four + * questions for a merge-base without checking it out. + */ +export type SourceTreeReader = { + exists(file: string): boolean; + isFile(file: string): boolean; + readdir(dir: string): string[]; + readFile(file: string): string; +}; + +const workingTreeReader: SourceTreeReader = { + exists: (file) => fs.existsSync(file), + isFile: (file) => fs.existsSync(file) && fs.statSync(file).isFile(), + readdir: (dir) => fs.readdirSync(dir), + readFile: (file) => fs.readFileSync(file, 'utf8'), +}; + +/** + * `.ts` only, matching what the repo counts as a production source: `tracked-sources.ts` scans + * `.ts` pathspecs and `isProductionSourceFile` accepts `.ts`, so a `.tsx` file under a walked root + * is invisible to every layering scan. Resolving one here would only produce an edge the committed + * tree reader cannot read, which crashes the ratchet instead of failing it. + */ +function resolveRelative(from: string, specifier: string, tree: SourceTreeReader): string | null { + const candidate = path.resolve(path.dirname(from), specifier); + if (tree.isFile(candidate)) return candidate; + for (const suffix of ['.ts', '/index.ts']) { + if (tree.exists(`${candidate}${suffix}`)) return `${candidate}${suffix}`; } return null; } /** `@agent-device/` -> that package's directory, keyed by its declared name. */ -function readWorkspacePackageDirs(): Map { +function readWorkspacePackageDirs(tree: SourceTreeReader): Map { const packagesRoot = path.resolve(import.meta.dirname, '../../packages'); const dirs = new Map(); - for (const entry of fs.readdirSync(packagesRoot)) { + for (const entry of tree.readdir(packagesRoot)) { const manifestPath = path.join(packagesRoot, entry, 'package.json'); - if (!fs.existsSync(manifestPath)) continue; - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: string }; + if (!tree.exists(manifestPath)) continue; + const manifest = JSON.parse(tree.readFile(manifestPath)) as { + name?: string; + }; if (manifest.name) dirs.set(manifest.name, path.join(packagesRoot, entry)); } return dirs; @@ -130,8 +157,8 @@ function readWorkspacePackageDirs(): Map { type ExportTarget = { default?: string; types?: string } | string; -function readExportTarget(packageDir: string, subpath: string): string | undefined { - const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as { +function exportTargetOf(dir: string, subpath: string, tree: SourceTreeReader): string | undefined { + const manifest = JSON.parse(tree.readFile(path.join(dir, 'package.json'))) as { exports?: Record; }; const target = manifest.exports?.[subpath]; @@ -144,39 +171,71 @@ function readExportTarget(packageDir: string, subpath: string): string | undefin * pull a heavy module in just as effectively as a file under src/, and stopping the * walk at the package boundary would be the same blind spot in a new place. */ -function resolveWorkspace(specifier: string, packageDirs: Map): string | null { +function resolveWorkspace( + specifier: string, + packageDirs: Map, + tree: SourceTreeReader, +): string | null { const match = WORKSPACE_SPECIFIER.exec(specifier); const packageName = match?.[1]; const packageDir = packageName ? packageDirs.get(packageName) : undefined; if (!packageDir) return null; - const target = readExportTarget(packageDir, `.${match?.[2] ?? ''}`); + const target = exportTargetOf(packageDir, `.${match?.[2] ?? ''}`, tree); if (!target) return null; const resolved = path.resolve(packageDir, target); - return fs.existsSync(resolved) ? resolved : null; + return tree.exists(resolved) ? resolved : null; } /** - * The repo files `file` evaluates directly, already resolved to absolute paths. - * - * Memoized: the budget table in `eager-closure-budgets.ts` walks ~100 entries whose - * subtrees overlap heavily (every contracts entry bottoms out in the same kernel - * modules), so without this each shared file is re-read and re-parsed once per entry - * that reaches it. Source files do not change during a run, so the cache is safe for - * the lifetime of the worker. + * Memoized per tree: the budget gate walks ~200 entries whose subtrees overlap heavily, so + * without this each shared file is re-read and resolved once per entry that reaches it. A + * tree's content does not change during a run, so the memo lives as long as its reader. */ -const directEdgeCache = new Map(); +type TreeMemo = { + packageDirs: Map; + directEdges: Map; +}; +const treeMemos = new WeakMap(); + +function memoOf(tree: SourceTreeReader): TreeMemo { + let memo = treeMemos.get(tree); + if (!memo) { + memo = { + packageDirs: readWorkspacePackageDirs(tree), + directEdges: new Map(), + }; + treeMemos.set(tree, memo); + } + return memo; +} + +/** + * Specifiers per file, keyed by content: a merge-base and a working tree share almost every file + * byte-for-byte and parsing is the expensive step, so a second tree parses only what differs. + */ +const parsedByFile = new Map(); + +function specifiersOf(file: string, source: string): string[] { + const cached = parsedByFile.get(file); + if (cached && cached.source === source) return cached.specifiers; + const specifiers = eagerlyEvaluatedModules(file, source); + parsedByFile.set(file, { source, specifiers }); + return specifiers; +} -function directEagerEdges(file: string, packageDirs: Map): string[] { - const cached = directEdgeCache.get(file); +/** The repo files `file` evaluates directly, already resolved to absolute paths. */ +function directEagerEdges(file: string, tree: SourceTreeReader): string[] { + const memo = memoOf(tree); + const cached = memo.directEdges.get(file); if (cached) return cached; const resolvedEdges: string[] = []; - for (const specifier of eagerlyEvaluatedModules(file, fs.readFileSync(file, 'utf8'))) { + for (const specifier of specifiersOf(file, tree.readFile(file))) { const resolved = specifier.startsWith('.') - ? resolveRelative(file, specifier) - : resolveWorkspace(specifier, packageDirs); + ? resolveRelative(file, specifier, tree) + : resolveWorkspace(specifier, memo.packageDirs, tree); if (resolved) resolvedEdges.push(resolved); } - directEdgeCache.set(file, resolvedEdges); + memo.directEdges.set(file, resolvedEdges); return resolvedEdges; } @@ -190,14 +249,16 @@ function directEagerEdges(file: string, packageDirs: Map): strin * Breadth-first, so following the links back yields the SHORTEST chain to each file * rather than whatever route a depth-first walk happened to take. */ -export function eagerClosureGraphOf(entryFile: string): Map { - const packageDirs = readWorkspacePackageDirs(); +export function eagerClosureGraphOf( + entryFile: string, + tree: SourceTreeReader = workingTreeReader, +): Map { const cameFrom = new Map([[entryFile, null]]); const queue = [entryFile]; for (let head = 0; head < queue.length; head += 1) { const current = queue[head]; if (current === undefined) continue; - for (const resolved of directEagerEdges(current, packageDirs)) { + for (const resolved of directEagerEdges(current, tree)) { if (cameFrom.has(resolved)) continue; cameFrom.set(resolved, current); queue.push(resolved); @@ -212,6 +273,9 @@ export function eagerClosureGraphOf(entryFile: string): Map