From 0ad75674fc8db2faacc39e221a4dc11939c60ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 18 Aug 2026 18:51:51 +0200 Subject: [PATCH 1/2] fix(layering): list the whole zone when R10's type-cycle ceiling is exceeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-zone R10 violation named members.find() — the alphabetically-first zone member, a file that had been in the cycle all along — so the +1 in #1825 x #1779 was found only by diffing largestTypeCycleMembers between commits. The ceiling records a count, not a membership, so the gate cannot name the joining file; it now lists every member of the over-budget zone and annotates the ceiling table instead. Closes #1837 --- scripts/layering/daemon-modularity.test.ts | 24 ++++++++++++++++ scripts/layering/daemon-modularity.ts | 32 ++++++++++++++++------ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 91b9d24df..187a0bd21 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -219,6 +219,30 @@ test('R9 records zone ceilings and keeps engine files outside the largest compon assert.ok(violations.some(({ message }) => /engine file entered/.test(message))); }); +// #1837: the zone violation used to name the alphabetically-first zone member — a file that had +// been in the cycle all along — so the +1 was found only by diffing member lists between commits. +// The ceiling records a count, not a membership, so the message lists every zone member instead. +test('R10 zone overflow lists the whole zone so the joining member is visible', () => { + const zones = DAEMON_MODULARITY_BASELINE.largestTypeCycle.zoneMembers; + // Sorts after the daemon-server probes: the old first-member pick could not name it by luck. + const joined = 'src/daemon/snapshot-interactor-capture.ts'; + const members = [...baselineTypeCycleMembers({ commands: zones.commands - 1 }), joined].sort(); + const daemonMembers = members.filter((member) => member.startsWith('src/daemon/')); + assert.notEqual(daemonMembers[0], joined); + + const violations = checkDaemonModularityRatchets(baselineEdges(), members); + + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation!.rule, 'R10 daemon-modularity'); + assert.equal(violation!.file, 'scripts/layering/daemon-modularity.ts'); + assert.match(violation!.message, /contains 17 daemon-server file\(s\) \(baseline 16\)/); + for (const member of daemonMembers) { + assert.ok(violation!.message.includes(member), `${member} missing from: ${violation!.message}`); + } + assert.match(violation!.message, /1 of these joined with this change/); +}); + // Growth was always rejected; a baseline left ABOVE the measured size used to be a suggestion // in the success line, which is headroom the next change spends without a number moving. test('R9 rejects a baseline left above the measured cycle', () => { diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index 09e47613e..f975f772a 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -165,15 +165,24 @@ function checkTypeCycleBaseline(members: readonly string[]): LayeringViolation[] }); } - const zoneCounts = countBy(members, targetDagZone); - for (const [zone, count] of zoneCounts) { + const membersByZone = groupBy(members, targetDagZone); + for (const [zone, zoneMembers] of membersByZone) { const allowed = baseline.zoneMembers[zone] ?? 0; - if (count <= allowed) continue; + if (zoneMembers.length <= allowed) continue; + // The ceiling records a count, not a membership, so the gate cannot name the file that + // joined; naming the alphabetically-first member instead sent #1837's diagnosis to a file + // that had been in the cycle all along. List the whole zone so the joining edge is one + // diff away from the author, who knows which of these files the change touched. violations.push({ rule: 'R10 daemon-modularity', - file: members.find((member) => targetDagZone(member) === zone) ?? 'scripts/layering/check.ts', + file: 'scripts/layering/daemon-modularity.ts', line: 1, - message: `the largest type cycle now contains ${count} ${zone} file(s) (baseline ${allowed}); extraction must not trade one zone's locality for another's.`, + message: + `the largest type cycle now contains ${zoneMembers.length} ${zone} file(s) (baseline ` + + `${allowed}); extraction must not trade one zone's locality for another's. ` + + `${zone} members: ${zoneMembers.join(', ')}. ${zoneMembers.length - allowed} of these ` + + `joined with this change (a new file, or a new import that closed the loop); cut that ` + + `edge rather than raising the ceiling.`, }); } @@ -285,13 +294,18 @@ function isInsideInternalTree(file: string, roots: readonly string[]): boolean { return roots.some((root) => file.startsWith(path.posix.join(root, 'internal/'))); } -function countBy(values: readonly string[], keyOf: (value: string) => string): Map { - const counts = new Map(); +function groupBy( + values: readonly string[], + keyOf: (value: string) => string, +): Map { + const groups = new Map(); for (const value of values) { const key = keyOf(value); - counts.set(key, (counts.get(key) ?? 0) + 1); + const group = groups.get(key) ?? []; + group.push(value); + groups.set(key, group); } - return counts; + return groups; } export function daemonModularitySummary(): string { From 60f88f3d521421585d17e2cad9e4ab9cec4d2bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 19 Aug 2026 07:51:51 +0200 Subject: [PATCH 2/2] fix(layering): state the zone overflow in net terms Review nit: the overflow is net growth over the ceiling, not a join count (two joins and one departure print "1"), so the message no longer claims N members joined. --- scripts/layering/daemon-modularity.test.ts | 2 +- scripts/layering/daemon-modularity.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/layering/daemon-modularity.test.ts b/scripts/layering/daemon-modularity.test.ts index 187a0bd21..ae8afd1e6 100644 --- a/scripts/layering/daemon-modularity.test.ts +++ b/scripts/layering/daemon-modularity.test.ts @@ -240,7 +240,7 @@ test('R10 zone overflow lists the whole zone so the joining member is visible', for (const member of daemonMembers) { assert.ok(violation!.message.includes(member), `${member} missing from: ${violation!.message}`); } - assert.match(violation!.message, /1 of these joined with this change/); + assert.match(violation!.message, /1 over the ceiling — the member\(s\) that joined are among/); }); // Growth was always rejected; a baseline left ABOVE the measured size used to be a suggestion diff --git a/scripts/layering/daemon-modularity.ts b/scripts/layering/daemon-modularity.ts index f975f772a..7d908f497 100644 --- a/scripts/layering/daemon-modularity.ts +++ b/scripts/layering/daemon-modularity.ts @@ -172,7 +172,9 @@ function checkTypeCycleBaseline(members: readonly string[]): LayeringViolation[] // The ceiling records a count, not a membership, so the gate cannot name the file that // joined; naming the alphabetically-first member instead sent #1837's diagnosis to a file // that had been in the cycle all along. List the whole zone so the joining edge is one - // diff away from the author, who knows which of these files the change touched. + // diff away from the author, who knows which of these files the change touched. The + // overflow is net growth (a join and a departure cancel out), so it bounds nothing about + // how many members are new — only that at least one of the listed files is. violations.push({ rule: 'R10 daemon-modularity', file: 'scripts/layering/daemon-modularity.ts', @@ -180,9 +182,9 @@ function checkTypeCycleBaseline(members: readonly string[]): LayeringViolation[] message: `the largest type cycle now contains ${zoneMembers.length} ${zone} file(s) (baseline ` + `${allowed}); extraction must not trade one zone's locality for another's. ` + - `${zone} members: ${zoneMembers.join(', ')}. ${zoneMembers.length - allowed} of these ` + - `joined with this change (a new file, or a new import that closed the loop); cut that ` + - `edge rather than raising the ceiling.`, + `${zoneMembers.length - allowed} over the ceiling — the member(s) that joined are among ` + + `these ${zone} files: ${zoneMembers.join(', ')}. Cut the edge that pulled them in ` + + `rather than raising the ceiling.`, }); }