Files
callstack__agent-device/scripts/layering/rule-ids.ts
Michał Pierzchała d8a7d03faf refactor: route application lifecycle through runtime facts (#1759)
* refactor: route application lifecycle through runtime facts

Moves the canonical `open`, `prepare`, `close` and internal `runtime` descriptors
behind package-owned lifecycle bindings admitted from device runtime facts, while
daemon request/session policy and public response construction stay put.

Based on main, which already carries the boot unit, the parametrized cutover gate
and the apps unit. Readiness is package-owned there, so the Apple and Android
bindings call ensureAppleReady/ensureAndroidReady rather than a root readiness
bag; ensureAppleReady gained an onColdBootStart hook so open keeps warming the
runner cache in parallel with a cold boot, and a narrow markBooted port publishes
readiness' fresh observation so a flow still makes one simctl listing.

Cutover rows take R24-R27, clear of the accepted catalog and the sibling install
stack, and cutoverTableDefects rejects a duplicate rule id.

Two defects this unit introduced are fixed here rather than shipped:
`open <app> <url>` dropped the URL on a first open, and test-IME activation was
first fatal on an unobtainable helper and then over-caught. Helper unavailability
is a typed non-activation outcome now; fence, lock and post-record failures
propagate.

The duplication the unit had accumulated is gone: one runtime-admission module
instead of five per-command copies, one direct-lifecycle binding factory instead
of six hand-rolled packages, one transport-hint predicate, one session
finalization path, and no identity-wrapper module.

* fix: allocate lifecycle cutover rows after deployment

* chore: preserve lifecycle union reconstruction

* fix: reconcile lifecycle runtime stack

* refactor: tighten lifecycle runtime topology

* refactor: remove superseded runtime adapters

* fix: preserve stacked runtime cutovers

* test: preserve migrated runtime ownership

* test: move Android deployment retry ownership

* test: extract runtime hint fixtures

* fix: preserve lifecycle stack invariants

* fix: complete lifecycle runtime cutover

* fix: remove lifecycle cutover residue
2026-08-16 15:13:10 +02:00

107 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();
}
/**
* Collisions that predate this gate. Transitional, and each entry expires on
* contact — see `ruleIdCollisionFailures`. Empty is the end state, and the gate
* gets there on its own.
*
* The R11/R13 collisions were retired by the accepted R18 contracts and R17 devices
* allocations. New collisions fail closed; there is no transitional allowance left.
*/
export const KNOWN_RULE_ID_COLLISIONS: readonly string[] = [];
/**
* Both halves of any transition, because an allowance that outlives the thing
* it allows fails OPEN: a list still naming a retired collision would wave it
* straight back through if anyone reintroduced it.
*
* 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'),
}));
}