Files
Michał Pierzchała 8f98d23f14 refactor(layering): give each colliding rule id its own number (#1750)
* refactor(layering): give each colliding rule id its own number

R11 and R13 each named two unrelated rules. report() groups violations by the
rule string and titles every annotation `Layering drift (${rule})`, so a shared
number made the guard's output ambiguous about which rule fired.

Reference counts decided which rule keeps its number. R11 package-boundaries is
named in ~30 places (CONTEXT.md, ADR 0019, testing.md, the mutation and
affected-check configs, four package source comments, its own tests) against one
for the contracts rule; R13 platform-package-substrate is the RULE in three
policy files plus CONTEXT.md, ADR 0019 and model.ts against two for the devices
cutover. Both keepers stay put and the two newest rules move up:

  R11 contracts-implementation-authority -> R18
  R13 device-inventory-cutover           -> R17

R17/R18 follow the namespace's order-of-addition convention (R14 #1701 < R15
#1702 < R16 #1724): device-inventory-cutover landed in #1699 and
contracts-implementation-authority in #1701. #1656 took R19 for
selector-pipeline-ownership on the same reading.

The rule-map header in check.ts is renumbered and reordered back into numeric
order, and gains the R18 entry the contracts rule never had -- without it a
reader looking up an R18 violation finds nothing where they used to find the
wrong rule. deviceInventoryCutoverSummary() was also the only OK-line summary
not leading with its rule number, which is what made the number unreadable from
the success line in the first place.

Also corrects a normative ADR reference. ADR 0019's platform-package import
rules -- contracts-to-platform, sibling-platform, root/daemon, raw-process --
are R13's, as CONTEXT.md:420 already says. The R11 attribution predates
platform-package-policy (#1697, a day before #1699), when R11 was the only
package rule.

* chore(layering): retire the expired R11/R13 collision allowances

KNOWN_RULE_ID_COLLISIONS was opened for exactly the two collisions the previous
commit renames apart, and ruleIdCollisionFailures expires an allowance on
contact: once the collision is gone the entry fails as stale, because a list
still naming it would wave it back through if anyone reintroduced it.

Both entries are therefore deleted in the change that removes the collisions,
leaving the empty list that admits nothing. The namespace is now one-to-one
across R2-R19.
2026-08-12 11:42:15 +02:00

106 lines
4.1 KiB
TypeScript

import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
/**
* Rule-ID uniqueness.
*
* Layering rules are addressed by number in review, in CI annotations, and in
* the code comments that point at them, so a number must name exactly one rule.
* Two branches allocating the same free number do not conflict in git — the
* files differ — and land as two rules answering to one id, after which every
* reference to it is ambiguous. #1656 and #1750 both reached for R17.
*
* The claim being checked is about source text (which id a rule declares), so
* reading the declarations IS the check rather than a proxy for one. It is a
* naming registry, not a behavioural claim.
*/
export type RuleDeclaration = {
id: string;
name: string;
file: string;
};
/**
* A rule id is declared as a WHOLE string literal — `rule: 'R7 …'` at an
* emitter, `const RULE = 'R17 …'` at the top of a policy module. Matching the
* complete literal is what separates a declaration from the prose that also
* names rules ("R17 holds the devices command to…"): a sentence keeps going
* where a declaration closes its quote.
*/
const RULE_LITERAL = /'(R\d+) ([a-z0-9-]+)'/g;
export function collectRuleDeclarations(files: readonly { path: string; source: string }[]) {
const declarations: RuleDeclaration[] = [];
for (const file of files) {
for (const match of file.source.matchAll(RULE_LITERAL)) {
declarations.push({ id: match[1]!, name: match[2]!, file: file.path });
}
}
return declarations;
}
export function duplicateRuleIds(declarations: readonly RuleDeclaration[]): string[] {
const byId = new Map<string, Set<string>>();
for (const declaration of declarations) {
const names = byId.get(declaration.id) ?? new Set<string>();
names.add(declaration.name);
byId.set(declaration.id, names);
}
return [...byId]
.filter(([, names]) => names.size > 1)
.map(([id, names]) => `${id} names ${[...names].sort().join(' and ')}`)
.sort();
}
/**
* Empty, which is the end state: the R11 and R13 collisions this list was opened
* for are gone (contracts-implementation-authority moved to R18, device-inventory-cutover
* to R17), so their allowances expired and were deleted with them. Every id now names
* exactly one rule, and an entry here would admit a collision nobody is fixing.
*/
export const KNOWN_RULE_ID_COLLISIONS: readonly string[] = [];
/**
* Both halves of the transition, because an allowance that outlives the thing
* it allows fails OPEN: once #1750 renames R11/R13 apart, a list still naming
* those exact collisions would wave them straight back through if anyone
* reintroduced them.
*
* So an allowance is only valid while its collision is actually present. A
* collision nobody allowed fails, and an allowance whose collision is gone
* fails too — which forces the entry to be deleted in the same change that
* removes the collision, and leaves an empty list that admits nothing.
*/
export function ruleIdCollisionFailures(params: {
declarations: readonly RuleDeclaration[];
allowed: readonly string[];
}): string[] {
const present = new Set(duplicateRuleIds(params.declarations));
const allowed = new Set(params.allowed);
return [
...[...present]
.filter((collision) => !allowed.has(collision))
.map(
(collision) => `two rules answer to one id: ${collision}. Allocate the next free number.`,
),
...[...allowed]
.filter((collision) => !present.has(collision))
.map(
(collision) =>
`stale allowance: "${collision}" no longer occurs, so the entry admits a collision ` +
'nobody is fixing. Delete it from KNOWN_RULE_ID_COLLISIONS.',
),
].sort();
}
/** The layering policy modules, which is where rule ids are declared. */
export function readLayeringSources(directory: string): { path: string; source: string }[] {
return readdirSync(directory)
.filter((entry) => entry.endsWith('.ts') && !entry.endsWith('.test.ts'))
.map((entry) => ({
path: `scripts/layering/${entry}`,
source: readFileSync(path.join(directory, entry), 'utf8'),
}));
}