Skip to content

Commit e2ce985

Browse files
authored
chore(gates): eager-closure budgets ratchet against merge-base with per-category ceilings (#2257)
* refactor(closure): walk a source tree through a reader seam The eager-import-closure walker read the working tree directly through fs, so every consumer could only ask about the checkout in front of it. Closure computation now takes a SourceTreeReader; the working tree stays the default, and a committed git tree answers the same four questions for any tree-ish without checking it out -- one `git ls-tree` for the tracked set and one long-lived `git cat-file --batch` for the sources the walker can reach. Per-tree memoization of package directories and direct edges, plus a content-keyed parse cache, keep a second tree paying only for what differs. * chore(gates): eager-closure budgets ratchet against merge-base with per-category ceilings The 202 façade and 6 hub numeric pins are gone. The six platform façades stay exact at one module, every other existing entry may evaluate no more than the same file evaluated at the merge-base with origin/main (renames followed), and an entry that did not exist there fits a per-category ceiling derived from its path, or carries an APPROVED_OVER_CEILING row naming issue, reason and owner. Shrinking now needs no gate edit, and a stale approval fails. The standing denial -- a façade closure never reaches a concrete platform implementation before discovery or binding selects an owner -- is unchanged. * chore(gates): scope stale approvals to introduced entries and keep readers in sync Address review findings on the eager-closure merge-base ratchet. - docs/agents/testing.md: drop the new bullet. The file was 386 bytes over the 10,000-byte focused-doc budget, and the gate module's header already owns the invariant, so the prose was duplication the ownership rule forbids. - The closure walker's relative resolver no longer tries a .tsx suffix. The repo defines a production source as .ts (tracked-sources.ts pathspecs and isProductionSourceFile), so the committed-tree reader never loads .tsx content; resolving one produced an edge that reader could not read, crashing the ratchet instead of failing it. - The APPROVED_OVER_CEILING staleness check now looks only at entries still first-introduced. Once the merge-base carries an entry, the no-growth rule governs it and nothing reads its row again, so the row is stale for the same reason a shrunk entry's row is.
1 parent cafaded commit e2ce985

4 files changed

Lines changed: 502 additions & 515 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// A committed git tree as the closure walker's source of truth: the merge-base with origin/main,
2+
// read without checking it out, so a ratchet compares against what actually landed.
3+
4+
import { execFileSync } from 'node:child_process';
5+
import path from 'node:path';
6+
import type { SourceTreeReader } from '../../src/__tests__/eager-import-closure.fixtures.ts';
7+
import { isProductionSourceFile } from '../layering/tracked-sources.ts';
8+
9+
const WALKED_SOURCE = /^(?:src|packages\/[^/]+\/src)\/.*\.ts$/;
10+
const WALKED_MANIFEST = /^packages\/[^/]+\/package\.json$/;
11+
12+
function git(repoRoot: string, args: readonly string[], input = ''): Buffer {
13+
return execFileSync('git', [...args], {
14+
cwd: repoRoot,
15+
input,
16+
maxBuffer: 256 * 1024 * 1024,
17+
stdio: ['pipe', 'pipe', 'pipe'],
18+
});
19+
}
20+
21+
/** The commit a branch's closures ratchet against: `git merge-base origin/main HEAD`. */
22+
export function mergeBaseWithMain(repoRoot: string): string {
23+
try {
24+
return git(repoRoot, ['merge-base', 'origin/main', 'HEAD']).toString('utf8').trim();
25+
} catch (error) {
26+
const stderr = (error as { stderr?: Buffer }).stderr?.toString('utf8').trim() ?? '';
27+
throw new Error(
28+
'The eager-closure ratchet needs origin/main to find its merge-base (git merge-base ' +
29+
`origin/main HEAD failed: ${stderr}). Fetch origin/main; the gate does not skip.`,
30+
{ cause: error },
31+
);
32+
}
33+
}
34+
35+
/** Files renamed since `base`, current path -> path at `base`, so a rename is not a new entry. */
36+
export function renamedSince(repoRoot: string, base: string): ReadonlyMap<string, string> {
37+
const renamed = new Map<string, string>();
38+
const status = git(repoRoot, ['diff', '--name-status', '-M', '--diff-filter=R', '-z', base]);
39+
const fields = status.toString('utf8').split('\0');
40+
for (let index = 0; index + 2 < fields.length; index += 3) {
41+
const [from, to] = [fields[index + 1], fields[index + 2]];
42+
if (from && to) renamed.set(to, from);
43+
}
44+
return renamed;
45+
}
46+
47+
/**
48+
* `<sha> blob <size>\n<size bytes>\n` per hit and `<request> missing\n` per miss, in request
49+
* order. Sizes are bytes, so this walks the raw buffer rather than a string offset.
50+
*/
51+
function parseCatFileBatch(output: Buffer, files: readonly string[]): Map<string, string> {
52+
const contents = new Map<string, string>();
53+
let offset = 0;
54+
for (const file of files) {
55+
const headerEnd = output.indexOf(0x0a, offset);
56+
const blob = /^\S+ blob (\d+)$/.exec(output.toString('utf8', offset, headerEnd));
57+
offset = headerEnd + 1;
58+
if (!blob) continue;
59+
const size = Number(blob[1]);
60+
contents.set(file, output.toString('utf8', offset, offset + size));
61+
offset += size + 1;
62+
}
63+
return contents;
64+
}
65+
66+
/** Every ancestor directory of the tracked paths, so `exists` answers for directories too. */
67+
function directoriesOf(files: ReadonlySet<string>): Set<string> {
68+
const directories = new Set<string>();
69+
for (const file of files) {
70+
for (let dir = path.posix.dirname(file); dir !== '.'; dir = path.posix.dirname(dir)) {
71+
if (directories.has(dir)) break;
72+
directories.add(dir);
73+
}
74+
}
75+
return directories;
76+
}
77+
78+
/**
79+
* The walker's view of `treeish`: tracked paths from one `git ls-tree`, and every source the
80+
* walker can reach (production TypeScript under `src/` and `packages/<pkg>/src/`, package
81+
* manifests) from ONE `git cat-file --batch` fed those paths up front -- two processes for the
82+
* whole tree, never one per file. A read outside that set is a widening request, not a fallback.
83+
*/
84+
export function createCommittedSourceTree(repoRoot: string, treeish: string): SourceTreeReader {
85+
const listing = git(repoRoot, ['ls-tree', '-r', '--name-only', '-z', treeish]).toString('utf8');
86+
const tracked = new Set(listing.split('\0').filter(Boolean));
87+
const directories = directoriesOf(tracked);
88+
const walked = [...tracked].filter(
89+
(file) =>
90+
WALKED_MANIFEST.test(file) || (WALKED_SOURCE.test(file) && isProductionSourceFile(file)),
91+
);
92+
const requests = walked.map((file) => `${treeish}:${file}\n`).join('');
93+
const contents = parseCatFileBatch(git(repoRoot, ['cat-file', '--batch'], requests), walked);
94+
const relative = (file: string) => path.relative(repoRoot, file).split(path.sep).join('/');
95+
return {
96+
exists: (file) => tracked.has(relative(file)) || directories.has(relative(file)),
97+
isFile: (file) => tracked.has(relative(file)),
98+
readdir: (dir) => {
99+
const prefix = `${relative(dir)}/`;
100+
const names = new Set<string>();
101+
for (const entry of [...tracked, ...directories]) {
102+
if (entry.startsWith(prefix)) names.add(entry.slice(prefix.length).split('/')[0] ?? '');
103+
}
104+
names.delete('');
105+
return [...names].sort();
106+
},
107+
readFile: (file) => {
108+
const source = contents.get(relative(file));
109+
if (source === undefined) {
110+
throw new Error(`${relative(file)} is not a source the closure walker reads at ${treeish}`);
111+
}
112+
return source;
113+
},
114+
};
115+
}

0 commit comments

Comments
 (0)