Files
callstack__agent-device/scripts/layering/contracts-implementation-policy.ts
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

161 lines
5.9 KiB
TypeScript

import { parseSync } from 'oxc-parser';
import type { LayeringViolation } from './model.ts';
export type ContractsProductionSource = Readonly<{ path: string; source: string }>;
const RULE = 'R18 contracts-implementation-authority';
const FORBIDDEN_HOST_MODULES = /^(?:node:)?(?:child_process|fs|timers)(?:\/|$)/;
const FORBIDDEN_TIMER_CALLS = new Set([
'clearImmediate',
'clearInterval',
'clearTimeout',
'setImmediate',
'setInterval',
'setTimeout',
]);
/** Contracts owns vocabulary. Host/process/timer mechanics belong in capture-kit or an adapter. */
export function contractsImplementationAuthorityViolations(
sources: readonly ContractsProductionSource[],
): LayeringViolation[] {
const violations: LayeringViolation[] = [];
for (const file of sources) {
if (!isContractsProduction(file.path)) continue;
const parsed = parseSync(file.path, file.source);
const networkTrafficViolation = networkTrafficImplementationViolation(
file.path,
file.source,
parsed.program.body,
);
if (networkTrafficViolation) violations.push(networkTrafficViolation);
for (const site of moduleSpecifiers(parsed.module, file.source)) {
if (!FORBIDDEN_HOST_MODULES.test(site.spec)) continue;
violations.push(
violation(
file.path,
site.line,
`contracts imports host implementation authority '${site.spec}'; move mechanics to @agent-device/capture-kit`,
),
);
}
visit(parsed.program, (node) => {
if (node.type !== 'CallExpression') return;
const timerName = timerCallName(node.callee);
if (!timerName) return;
violations.push(
violation(
file.path,
lineAt(file.source, Number(node.start ?? 0)),
`contracts calls timer primitive '${timerName}'; move lifecycle mechanics to @agent-device/capture-kit`,
),
);
});
}
return violations;
}
function networkTrafficImplementationViolation(
file: string,
source: string,
body: readonly unknown[],
): LayeringViolation | undefined {
if (!file.startsWith('packages/contracts/src/network-traffic')) return undefined;
if (file !== 'packages/contracts/src/network-traffic.ts') {
return violation(
file,
1,
'contracts may own only the neutral network-traffic vocabulary; parser modules belong in @agent-device/capture-kit',
);
}
const implementation = body.find((statement) => !isTypeOnlyStatement(statement));
if (!implementation || typeof implementation !== 'object') return undefined;
return violation(
file,
lineAt(source, Number((implementation as Record<string, unknown>).start ?? 0)),
'contracts network-traffic vocabulary contains runtime implementation; move parser mechanics to @agent-device/capture-kit',
);
}
function isTypeOnlyStatement(value: unknown): boolean {
if (value === null || typeof value !== 'object') return false;
const statement = value as Record<string, unknown>;
if (statement.type === 'ImportDeclaration') return statement.importKind === 'type';
if (statement.type === 'TSTypeAliasDeclaration' || statement.type === 'TSInterfaceDeclaration') {
return true;
}
if (statement.type !== 'ExportNamedDeclaration') return false;
if (statement.exportKind === 'type') return true;
const declaration = statement.declaration as Record<string, unknown> | undefined;
return (
declaration?.type === 'TSTypeAliasDeclaration' || declaration?.type === 'TSInterfaceDeclaration'
);
}
function moduleSpecifiers(
module: ReturnType<typeof parseSync>['module'],
source: string,
): ReadonlyArray<{ spec: string; line: number }> {
const sites: Array<{ spec: string; line: number }> = [];
const add = (request: { value?: string; start?: number } | undefined): void => {
if (request?.value)
sites.push({ spec: request.value, line: lineAt(source, request.start ?? 0) });
};
for (const entry of module.staticImports) add(entry.moduleRequest);
for (const entry of module.staticExports) {
for (const exported of entry.entries) add(exported.moduleRequest);
}
for (const entry of module.dynamicImports) {
const raw = source.slice(entry.moduleRequest.start, entry.moduleRequest.end);
const literal = /^(['"])([^'"]*)\1$/.exec(raw);
if (literal) sites.push({ spec: literal[2]!, line: lineAt(source, entry.moduleRequest.start) });
}
return sites;
}
function timerCallName(value: unknown): string | undefined {
if (value === null || typeof value !== 'object') return undefined;
const callee = value as Record<string, unknown>;
if (callee.type === 'Identifier' && FORBIDDEN_TIMER_CALLS.has(String(callee.name))) {
return String(callee.name);
}
if (callee.type !== 'MemberExpression' || callee.computed === true) return undefined;
const object = callee.object as Record<string, unknown> | undefined;
const property = callee.property as Record<string, unknown> | undefined;
if (
object?.type !== 'Identifier' ||
!['global', 'globalThis', 'window'].includes(String(object.name)) ||
property?.type !== 'Identifier' ||
!FORBIDDEN_TIMER_CALLS.has(String(property.name))
) {
return undefined;
}
return String(property.name);
}
function isContractsProduction(file: string): boolean {
return (
file.startsWith('packages/contracts/src/') &&
!file.endsWith('.test.ts') &&
!file.includes('/__tests__/')
);
}
function violation(file: string, line: number, message: string): LayeringViolation {
return { rule: RULE, file, line, message };
}
function lineAt(source: string, offset: number): number {
return source.slice(0, offset).split('\n').length;
}
function visit(node: unknown, callback: (node: Record<string, unknown>) => void): void {
if (node === null || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const child of node) visit(child, callback);
return;
}
const record = node as Record<string, unknown>;
callback(record);
for (const value of Object.values(record)) visit(value, callback);
}