Skip to content

Commit 057ab1c

Browse files
authored
fix(layering): stop double-reporting contracts-authority violations (#1746)
* fix(layering): stop double-reporting contracts-authority violations main()'s violation list spread checkContractsImplementationAuthority(sources) twice, so every R11 contracts-implementation-authority finding was printed and ::error-annotated twice on a red run — inflating the headline violation count and producing duplicate GitHub annotations on the same file:line. Verified by planting a `setTimeout` call in a contracts production source: the rule reported 2 identical violations before and 1 after, with the extra annotation gone. `pnpm check:layering` stays green (136/136 policy tests). Nothing in the suite covers main()'s assembly of the violation list — the policy tests all call their rule functions directly — so neither a duplicated nor a dropped entry there is currently detectable. * test(layering): hold main() to wiring every rule exactly once The duplicate this branch removed survived because nothing enumerates the guard's rules: main()'s violation list is hand-written, and the per-policy tests call their rule functions directly, never seeing the wiring. A lost spread is the dangerous version of the same gap — the rule stops being enforced and the run still prints OK. Make the file's own bindings the oracle: every in-scope `check*` value, local or imported, must be spread into main()'s violation list exactly once. That covers both directions plus a third case — a policy written and never wired in. Fails closed if main() or the array is renamed, so the instrument cannot pass by finding nothing. Test-only rather than an R17 inside the guard: a self-referential rule is defeated by dropping its own spread, which is exactly the failure it exists to catch. Verified by mutating the real check.ts in both directions (re-planting the duplicate, then dropping checkZeroDepJobs) — each turns the run red, and the restored file is green at 143/143. * test(layering): discover layering suites by glob instead of by hand check:layering named its 14 test files one by one, so adding a policy test meant remembering to register it — and twice nobody did. Both halves of the R16 record cutover shipped with tests that have never run: scripts/layering/record-runtime-mechanics-policy.test.ts (2 tests) scripts/layering/record-runtime-registry-policy.test.ts (1 test) Their policies are live in the guard; only the tests were dormant. All three pass, so nothing had rotted — the coverage was simply never being collected. Glob the directory the way mutation:test already globs its own, which makes the filesystem the enumeration and retires the registration step. 143 -> 146 tests, still green. This is the same defect as the duplicate spread this branch opened with, one level up: a hand-maintained list with nothing checking it against reality. * refactor(layering): register guard rules in a keyed table Replaces the AST wiring guard with a construction that cannot express the defect, per review on #1746. The parser was the wrong instrument: it reconstructed one array's shape from TypeScript syntax, so it only recognised top-level function declarations and imports whose local name matched /^check[A-Z]/. A const-defined or aliased rule was invisible to it, a helper named checkX was a false positive, and naming and syntax became part of the interface — all to detect a mistake rather than prevent it. Rules now live in a keyed table over a shared context, executed once via Object.values. An object cannot hold a key twice, so double registration is unrepresentable rather than merely detected, and oxlint's no-dupe-keys rejects the attempt at the source. LayeringRuleId makes a missing key a type error, and LAYERING_RULE_IDS gives the catalog to check exhaustiveness against. Call sites and order are unchanged, so grouped output and the success line are byte-identical. One regression test remains, through the production interface: scripts/ is outside tsconfig.json's `include`, so the Record's exhaustiveness is an editor signal rather than a CI gate, and the catalog assertion is what fails the build when wiring goes missing. Verified by mutation: dropping an entry and registering an uncatalogued one both fail the test, a duplicated key fails oxlint, and re-planting the original contracts violation reports it exactly once. Net -133 LOC.
1 parent 52402ae commit 057ab1c

3 files changed

Lines changed: 97 additions & 26 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@
130130
"check:affected:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/check-affected/model.test.ts scripts/check-affected/platform-packages.test.ts scripts/check-affected/run.test.ts",
131131
"check:coverage-changed": "node --experimental-strip-types scripts/coverage-changed/run.ts",
132132
"check:coverage-changed:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/coverage-changed/model.test.ts scripts/coverage-changed/run.test.ts",
133-
"check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/model.test.ts scripts/layering/zone-policy.test.ts scripts/layering/daemon-modularity.test.ts scripts/layering/package-boundaries.test.ts scripts/layering/platform-package-policy.test.ts scripts/layering/platform-package-repository.test.ts scripts/layering/platform-package-source-policy.test.ts scripts/layering/device-inventory-cutover-policy.test.ts scripts/layering/logs-runtime-cutover-policy.test.ts scripts/layering/network-runtime-cutover-policy.test.ts scripts/layering/record-runtime-cutover-policy.test.ts scripts/layering/contracts-implementation-policy.test.ts scripts/layering/facade-exports.test.ts scripts/layering/bin-alias-fast-path.test.ts && node --experimental-strip-types scripts/layering/check.ts",
133+
"check:layering": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/layering/*.test.ts && node --experimental-strip-types scripts/layering/check.ts",
134134
"depgraph": "node --experimental-strip-types scripts/depgraph/build.ts",
135135
"depgraph:test": "node --experimental-strip-types scripts/node-test-tmpdir.ts --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts",
136136
"check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// The seam bin-alias-fast-path.test.ts calls out as untested — "the check.ts wiring that turns it
2+
// into a violation." The registry makes duplicate registration unrepresentable on its own (an
3+
// object holds a key once, and oxlint's no-dupe-keys rejects the attempt), so what is left to check
4+
// is the other direction: that the catalog and the registry still describe the same set of rules.
5+
// scripts/ is outside tsconfig.json's `include`, so the Record's exhaustiveness is an editor
6+
// signal, not a CI gate — this test is what fails the build when wiring goes missing.
7+
8+
import assert from 'node:assert/strict';
9+
import { test } from 'node:test';
10+
import { LAYERING_RULE_IDS, LAYERING_RULES } from './check.ts';
11+
12+
test('every catalogued rule is registered, and nothing else is', () => {
13+
assert.deepEqual(Object.keys(LAYERING_RULES), [...LAYERING_RULE_IDS]);
14+
});
15+
16+
test('every registered rule is callable, so no entry is a stale reference', () => {
17+
for (const id of LAYERING_RULE_IDS) {
18+
assert.equal(typeof LAYERING_RULES[id], 'function', `${id} is not callable`);
19+
}
20+
});

scripts/layering/check.ts

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,74 @@ function report(
646646
return 1;
647647
}
648648

649+
/** Everything the guard reads once per run, so a rule takes one argument whatever it needs. */
650+
export type LayeringContext = Readonly<{
651+
sourceFiles: readonly string[];
652+
sources: ReadonlyMap<string, string>;
653+
allTypeScriptSources: ReadonlyMap<string, string>;
654+
edges: readonly ResolvedImportEdge[];
655+
typeCycleMembers: readonly string[];
656+
}>;
657+
658+
export type LayeringRule = (context: LayeringContext) => LayeringViolation[];
659+
660+
/**
661+
* The rules this guard runs. Registering one is writing a key here, which is why the list is data
662+
* rather than a hand-written array of spreads: an object cannot hold the same key twice, so a rule
663+
* cannot be run — and reported, and ::error-annotated — twice by a copy-paste. `LayeringRuleId`
664+
* then makes a missing key a type error rather than a silently retired rule.
665+
*
666+
* Order is the reporting order: report() groups by rule in first-seen order.
667+
*/
668+
export const LAYERING_RULE_IDS = [
669+
'zone-policies',
670+
'value-import-cycles',
671+
'logs-runtime-cutover',
672+
'contracts-implementation-authority',
673+
'network-runtime-cutover',
674+
'record-runtime-cutover',
675+
'back-edges',
676+
'type-spine-inversions',
677+
'session-state-ownership',
678+
'daemon-modularity-ratchets',
679+
'zero-dep-job-closure',
680+
'bin-alias-fast-path',
681+
'package-boundaries',
682+
'platform-package-policy',
683+
'device-inventory-cutover',
684+
] as const;
685+
686+
export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
687+
688+
export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
689+
'zone-policies': (context) => checkLayeringRules(context.edges),
690+
'value-import-cycles': (context) => checkCycles(context.edges),
691+
'logs-runtime-cutover': (context) => checkLogsRuntimeCutover(context.sources),
692+
'contracts-implementation-authority': (context) =>
693+
checkContractsImplementationAuthority(context.sources),
694+
'network-runtime-cutover': (context) => checkNetworkRuntimeCutover(context.sources),
695+
'record-runtime-cutover': (context) => checkRecordRuntimeCutover(context.sources),
696+
'back-edges': (context) => checkBackEdges(context.edges),
697+
'type-spine-inversions': (context) => checkTypeInversions(context.edges),
698+
'session-state-ownership': (context) => checkSessionStateOwnership(context.sources),
699+
'daemon-modularity-ratchets': (context) =>
700+
checkDaemonModularityRatchets(context.edges, context.typeCycleMembers),
701+
'zero-dep-job-closure': () => checkZeroDepJobs(),
702+
'bin-alias-fast-path': (context) => checkBinAliasFastPath(context.sources),
703+
'package-boundaries': () =>
704+
checkPackageBoundaries(
705+
repoRoot,
706+
zeroDepClosureFiles(repoZeroDepJobs(), readSourceOrNull, fileExists),
707+
),
708+
'platform-package-policy': (context) =>
709+
checkPlatformPackagePolicy(
710+
context.allTypeScriptSources,
711+
readTrackedPlatformPackageDeclarations(repoRoot),
712+
{ untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) },
713+
),
714+
'device-inventory-cutover': (context) => checkDeviceInventoryCutover(context.sources),
715+
};
716+
649717
export function main(): number {
650718
const sourceFiles = listSourceFiles();
651719
const sources = readSources(sourceFiles);
@@ -654,31 +722,14 @@ export function main(): number {
654722
// Computed once and threaded: the rule and the success line must report the same number.
655723
const typeCycleMembers = largestTypeCycleMembers(edges);
656724
const typeCycle = typeCycleMembers.length;
657-
const violations = [
658-
...checkLayeringRules(edges),
659-
...checkCycles(edges),
660-
...checkLogsRuntimeCutover(sources),
661-
...checkContractsImplementationAuthority(sources),
662-
...checkNetworkRuntimeCutover(sources),
663-
...checkRecordRuntimeCutover(sources),
664-
...checkContractsImplementationAuthority(sources),
665-
...checkBackEdges(edges),
666-
...checkTypeInversions(edges),
667-
...checkSessionStateOwnership(sources),
668-
...checkDaemonModularityRatchets(edges, typeCycleMembers),
669-
...checkZeroDepJobs(),
670-
...checkBinAliasFastPath(sources),
671-
...checkPackageBoundaries(
672-
repoRoot,
673-
zeroDepClosureFiles(repoZeroDepJobs(), readSourceOrNull, fileExists),
674-
),
675-
...checkPlatformPackagePolicy(
676-
allTypeScriptSources,
677-
readTrackedPlatformPackageDeclarations(repoRoot),
678-
{ untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) },
679-
),
680-
...checkDeviceInventoryCutover(sources),
681-
];
725+
const context: LayeringContext = {
726+
sourceFiles,
727+
sources,
728+
allTypeScriptSources,
729+
edges,
730+
typeCycleMembers,
731+
};
732+
const violations = Object.values(LAYERING_RULES).flatMap((rule) => rule(context));
682733
return report(sourceFiles, violations, typeCycle);
683734
}
684735

0 commit comments

Comments
 (0)