Skip to content

Commit 006f2d9

Browse files
authored
chore(gates): layering baselines ratchet against merge-base (#2299)
* refactor(layering): ratchet R6, R9 and R10 against the merge-base tree R6 type-spine inversions, R9's largest type cycle and R10's R7 ownership pressure now compare the working tree with the same measurement taken over the merge-base with origin/main, read through the shared committed-tree reader (one git ls-tree, one git cat-file --batch, no second checkout). Growth still fails with the same message shape, a shrink needs no edit, and no change can bank headroom by leaving a number above the tree. R9's per-zone check gains membership from the reference, so the overflow message names the file that joined instead of listing the whole zone. * chore(gates): delete the R6, R9 and R10 pins the merge-base now supplies TYPE_INVERSION_BASELINE, LARGEST_TYPE_CYCLE_ZONE_CEILINGS, TYPE_CYCLE_BASELINE and DAEMON_MODULARITY_BASELINE.sessionState were the hand-edited references these three ratchets compared against. The merge-base measurement replaces them, so there is no number left to leave above the tree and no entry to raise. externalDaemonTypesImporters stays: it names files, not a count.
1 parent 17e0c86 commit 006f2d9

15 files changed

Lines changed: 659 additions & 296 deletions

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,12 @@ jobs:
100100
runs-on: ubuntu-latest
101101
timeout-minutes: 15
102102
steps:
103+
# The layering ratchets (R6/R9/R10) measure the merge-base with origin/main, which a
104+
# shallow checkout cannot reach.
103105
- name: Checkout
104106
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
107+
with:
108+
fetch-depth: 0
105109

106110
# The layering gate parses production sources with `oxc-parser`, so
107111
# dependencies are required; keep install-deps enabled.
@@ -117,7 +121,7 @@ jobs:
117121

118122
# Model tests for the dependency-graph report and its blast-radius query. The report
119123
# reads the gate's model (scripts/layering/model.ts) and applies the gate's own R6
120-
# counting rule, so it is not a second measurement of TYPE_INVERSION_BASELINE.
124+
# counting rule, so it is not a second measurement of the R6 ratchet.
121125
- name: Check the depgraph report model
122126
uses: ./.github/actions/run-gate
123127
with: { gate: depgraph }

docs/dependency-graph-findings.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ const files = listSourceFiles();
2222
const sources = new Map(files.map((f) => [f, fs.readFileSync(f, 'utf8')]));
2323
const edges = resolveImportEdges(sources);
2424

25-
// e.g. R6 inversions per zone pair, deduplicated by file pair — reproduces
26-
// TYPE_INVERSION_BASELINE, so a mismatch means one of the two is stale.
25+
// e.g. R6 inversions per zone pair, deduplicated by file pair — the same count the gate
26+
// ratchets against the merge-base with origin/main.
2727
const seen = new Set<string>();
2828
const byPair = new Map<string, number>();
2929
for (const edge of edges) {
@@ -126,8 +126,9 @@ narrow name replaced both.
126126
from `daemon-command-registry.ts` to key an exhaustive owner-file map; that remaining inversion
127127
is the commands-zone consumer, not a second source of truth for the union.
128128

129-
All remaining inversions are argued at `TYPE_INVERSION_BASELINE` in `scripts/layering/check.ts`, next
130-
to the numbers they explain.
129+
All remaining inversions are argued here. R6 (`scripts/layering/type-inversion-ratchet.ts`) records
130+
no numbers of its own: its reference is the same count taken at the merge-base with `origin/main`,
131+
so a zone pair can only shrink.
131132

132133
## 0b. The biggest structural finding is not an inversion
133134

@@ -156,13 +157,13 @@ but it is a comprehension one, and it is the single largest obstacle to reading
156157
isolation. At the current measured commit it spans `commands` (33), `daemon-server` (30),
157158
`platforms` (19), `core` (12), root composition (5), `contracts` (2), and `client` (1).
158159

159-
Now ratcheted for growth by **R9** (`TYPE_CYCLE_BASELINE`, derived from the zone ceilings in
160-
`scripts/layering/daemon-modularity.ts`), so it cannot get worse
160+
Now ratcheted for growth by **R9** (`scripts/layering/daemon-modularity.ts`), so it cannot get worse
161161
while nobody is looking — a type-only import that closes a new loop fails the gate, verified by
162162
adding one type-only import that closes a loop and watching the gate reject it. It was growth-only
163-
here; #1781 A6 made it an equality pin, so a baseline left above the measured size fails too and a
164-
shrink is banked by the change that earns it. The refactor itself is still deliberately not
165-
attempted; it starts at those four hubs.
163+
here; #1781 A6 made it an equality pin, and the pin is now the merge-base's own measurement
164+
(`scripts/layering/ratchet-reference.ts`), so a shrink is banked the moment it merges and there is
165+
no slack left to spend. The refactor itself is still deliberately not attempted; it starts at those
166+
four hubs.
166167

167168
### The facade cycle: investigated, no narrower port exists
168169

@@ -183,14 +184,15 @@ duplicate the public API shape — a second source of truth for it — or derive
183184
carry the same dependency.
184185

185186
Those four files are therefore the minimum number of naming sites, not an accident: they are the
186-
choke point. Accepted as a position, argued at `TYPE_INVERSION_BASELINE`. The option this section
187+
choke point. Accepted as a position, argued in §0 above. The option this section
187188
used to hold open — moving `NAVIGATION_COMMAND_PROJECTIONS` out of `commands/` — was answered by
188189
deleting it: five direct signatures replaced the registry, so there is no longer a projection
189190
registry whose home is in question.
190191

191192
## 1. The two remaining type-inversion clusters
192193

193-
`TYPE_INVERSION_BASELINE` in `scripts/layering/check.ts` holds both, with the reasoning inline.
194+
§0 above holds both, with the reasoning inline; the gate measures them against the merge-base
195+
rather than recording them.
194196

195197
**28 + 1 edges → `client/client-types.ts`***done, mostly.* Now 5 edges. The vocabulary moved into
196198
the `contracts/client-*.ts` family files — one file per command/domain family, largest 137 LOC —

scripts/__tests__/committed-source-tree.ts

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,56 @@ export function renamedSince(repoRoot: string, base: string): ReadonlyMap<string
4444
return renamed;
4545
}
4646

47+
/** Every path tracked at `treeish`, repo-root-relative, from ONE `git ls-tree`. */
48+
function listCommittedTree(repoRoot: string, treeish: string): string[] {
49+
const listing = git(repoRoot, ['ls-tree', '-r', '--name-only', '-z', treeish]).toString('utf8');
50+
return listing.split('\0').filter(Boolean);
51+
}
52+
53+
/**
54+
* The sources this reader serves, split by kind: production TypeScript under `src/` and
55+
* `packages/<pkg>/src/`, and workspace package manifests. One definition, so a consumer reading
56+
* a committed tree cannot classify it differently from the walker.
57+
*/
58+
function committedSourceSet(tracked: readonly string[]): {
59+
sources: string[];
60+
manifests: string[];
61+
} {
62+
return {
63+
sources: tracked.filter((file) => WALKED_SOURCE.test(file) && isProductionSourceFile(file)),
64+
manifests: tracked.filter((file) => WALKED_MANIFEST.test(file)),
65+
};
66+
}
67+
68+
/** Contents of `files` at `treeish`, through ONE long-lived `git cat-file --batch`. */
69+
function readCommittedBlobs(
70+
repoRoot: string,
71+
treeish: string,
72+
files: readonly string[],
73+
): Map<string, string> {
74+
if (files.length === 0) return new Map();
75+
const requests = files.map((file) => `${treeish}:${file}\n`).join('');
76+
return parseCatFileBatch(git(repoRoot, ['cat-file', '--batch'], requests), files);
77+
}
78+
79+
/**
80+
* The same enumeration and blob read as `createCommittedSourceTree`, handed over as text: the
81+
* production sources and workspace manifests committed at `treeish`. A whole-tree measurement
82+
* (the layering ratchets) needs the corpus rather than a reader, and taking it from here is what
83+
* keeps its file set identical to the closure walker's.
84+
*/
85+
// fallow-ignore-next-line unused-export -- consumed by scripts/layering, outside fallow's scope
86+
export function readCommittedSources(
87+
repoRoot: string,
88+
treeish: string,
89+
): { sources: Map<string, string>; manifests: Map<string, string> } {
90+
const { sources, manifests } = committedSourceSet(listCommittedTree(repoRoot, treeish));
91+
const blobs = readCommittedBlobs(repoRoot, treeish, [...sources, ...manifests]);
92+
const only = (files: readonly string[]) =>
93+
new Map(files.flatMap((file) => (blobs.has(file) ? [[file, blobs.get(file)!] as const] : [])));
94+
return { sources: only(sources), manifests: only(manifests) };
95+
}
96+
4797
/**
4898
* `<sha> blob <size>\n<size bytes>\n` per hit and `<request> missing\n` per miss, in request
4999
* order. Sizes are bytes, so this walks the raw buffer rather than a string offset.
@@ -82,15 +132,11 @@ function directoriesOf(files: ReadonlySet<string>): Set<string> {
82132
* whole tree, never one per file. A read outside that set is a widening request, not a fallback.
83133
*/
84134
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));
135+
const listing = listCommittedTree(repoRoot, treeish);
136+
const tracked = new Set(listing);
87137
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);
138+
const { sources, manifests } = committedSourceSet(listing);
139+
const contents = readCommittedBlobs(repoRoot, treeish, [...sources, ...manifests]);
94140
const relative = (file: string) => path.relative(repoRoot, file).split(path.sep).join('/');
95141
return {
96142
exists: (file) => tracked.has(relative(file)) || directories.has(relative(file)),

scripts/depgraph/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ it returns an empty list, which is the gate passing, not a broken query.
8181

8282
`pnpm check:layering` is. The report reads the same model (`scripts/layering/model.ts`) and applies
8383
the gate's own counting rule — `typeInversionsByPair` counts once per file pair over the raw edges,
84-
exactly as `checkTypeInversions` in `scripts/layering/check.ts` does — so `typeInversions` reproduces
85-
`TYPE_INVERSION_BASELINE` by construction, not by a second measurement. CI used to assert that
86-
equality; it was a duplicate detector of the same code path and was removed. In particular the count
84+
exactly as `typeInversionCounts` in `scripts/layering/model.ts` does — so `typeInversions` reproduces
85+
the gate's R6 measurement by construction, not by a second measurement. The gate compares that
86+
measurement with the merge-base's; CI used to assert the report agreed with a recorded baseline,
87+
which was a duplicate detector of the same code path and was removed. In particular the count
8788
does NOT come from the collapsed edge list, where `dynamic` outranks `type` and a module imported
8889
both lazily and for its types would drop out.
8990

0 commit comments

Comments
 (0)