mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
refactor(layering): one parametrized runtime-command-cutover gate (ADR 0019 §8) (#1745)
* refactor(layering): fold the four cutover policies into one parametrized gate ADR 0019 §8: the per-command cutover gates consolidate into one parametrized runtime-command-cutover gate driven by a table of migrated commands. Adding a migrated command adds a row; the mechanism carries one planted-red proof instead of one per command. Part of #1739 (wave 0) * fix(layering): scope cutover calls to lexical owners * chore: format cutover ownership model
This commit is contained in:
committed by
GitHub
parent
8f98d23f14
commit
ba48146520
+19
-68
@@ -33,10 +33,10 @@
|
||||
// owner" shape as R7's SessionState ownership, applied to bin.ts's `--help` fast path.
|
||||
// - Over PLATFORM PACKAGE COMPOSITION: six private metadata façades meet at the exact root
|
||||
// composition file; premature implementation loading and forbidden cross-boundary edges fail (R13).
|
||||
// - Over COMMAND-ATOMIC RUNTIME CUTOVERS: retired logs, network, and record routes/admission cannot
|
||||
// coexist with their operation-fact-derived descriptor and handler paths (R14-R16).
|
||||
// - Over the DEVICES COMMAND CUTOVER: the handler calls the neutral inventory gateway and no
|
||||
// superseded inventory module, import, or identifier remains in production (R17).
|
||||
// - Over COMMAND-ATOMIC RUNTIME CUTOVERS: one parametrized gate reads the migrated-command
|
||||
// table (devices R17, logs R14, network R15, record R16) and proves each command keeps
|
||||
// exactly one platform-execution path — retired routes, admission, modules, and widened
|
||||
// runtime access cannot coexist with its operation-fact-derived descriptor and handler.
|
||||
// - Over CONTRACTS PRODUCTION SOURCE: contracts owns vocabulary only — host, process, and timer
|
||||
// mechanics belong in capture-kit or an adapter (R18).
|
||||
// Only `(root)` is unranked among src/ zones (see `UNRANKED_ZONES` in model.ts):
|
||||
@@ -89,33 +89,16 @@ import {
|
||||
platformPackagePolicySummary,
|
||||
} from './platform-package-policy.ts';
|
||||
import {
|
||||
checkDeviceInventoryCutover,
|
||||
deviceInventoryCutoverSummary,
|
||||
} from './device-inventory-cutover-policy.ts';
|
||||
checkRuntimeCommandCutover,
|
||||
runtimeCommandCutoverSummary,
|
||||
} from './runtime-command-cutover-policy.ts';
|
||||
import {
|
||||
listUntrackedProductionTypeScriptFiles,
|
||||
readTrackedPlatformPackageDeclarations,
|
||||
} from './platform-package-repository.ts';
|
||||
import { policyLead, policyViolation, ZONE_POLICIES } from './zone-policy.ts';
|
||||
import {
|
||||
logsLegacyRouteViolations,
|
||||
logsRuntimeNarrowingViolations,
|
||||
logsSessionStateOwnershipViolations,
|
||||
sourceExecutedUsingDeclarationViolations,
|
||||
} from './logs-runtime-cutover-policy.ts';
|
||||
import { contractsImplementationAuthorityViolations } from './contracts-implementation-policy.ts';
|
||||
import { selectorPipelineOwnershipViolations } from './selector-pipeline-ownership.ts';
|
||||
import {
|
||||
networkLegacyRouteViolations,
|
||||
networkRuntimeNarrowingViolations,
|
||||
networkRuntimeRouteViolations,
|
||||
} from './network-runtime-cutover-policy.ts';
|
||||
import {
|
||||
recordLegacyRouteViolations,
|
||||
recordRuntimeNarrowingViolations,
|
||||
recordRuntimeRouteViolations,
|
||||
} from './record-runtime-cutover-policy.ts';
|
||||
import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts';
|
||||
import { recordRuntimeRegistryJoinViolations } from './record-runtime-registry-policy.ts';
|
||||
|
||||
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
@@ -194,16 +177,6 @@ function checkCycles(edges: readonly ResolvedImportEdge[]): LayeringViolation[]
|
||||
}));
|
||||
}
|
||||
|
||||
function checkLogsRuntimeCutover(sources: ReadonlyMap<string, string>): LayeringViolation[] {
|
||||
const production = [...sources].map(([file, source]) => ({ path: file, source }));
|
||||
return [
|
||||
...logsLegacyRouteViolations(production),
|
||||
...logsRuntimeNarrowingViolations(production),
|
||||
...logsSessionStateOwnershipViolations(production),
|
||||
...sourceExecutedUsingDeclarationViolations(production),
|
||||
];
|
||||
}
|
||||
|
||||
function checkContractsImplementationAuthority(
|
||||
sources: ReadonlyMap<string, string>,
|
||||
): LayeringViolation[] {
|
||||
@@ -212,32 +185,14 @@ function checkContractsImplementationAuthority(
|
||||
);
|
||||
}
|
||||
|
||||
function checkNetworkRuntimeCutover(sources: ReadonlyMap<string, string>): LayeringViolation[] {
|
||||
/**
|
||||
* The record registry-join scan is a descriptor-shape check, not a cutover claim, so it
|
||||
* stays its own module. Record's daemon-mechanics scan is the row's lifecycle proof and
|
||||
* runs from the cutover table. Both report under R16.
|
||||
*/
|
||||
function checkRecordRuntimeRegistryJoin(sources: ReadonlyMap<string, string>): LayeringViolation[] {
|
||||
const production = [...sources].map(([file, source]) => ({ path: file, source }));
|
||||
return [
|
||||
...networkLegacyRouteViolations(production),
|
||||
...networkRuntimeNarrowingViolations(production),
|
||||
...networkRuntimeRouteViolations(production),
|
||||
].map((violation) => {
|
||||
const separator = violation.indexOf(': ');
|
||||
return {
|
||||
rule: 'R15 network-runtime-cutover',
|
||||
file: separator < 0 ? '(network runtime)' : violation.slice(0, separator),
|
||||
line: 1,
|
||||
message: separator < 0 ? violation : violation.slice(separator + 2),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function checkRecordRuntimeCutover(sources: ReadonlyMap<string, string>): LayeringViolation[] {
|
||||
const production = [...sources].map(([file, source]) => ({ path: file, source }));
|
||||
return [
|
||||
...recordLegacyRouteViolations(production),
|
||||
...recordRuntimeDaemonMechanicsViolations(production),
|
||||
...recordRuntimeNarrowingViolations(production),
|
||||
...recordRuntimeRegistryJoinViolations(production),
|
||||
...recordRuntimeRouteViolations(production),
|
||||
].map((violation) => {
|
||||
return recordRuntimeRegistryJoinViolations(production).map((violation) => {
|
||||
const separator = violation.indexOf(': ');
|
||||
return {
|
||||
rule: 'R16 record-runtime-cutover',
|
||||
@@ -621,7 +576,7 @@ function report(
|
||||
`inside its declared owner (R7); every zero-dep CI job resolves without ` +
|
||||
`node_modules (R8); ${typeCycleNote(typeCycle)}; ${daemonModularitySummary()}; ` +
|
||||
`${packageBoundariesSummary(repoRoot)}; ${platformPackagePolicySummary()}; ` +
|
||||
`${deviceInventoryCutoverSummary()}; and bin.ts imports normalizeCliCommandAlias, ` +
|
||||
`${runtimeCommandCutoverSummary()}; and bin.ts imports normalizeCliCommandAlias, ` +
|
||||
`actually passes it into buildCommandUsageText, and holds no local alias literals ` +
|
||||
`(R12).\n`,
|
||||
);
|
||||
@@ -671,11 +626,10 @@ export type LayeringRule = (context: LayeringContext) => LayeringViolation[];
|
||||
export const LAYERING_RULE_IDS = [
|
||||
'zone-policies',
|
||||
'value-import-cycles',
|
||||
'logs-runtime-cutover',
|
||||
'runtime-command-cutover',
|
||||
'record-runtime-registry-join',
|
||||
'contracts-implementation-authority',
|
||||
'selector-pipeline-ownership',
|
||||
'network-runtime-cutover',
|
||||
'record-runtime-cutover',
|
||||
'back-edges',
|
||||
'type-spine-inversions',
|
||||
'session-state-ownership',
|
||||
@@ -684,7 +638,6 @@ export const LAYERING_RULE_IDS = [
|
||||
'bin-alias-fast-path',
|
||||
'package-boundaries',
|
||||
'platform-package-policy',
|
||||
'device-inventory-cutover',
|
||||
] as const;
|
||||
|
||||
export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
|
||||
@@ -692,13 +645,12 @@ export type LayeringRuleId = (typeof LAYERING_RULE_IDS)[number];
|
||||
export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
|
||||
'zone-policies': (context) => checkLayeringRules(context.edges),
|
||||
'value-import-cycles': (context) => checkCycles(context.edges),
|
||||
'logs-runtime-cutover': (context) => checkLogsRuntimeCutover(context.sources),
|
||||
'runtime-command-cutover': (context) => checkRuntimeCommandCutover(context.sources),
|
||||
'record-runtime-registry-join': (context) => checkRecordRuntimeRegistryJoin(context.sources),
|
||||
'contracts-implementation-authority': (context) =>
|
||||
checkContractsImplementationAuthority(context.sources),
|
||||
'selector-pipeline-ownership': (context) =>
|
||||
selectorPipelineOwnershipViolations(context.edges, workspaceSpecifierTargets(repoRoot)),
|
||||
'network-runtime-cutover': (context) => checkNetworkRuntimeCutover(context.sources),
|
||||
'record-runtime-cutover': (context) => checkRecordRuntimeCutover(context.sources),
|
||||
'back-edges': (context) => checkBackEdges(context.edges),
|
||||
'type-spine-inversions': (context) => checkTypeInversions(context.edges),
|
||||
'session-state-ownership': (context) => checkSessionStateOwnership(context.sources),
|
||||
@@ -717,7 +669,6 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
|
||||
readTrackedPlatformPackageDeclarations(repoRoot),
|
||||
{ untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) },
|
||||
),
|
||||
'device-inventory-cutover': (context) => checkDeviceInventoryCutover(context.sources),
|
||||
};
|
||||
|
||||
export function main(): number {
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
export type RecordRuntimeProductionSource = Readonly<{ path: string; source: string }>;
|
||||
/** A production file the cutover policies scan: repo-relative path plus its source. */
|
||||
export type ProductionSource = Readonly<{ path: string; source: string }>;
|
||||
|
||||
export function memberName(node: Record<string, unknown>): string | undefined {
|
||||
const property = node.property as Record<string, unknown> | undefined;
|
||||
@@ -1,100 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { checkDeviceInventoryCutover } from './device-inventory-cutover-policy.ts';
|
||||
|
||||
const HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts';
|
||||
|
||||
function handlerSource(localName = 'listDeviceInventory'): string {
|
||||
return [
|
||||
`import { listDeviceInventory as ${localName} } from '../../core/dispatch-resolve.ts';`,
|
||||
`export async function handleDevices(request: Request) { return await ${localName}(request); }`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sources(
|
||||
handler = handlerSource(),
|
||||
extra: readonly (readonly [string, string])[] = [],
|
||||
): ReadonlyMap<string, string> {
|
||||
return new Map([[HANDLER_FILE, handler], ...extra]);
|
||||
}
|
||||
|
||||
function messages(input: ReadonlyMap<string, string>): string {
|
||||
return checkDeviceInventoryCutover(input)
|
||||
.map(({ message }) => message)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
test('devices keeps one gateway-owned handler route through an aliased import', () => {
|
||||
assert.deepEqual(checkDeviceInventoryCutover(sources(handlerSource('discoverInventory'))), []);
|
||||
});
|
||||
|
||||
test('devices rejects a handler that imports but never calls the inventory gateway', () => {
|
||||
const handler = [
|
||||
"import { listDeviceInventory } from '../../core/dispatch-resolve.ts';",
|
||||
'export async function handleDevices() { return []; }',
|
||||
].join('\n');
|
||||
|
||||
assert.match(messages(sources(handler)), /must call its imported listDeviceInventory binding/);
|
||||
});
|
||||
|
||||
test('devices does not accept a call through a binding that shadows the imported alias', () => {
|
||||
const handler = [
|
||||
"import { listDeviceInventory as discoverInventory } from '../../core/dispatch-resolve.ts';",
|
||||
'export async function decoy(discoverInventory: (request: Request) => Promise<unknown>) {',
|
||||
' return await discoverInventory({});',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
assert.match(messages(sources(handler)), /shadows its imported listDeviceInventory binding/);
|
||||
});
|
||||
|
||||
test('devices rejects an exact legacy inventory module path', () => {
|
||||
assert.match(
|
||||
messages(
|
||||
sources(handlerSource(), [
|
||||
['src/platforms/apple/devices.ts', 'export const staleInventoryRoute = true;'],
|
||||
]),
|
||||
),
|
||||
/legacy device inventory module path remains/,
|
||||
);
|
||||
});
|
||||
|
||||
test('devices rejects executable legacy imports and identifiers', () => {
|
||||
const legacy = [
|
||||
"import { listAppleDevices } from './platforms/apple/devices.ts';",
|
||||
'export async function discover() { return await listAppleDevices(); }',
|
||||
].join('\n');
|
||||
const found = messages(sources(handlerSource(), [['src/legacy-inventory.ts', legacy]]));
|
||||
|
||||
assert.match(found, /imports legacy device inventory module/);
|
||||
assert.match(found, /legacy device inventory identifier 'listAppleDevices' remains/);
|
||||
});
|
||||
|
||||
test('devices rejects dynamic imports and re-exports of legacy inventory modules', () => {
|
||||
for (const route of [
|
||||
"export const legacy = import('./platforms/apple/devices.ts');",
|
||||
"export { staleInventory } from './core/platform-inventory.ts';",
|
||||
]) {
|
||||
assert.match(
|
||||
messages(sources(handlerSource(), [['src/legacy-inventory.ts', route]])),
|
||||
/imports legacy device inventory module/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('devices ignores comments, strings, and similarly named non-route modules', () => {
|
||||
const prose = [
|
||||
"const note = 'listAppleDevices from core/platform-inventory';",
|
||||
'// discoverDevices used to live in platforms/apple/devices.ts',
|
||||
'export const cacheKey = note;',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
checkDeviceInventoryCutover(
|
||||
sources(handlerSource('discoverInventory'), [
|
||||
['src/platforms/apple/devices-cache.ts', prose],
|
||||
]),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
import { parseSync } from 'oxc-parser';
|
||||
import type { LayeringViolation } from './model.ts';
|
||||
|
||||
const RULE = 'R17 device-inventory-cutover';
|
||||
const HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts';
|
||||
const INVENTORY_IMPORT_SOURCES = new Set([
|
||||
'../../core/device-inventory-context.ts',
|
||||
'../../core/dispatch-resolve.ts',
|
||||
]);
|
||||
const LEGACY_INVENTORY_IDENTIFIERS = new Set([
|
||||
'discoverDevices',
|
||||
'listAndroidDevices',
|
||||
'listAppleDevices',
|
||||
'listHarmonyDevices',
|
||||
'listHarmonyOsDevices',
|
||||
'listLinuxDevices',
|
||||
'listMacosDevices',
|
||||
'listVegaDevices',
|
||||
'listWebDevices',
|
||||
]);
|
||||
|
||||
type AstNode = Record<string, unknown>;
|
||||
|
||||
function violation(file: string, line: number, message: string): LayeringViolation {
|
||||
return { rule: RULE, file, line, message };
|
||||
}
|
||||
|
||||
function lineOf(source: string, node: AstNode): number {
|
||||
return source.slice(0, (node['start'] as number | undefined) ?? 0).split('\n').length;
|
||||
}
|
||||
|
||||
function identifierName(node: unknown): string | undefined {
|
||||
if (node === null || typeof node !== 'object') return undefined;
|
||||
const record = node as AstNode;
|
||||
return record['type'] === 'Identifier' && typeof record['name'] === 'string'
|
||||
? record['name']
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function literalString(node: unknown): string | undefined {
|
||||
if (node === null || typeof node !== 'object') return undefined;
|
||||
const value = (node as AstNode)['value'];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function visitAst(node: unknown, visitor: (node: AstNode) => void): void {
|
||||
if (node === null || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) visitAst(child, visitor);
|
||||
return;
|
||||
}
|
||||
const record = node as AstNode;
|
||||
visitor(record);
|
||||
for (const value of Object.values(record)) visitAst(value, visitor);
|
||||
}
|
||||
|
||||
function isLegacyInventoryModulePath(file: string): boolean {
|
||||
return (
|
||||
file === 'src/core/platform-inventory.ts' ||
|
||||
/^src\/platforms\/(?:[^/]+\/)*devices\.ts$/.test(file)
|
||||
);
|
||||
}
|
||||
|
||||
function isLegacyInventoryImport(specifier: string): boolean {
|
||||
return (
|
||||
/(?:^|\/)core\/platform-inventory(?:\.[cm]?[jt]s)?$/.test(specifier) ||
|
||||
/(?:^|\/)platforms\/(?:[^/]+\/)*devices(?:\.[cm]?[jt]s)?$/.test(specifier)
|
||||
);
|
||||
}
|
||||
|
||||
function importedModuleSpecifier(node: AstNode): string | undefined {
|
||||
if (
|
||||
node['type'] === 'ImportDeclaration' ||
|
||||
node['type'] === 'ImportExpression' ||
|
||||
node['type'] === 'ExportNamedDeclaration' ||
|
||||
node['type'] === 'ExportAllDeclaration'
|
||||
) {
|
||||
return literalString(node['source']);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function importedInventoryBinding(program: AstNode): string | undefined {
|
||||
const body = program['body'];
|
||||
if (!Array.isArray(body)) return undefined;
|
||||
for (const statement of body as AstNode[]) {
|
||||
if (statement['type'] !== 'ImportDeclaration') continue;
|
||||
const specifier = literalString(statement['source']);
|
||||
if (!specifier || !INVENTORY_IMPORT_SOURCES.has(specifier)) continue;
|
||||
const importSpecifiers = statement['specifiers'];
|
||||
if (!Array.isArray(importSpecifiers)) continue;
|
||||
for (const imported of importSpecifiers as AstNode[]) {
|
||||
if (
|
||||
imported['type'] === 'ImportSpecifier' &&
|
||||
identifierName(imported['imported']) === 'listDeviceInventory'
|
||||
) {
|
||||
return identifierName(imported['local']);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function callsBinding(program: AstNode, binding: string): boolean {
|
||||
let called = false;
|
||||
visitAst(program, (node) => {
|
||||
if (node['type'] === 'CallExpression' && identifierName(node['callee']) === binding) {
|
||||
called = true;
|
||||
}
|
||||
});
|
||||
return called;
|
||||
}
|
||||
|
||||
function patternBinds(node: unknown, binding: string): boolean {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
const record = node as AstNode;
|
||||
if (record['type'] === 'Identifier') return record['name'] === binding;
|
||||
if (record['type'] === 'RestElement') return patternBinds(record['argument'], binding);
|
||||
if (record['type'] === 'AssignmentPattern') return patternBinds(record['left'], binding);
|
||||
if (record['type'] === 'TSParameterProperty') return patternBinds(record['parameter'], binding);
|
||||
if (record['type'] === 'ArrayPattern') {
|
||||
const elements = record['elements'];
|
||||
return Array.isArray(elements) && elements.some((element) => patternBinds(element, binding));
|
||||
}
|
||||
if (record['type'] === 'ObjectPattern') {
|
||||
const properties = record['properties'];
|
||||
return (
|
||||
Array.isArray(properties) &&
|
||||
properties.some((property) => {
|
||||
if (property === null || typeof property !== 'object') return false;
|
||||
const entry = property as AstNode;
|
||||
return entry['type'] === 'Property'
|
||||
? patternBinds(entry['value'], binding)
|
||||
: patternBinds(entry['argument'], binding);
|
||||
})
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shadowingBindingSite(program: AstNode, binding: string): AstNode | undefined {
|
||||
let found: AstNode | undefined;
|
||||
visitAst(program, (node) => {
|
||||
if (found) return;
|
||||
if (
|
||||
node['type'] === 'VariableDeclarator' ||
|
||||
node['type'] === 'FunctionDeclaration' ||
|
||||
node['type'] === 'FunctionExpression' ||
|
||||
node['type'] === 'ClassDeclaration' ||
|
||||
node['type'] === 'ClassExpression'
|
||||
) {
|
||||
if (patternBinds(node['id'], binding)) found = node;
|
||||
if (
|
||||
!found &&
|
||||
Array.isArray(node['params']) &&
|
||||
node['params'].some((param) => patternBinds(param, binding))
|
||||
) {
|
||||
found = node;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node['type'] === 'ArrowFunctionExpression') {
|
||||
if (
|
||||
Array.isArray(node['params']) &&
|
||||
node['params'].some((param) => patternBinds(param, binding))
|
||||
) {
|
||||
found = node;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node['type'] === 'CatchClause' && patternBinds(node['param'], binding)) found = node;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function checkHandlerRoute(source: string | undefined): LayeringViolation[] {
|
||||
if (source === undefined) {
|
||||
return [violation(HANDLER_FILE, 1, 'devices gateway-owned handler module is missing')];
|
||||
}
|
||||
const program = parseSync(HANDLER_FILE, source).program as AstNode;
|
||||
const binding = importedInventoryBinding(program);
|
||||
if (!binding) {
|
||||
return [
|
||||
violation(
|
||||
HANDLER_FILE,
|
||||
1,
|
||||
'devices handler must import listDeviceInventory from the neutral inventory owner',
|
||||
),
|
||||
];
|
||||
}
|
||||
const shadow = shadowingBindingSite(program, binding);
|
||||
if (shadow) {
|
||||
return [
|
||||
violation(
|
||||
HANDLER_FILE,
|
||||
lineOf(source, shadow),
|
||||
'devices handler shadows its imported listDeviceInventory binding',
|
||||
),
|
||||
];
|
||||
}
|
||||
if (!callsBinding(program, binding)) {
|
||||
return [
|
||||
violation(
|
||||
HANDLER_FILE,
|
||||
1,
|
||||
'devices handler must call its imported listDeviceInventory binding',
|
||||
),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function checkLegacyRoutes(file: string, source: string): LayeringViolation[] {
|
||||
const violations: LayeringViolation[] = [];
|
||||
if (isLegacyInventoryModulePath(file)) {
|
||||
violations.push(violation(file, 1, 'legacy device inventory module path remains'));
|
||||
}
|
||||
const program = parseSync(file, source).program as AstNode;
|
||||
const seenIdentifiers = new Set<string>();
|
||||
visitAst(program, (node) => {
|
||||
const specifier = importedModuleSpecifier(node);
|
||||
if (specifier && isLegacyInventoryImport(specifier)) {
|
||||
violations.push(
|
||||
violation(
|
||||
file,
|
||||
lineOf(source, node),
|
||||
`production source imports legacy device inventory module '${specifier}'`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (node['type'] !== 'Identifier') return;
|
||||
const name = identifierName(node);
|
||||
if (!name || !LEGACY_INVENTORY_IDENTIFIERS.has(name) || seenIdentifiers.has(name)) return;
|
||||
seenIdentifiers.add(name);
|
||||
violations.push(
|
||||
violation(
|
||||
file,
|
||||
lineOf(source, node),
|
||||
`legacy device inventory identifier '${name}' remains in production code`,
|
||||
),
|
||||
);
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function checkDeviceInventoryCutover(
|
||||
sources: ReadonlyMap<string, string>,
|
||||
): LayeringViolation[] {
|
||||
return [
|
||||
...checkHandlerRoute(sources.get(HANDLER_FILE)),
|
||||
...[...sources].flatMap(([file, source]) => checkLegacyRoutes(file, source)),
|
||||
];
|
||||
}
|
||||
|
||||
export function deviceInventoryCutoverSummary(): string {
|
||||
return 'R17 holds the devices command to one gateway-owned inventory route with no legacy route';
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
logsLegacyRouteViolations,
|
||||
logsRuntimeNarrowingViolations,
|
||||
logsSessionStateOwnershipViolations,
|
||||
sourceExecutedUsingDeclarationViolations,
|
||||
} from './logs-runtime-cutover-policy.ts';
|
||||
|
||||
function summaries(violations: readonly Readonly<{ file: string; message: string }>[]): string[] {
|
||||
return violations.map(({ file, message }) => `${file}: ${message}`);
|
||||
}
|
||||
|
||||
test('legacy logs scan catches a planted provider route and plugin facet', () => {
|
||||
assert.deepEqual(
|
||||
summaries(
|
||||
logsLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/planted.ts',
|
||||
source: 'await withAppLogProvider(provider, task); await startAppLog(request);',
|
||||
},
|
||||
{
|
||||
path: 'src/platforms/planted/plugin.ts',
|
||||
source:
|
||||
'export const plugin = { appLog: { resolveBackend() {} } } satisfies PlatformPlugin;',
|
||||
},
|
||||
]),
|
||||
),
|
||||
[
|
||||
'src/daemon/planted.ts: legacy logs route withAppLogProvider',
|
||||
'src/daemon/planted.ts: legacy logs route startAppLog',
|
||||
'src/platforms/planted/plugin.ts: legacy PlatformPlugin appLog facet',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy logs scan ignores retired names in comments and string data', () => {
|
||||
assert.deepEqual(
|
||||
summaries(
|
||||
logsLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/planted.ts',
|
||||
source: `
|
||||
// AppLogProvider and startAppLog were removed.
|
||||
const migrationNote = 'withAppLogProvider and requireCommandSupported("logs")';
|
||||
const metadata = { note: 'appLogProvider' };
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'src/platforms/apple/plugin.ts',
|
||||
source: `const note = 'PUBLIC_COMMANDS.logs';`,
|
||||
},
|
||||
]),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy logs scan retains type-import and computed executable route coverage', () => {
|
||||
assert.deepEqual(
|
||||
summaries(
|
||||
logsLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/planted.ts',
|
||||
source: `
|
||||
import type { AppLogProvider as LegacyProvider } from './old-app-log.ts';
|
||||
const route = handlers['startAppLog'];
|
||||
`,
|
||||
},
|
||||
]),
|
||||
),
|
||||
[
|
||||
'src/daemon/planted.ts: legacy logs route AppLogProvider',
|
||||
'src/daemon/planted.ts: legacy logs route startAppLog',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy logs scan catches planted capability and dual-admission routes', () => {
|
||||
const violations = logsLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/core/command-descriptor/registry.ts',
|
||||
source: `
|
||||
const registry = [
|
||||
{ name: 'logs', capability: { apple: {} }, platformExecution: runtime },
|
||||
{ name: 'events', capability: { apple: {} } },
|
||||
];
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'src/daemon/handlers/planted.ts',
|
||||
source: `
|
||||
const literal = requireCommandSupported('logs', device);
|
||||
const symbolic = requireCommandSupported(PUBLIC_COMMANDS.logs, device);
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'src/platforms/apple/plugin.ts',
|
||||
source: `const supports = { [PUBLIC_COMMANDS.logs]: supportsCoreDevice };`,
|
||||
},
|
||||
{
|
||||
path: 'src/core/capabilities.ts',
|
||||
source: `const HARMONYOS_SUPPORTED_COMMANDS = new Set(['open', 'logs']);`,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(summaries(violations), [
|
||||
'src/core/command-descriptor/registry.ts: logs descriptor retains legacy capability admission',
|
||||
'src/daemon/handlers/planted.ts: legacy logs capability admission requireCommandSupported',
|
||||
'src/daemon/handlers/planted.ts: legacy logs capability admission requireCommandSupported',
|
||||
'src/platforms/apple/plugin.ts: Apple plugin retains legacy logs support or hint closure',
|
||||
'src/core/capabilities.ts: HarmonyOS static command set retains logs admission',
|
||||
]);
|
||||
});
|
||||
|
||||
test('legacy logs scan follows declaration roles after files move', () => {
|
||||
const violations = logsLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/moved/command-declarations.ts',
|
||||
source: `const descriptor = { name: 'logs', capability: { apple: {} } };`,
|
||||
},
|
||||
{
|
||||
path: 'src/moved/platform-contract.ts',
|
||||
source: `export type PlatformPlugin = { readonly appLog?: { inspect(): void } };`,
|
||||
},
|
||||
{
|
||||
path: 'src/moved/apple-family.ts',
|
||||
source: `const plugin = { appLog: { inspect() {} } } as const satisfies PlatformPlugin;`,
|
||||
},
|
||||
{
|
||||
path: 'src/moved/typed-plugin.ts',
|
||||
source: `const plugin: PlatformPlugin = { appLog: { inspect() {} } };`,
|
||||
},
|
||||
{
|
||||
path: 'src/moved/capability-data.ts',
|
||||
source: `const HARMONYOS_SUPPORTED_COMMANDS = new Set(['logs']);`,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(
|
||||
violations.map(({ file, message }) => ({ file, message })),
|
||||
[
|
||||
{
|
||||
file: 'src/moved/command-declarations.ts',
|
||||
message: 'logs descriptor retains legacy capability admission',
|
||||
},
|
||||
{
|
||||
file: 'src/moved/platform-contract.ts',
|
||||
message: 'legacy PlatformPlugin appLog facet',
|
||||
},
|
||||
{
|
||||
file: 'src/moved/apple-family.ts',
|
||||
message: 'legacy PlatformPlugin appLog facet',
|
||||
},
|
||||
{
|
||||
file: 'src/moved/typed-plugin.ts',
|
||||
message: 'legacy PlatformPlugin appLog facet',
|
||||
},
|
||||
{
|
||||
file: 'src/moved/capability-data.ts',
|
||||
message: 'HarmonyOS static command set retains logs admission',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('R14 violations retain their exact source line and punctuation', () => {
|
||||
const [violation] = logsRuntimeNarrowingViolations([
|
||||
{
|
||||
path: 'src/daemon/moved-handler.ts',
|
||||
source: `
|
||||
const note = 'reason: retained';
|
||||
const widened = runtime as BoundDeviceRuntime<typeof use>;
|
||||
`,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(violation, {
|
||||
rule: 'R14 logs-runtime-cutover',
|
||||
file: 'src/daemon/moved-handler.ts',
|
||||
line: 3,
|
||||
message: 'widened logs runtime access as BoundDeviceRuntime',
|
||||
});
|
||||
});
|
||||
|
||||
test('narrowing scan catches planted assertions, non-null repair, and bracket access', () => {
|
||||
const violations = logsRuntimeNarrowingViolations([
|
||||
{
|
||||
path: 'src/daemon/handlers/planted.ts',
|
||||
source: `
|
||||
const widened = runtime as BoundDeviceRuntime<typeof use>;
|
||||
widened.operations.appLogStart!({});
|
||||
widened.operations['appLogDoctor']({});
|
||||
`,
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(violations.length, 3);
|
||||
const messages = violations.map(({ message }) => message).join('\n');
|
||||
assert.match(messages, /BoundDeviceRuntime/);
|
||||
assert.match(messages, /appLogStart!/);
|
||||
assert.match(messages, /appLogDoctor/);
|
||||
});
|
||||
|
||||
test('session state scan catches planted app-log record construction outside its owner', () => {
|
||||
assert.deepEqual(
|
||||
summaries(
|
||||
logsSessionStateOwnershipViolations([
|
||||
{
|
||||
path: 'src/daemon/handlers/planted.ts',
|
||||
source: `sessionStore.set(name, { ...session, appLog: resource, appLogFailure: undefined });`,
|
||||
},
|
||||
{
|
||||
path: 'src/daemon/request-platform-providers.ts',
|
||||
source: `
|
||||
type Scope = { appLog: { provider?: AppLogProvider } };
|
||||
const scope = { appLog: { provider } };
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'src/daemon/app-log-session-resource.ts',
|
||||
source: `sessionStore.set(name, { ...session, appLog: resource });`,
|
||||
},
|
||||
{
|
||||
path: 'src/daemon/session-teardown.ts',
|
||||
source: `teardownSessionResources({ appLog: 'run' }); teardownSessionResources({ appLog: 'already-settled' });`,
|
||||
},
|
||||
{
|
||||
path: 'src/daemon/handlers/invalid-teardown.ts',
|
||||
source: `teardownSessionResources({ appLog: 'skip' });`,
|
||||
},
|
||||
]),
|
||||
),
|
||||
[
|
||||
'src/daemon/handlers/planted.ts: session appLog record constructed outside its owner',
|
||||
'src/daemon/handlers/planted.ts: session appLogFailure record constructed outside its owner',
|
||||
'src/daemon/request-platform-providers.ts: session appLog record constructed outside its owner',
|
||||
'src/daemon/handlers/invalid-teardown.ts: session appLog record constructed outside its owner',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('source-executed syntax scan rejects using declarations but ignores prose', () => {
|
||||
assert.deepEqual(
|
||||
summaries(
|
||||
sourceExecutedUsingDeclarationViolations([
|
||||
{
|
||||
path: 'packages/platform-apple/src/logs/planted.ts',
|
||||
source: `
|
||||
// await using oldHandle = acquire();
|
||||
const migrationNote = 'using replacement = acquire()';
|
||||
async function run() { await using handle = acquire(); }
|
||||
function runSync() { using cleanup = acquireSync(); }
|
||||
`,
|
||||
},
|
||||
]),
|
||||
),
|
||||
[
|
||||
'packages/platform-apple/src/logs/planted.ts: source-executed TypeScript uses unsupported await using declaration',
|
||||
'packages/platform-apple/src/logs/planted.ts: source-executed TypeScript uses unsupported using declaration',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -1,312 +0,0 @@
|
||||
import { parseSync } from 'oxc-parser';
|
||||
import type { LayeringViolation } from './model.ts';
|
||||
|
||||
export type LogsRuntimeProductionSource = Readonly<{ path: string; source: string }>;
|
||||
|
||||
const LOGS_RUNTIME_CUTOVER_RULE = 'R14 logs-runtime-cutover';
|
||||
|
||||
const LEGACY_LOG_ROUTE_NAMES = new Set([
|
||||
'startAppLog',
|
||||
'stopAppLog',
|
||||
'runAppLogDoctor',
|
||||
'resolveLogBackend',
|
||||
'withAppLogProvider',
|
||||
'appLogProvider',
|
||||
'AppLogProviderResolver',
|
||||
'AppLogProvider',
|
||||
]);
|
||||
const RUNTIME_TYPE_ASSERTION =
|
||||
/\bas\s+(?:BoundDeviceRuntime|AppLogRuntimeOperations|AppLogLiveHandle)\b/g;
|
||||
const NON_NULL_RUNTIME_OPERATION = /\.operations(?:\.[A-Za-z_$][\w$]*|\[[^\]]+\])!/g;
|
||||
const BRACKETED_RUNTIME_OPERATION = /\.operations\[['"]appLog[A-Za-z]+['"]\]/g;
|
||||
|
||||
/** Legacy provider/tag execution is forbidden once the logs descriptor is runtime-backed. */
|
||||
export function logsLegacyRouteViolations(
|
||||
sources: readonly LogsRuntimeProductionSource[],
|
||||
): LayeringViolation[] {
|
||||
const violations: LayeringViolation[] = [];
|
||||
for (const file of sources) {
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
const seenRoutes = new Set<string>();
|
||||
visitAst(parsed.program, (node) => {
|
||||
const route = legacyRouteName(node);
|
||||
if (route) {
|
||||
const identity = `${String(node.start ?? '')}:${route}`;
|
||||
if (!seenRoutes.has(identity)) {
|
||||
seenRoutes.add(identity);
|
||||
violations.push(astViolation(file, node, `legacy logs route ${route}`));
|
||||
}
|
||||
}
|
||||
if (isLegacyLogsAdmission(node)) {
|
||||
violations.push(
|
||||
astViolation(file, node, 'legacy logs capability admission requireCommandSupported'),
|
||||
);
|
||||
}
|
||||
if (isLogsDescriptorWithCapability(node)) {
|
||||
violations.push(
|
||||
astViolation(file, node, 'logs descriptor retains legacy capability admission'),
|
||||
);
|
||||
}
|
||||
if (isAppleLogsCommandMember(node)) {
|
||||
violations.push(
|
||||
astViolation(file, node, 'Apple plugin retains legacy logs support or hint closure'),
|
||||
);
|
||||
}
|
||||
if (isHarmonyLogsCommandSetDeclaration(node)) {
|
||||
violations.push(
|
||||
astViolation(file, node, 'HarmonyOS static command set retains logs admission'),
|
||||
);
|
||||
}
|
||||
if (isPlatformPluginAppLogDeclaration(node)) {
|
||||
violations.push(astViolation(file, node, 'legacy PlatformPlugin appLog facet'));
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function legacyRouteName(node: Record<string, unknown>): string | undefined {
|
||||
if (node.type === 'Identifier' && LEGACY_LOG_ROUTE_NAMES.has(String(node.name))) {
|
||||
return String(node.name);
|
||||
}
|
||||
if (node.type === 'Property' || node.type === 'TSPropertySignature') {
|
||||
const key = propertyName(node.key);
|
||||
return key && LEGACY_LOG_ROUTE_NAMES.has(key) ? key : undefined;
|
||||
}
|
||||
if (node.type === 'MemberExpression' && node.computed === true) {
|
||||
const key = propertyName(node.property);
|
||||
return key && LEGACY_LOG_ROUTE_NAMES.has(key) ? key : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLegacyLogsAdmission(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee as Record<string, unknown> | undefined;
|
||||
const args = node.arguments as readonly Record<string, unknown>[] | undefined;
|
||||
return (
|
||||
callee?.type === 'Identifier' &&
|
||||
callee.name === 'requireCommandSupported' &&
|
||||
isLogsCommandExpression(args?.[0])
|
||||
);
|
||||
}
|
||||
|
||||
function isLogsCommandExpression(node: Record<string, unknown> | undefined): boolean {
|
||||
if (node?.type === 'Literal') return node.value === 'logs';
|
||||
if (node?.type !== 'MemberExpression') return false;
|
||||
const object = node.object as Record<string, unknown> | undefined;
|
||||
return (
|
||||
object?.type === 'Identifier' &&
|
||||
object.name === 'PUBLIC_COMMANDS' &&
|
||||
memberName(node) === 'logs'
|
||||
);
|
||||
}
|
||||
|
||||
function isLogsDescriptorWithCapability(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return false;
|
||||
let nameIsLogs = false;
|
||||
let hasCapability = false;
|
||||
for (const property of node.properties as Record<string, unknown>[]) {
|
||||
if (property.type !== 'Property' || property.computed === true) continue;
|
||||
const key = propertyName(property.key);
|
||||
const value = property.value as Record<string, unknown> | undefined;
|
||||
if (key === 'name' && value?.type === 'Literal' && value.value === 'logs') {
|
||||
nameIsLogs = true;
|
||||
}
|
||||
if (key === 'capability') hasCapability = true;
|
||||
}
|
||||
return nameIsLogs && hasCapability;
|
||||
}
|
||||
|
||||
function isAppleLogsCommandMember(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'Property' || node.computed !== true) return false;
|
||||
return isLogsCommandExpression(node.key as Record<string, unknown> | undefined);
|
||||
}
|
||||
|
||||
function isHarmonyLogsCommandSetDeclaration(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'VariableDeclarator') return false;
|
||||
const id = node.id as Record<string, unknown> | undefined;
|
||||
return (
|
||||
id?.type === 'Identifier' &&
|
||||
id.name === 'HARMONYOS_SUPPORTED_COMMANDS' &&
|
||||
astContainsString(node.init, 'logs')
|
||||
);
|
||||
}
|
||||
|
||||
function isPlatformPluginAppLogDeclaration(node: Record<string, unknown>): boolean {
|
||||
if (node.type === 'TSTypeAliasDeclaration' || node.type === 'TSInterfaceDeclaration') {
|
||||
const id = node.id as Record<string, unknown> | undefined;
|
||||
return id?.type === 'Identifier' && id.name === 'PlatformPlugin' && astContainsAppLog(node);
|
||||
}
|
||||
if (node.type === 'TSSatisfiesExpression' || node.type === 'TSAsExpression') {
|
||||
return isNamedType(node.typeAnnotation, 'PlatformPlugin') && astContainsAppLog(node.expression);
|
||||
}
|
||||
if (node.type !== 'VariableDeclarator') return false;
|
||||
const id = node.id as Record<string, unknown> | undefined;
|
||||
return isNamedType(id?.typeAnnotation, 'PlatformPlugin') && astContainsAppLog(node.init);
|
||||
}
|
||||
|
||||
function isNamedType(node: unknown, expected: string): boolean {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
const record = node as Record<string, unknown>;
|
||||
if (record.type === 'TSTypeAnnotation') return isNamedType(record.typeAnnotation, expected);
|
||||
if (record.type !== 'TSTypeReference') return false;
|
||||
const name = record.typeName as Record<string, unknown> | undefined;
|
||||
return name?.type === 'Identifier' && name.name === expected;
|
||||
}
|
||||
|
||||
function astContainsAppLog(node: unknown): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (
|
||||
(candidate.type === 'Property' || candidate.type === 'TSPropertySignature') &&
|
||||
candidate.computed !== true &&
|
||||
propertyName(candidate.key) === 'appLog'
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function memberName(node: Record<string, unknown>): string | undefined {
|
||||
const property = node.property as Record<string, unknown> | undefined;
|
||||
if (!property) return undefined;
|
||||
if (node.computed === true) return propertyName(property);
|
||||
return property.type === 'Identifier' ? String(property.name) : undefined;
|
||||
}
|
||||
|
||||
function astContainsString(node: unknown, expected: string): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (candidate.type === 'Literal' && candidate.value === expected) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function visitAst(node: unknown, visitor: (node: Record<string, unknown>) => void): void {
|
||||
if (node === null || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) visitAst(child, visitor);
|
||||
return;
|
||||
}
|
||||
const record = node as Record<string, unknown>;
|
||||
visitor(record);
|
||||
for (const child of Object.values(record)) visitAst(child, visitor);
|
||||
}
|
||||
|
||||
/** Migrated daemon owners consume narrowed operations without manufacturing facet proof. */
|
||||
export function logsRuntimeNarrowingViolations(
|
||||
sources: readonly LogsRuntimeProductionSource[],
|
||||
): LayeringViolation[] {
|
||||
const violations: LayeringViolation[] = [];
|
||||
for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) {
|
||||
for (const pattern of [
|
||||
RUNTIME_TYPE_ASSERTION,
|
||||
NON_NULL_RUNTIME_OPERATION,
|
||||
BRACKETED_RUNTIME_OPERATION,
|
||||
]) {
|
||||
for (const match of file.source.matchAll(pattern)) {
|
||||
violations.push(
|
||||
offsetViolation(file, match.index, `widened logs runtime access ${match[0]}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** Session app-log state transitions have one whole-record replacement owner. */
|
||||
export function logsSessionStateOwnershipViolations(
|
||||
sources: readonly LogsRuntimeProductionSource[],
|
||||
): LayeringViolation[] {
|
||||
const violations: LayeringViolation[] = [];
|
||||
for (const file of sources) {
|
||||
if (
|
||||
!file.path.startsWith('src/daemon/') ||
|
||||
file.path === 'src/daemon/app-log-session-resource.ts' ||
|
||||
file.path === 'src/daemon/types.ts'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
visitAst(parsed.program, (node) => {
|
||||
if (node.type === 'Property' && node.kind === 'init' && node.computed !== true) {
|
||||
const field = propertyName(node.key);
|
||||
if (
|
||||
(field === 'appLog' || field === 'appLogFailure') &&
|
||||
!isAppLogTeardownDiscriminant(field, node.value)
|
||||
) {
|
||||
violations.push(
|
||||
astViolation(file, node, `session ${field} record constructed outside its owner`),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function isAppLogTeardownDiscriminant(field: string, valueNode: unknown): boolean {
|
||||
if (field !== 'appLog' || !valueNode || typeof valueNode !== 'object') return false;
|
||||
const value = valueNode as Record<string, unknown>;
|
||||
return value.type === 'Literal' && (value.value === 'run' || value.value === 'already-settled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-executed TypeScript must remain parseable by the supported Node 22
|
||||
* type-stripping runtime. OXC identifies both declaration forms without
|
||||
* mistaking comments or string data for executable syntax.
|
||||
*/
|
||||
export function sourceExecutedUsingDeclarationViolations(
|
||||
sources: readonly LogsRuntimeProductionSource[],
|
||||
): LayeringViolation[] {
|
||||
const violations: LayeringViolation[] = [];
|
||||
for (const file of sources) {
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
visitAst(parsed.program, (node) => {
|
||||
if (
|
||||
node.type === 'VariableDeclaration' &&
|
||||
(node.kind === 'using' || node.kind === 'await using')
|
||||
) {
|
||||
violations.push(
|
||||
astViolation(
|
||||
file,
|
||||
node,
|
||||
`source-executed TypeScript uses unsupported ${String(node.kind)} declaration`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function astViolation(
|
||||
file: LogsRuntimeProductionSource,
|
||||
node: Record<string, unknown>,
|
||||
message: string,
|
||||
): LayeringViolation {
|
||||
return offsetViolation(file, typeof node.start === 'number' ? node.start : 0, message);
|
||||
}
|
||||
|
||||
function offsetViolation(
|
||||
file: LogsRuntimeProductionSource,
|
||||
offset: number,
|
||||
message: string,
|
||||
): LayeringViolation {
|
||||
return {
|
||||
rule: LOGS_RUNTIME_CUTOVER_RULE,
|
||||
file: file.path,
|
||||
line: file.source.slice(0, offset).split('\n').length,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function propertyName(node: unknown): string | undefined {
|
||||
if (node === null || typeof node !== 'object') return undefined;
|
||||
const record = node as Record<string, unknown>;
|
||||
return record.type === 'Identifier' || record.type === 'Literal'
|
||||
? ((record.name as string | undefined) ?? (record.value as string | undefined))
|
||||
: undefined;
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
networkLegacyRouteViolations,
|
||||
networkRuntimeNarrowingViolations,
|
||||
networkRuntimeRouteViolations,
|
||||
} from './network-runtime-cutover-policy.ts';
|
||||
|
||||
test('network cutover scan catches planted legacy execution and dual admission', () => {
|
||||
assert.deepEqual(
|
||||
networkLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/handlers/planted.ts',
|
||||
source: `
|
||||
await readSessionNetworkCapture(input);
|
||||
await provider.dumpNetwork({ include: 'all' });
|
||||
requireCommandSupported('network', device);
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'src/core/command-descriptor/registry.ts',
|
||||
source: `const commands = [{ name: 'network', capability: { apple: {} } }];`,
|
||||
},
|
||||
{
|
||||
path: 'src/core/capabilities.ts',
|
||||
source: `const WEB_QUERY_COMMANDS = ['find', 'network'];`,
|
||||
},
|
||||
{
|
||||
path: 'src/platforms/apple/plugin.ts',
|
||||
source: `const supports = { [PUBLIC_COMMANDS.network]: supportsDevice };`,
|
||||
},
|
||||
]),
|
||||
[
|
||||
'src/daemon/handlers/planted.ts: legacy network route readSessionNetworkCapture',
|
||||
'src/daemon/handlers/planted.ts: daemon invokes legacy WebProvider.dumpNetwork',
|
||||
'src/daemon/handlers/planted.ts: legacy network capability admission requireCommandSupported',
|
||||
'src/core/command-descriptor/registry.ts: network descriptor retains legacy capability admission',
|
||||
'src/core/capabilities.ts: static platform command set retains network admission',
|
||||
'src/platforms/apple/plugin.ts: Apple plugin retains legacy network support or hint closure',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('network cutover scan rejects a retired implementation by committed path', () => {
|
||||
assert.deepEqual(
|
||||
networkLegacyRouteViolations([
|
||||
{ path: 'src/daemon/network-log.ts', source: `export const parser = () => [];` },
|
||||
]),
|
||||
['src/daemon/network-log.ts: retired daemon network implementation remains'],
|
||||
);
|
||||
});
|
||||
|
||||
test('network cutover scan ignores retired names in comments and string data', () => {
|
||||
assert.deepEqual(
|
||||
networkLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/planted.ts',
|
||||
source: `
|
||||
// readSessionNetworkCapture and requireCommandSupported('network') were removed.
|
||||
const note = 'provider.dumpNetwork and PUBLIC_COMMANDS.network';
|
||||
`,
|
||||
},
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('network narrowing scan catches planted casts, non-null repair, and bracket access', () => {
|
||||
const violations = networkRuntimeNarrowingViolations([
|
||||
{
|
||||
path: 'src/daemon/handlers/planted.ts',
|
||||
source: `
|
||||
const widened = runtime as BoundDeviceRuntime<typeof use>;
|
||||
widened.operations.networkDump!({});
|
||||
widened.operations['networkDump']({});
|
||||
`,
|
||||
},
|
||||
]);
|
||||
assert.equal(violations.length, 3);
|
||||
assert.match(violations.join('\n'), /type assertion/);
|
||||
assert.match(violations.join('\n'), /non-null/);
|
||||
assert.match(violations.join('\n'), /bracketed/);
|
||||
});
|
||||
|
||||
test('network route scan rejects planted neither and duplicate routes', () => {
|
||||
assert.deepEqual(networkRuntimeRouteViolations([]), [
|
||||
'(network runtime): expected one handleNetworkCommand route, found 0',
|
||||
'(network runtime): expected one narrowed networkDump call, found 0',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
networkRuntimeRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/planted.ts',
|
||||
source: `
|
||||
handleNetworkCommand(first);
|
||||
handleNetworkCommand(second);
|
||||
runtime.operations.networkDump(input);
|
||||
fallback.operations.networkDump(input);
|
||||
`,
|
||||
},
|
||||
]),
|
||||
[
|
||||
'(network runtime): expected one handleNetworkCommand route, found 2',
|
||||
'(network runtime): expected one narrowed networkDump call, found 2',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
import { parseSync } from 'oxc-parser';
|
||||
|
||||
export type NetworkRuntimeProductionSource = Readonly<{ path: string; source: string }>;
|
||||
|
||||
const RETIRED_DAEMON_MODULES = new Set([
|
||||
'src/daemon/network-log.ts',
|
||||
'src/daemon/app-log-network-recovery.ts',
|
||||
'src/daemon/network-log-android-recovery.ts',
|
||||
'src/daemon/network-log-ios-simulator-recovery.ts',
|
||||
]);
|
||||
const LEGACY_NETWORK_ROUTE_NAMES = new Set([
|
||||
'handleWebNetworkCommand',
|
||||
'readSessionNetworkCapture',
|
||||
'readRecentNetworkTraffic',
|
||||
'readRecentAndroidLogcatForPackage',
|
||||
'readRecentIosSimulatorLogShowForBundle',
|
||||
]);
|
||||
|
||||
/** Network command admission and execution have one operation-fact-derived route. */
|
||||
export function networkLegacyRouteViolations(
|
||||
sources: readonly NetworkRuntimeProductionSource[],
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const file of sources) {
|
||||
if (RETIRED_DAEMON_MODULES.has(file.path)) {
|
||||
violations.push(`${file.path}: retired daemon network implementation remains`);
|
||||
}
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
const seen = new Set<string>();
|
||||
visitAst(parsed.program, (node) => {
|
||||
const route = legacyRouteName(node);
|
||||
if (route && file.path.startsWith('src/daemon/')) {
|
||||
const identity = `${String(node.start ?? '')}:${route}`;
|
||||
if (!seen.has(identity)) {
|
||||
seen.add(identity);
|
||||
violations.push(`${file.path}: legacy network route ${route}`);
|
||||
}
|
||||
}
|
||||
if (isLegacyNetworkAdmission(node)) {
|
||||
violations.push(
|
||||
`${file.path}: legacy network capability admission requireCommandSupported`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
file.path === 'src/core/command-descriptor/registry.ts' &&
|
||||
isNetworkDescriptorWithCapability(node)
|
||||
) {
|
||||
violations.push(`${file.path}: network descriptor retains legacy capability admission`);
|
||||
}
|
||||
if (file.path === 'src/core/capabilities.ts' && isStaticNetworkAdmission(node)) {
|
||||
violations.push(`${file.path}: static platform command set retains network admission`);
|
||||
}
|
||||
if (file.path === 'src/platforms/apple/plugin.ts' && isAppleNetworkCommandMember(node)) {
|
||||
violations.push(
|
||||
`${file.path}: Apple plugin retains legacy network support or hint closure`,
|
||||
);
|
||||
}
|
||||
if (file.path.startsWith('src/daemon/') && isLegacyWebDumpCall(node)) {
|
||||
violations.push(`${file.path}: daemon invokes legacy WebProvider.dumpNetwork`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** Daemon consumers must use the use-narrowed network operation without repairing proof. */
|
||||
export function networkRuntimeNarrowingViolations(
|
||||
sources: readonly NetworkRuntimeProductionSource[],
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) {
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
visitAst(parsed.program, (node) => {
|
||||
if (node.type === 'TSAsExpression' && containsRuntimeTypeRepair(node.typeAnnotation)) {
|
||||
violations.push(`${file.path}: widened network runtime type assertion`);
|
||||
}
|
||||
if (node.type === 'TSNonNullExpression' && isNetworkOperationAccess(node.expression)) {
|
||||
violations.push(`${file.path}: non-null repair of networkDump operation`);
|
||||
}
|
||||
if (isBracketedNetworkOperationAccess(node)) {
|
||||
violations.push(`${file.path}: bracketed networkDump operation access`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** The command has one daemon orchestration route and one operation projection call. */
|
||||
export function networkRuntimeRouteViolations(
|
||||
sources: readonly NetworkRuntimeProductionSource[],
|
||||
): string[] {
|
||||
let handlerCalls = 0;
|
||||
let operationCalls = 0;
|
||||
for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) {
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
visitAst(parsed.program, (node) => {
|
||||
if (node.type !== 'CallExpression') return;
|
||||
const callee = node.callee as Record<string, unknown> | undefined;
|
||||
if (callee?.type === 'Identifier' && callee.name === 'handleNetworkCommand') {
|
||||
handlerCalls += 1;
|
||||
}
|
||||
if (callee?.type === 'MemberExpression' && isRuntimeNetworkOperation(callee)) {
|
||||
operationCalls += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
const violations: string[] = [];
|
||||
if (handlerCalls !== 1) {
|
||||
violations.push(
|
||||
`(network runtime): expected one handleNetworkCommand route, found ${handlerCalls}`,
|
||||
);
|
||||
}
|
||||
if (operationCalls !== 1) {
|
||||
violations.push(
|
||||
`(network runtime): expected one narrowed networkDump call, found ${operationCalls}`,
|
||||
);
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function legacyRouteName(node: Record<string, unknown>): string | undefined {
|
||||
if (node.type === 'Identifier' && LEGACY_NETWORK_ROUTE_NAMES.has(String(node.name))) {
|
||||
return String(node.name);
|
||||
}
|
||||
if (node.type === 'Property' || node.type === 'TSPropertySignature') {
|
||||
const name = propertyName(node.key);
|
||||
return name && LEGACY_NETWORK_ROUTE_NAMES.has(name) ? name : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLegacyNetworkAdmission(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee as Record<string, unknown> | undefined;
|
||||
const args = node.arguments as readonly Record<string, unknown>[] | undefined;
|
||||
return (
|
||||
callee?.type === 'Identifier' &&
|
||||
callee.name === 'requireCommandSupported' &&
|
||||
args?.[0]?.type === 'Literal' &&
|
||||
args[0].value === 'network'
|
||||
);
|
||||
}
|
||||
|
||||
function isNetworkDescriptorWithCapability(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return false;
|
||||
let network = false;
|
||||
let capability = false;
|
||||
for (const property of node.properties as Record<string, unknown>[]) {
|
||||
if (property.type !== 'Property' || property.computed === true) continue;
|
||||
const key = propertyName(property.key);
|
||||
const value = property.value as Record<string, unknown> | undefined;
|
||||
if (key === 'name' && value?.type === 'Literal' && value.value === 'network') network = true;
|
||||
if (key === 'capability') capability = true;
|
||||
}
|
||||
return network && capability;
|
||||
}
|
||||
|
||||
function isStaticNetworkAdmission(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'VariableDeclarator') return false;
|
||||
const id = node.id as Record<string, unknown> | undefined;
|
||||
return (
|
||||
id?.type === 'Identifier' &&
|
||||
/(?:WEB|HARMONY).*COMMANDS/.test(String(id.name)) &&
|
||||
astContainsString(node.init, 'network')
|
||||
);
|
||||
}
|
||||
|
||||
function isAppleNetworkCommandMember(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'MemberExpression') return false;
|
||||
const object = node.object as Record<string, unknown> | undefined;
|
||||
return (
|
||||
object?.type === 'Identifier' &&
|
||||
object.name === 'PUBLIC_COMMANDS' &&
|
||||
memberName(node) === 'network'
|
||||
);
|
||||
}
|
||||
|
||||
function isLegacyWebDumpCall(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee as Record<string, unknown> | undefined;
|
||||
return callee?.type === 'MemberExpression' && memberName(callee) === 'dumpNetwork';
|
||||
}
|
||||
|
||||
function containsRuntimeTypeRepair(node: unknown): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (
|
||||
candidate.type === 'Identifier' &&
|
||||
(candidate.name === 'BoundDeviceRuntime' || candidate.name === 'NetworkRuntimeOperations')
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function isNetworkOperationAccess(node: unknown): boolean {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
const member = node as Record<string, unknown>;
|
||||
return member.type === 'MemberExpression' && memberName(member) === 'networkDump';
|
||||
}
|
||||
|
||||
function isBracketedNetworkOperationAccess(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'MemberExpression' || node.computed !== true) return false;
|
||||
const object = node.object as Record<string, unknown> | undefined;
|
||||
return (
|
||||
memberName(node) === 'networkDump' &&
|
||||
object?.type === 'MemberExpression' &&
|
||||
memberName(object) === 'operations'
|
||||
);
|
||||
}
|
||||
|
||||
function isRuntimeNetworkOperation(node: Record<string, unknown>): boolean {
|
||||
const object = node.object as Record<string, unknown> | undefined;
|
||||
return (
|
||||
memberName(node) === 'networkDump' &&
|
||||
object?.type === 'MemberExpression' &&
|
||||
memberName(object) === 'operations'
|
||||
);
|
||||
}
|
||||
|
||||
function memberName(node: Record<string, unknown>): string | undefined {
|
||||
const property = node.property as Record<string, unknown> | undefined;
|
||||
if (!property) return undefined;
|
||||
return node.computed === true
|
||||
? propertyName(property)
|
||||
: property.type === 'Identifier'
|
||||
? String(property.name)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function astContainsString(node: unknown, expected: string): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (candidate.type === 'Literal' && candidate.value === expected) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function propertyName(node: unknown): string | undefined {
|
||||
if (node === null || typeof node !== 'object') return undefined;
|
||||
const value = node as Record<string, unknown>;
|
||||
return value.type === 'Identifier' || value.type === 'Literal'
|
||||
? ((value.name as string | undefined) ?? (value.value as string | undefined))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function visitAst(node: unknown, visitor: (node: Record<string, unknown>) => void): void {
|
||||
if (node === null || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) visitAst(child, visitor);
|
||||
return;
|
||||
}
|
||||
const record = node as Record<string, unknown>;
|
||||
visitor(record);
|
||||
for (const child of Object.values(record)) visitAst(child, visitor);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
recordLegacyRouteViolations,
|
||||
recordRuntimeNarrowingViolations,
|
||||
recordRuntimeRouteViolations,
|
||||
} from './record-runtime-cutover-policy.ts';
|
||||
|
||||
test('R16 catches a planted legacy recording backend, admission, and plugin facet', () => {
|
||||
assert.deepEqual(
|
||||
recordLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/handlers/planted.ts',
|
||||
source: `
|
||||
await resolveRecordingBackendForDevice(device).start(input);
|
||||
requireCommandSupported('record', device);
|
||||
requireCommandSupported(PUBLIC_COMMANDS.record, device);
|
||||
const backend = RECORDING_BACKENDS_BY_TAG.android;
|
||||
type LegacyTag = RecordingBackendTag;
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'src/core/command-descriptor/registry.ts',
|
||||
source: `const commands = [{ name: 'record', capability: { apple: {} } }];`,
|
||||
},
|
||||
{
|
||||
path: 'src/platforms/apple/plugin.ts',
|
||||
source: `const plugin = { recording: { resolveBackendTag() {} } } satisfies PlatformPlugin;`,
|
||||
},
|
||||
]),
|
||||
[
|
||||
'src/daemon/handlers/planted.ts: legacy recording route resolveRecordingBackendForDevice',
|
||||
'src/daemon/handlers/planted.ts: legacy record capability admission requireCommandSupported',
|
||||
'src/daemon/handlers/planted.ts: legacy record capability admission requireCommandSupported',
|
||||
'src/daemon/handlers/planted.ts: legacy recording backend map RECORDING_BACKENDS_BY_TAG',
|
||||
'src/daemon/handlers/planted.ts: legacy recording backend tag RecordingBackendTag',
|
||||
'src/core/command-descriptor/registry.ts: record descriptor retains legacy capability admission',
|
||||
'src/platforms/apple/plugin.ts: legacy PlatformPlugin recording facet',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('R16 excludes trace-only mechanics from the record cutover scan', () => {
|
||||
assert.deepEqual(
|
||||
recordLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/handlers/record-trace.ts',
|
||||
source: `
|
||||
const traceRecording = { startTrace() {}, stopTrace() {} };
|
||||
traceRecording.startTrace();
|
||||
`,
|
||||
},
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('R16 preserves the one record-trace gateway while requiring exact recording operation routes', () => {
|
||||
assert.deepEqual(
|
||||
recordRuntimeRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/handlers/session.ts',
|
||||
source: `
|
||||
handleRecordTraceCommands(input);
|
||||
runtime.operations.screenRecordingStart(input);
|
||||
runtime.operations.screenRecordingReattach(input);
|
||||
runtime.operations.screenRecordingCleanup(input);
|
||||
`,
|
||||
},
|
||||
]),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(recordRuntimeRouteViolations([]), [
|
||||
'(record runtime): expected one handleRecordTraceCommands route, found 0',
|
||||
'(record runtime): expected one narrowed screenRecordingStart call, found 0',
|
||||
'(record runtime): expected one narrowed screenRecordingReattach call, found 0',
|
||||
'(record runtime): expected one narrowed screenRecordingCleanup call, found 0',
|
||||
]);
|
||||
});
|
||||
|
||||
test('R16 rejects the retired recording-provider scope while allowing the focused transport', () => {
|
||||
const violations = recordLegacyRouteViolations([
|
||||
{
|
||||
path: 'src/daemon/request-platform-providers.ts',
|
||||
source: `
|
||||
import type { RecordingProvider } from './recording-provider.ts';
|
||||
const recordingProvider = resolveRecordingProvider();
|
||||
await withRecordingProvider(undefined, task);
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'src/platform-runtime-screen-recording-apple-transport.ts',
|
||||
source: `
|
||||
// recordingProvider and withRecordingProvider are retired prose.
|
||||
const prose = 'src/daemon/recording-provider.ts';
|
||||
withAppleSimulatorScreenRecordingTransport(transport, task);
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'src/daemon/recording-provider.ts',
|
||||
source: `export const retired = true;`,
|
||||
},
|
||||
{
|
||||
path: 'src/daemon/dynamic-provider.ts',
|
||||
source: `
|
||||
const providers = { ['recordingProvider']: resolver };
|
||||
await import('./recording-provider.ts');
|
||||
`,
|
||||
},
|
||||
]);
|
||||
assert.equal(violations.length, 8);
|
||||
assert.match(violations.join('\n'), /retired recording provider module/);
|
||||
assert.match(violations.join('\n'), /RecordingProvider/);
|
||||
assert.match(violations.join('\n'), /recordingProvider/);
|
||||
assert.match(violations.join('\n'), /resolveRecordingProvider/);
|
||||
assert.match(violations.join('\n'), /withRecordingProvider/);
|
||||
});
|
||||
|
||||
test('R16 rejects proof repair for a narrowed screen-recording operation', () => {
|
||||
const violations = recordRuntimeNarrowingViolations([
|
||||
{
|
||||
path: 'src/daemon/handlers/planted.ts',
|
||||
source: `
|
||||
const widened = runtime as BoundDeviceRuntime<typeof use>;
|
||||
widened.operations.screenRecordingStart!({});
|
||||
widened.operations['screenRecordingReattach']({});
|
||||
`,
|
||||
},
|
||||
]);
|
||||
assert.equal(violations.length, 3);
|
||||
assert.match(violations.join('\n'), /type assertion/);
|
||||
assert.match(violations.join('\n'), /non-null/);
|
||||
assert.match(violations.join('\n'), /bracketed/);
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
import { parseSync } from 'oxc-parser';
|
||||
import {
|
||||
memberName,
|
||||
propertyName,
|
||||
type RecordRuntimeProductionSource,
|
||||
visitAst,
|
||||
} from './record-runtime-policy-ast.ts';
|
||||
|
||||
const LEGACY_RECORD_ROUTE_NAMES = new Set([
|
||||
'resolveRecordingBackendForDevice',
|
||||
'stopActiveRecording',
|
||||
'RecordingBackend',
|
||||
'RecordingStartBackend',
|
||||
]);
|
||||
const RETIRED_RECORDING_PROVIDER_NAMES = new Set([
|
||||
'RecordingProvider',
|
||||
'RecordingProviderResolver',
|
||||
'recordingProvider',
|
||||
'resolveRecordingProvider',
|
||||
'withRecordingProvider',
|
||||
'createLocalRecordingProvider',
|
||||
]);
|
||||
const SCREEN_RECORDING_OPERATION_NAMES = new Set([
|
||||
'screenRecordingStart',
|
||||
'screenRecordingReattach',
|
||||
'screenRecordingCleanup',
|
||||
]);
|
||||
|
||||
/** Record owns one runtime-backed route: legacy tag/backend selection may not survive it. */
|
||||
export function recordLegacyRouteViolations(
|
||||
sources: readonly RecordRuntimeProductionSource[],
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const file of sources) {
|
||||
if (file.path === 'src/daemon/recording-provider.ts') {
|
||||
violations.push(`${file.path}: retired recording provider module`);
|
||||
}
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
const seen = new Set<string>();
|
||||
visitAst(parsed.program, (node) => {
|
||||
if (isRetiredRecordingProviderImport(node)) {
|
||||
pushOnce(violations, seen, node, file, 'retired recording provider module');
|
||||
}
|
||||
if (node.type === 'Identifier' && RETIRED_RECORDING_PROVIDER_NAMES.has(String(node.name))) {
|
||||
pushOnce(
|
||||
violations,
|
||||
seen,
|
||||
node,
|
||||
file,
|
||||
`retired recording provider name ${String(node.name)}`,
|
||||
);
|
||||
}
|
||||
if (isRetiredRecordingProviderComputedProperty(node)) {
|
||||
pushOnce(
|
||||
violations,
|
||||
seen,
|
||||
node,
|
||||
file,
|
||||
`retired recording provider name ${String(propertyName(node.key))}`,
|
||||
);
|
||||
}
|
||||
if (isNamedIdentifier(node, 'RECORDING_BACKENDS_BY_TAG')) {
|
||||
pushOnce(
|
||||
violations,
|
||||
seen,
|
||||
node,
|
||||
file,
|
||||
'legacy recording backend map RECORDING_BACKENDS_BY_TAG',
|
||||
);
|
||||
}
|
||||
if (isNamedIdentifier(node, 'RecordingBackendTag')) {
|
||||
pushOnce(violations, seen, node, file, 'legacy recording backend tag RecordingBackendTag');
|
||||
}
|
||||
const route = legacyRecordRouteName(node);
|
||||
if (route) pushOnce(violations, seen, node, file, `legacy recording route ${route}`);
|
||||
if (isLegacyRecordAdmission(node)) {
|
||||
pushOnce(
|
||||
violations,
|
||||
seen,
|
||||
node,
|
||||
file,
|
||||
'legacy record capability admission requireCommandSupported',
|
||||
);
|
||||
}
|
||||
if (isRecordDescriptorWithCapability(node)) {
|
||||
pushOnce(
|
||||
violations,
|
||||
seen,
|
||||
node,
|
||||
file,
|
||||
'record descriptor retains legacy capability admission',
|
||||
);
|
||||
}
|
||||
if (isPlatformPluginRecordingDeclaration(node)) {
|
||||
pushOnce(violations, seen, node, file, 'legacy PlatformPlugin recording facet');
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function isNamedIdentifier(node: Record<string, unknown>, name: string): boolean {
|
||||
return node.type === 'Identifier' && node.name === name;
|
||||
}
|
||||
|
||||
/** Daemon consumers must not manufacture proof for screen-recording runtime operations. */
|
||||
export function recordRuntimeNarrowingViolations(
|
||||
sources: readonly RecordRuntimeProductionSource[],
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) {
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
visitAst(parsed.program, (node) => {
|
||||
if (node.type === 'TSAsExpression' && containsRuntimeTypeRepair(node.typeAnnotation)) {
|
||||
violations.push(`${file.path}: widened screen-recording runtime type assertion`);
|
||||
}
|
||||
if (
|
||||
node.type === 'TSNonNullExpression' &&
|
||||
isScreenRecordingOperationAccess(node.expression)
|
||||
) {
|
||||
violations.push(`${file.path}: non-null repair of screen-recording operation`);
|
||||
}
|
||||
if (isBracketedScreenRecordingOperationAccess(node)) {
|
||||
violations.push(`${file.path}: bracketed screen-recording operation access`);
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** Record keeps the one existing record-trace gateway; trace mechanics are otherwise out of scope. */
|
||||
export function recordRuntimeRouteViolations(
|
||||
sources: readonly RecordRuntimeProductionSource[],
|
||||
): string[] {
|
||||
let recordTraceGatewayCalls = 0;
|
||||
let startCalls = 0;
|
||||
let reattachCalls = 0;
|
||||
let cleanupCalls = 0;
|
||||
for (const file of sources.filter(({ path }) => path.startsWith('src/daemon/'))) {
|
||||
const parsed = parseSync(file.path, file.source);
|
||||
visitAst(parsed.program, (node) => {
|
||||
if (node.type !== 'CallExpression') return;
|
||||
const callee = node.callee as Record<string, unknown> | undefined;
|
||||
if (callee?.type === 'Identifier' && callee.name === 'handleRecordTraceCommands') {
|
||||
recordTraceGatewayCalls += 1;
|
||||
}
|
||||
if (callee?.type !== 'MemberExpression' || !isRuntimeScreenRecordingOperation(callee)) return;
|
||||
switch (memberName(callee)) {
|
||||
case 'screenRecordingStart':
|
||||
startCalls += 1;
|
||||
break;
|
||||
case 'screenRecordingReattach':
|
||||
reattachCalls += 1;
|
||||
break;
|
||||
case 'screenRecordingCleanup':
|
||||
cleanupCalls += 1;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
return [
|
||||
exactRouteViolation('handleRecordTraceCommands', recordTraceGatewayCalls),
|
||||
exactOperationViolation('screenRecordingStart', startCalls),
|
||||
exactOperationViolation('screenRecordingReattach', reattachCalls),
|
||||
exactOperationViolation('screenRecordingCleanup', cleanupCalls),
|
||||
].filter((violation): violation is string => violation !== null);
|
||||
}
|
||||
|
||||
function isRetiredRecordingProviderImport(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'ImportDeclaration' && node.type !== 'ImportExpression') return false;
|
||||
const source = node.source as Record<string, unknown> | undefined;
|
||||
return (
|
||||
source?.type === 'Literal' &&
|
||||
/(?:^|\/)recording-provider(?:\.[cm]?[jt]s)?$/.test(String(source.value))
|
||||
);
|
||||
}
|
||||
|
||||
function isRetiredRecordingProviderComputedProperty(node: Record<string, unknown>): boolean {
|
||||
return (
|
||||
(node.type === 'Property' || node.type === 'TSPropertySignature') &&
|
||||
node.computed === true &&
|
||||
RETIRED_RECORDING_PROVIDER_NAMES.has(propertyName(node.key) ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
function legacyRecordRouteName(node: Record<string, unknown>): string | undefined {
|
||||
if (node.type === 'Identifier' && LEGACY_RECORD_ROUTE_NAMES.has(String(node.name))) {
|
||||
return String(node.name);
|
||||
}
|
||||
if (node.type === 'Property' || node.type === 'TSPropertySignature') {
|
||||
const name = propertyName(node.key);
|
||||
return name && LEGACY_RECORD_ROUTE_NAMES.has(name) ? name : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLegacyRecordAdmission(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee as Record<string, unknown> | undefined;
|
||||
const args = node.arguments as readonly Record<string, unknown>[] | undefined;
|
||||
return (
|
||||
callee?.type === 'Identifier' &&
|
||||
callee.name === 'requireCommandSupported' &&
|
||||
isRecordCommandExpression(args?.[0])
|
||||
);
|
||||
}
|
||||
|
||||
function isRecordCommandExpression(node: Record<string, unknown> | undefined): boolean {
|
||||
if (node?.type === 'Literal') return node.value === 'record';
|
||||
if (node?.type !== 'MemberExpression') return false;
|
||||
const object = node.object as Record<string, unknown> | undefined;
|
||||
return (
|
||||
object?.type === 'Identifier' &&
|
||||
object.name === 'PUBLIC_COMMANDS' &&
|
||||
memberName(node) === 'record'
|
||||
);
|
||||
}
|
||||
|
||||
function isRecordDescriptorWithCapability(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'ObjectExpression' || !Array.isArray(node.properties)) return false;
|
||||
let record = false;
|
||||
let capability = false;
|
||||
for (const property of node.properties as Record<string, unknown>[]) {
|
||||
if (property.type !== 'Property' || property.computed === true) continue;
|
||||
const key = propertyName(property.key);
|
||||
const value = property.value as Record<string, unknown> | undefined;
|
||||
if (key === 'name' && value?.type === 'Literal' && value.value === 'record') record = true;
|
||||
if (key === 'capability') capability = true;
|
||||
}
|
||||
return record && capability;
|
||||
}
|
||||
|
||||
function isPlatformPluginRecordingDeclaration(node: Record<string, unknown>): boolean {
|
||||
if (node.type === 'TSTypeAliasDeclaration' || node.type === 'TSInterfaceDeclaration') {
|
||||
const id = node.id as Record<string, unknown> | undefined;
|
||||
return id?.type === 'Identifier' && id.name === 'PlatformPlugin' && astContainsRecording(node);
|
||||
}
|
||||
if (node.type === 'TSSatisfiesExpression' || node.type === 'TSAsExpression') {
|
||||
return (
|
||||
isNamedType(node.typeAnnotation, 'PlatformPlugin') && astContainsRecording(node.expression)
|
||||
);
|
||||
}
|
||||
if (node.type !== 'VariableDeclarator') return false;
|
||||
const id = node.id as Record<string, unknown> | undefined;
|
||||
return isNamedType(id?.typeAnnotation, 'PlatformPlugin') && astContainsRecording(node.init);
|
||||
}
|
||||
|
||||
function astContainsRecording(node: unknown): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (
|
||||
(candidate.type === 'Property' || candidate.type === 'TSPropertySignature') &&
|
||||
candidate.computed !== true &&
|
||||
propertyName(candidate.key) === 'recording'
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function containsRuntimeTypeRepair(node: unknown): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (
|
||||
candidate.type === 'Identifier' &&
|
||||
(candidate.name === 'BoundDeviceRuntime' ||
|
||||
candidate.name === 'ScreenRecordingRuntimeOperations' ||
|
||||
candidate.name === 'ScreenRecordingLiveHandle')
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function isScreenRecordingOperationAccess(node: unknown): boolean {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
const member = node as Record<string, unknown>;
|
||||
return (
|
||||
member.type === 'MemberExpression' &&
|
||||
SCREEN_RECORDING_OPERATION_NAMES.has(memberName(member) ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
function isBracketedScreenRecordingOperationAccess(node: Record<string, unknown>): boolean {
|
||||
if (node.type !== 'MemberExpression' || node.computed !== true) return false;
|
||||
const object = node.object as Record<string, unknown> | undefined;
|
||||
return (
|
||||
SCREEN_RECORDING_OPERATION_NAMES.has(memberName(node) ?? '') &&
|
||||
object?.type === 'MemberExpression' &&
|
||||
memberName(object) === 'operations'
|
||||
);
|
||||
}
|
||||
|
||||
function isRuntimeScreenRecordingOperation(node: Record<string, unknown>): boolean {
|
||||
const object = node.object as Record<string, unknown> | undefined;
|
||||
return (
|
||||
SCREEN_RECORDING_OPERATION_NAMES.has(memberName(node) ?? '') &&
|
||||
object?.type === 'MemberExpression' &&
|
||||
memberName(object) === 'operations'
|
||||
);
|
||||
}
|
||||
|
||||
function exactRouteViolation(name: string, calls: number): string | null {
|
||||
return calls === 1 ? null : `(record runtime): expected one ${name} route, found ${calls}`;
|
||||
}
|
||||
|
||||
function exactOperationViolation(name: string, calls: number): string | null {
|
||||
return calls === 1
|
||||
? null
|
||||
: `(record runtime): expected one narrowed ${name} call, found ${calls}`;
|
||||
}
|
||||
|
||||
function pushOnce(
|
||||
violations: string[],
|
||||
seen: Set<string>,
|
||||
node: Record<string, unknown>,
|
||||
file: RecordRuntimeProductionSource,
|
||||
message: string,
|
||||
): void {
|
||||
const identity = `${String(node.start ?? '')}:${message}`;
|
||||
if (seen.has(identity)) return;
|
||||
seen.add(identity);
|
||||
violations.push(`${file.path}: ${message}`);
|
||||
}
|
||||
|
||||
function isNamedType(node: unknown, expected: string): boolean {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
const record = node as Record<string, unknown>;
|
||||
if (record.type === 'TSTypeAnnotation') return isNamedType(record.typeAnnotation, expected);
|
||||
if (record.type !== 'TSTypeReference') return false;
|
||||
const name = record.typeName as Record<string, unknown> | undefined;
|
||||
return name?.type === 'Identifier' && name.name === expected;
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import { parseSync } from 'oxc-parser';
|
||||
import {
|
||||
memberName,
|
||||
type RecordRuntimeProductionSource,
|
||||
visitAst,
|
||||
} from './record-runtime-policy-ast.ts';
|
||||
import { memberName, type ProductionSource, visitAst } from './cutover-policy-ast.ts';
|
||||
|
||||
const DAEMON_RECORD_MECHANIC_CALLS = new Set([
|
||||
'runCmd',
|
||||
@@ -19,7 +15,7 @@ const DAEMON_RECORD_MECHANIC_CALLS = new Set([
|
||||
|
||||
/** The daemon record owner coordinates runtime operations; native mechanics stay behind owners. */
|
||||
export function recordRuntimeDaemonMechanicsViolations(
|
||||
sources: readonly RecordRuntimeProductionSource[],
|
||||
sources: readonly ProductionSource[],
|
||||
): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const file of sources.filter(({ path }) => isDaemonRecordOwner(path))) {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { parseSync } from 'oxc-parser';
|
||||
import {
|
||||
memberName,
|
||||
type RecordRuntimeProductionSource,
|
||||
visitAst,
|
||||
} from './record-runtime-policy-ast.ts';
|
||||
import { memberName, type ProductionSource, visitAst } from './cutover-policy-ast.ts';
|
||||
|
||||
export function recordRuntimeRegistryJoinViolations(
|
||||
sources: readonly RecordRuntimeProductionSource[],
|
||||
sources: readonly ProductionSource[],
|
||||
): string[] {
|
||||
const file = sources.find(({ path }) => path === 'src/core/command-descriptor/registry.ts');
|
||||
if (!file) return [missingRecordRuntimeRegistryJoin()];
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
appLogSessionStateOwnershipViolations,
|
||||
devicesGatewayBindingViolations,
|
||||
sourceExecutedUsingDeclarationViolations,
|
||||
} from './runtime-command-cutover-extensions.ts';
|
||||
|
||||
const HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts';
|
||||
|
||||
function handlerSource(localName = 'listDeviceInventory'): string {
|
||||
return [
|
||||
`import { listDeviceInventory as ${localName} } from '../../core/dispatch-resolve.ts';`,
|
||||
`export async function handleDevices(request: Request) { return await ${localName}(request); }`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function sources(entries: readonly (readonly [string, string])[]): ReadonlyMap<string, string> {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
function summaries(violations: readonly Readonly<{ file: string; message: string }>[]): string[] {
|
||||
return violations.map(({ file, message }) => `${file}: ${message}`);
|
||||
}
|
||||
|
||||
test('devices keeps one gateway-owned handler route through an aliased import', () => {
|
||||
assert.deepEqual(
|
||||
devicesGatewayBindingViolations(sources([[HANDLER_FILE, handlerSource('discoverInventory')]])),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('devices rejects a missing handler module', () => {
|
||||
assert.match(
|
||||
summaries(devicesGatewayBindingViolations(sources([]))).join('\n'),
|
||||
/gateway-owned handler module is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test('devices rejects a handler that imports but never calls the inventory gateway', () => {
|
||||
const handler = [
|
||||
"import { listDeviceInventory } from '../../core/dispatch-resolve.ts';",
|
||||
'export async function handleDevices() { return []; }',
|
||||
].join('\n');
|
||||
|
||||
assert.match(
|
||||
summaries(devicesGatewayBindingViolations(sources([[HANDLER_FILE, handler]]))).join('\n'),
|
||||
/must call its imported listDeviceInventory binding/,
|
||||
);
|
||||
});
|
||||
|
||||
test('devices rejects a handler that never imports the inventory gateway', () => {
|
||||
assert.match(
|
||||
summaries(
|
||||
devicesGatewayBindingViolations(
|
||||
sources([[HANDLER_FILE, 'export const handleDevices = () => [];']]),
|
||||
),
|
||||
).join('\n'),
|
||||
/must import listDeviceInventory from the neutral inventory owner/,
|
||||
);
|
||||
});
|
||||
|
||||
test('devices does not accept a call through a binding that shadows the imported alias', () => {
|
||||
const handler = [
|
||||
"import { listDeviceInventory as discoverInventory } from '../../core/dispatch-resolve.ts';",
|
||||
'export async function decoy(discoverInventory: (request: Request) => Promise<unknown>) {',
|
||||
' return await discoverInventory({});',
|
||||
'}',
|
||||
].join('\n');
|
||||
|
||||
assert.match(
|
||||
summaries(devicesGatewayBindingViolations(sources([[HANDLER_FILE, handler]]))).join('\n'),
|
||||
/shadows its imported listDeviceInventory binding/,
|
||||
);
|
||||
});
|
||||
|
||||
test('session state scan catches planted app-log record construction outside its owner', () => {
|
||||
assert.deepEqual(
|
||||
summaries(
|
||||
appLogSessionStateOwnershipViolations(
|
||||
sources([
|
||||
[
|
||||
'src/daemon/handlers/planted.ts',
|
||||
`sessionStore.set(name, { ...session, appLog: resource, appLogFailure: undefined });`,
|
||||
],
|
||||
[
|
||||
'src/daemon/request-platform-providers.ts',
|
||||
`
|
||||
type Scope = { appLog: { provider?: AppLogProvider } };
|
||||
const scope = { appLog: { provider } };
|
||||
`,
|
||||
],
|
||||
[
|
||||
'src/daemon/app-log-session-resource.ts',
|
||||
`sessionStore.set(name, { ...session, appLog: resource });`,
|
||||
],
|
||||
[
|
||||
'src/daemon/session-teardown.ts',
|
||||
`teardownSessionResources({ appLog: 'run' }); teardownSessionResources({ appLog: 'already-settled' });`,
|
||||
],
|
||||
[
|
||||
'src/daemon/handlers/invalid-teardown.ts',
|
||||
`teardownSessionResources({ appLog: 'skip' });`,
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
[
|
||||
'src/daemon/handlers/planted.ts: session appLog record constructed outside its owner',
|
||||
'src/daemon/handlers/planted.ts: session appLogFailure record constructed outside its owner',
|
||||
'src/daemon/request-platform-providers.ts: session appLog record constructed outside its owner',
|
||||
'src/daemon/handlers/invalid-teardown.ts: session appLog record constructed outside its owner',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('source-executed syntax scan rejects using declarations but ignores prose', () => {
|
||||
assert.deepEqual(
|
||||
summaries(
|
||||
sourceExecutedUsingDeclarationViolations(
|
||||
sources([
|
||||
[
|
||||
'packages/platform-apple/src/logs/planted.ts',
|
||||
`
|
||||
// await using oldHandle = acquire();
|
||||
const migrationNote = 'using replacement = acquire()';
|
||||
async function run() { await using handle = acquire(); }
|
||||
function runSync() { using cleanup = acquireSync(); }
|
||||
`,
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
[
|
||||
'packages/platform-apple/src/logs/planted.ts: source-executed TypeScript uses unsupported await using declaration',
|
||||
'packages/platform-apple/src/logs/planted.ts: source-executed TypeScript uses unsupported using declaration',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { parseSync } from 'oxc-parser';
|
||||
import { propertyName, visitAst, type ProductionSource } from './cutover-policy-ast.ts';
|
||||
import type { UnruledViolation } from './runtime-command-cutover-model.ts';
|
||||
|
||||
type AstNode = Record<string, unknown>;
|
||||
|
||||
const DEVICES_HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts';
|
||||
const DEVICES_INVENTORY_IMPORT_SOURCES = new Set([
|
||||
'../../core/device-inventory-context.ts',
|
||||
'../../core/dispatch-resolve.ts',
|
||||
]);
|
||||
const DEVICES_GATEWAY_BINDING = 'listDeviceInventory';
|
||||
const APP_LOG_SESSION_STATE_OWNERS = new Set([
|
||||
'src/daemon/app-log-session-resource.ts',
|
||||
'src/daemon/types.ts',
|
||||
]);
|
||||
|
||||
/**
|
||||
* `devices` proves its single inventory route by binding identity, not by name: the
|
||||
* handler must import the neutral gateway, must not shadow the binding it imported, and
|
||||
* must actually call it. No other migrated command routes through a named gateway
|
||||
* binding, so this stays a row extension rather than a table column.
|
||||
*/
|
||||
export function devicesGatewayBindingViolations(
|
||||
sources: ReadonlyMap<string, string>,
|
||||
): UnruledViolation[] {
|
||||
const source = sources.get(DEVICES_HANDLER_FILE);
|
||||
if (source === undefined) {
|
||||
return [
|
||||
{
|
||||
file: DEVICES_HANDLER_FILE,
|
||||
line: 1,
|
||||
message: 'devices gateway-owned handler module is missing',
|
||||
},
|
||||
];
|
||||
}
|
||||
const program = parseSync(DEVICES_HANDLER_FILE, source).program as AstNode;
|
||||
const binding = importedGatewayBinding(program);
|
||||
if (binding === undefined) {
|
||||
return [
|
||||
{
|
||||
file: DEVICES_HANDLER_FILE,
|
||||
line: 1,
|
||||
message: `devices handler must import ${DEVICES_GATEWAY_BINDING} from the neutral inventory owner`,
|
||||
},
|
||||
];
|
||||
}
|
||||
const shadow = shadowingBindingSite(program, binding);
|
||||
if (shadow !== undefined) {
|
||||
return [
|
||||
{
|
||||
file: DEVICES_HANDLER_FILE,
|
||||
line: lineOf(source, shadow),
|
||||
message: `devices handler shadows its imported ${DEVICES_GATEWAY_BINDING} binding`,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (!callsBinding(program, binding)) {
|
||||
return [
|
||||
{
|
||||
file: DEVICES_HANDLER_FILE,
|
||||
line: 1,
|
||||
message: `devices handler must call its imported ${DEVICES_GATEWAY_BINDING} binding`,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The app-log session record has one whole-record replacement owner (ADR 0019 §4-5).
|
||||
* `logs` is the durable-resource pilot; request-scoped rows own no session record, so
|
||||
* this stays a row extension.
|
||||
*/
|
||||
export function appLogSessionStateOwnershipViolations(
|
||||
sources: ReadonlyMap<string, string>,
|
||||
): UnruledViolation[] {
|
||||
const violations: UnruledViolation[] = [];
|
||||
for (const file of productionSources(sources)) {
|
||||
if (!file.path.startsWith('src/daemon/') || APP_LOG_SESSION_STATE_OWNERS.has(file.path)) {
|
||||
continue;
|
||||
}
|
||||
const program = parseSync(file.path, file.source).program as AstNode;
|
||||
visitAst(program, (node) => {
|
||||
if (node['type'] !== 'Property' || node['kind'] !== 'init' || node['computed'] === true) {
|
||||
return;
|
||||
}
|
||||
const field = propertyName(node['key']);
|
||||
if (
|
||||
(field === 'appLog' || field === 'appLogFailure') &&
|
||||
!isAppLogTeardownDiscriminant(field, node['value'])
|
||||
) {
|
||||
violations.push({
|
||||
file: file.path,
|
||||
line: lineOf(file.source, node),
|
||||
message: `session ${field} record constructed outside its owner`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-executed TypeScript must stay parseable by the Node 22 type-stripping runtime.
|
||||
* Not command-specific: it is declared on the logs row so its violations carry R14, the
|
||||
* id the layering report groups them under.
|
||||
*/
|
||||
export function sourceExecutedUsingDeclarationViolations(
|
||||
sources: ReadonlyMap<string, string>,
|
||||
): UnruledViolation[] {
|
||||
const violations: UnruledViolation[] = [];
|
||||
for (const file of productionSources(sources)) {
|
||||
const program = parseSync(file.path, file.source).program as AstNode;
|
||||
visitAst(program, (node) => {
|
||||
if (
|
||||
node['type'] === 'VariableDeclaration' &&
|
||||
(node['kind'] === 'using' || node['kind'] === 'await using')
|
||||
) {
|
||||
violations.push({
|
||||
file: file.path,
|
||||
line: lineOf(file.source, node),
|
||||
message: `source-executed TypeScript uses unsupported ${String(node['kind'])} declaration`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function productionSources(sources: ReadonlyMap<string, string>): ProductionSource[] {
|
||||
return [...sources].map(([path, source]) => ({ path, source }));
|
||||
}
|
||||
|
||||
function isAppLogTeardownDiscriminant(field: string, valueNode: unknown): boolean {
|
||||
if (field !== 'appLog' || valueNode === null || typeof valueNode !== 'object') return false;
|
||||
const value = valueNode as AstNode;
|
||||
return (
|
||||
value['type'] === 'Literal' &&
|
||||
(value['value'] === 'run' || value['value'] === 'already-settled')
|
||||
);
|
||||
}
|
||||
|
||||
function importedGatewayBinding(program: AstNode): string | undefined {
|
||||
const body = program['body'];
|
||||
if (!Array.isArray(body)) return undefined;
|
||||
for (const statement of body as AstNode[]) {
|
||||
if (statement['type'] !== 'ImportDeclaration') continue;
|
||||
const source = statement['source'] as AstNode | undefined;
|
||||
const specifier = source === undefined ? undefined : source['value'];
|
||||
if (typeof specifier !== 'string' || !DEVICES_INVENTORY_IMPORT_SOURCES.has(specifier)) continue;
|
||||
const specifiers = statement['specifiers'];
|
||||
if (!Array.isArray(specifiers)) continue;
|
||||
for (const imported of specifiers as AstNode[]) {
|
||||
if (
|
||||
imported['type'] === 'ImportSpecifier' &&
|
||||
identifierName(imported['imported']) === DEVICES_GATEWAY_BINDING
|
||||
) {
|
||||
return identifierName(imported['local']);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function callsBinding(program: AstNode, binding: string): boolean {
|
||||
let called = false;
|
||||
visitAst(program, (node) => {
|
||||
if (node['type'] === 'CallExpression' && identifierName(node['callee']) === binding) {
|
||||
called = true;
|
||||
}
|
||||
});
|
||||
return called;
|
||||
}
|
||||
|
||||
function shadowingBindingSite(program: AstNode, binding: string): AstNode | undefined {
|
||||
let found: AstNode | undefined;
|
||||
visitAst(program, (node) => {
|
||||
if (found !== undefined) return;
|
||||
if (
|
||||
node['type'] === 'VariableDeclarator' ||
|
||||
node['type'] === 'FunctionDeclaration' ||
|
||||
node['type'] === 'FunctionExpression' ||
|
||||
node['type'] === 'ClassDeclaration' ||
|
||||
node['type'] === 'ClassExpression'
|
||||
) {
|
||||
if (patternBinds(node['id'], binding)) found = node;
|
||||
if (found === undefined && bindsInParams(node, binding)) found = node;
|
||||
return;
|
||||
}
|
||||
if (node['type'] === 'ArrowFunctionExpression' && bindsInParams(node, binding)) {
|
||||
found = node;
|
||||
return;
|
||||
}
|
||||
if (node['type'] === 'CatchClause' && patternBinds(node['param'], binding)) found = node;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function bindsInParams(node: AstNode, binding: string): boolean {
|
||||
const params = node['params'];
|
||||
return Array.isArray(params) && params.some((param) => patternBinds(param, binding));
|
||||
}
|
||||
|
||||
function patternBinds(node: unknown, binding: string): boolean {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
const record = node as AstNode;
|
||||
if (record['type'] === 'Identifier') return record['name'] === binding;
|
||||
if (record['type'] === 'RestElement') return patternBinds(record['argument'], binding);
|
||||
if (record['type'] === 'AssignmentPattern') return patternBinds(record['left'], binding);
|
||||
if (record['type'] === 'TSParameterProperty') return patternBinds(record['parameter'], binding);
|
||||
if (record['type'] === 'ArrayPattern') {
|
||||
const elements = record['elements'];
|
||||
return Array.isArray(elements) && elements.some((element) => patternBinds(element, binding));
|
||||
}
|
||||
if (record['type'] === 'ObjectPattern') {
|
||||
const properties = record['properties'];
|
||||
return (
|
||||
Array.isArray(properties) &&
|
||||
properties.some((property) => {
|
||||
if (property === null || typeof property !== 'object') return false;
|
||||
const entry = property as AstNode;
|
||||
return entry['type'] === 'Property'
|
||||
? patternBinds(entry['value'], binding)
|
||||
: patternBinds(entry['argument'], binding);
|
||||
})
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function identifierName(node: unknown): string | undefined {
|
||||
if (node === null || typeof node !== 'object') return undefined;
|
||||
const record = node as AstNode;
|
||||
return record['type'] === 'Identifier' && typeof record['name'] === 'string'
|
||||
? record['name']
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function lineOf(source: string, node: AstNode): number {
|
||||
return source.slice(0, (node['start'] as number | undefined) ?? 0).split('\n').length;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { checkRuntimeCommandCutover } from './runtime-command-cutover-policy.ts';
|
||||
import { MIGRATED_COMMAND_CUTOVERS } from './runtime-command-cutover-table.ts';
|
||||
import type { MigratedCommandCutover } from './runtime-command-cutover-model.ts';
|
||||
|
||||
export type SourceEntry = readonly [path: string, source: string];
|
||||
|
||||
export function sources(entries: readonly SourceEntry[]): ReadonlyMap<string, string> {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
/** The four rows share one scan, so per-row assertions read their own rule's output. */
|
||||
export function summariesFor(
|
||||
rule: string,
|
||||
entries: readonly SourceEntry[],
|
||||
table: readonly MigratedCommandCutover[] = MIGRATED_COMMAND_CUTOVERS,
|
||||
): string[] {
|
||||
return checkRuntimeCommandCutover(sources(entries), table)
|
||||
.filter((violation) => violation.rule === rule)
|
||||
.map(({ file, message }) => `${file}: ${message}`);
|
||||
}
|
||||
|
||||
export function rowFor(command: string): MigratedCommandCutover {
|
||||
const row = MIGRATED_COMMAND_CUTOVERS.find((candidate) => candidate.command === command);
|
||||
if (row === undefined) throw new Error(`no cutover row for ${command}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row for a command that does not exist, so the mechanism can be exercised without
|
||||
* the real table's rows reacting to the planted sources.
|
||||
*/
|
||||
export const PLANTED_ROW: MigratedCommandCutover = {
|
||||
rule: 'RPlanted planted-cutover',
|
||||
command: 'planted',
|
||||
subject: 'planted',
|
||||
tier: 'request-scoped',
|
||||
execution: 'device-runtime',
|
||||
legacyRetirement: {
|
||||
modulePaths: ['src/daemon/planted-legacy.ts'],
|
||||
importPatterns: [/(?:^|\/)planted-legacy(?:\.[cm]?[jt]s)?$/],
|
||||
routeNames: ['resolvePlantedBackend'],
|
||||
pluginFacetKeys: ['planted'],
|
||||
},
|
||||
admissionMember: {
|
||||
forms: ['computed-property'],
|
||||
message: 'Apple plugin retains legacy planted support or hint closure',
|
||||
},
|
||||
runtimeTypeNames: ['PlantedRuntimeOperations'],
|
||||
operations: { names: ['plantedDump'] },
|
||||
singularExecution: {
|
||||
routes: ['handlePlantedCommand'],
|
||||
operations: ['plantedDump'],
|
||||
operationOwners: { plantedDump: ['handlePlantedCommand'] },
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { LayeringViolation } from './model.ts';
|
||||
|
||||
/** A violation before the parametrized gate stamps its row's rule id. */
|
||||
export type UnruledViolation = Omit<LayeringViolation, 'rule'>;
|
||||
|
||||
export type CutoverCheck = (sources: ReadonlyMap<string, string>) => UnruledViolation[];
|
||||
|
||||
/** At least one element, so "declared but empty" cannot satisfy a mandatory claim. */
|
||||
export type NonEmpty<T> = readonly [T, ...T[]];
|
||||
|
||||
/**
|
||||
* What a migrated command must retire. A row declares every form that applies to it;
|
||||
* `cutoverRowDefects` requires at least one, because a cutover that retires nothing is
|
||||
* not a cutover.
|
||||
*/
|
||||
export type LegacyRetirementClaim = Readonly<{
|
||||
/** Retired module paths that must not exist in production. */
|
||||
modulePaths?: readonly string[];
|
||||
/** Retired module path shapes (matched against the repo-relative path). */
|
||||
modulePathPatterns?: readonly RegExp[];
|
||||
/** Import/re-export specifiers of retired modules, static or dynamic. */
|
||||
importPatterns?: readonly RegExp[];
|
||||
/** Retired executable names, scanned across all production sources. */
|
||||
routeNames?: readonly string[];
|
||||
/** Retired executable names scanned in `src/daemon/` production only. */
|
||||
daemonOnlyRouteNames?: readonly string[];
|
||||
/** Legacy provider methods the daemon may not call (`provider.dumpNetwork`). */
|
||||
daemonOnlyProviderMethods?: readonly string[];
|
||||
/** `PlatformPlugin` facet keys retired with the legacy adapter. */
|
||||
pluginFacetKeys?: readonly string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Command-keyed plugin admission members. Form and file scope differ per row because
|
||||
* the same syntax means admission for one command and opaque identifier data for
|
||||
* another — `PUBLIC_COMMANDS.record` is live session-event data.
|
||||
*/
|
||||
export type AdmissionMemberClaim = Readonly<{
|
||||
forms: NonEmpty<'computed-property' | 'public-commands-member'>;
|
||||
/** Undefined scans every production source. */
|
||||
files?: readonly string[];
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
/** Which operations belong to this command: an exact set, or an input-dependent family. */
|
||||
export type OperationClaim =
|
||||
| Readonly<{ names: NonEmpty<string>; pattern?: RegExp }>
|
||||
| Readonly<{ names?: undefined; pattern: RegExp }>;
|
||||
|
||||
export type CutoverIdentity = Readonly<{
|
||||
/** Layering rule id. Per row, so the report keeps each cutover's identity. */
|
||||
rule: string;
|
||||
/** Canonical descriptor name: drives admission, descriptor, and static-set checks. */
|
||||
command: string;
|
||||
/** Noun used in prose for this command's mechanics ('recording', 'device inventory'). */
|
||||
subject: string;
|
||||
/**
|
||||
* ADR 0019 §8 evidence tier. A `durable-resource` row must carry a lifecycle proof;
|
||||
* a request-scoped row must not need one.
|
||||
*/
|
||||
tier: 'request-scoped' | 'durable-resource';
|
||||
legacyRetirement: LegacyRetirementClaim;
|
||||
admissionMember?: AdmissionMemberClaim;
|
||||
/** Assertions that do not generalize. Stamped with this row's rule id. */
|
||||
extensions?: readonly CutoverCheck[];
|
||||
/** Required iff `tier` is `durable-resource`. */
|
||||
lifecycleProof?: CutoverCheck;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* An inventory command binds no device runtime, so its singular-execution proof is the
|
||||
* identity of the one gateway binding its handler must import and call.
|
||||
*/
|
||||
export type InventoryCutover = CutoverIdentity &
|
||||
Readonly<{
|
||||
execution: 'inventory';
|
||||
singularExecution: Readonly<{ gatewayProof: CutoverCheck }>;
|
||||
}>;
|
||||
|
||||
type DeviceRuntimeBase = CutoverIdentity &
|
||||
Readonly<{
|
||||
execution: 'device-runtime';
|
||||
runtimeTypeNames: NonEmpty<string>;
|
||||
/**
|
||||
* `declared` checks this row's operations. `any-operation` covers every daemon
|
||||
* runtime operation, which a row whose operation set is input-dependent needs.
|
||||
*/
|
||||
nonNullRepairScope?: 'declared' | 'any-operation';
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A command that names its operations exactly must also enforce each of them exactly
|
||||
* once. Knowing the whole operation set and checking only the route would leave the
|
||||
* operations themselves free to be called twice or not at all.
|
||||
*/
|
||||
type NamedOperationCutover = DeviceRuntimeBase &
|
||||
Readonly<{
|
||||
operations: Readonly<{ names: NonEmpty<string>; pattern?: RegExp }>;
|
||||
singularExecution: Readonly<{
|
||||
/** Daemon route functions that must be called exactly once. */
|
||||
routes: NonEmpty<string>;
|
||||
/** Narrowed operations that must be called exactly once — the named set. */
|
||||
operations: NonEmpty<string>;
|
||||
/**
|
||||
* Lexical function owner for each narrowed operation call. An unrelated daemon
|
||||
* caller must neither satisfy a missing command call nor create a false duplicate.
|
||||
*/
|
||||
operationOwners: Readonly<Record<string, NonEmpty<string>>>;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A command whose operation set is input-dependent (the app-log plan picks its
|
||||
* operations from the request) cannot name a fixed set to enforce, so route
|
||||
* singularity is the whole claim. Route-only singularity is permitted here alone.
|
||||
*/
|
||||
type PatternOperationCutover = DeviceRuntimeBase &
|
||||
Readonly<{
|
||||
operations: Readonly<{ names?: undefined; pattern: RegExp }>;
|
||||
singularExecution: Readonly<{ routes: NonEmpty<string>; operations?: undefined }>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* A device-runtime command must prove its narrowing (which types re-widen a bound
|
||||
* runtime, which operations are its own) and that its execution is singular.
|
||||
*/
|
||||
export type DeviceRuntimeCutover = NamedOperationCutover | PatternOperationCutover;
|
||||
|
||||
/**
|
||||
* One migrated command's cutover row (ADR 0019 §6/§8). The gate that reads this table
|
||||
* proves the command has exactly one platform-execution path.
|
||||
*
|
||||
* The union is discriminated by `execution` and its claims are mandatory, so a row is
|
||||
* not a place to opt out of enforcement: adding a command means stating what it
|
||||
* retired, how its admission is derived, and what makes its execution singular.
|
||||
* `cutoverRowDefects` rejects the claims the type system cannot require.
|
||||
*/
|
||||
export type MigratedCommandCutover = InventoryCutover | DeviceRuntimeCutover;
|
||||
|
||||
const RETIREMENT_FORMS = [
|
||||
'modulePaths',
|
||||
'modulePathPatterns',
|
||||
'importPatterns',
|
||||
'routeNames',
|
||||
'daemonOnlyRouteNames',
|
||||
'daemonOnlyProviderMethods',
|
||||
'pluginFacetKeys',
|
||||
] as const satisfies readonly (keyof LegacyRetirementClaim)[];
|
||||
|
||||
/**
|
||||
* Cross-field claims the type system cannot express. Returns one message per defect so
|
||||
* an incomplete row fails the gate instead of passing with nothing to enforce.
|
||||
*
|
||||
* Reads defensively: a row that omits whole claim objects is exactly the input this
|
||||
* rejects, so it must not depend on them being present.
|
||||
*/
|
||||
export function cutoverRowDefects(row: MigratedCommandCutover): string[] {
|
||||
const defects: string[] = [];
|
||||
if (!hasAnyRetirementForm(row.legacyRetirement)) {
|
||||
defects.push('declares no legacy retirement form');
|
||||
}
|
||||
if (row.tier !== 'request-scoped' && row.tier !== 'durable-resource') {
|
||||
defects.push('declares no evidence tier');
|
||||
}
|
||||
if (row.tier === 'durable-resource' && row.lifecycleProof === undefined) {
|
||||
defects.push('is durable-resource tier but declares no lifecycle proof');
|
||||
}
|
||||
if (row.tier === 'request-scoped' && row.lifecycleProof !== undefined) {
|
||||
defects.push('is request-scoped tier but declares a durable lifecycle proof');
|
||||
}
|
||||
defects.push(...executionDefects(row));
|
||||
return defects;
|
||||
}
|
||||
|
||||
function executionDefects(row: MigratedCommandCutover): string[] {
|
||||
if (row.execution === 'inventory') {
|
||||
return row.singularExecution?.gatewayProof === undefined
|
||||
? ['declares no singular gateway proof']
|
||||
: [];
|
||||
}
|
||||
if (row.execution !== 'device-runtime') return ['declares no execution kind'];
|
||||
const defects: string[] = [];
|
||||
if ((row.runtimeTypeNames?.length ?? 0) === 0) defects.push('declares no runtime type names');
|
||||
if (!hasOperationClaim(row.operations)) defects.push('declares no operations');
|
||||
if ((row.singularExecution?.routes?.length ?? 0) === 0) {
|
||||
defects.push('declares no singular daemon route');
|
||||
}
|
||||
defects.push(...namedOperationDefects(row));
|
||||
return defects;
|
||||
}
|
||||
|
||||
/**
|
||||
* The named set and the enforced set must be the same set, each listed once. Equality
|
||||
* has to hold in both directions: an unenforced name leaves an operation free to be
|
||||
* called twice or never, and an enforced name the row does not claim asserts singularity
|
||||
* over an operation this command does not own. Route-only singularity stays available to
|
||||
* pattern-only rows, whose operations are not knowable statically.
|
||||
*/
|
||||
function namedOperationDefects(row: DeviceRuntimeCutover): string[] {
|
||||
const named = row.operations?.names ?? [];
|
||||
if (named.length === 0) return [];
|
||||
const enforced = row.singularExecution?.operations ?? [];
|
||||
if (enforced.length === 0) {
|
||||
return ['names its operations but enforces none of them exactly once'];
|
||||
}
|
||||
|
||||
const defects: string[] = [];
|
||||
defects.push(...duplicateDefects('names', named));
|
||||
defects.push(...duplicateDefects('enforces', enforced));
|
||||
|
||||
const enforcedSet = new Set(enforced);
|
||||
const namedSet = new Set(named);
|
||||
const unenforced = named.filter((name) => !enforcedSet.has(name));
|
||||
const unnamed = enforced.filter((name) => !namedSet.has(name));
|
||||
if (unenforced.length > 0) {
|
||||
defects.push(
|
||||
`names operations it does not enforce exactly once: ${sortedUnique(unenforced).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (unnamed.length > 0) {
|
||||
defects.push(`enforces operations it does not name: ${sortedUnique(unnamed).join(', ')}`);
|
||||
}
|
||||
const owners = row.singularExecution?.operationOwners ?? {};
|
||||
const owned = Object.keys(owners);
|
||||
const ownerless = named.filter((name) => (owners[name]?.length ?? 0) === 0);
|
||||
const unclaimedOwners = owned.filter((name) => !namedSet.has(name));
|
||||
if (ownerless.length > 0) {
|
||||
defects.push(`names operations without lexical owners: ${sortedUnique(ownerless).join(', ')}`);
|
||||
}
|
||||
if (unclaimedOwners.length > 0) {
|
||||
defects.push(
|
||||
`declares lexical owners for unnamed operations: ${sortedUnique(unclaimedOwners).join(', ')}`,
|
||||
);
|
||||
}
|
||||
for (const [operation, lexicalOwners] of Object.entries(owners)) {
|
||||
const duplicates = duplicateValues(lexicalOwners);
|
||||
if (duplicates.length > 0) {
|
||||
defects.push(`declares duplicate lexical owners for ${operation}: ${duplicates.join(', ')}`);
|
||||
}
|
||||
}
|
||||
return defects;
|
||||
}
|
||||
|
||||
function duplicateDefects(verb: 'names' | 'enforces', values: readonly string[]): string[] {
|
||||
const duplicates = duplicateValues(values);
|
||||
return duplicates.length === 0 ? [] : [`${verb} duplicate operations: ${duplicates.join(', ')}`];
|
||||
}
|
||||
|
||||
function duplicateValues(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const duplicates = values.filter((value) => {
|
||||
if (seen.has(value)) return true;
|
||||
seen.add(value);
|
||||
return false;
|
||||
});
|
||||
return sortedUnique(duplicates);
|
||||
}
|
||||
|
||||
function sortedUnique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)].sort();
|
||||
}
|
||||
|
||||
function hasAnyRetirementForm(claim: LegacyRetirementClaim | undefined): boolean {
|
||||
if (claim === undefined) return false;
|
||||
return RETIREMENT_FORMS.some((form) => (claim[form]?.length ?? 0) > 0);
|
||||
}
|
||||
|
||||
function hasOperationClaim(claim: OperationClaim | undefined): boolean {
|
||||
if (claim === undefined) return false;
|
||||
return (claim.names?.length ?? 0) > 0 || claim.pattern !== undefined;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { checkRuntimeCommandCutover } from './runtime-command-cutover-policy.ts';
|
||||
import { MIGRATED_COMMAND_CUTOVERS } from './runtime-command-cutover-table.ts';
|
||||
import { cutoverRowDefects, type MigratedCommandCutover } from './runtime-command-cutover-model.ts';
|
||||
import { PLANTED_ROW, rowFor, sources, summariesFor } from './runtime-command-cutover-fixtures.ts';
|
||||
|
||||
// The mechanism itself (ADR 0019 §8): one planted row, one planted red. Per-row
|
||||
// acceptance for the four migrated commands lives in the table test.
|
||||
|
||||
const PLANTED_RULE = 'RPlanted planted-cutover';
|
||||
|
||||
test('the parametrized gate goes red on a planted row across every generalized column', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor(
|
||||
PLANTED_RULE,
|
||||
[
|
||||
['src/daemon/planted-legacy.ts', 'export const retired = true;'],
|
||||
[
|
||||
'src/daemon/planted-handler.ts',
|
||||
[
|
||||
"import { resolvePlantedBackend } from './planted-legacy.ts';",
|
||||
"requireCommandSupported('planted', device);",
|
||||
'const widened = runtime as PlantedRuntimeOperations;',
|
||||
"function handlePlantedCommand() { widened.operations['plantedDump']({}); }",
|
||||
].join('\n'),
|
||||
],
|
||||
[
|
||||
'src/core/command-descriptor/registry.ts',
|
||||
`const commands = [{ name: 'planted', capability: { apple: {} } }];`,
|
||||
],
|
||||
['src/core/capabilities.ts', `const WEB_QUERY_COMMANDS = ['find', 'planted'];`],
|
||||
[
|
||||
'src/platforms/apple/plugin.ts',
|
||||
`const supports = { [PUBLIC_COMMANDS.planted]: supportsDevice };
|
||||
const plugin = { planted: { resolveBackend() {} } } satisfies PlatformPlugin;`,
|
||||
],
|
||||
],
|
||||
[PLANTED_ROW],
|
||||
),
|
||||
[
|
||||
'src/daemon/planted-legacy.ts: retired planted module remains',
|
||||
"src/daemon/planted-handler.ts: production source imports retired planted module './planted-legacy.ts'",
|
||||
'src/daemon/planted-handler.ts: legacy planted route resolvePlantedBackend',
|
||||
'src/daemon/planted-handler.ts: legacy planted capability admission requireCommandSupported',
|
||||
'src/daemon/planted-handler.ts: widened planted runtime type assertion',
|
||||
'src/daemon/planted-handler.ts: bracketed planted operation access',
|
||||
'src/core/command-descriptor/registry.ts: planted descriptor retains legacy capability admission',
|
||||
'src/core/capabilities.ts: static platform command set retains planted admission',
|
||||
'src/platforms/apple/plugin.ts: legacy PlatformPlugin planted facet',
|
||||
'src/platforms/apple/plugin.ts: Apple plugin retains legacy planted support or hint closure',
|
||||
// The bracketed access is still a call, so the operation count is satisfied while the
|
||||
// route is missing: the narrowing column and the singular-route column are independent.
|
||||
'(planted runtime): expected one handlePlantedCommand route, found 0',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('the planted row is green once the command has exactly one execution path', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor(
|
||||
PLANTED_RULE,
|
||||
[
|
||||
[
|
||||
'src/daemon/planted-handler.ts',
|
||||
`
|
||||
// resolvePlantedBackend and requireCommandSupported('planted') were removed.
|
||||
const note = 'planted-legacy.ts';
|
||||
function handlePlantedCommand() {
|
||||
runtime.operations.plantedDump(input);
|
||||
}
|
||||
handlePlantedCommand(request);
|
||||
`,
|
||||
],
|
||||
],
|
||||
[PLANTED_ROW],
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('rows are independent: one command going red leaves the others alone', () => {
|
||||
const violations = checkRuntimeCommandCutover(
|
||||
sources([['src/daemon/network-log.ts', 'export const parser = () => [];']]),
|
||||
MIGRATED_COMMAND_CUTOVERS.filter(
|
||||
({ command }) => command === 'network' || command === 'record',
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
violations
|
||||
.filter(({ rule }) => rule === 'R15 network-runtime-cutover')
|
||||
.map(({ message }) => message),
|
||||
[
|
||||
'retired network module remains',
|
||||
'expected one handleNetworkCommand route, found 0',
|
||||
'expected one narrowed networkDump call, found 0',
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
violations
|
||||
.filter(({ rule }) => rule === 'R16 record-runtime-cutover')
|
||||
.map(({ message }) => message),
|
||||
[
|
||||
'expected one handleRecordTraceCommands route, found 0',
|
||||
'expected one narrowed screenRecordingStart call, found 0',
|
||||
'expected one narrowed screenRecordingReattach call, found 0',
|
||||
'expected one narrowed screenRecordingCleanup call, found 0',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A row cannot opt out of enforcement by leaving its claims unstated.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('planted red: an incomplete row fails instead of enforcing nothing', () => {
|
||||
const incomplete = {
|
||||
rule: PLANTED_RULE,
|
||||
command: 'planted',
|
||||
subject: 'planted',
|
||||
} as unknown as MigratedCommandCutover;
|
||||
|
||||
assert.deepEqual(
|
||||
summariesFor(PLANTED_RULE, [['src/daemon/anything.ts', 'export const x = 1;']], [incomplete]),
|
||||
[
|
||||
'(planted cutover row): cutover row declares no legacy retirement form',
|
||||
'(planted cutover row): cutover row declares no evidence tier',
|
||||
'(planted cutover row): cutover row declares no execution kind',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('planted red: a row that retires nothing is rejected', () => {
|
||||
assert.deepEqual(cutoverRowDefects({ ...PLANTED_ROW, legacyRetirement: {} }), [
|
||||
'declares no legacy retirement form',
|
||||
]);
|
||||
});
|
||||
|
||||
test('planted red: an empty singular-execution claim is rejected', () => {
|
||||
assert.deepEqual(
|
||||
cutoverRowDefects({
|
||||
...PLANTED_ROW,
|
||||
singularExecution: { routes: [] as unknown as [string, ...string[]] },
|
||||
}),
|
||||
[
|
||||
'declares no singular daemon route',
|
||||
'names its operations but enforces none of them exactly once',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('planted red: a named-operation row that enforces no operation is rejected', () => {
|
||||
assert.deepEqual(
|
||||
cutoverRowDefects({
|
||||
...PLANTED_ROW,
|
||||
singularExecution: {
|
||||
routes: ['handlePlantedCommand'],
|
||||
} as unknown as (typeof PLANTED_ROW)['singularExecution'],
|
||||
}),
|
||||
['names its operations but enforces none of them exactly once'],
|
||||
);
|
||||
});
|
||||
|
||||
test('planted red: a named-operation row that enforces only some operations is rejected', () => {
|
||||
assert.deepEqual(
|
||||
cutoverRowDefects({
|
||||
...PLANTED_ROW,
|
||||
operations: { names: ['plantedDump', 'plantedReattach'] },
|
||||
singularExecution: {
|
||||
routes: ['handlePlantedCommand'],
|
||||
operations: ['plantedDump'],
|
||||
operationOwners: {
|
||||
plantedDump: ['handlePlantedCommand'],
|
||||
plantedReattach: ['handlePlantedCommand'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
['names operations it does not enforce exactly once: plantedReattach'],
|
||||
);
|
||||
});
|
||||
|
||||
test('planted red: a row that enforces an operation it does not name is rejected', () => {
|
||||
assert.deepEqual(
|
||||
cutoverRowDefects({
|
||||
...PLANTED_ROW,
|
||||
operations: { names: ['plantedDump'] },
|
||||
singularExecution: {
|
||||
routes: ['handlePlantedCommand'],
|
||||
operations: ['plantedDump', 'somebodyElsesOperation'],
|
||||
operationOwners: { plantedDump: ['handlePlantedCommand'] },
|
||||
},
|
||||
}),
|
||||
['enforces operations it does not name: somebodyElsesOperation'],
|
||||
);
|
||||
});
|
||||
|
||||
test('planted red: duplicate operations on either side are rejected', () => {
|
||||
assert.deepEqual(
|
||||
cutoverRowDefects({
|
||||
...PLANTED_ROW,
|
||||
operations: { names: ['plantedDump', 'plantedDump'] },
|
||||
singularExecution: {
|
||||
routes: ['handlePlantedCommand'],
|
||||
operations: ['plantedDump', 'plantedDump'],
|
||||
operationOwners: { plantedDump: ['handlePlantedCommand'] },
|
||||
},
|
||||
}),
|
||||
['names duplicate operations: plantedDump', 'enforces duplicate operations: plantedDump'],
|
||||
);
|
||||
});
|
||||
|
||||
test('planted red: every named operation declares its lexical owner', () => {
|
||||
assert.deepEqual(
|
||||
cutoverRowDefects({
|
||||
...PLANTED_ROW,
|
||||
singularExecution: {
|
||||
routes: ['handlePlantedCommand'],
|
||||
operations: ['plantedDump'],
|
||||
operationOwners: {},
|
||||
},
|
||||
}),
|
||||
['names operations without lexical owners: plantedDump'],
|
||||
);
|
||||
});
|
||||
|
||||
test('unrelated daemon calls neither satisfy nor duplicate an owner-scoped operation', () => {
|
||||
const missing = summariesFor(
|
||||
PLANTED_RULE,
|
||||
[
|
||||
[
|
||||
'src/daemon/planted-handler.ts',
|
||||
`
|
||||
function handlePlantedCommand() {}
|
||||
function handleUnrelatedCommand() { runtime.operations.plantedDump(input); }
|
||||
handlePlantedCommand(request);
|
||||
`,
|
||||
],
|
||||
],
|
||||
[PLANTED_ROW],
|
||||
);
|
||||
assert.deepEqual(missing, ['(planted runtime): expected one narrowed plantedDump call, found 0']);
|
||||
|
||||
const present = summariesFor(
|
||||
PLANTED_RULE,
|
||||
[
|
||||
[
|
||||
'src/daemon/planted-handler.ts',
|
||||
`
|
||||
function handlePlantedCommand() { runtime.operations.plantedDump(input); }
|
||||
function handleUnrelatedCommand() { runtime.operations.plantedDump(input); }
|
||||
handlePlantedCommand(request);
|
||||
`,
|
||||
],
|
||||
],
|
||||
[PLANTED_ROW],
|
||||
);
|
||||
assert.deepEqual(present, []);
|
||||
});
|
||||
|
||||
test('a pattern-only row may prove singularity through its route alone', () => {
|
||||
assert.deepEqual(cutoverRowDefects(rowFor('logs')), []);
|
||||
});
|
||||
|
||||
test('planted red: a durable row without a lifecycle proof is rejected', () => {
|
||||
assert.deepEqual(cutoverRowDefects({ ...PLANTED_ROW, tier: 'durable-resource' }), [
|
||||
'is durable-resource tier but declares no lifecycle proof',
|
||||
]);
|
||||
});
|
||||
|
||||
test('planted red: a request-scoped row claiming durable machinery is rejected', () => {
|
||||
assert.deepEqual(cutoverRowDefects({ ...PLANTED_ROW, lifecycleProof: () => [] }), [
|
||||
'is request-scoped tier but declares a durable lifecycle proof',
|
||||
]);
|
||||
});
|
||||
|
||||
test('every shipped row states its claims', () => {
|
||||
assert.deepEqual(
|
||||
MIGRATED_COMMAND_CUTOVERS.flatMap((row) =>
|
||||
cutoverRowDefects(row).map((defect) => `${row.command}: ${defect}`),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,539 @@
|
||||
import { parseSync } from 'oxc-parser';
|
||||
import type { LayeringViolation } from './model.ts';
|
||||
import {
|
||||
cutoverRowDefects,
|
||||
type CutoverCheck,
|
||||
type DeviceRuntimeCutover,
|
||||
type MigratedCommandCutover,
|
||||
type UnruledViolation,
|
||||
} from './runtime-command-cutover-model.ts';
|
||||
import { MIGRATED_COMMAND_CUTOVERS } from './runtime-command-cutover-table.ts';
|
||||
import { memberName, propertyName, visitAst, type ProductionSource } from './cutover-policy-ast.ts';
|
||||
|
||||
type AstNode = Record<string, unknown>;
|
||||
|
||||
const SHARED_RUNTIME_TYPE_NAME = 'BoundDeviceRuntime';
|
||||
|
||||
/**
|
||||
* The one parametrized runtime-command-cutover gate (ADR 0019 §8). Every migrated
|
||||
* command is a row in {@link MIGRATED_COMMAND_CUTOVERS}; the mechanism below is proven
|
||||
* once by a planted row rather than once per command.
|
||||
*/
|
||||
export function checkRuntimeCommandCutover(
|
||||
sources: ReadonlyMap<string, string>,
|
||||
table: readonly MigratedCommandCutover[] = MIGRATED_COMMAND_CUTOVERS,
|
||||
): LayeringViolation[] {
|
||||
const files: ProductionSource[] = [...sources].map(([path, source]) => ({ path, source }));
|
||||
const programs = new Map<string, AstNode>(
|
||||
files.map(({ path, source }) => [path, parseSync(path, source).program as AstNode]),
|
||||
);
|
||||
return table.flatMap((row) =>
|
||||
rowViolations(row, files, programs).map((violation) => ({ rule: row.rule, ...violation })),
|
||||
);
|
||||
}
|
||||
|
||||
export function runtimeCommandCutoverSummary(
|
||||
table: readonly MigratedCommandCutover[] = MIGRATED_COMMAND_CUTOVERS,
|
||||
): string {
|
||||
const commands = table.map(({ command }) => command).join(', ');
|
||||
return `each migrated command (${commands}) keeps exactly one platform-execution path`;
|
||||
}
|
||||
|
||||
function rowViolations(
|
||||
row: MigratedCommandCutover,
|
||||
files: readonly ProductionSource[],
|
||||
programs: ReadonlyMap<string, AstNode>,
|
||||
): UnruledViolation[] {
|
||||
// An under-declared row would enforce nothing and read green, so the row itself is
|
||||
// checked before the repo is.
|
||||
const defects = cutoverRowDefects(row);
|
||||
if (defects.length > 0) {
|
||||
return defects.map((defect) => ({
|
||||
file: `(${row.command} cutover row)`,
|
||||
line: 1,
|
||||
message: `cutover row ${defect}`,
|
||||
}));
|
||||
}
|
||||
|
||||
const violations: UnruledViolation[] = [];
|
||||
for (const file of files) {
|
||||
const program = programs.get(file.path);
|
||||
if (!program) continue;
|
||||
violations.push(...retiredModuleViolations(row, file));
|
||||
violations.push(...legacyRouteViolations(row, file, program));
|
||||
violations.push(...admissionViolations(row, file, program));
|
||||
violations.push(...narrowingViolations(row, file, program));
|
||||
}
|
||||
violations.push(...exactCallViolations(row, files, programs));
|
||||
const sources = new Map(files.map(({ path, source }) => [path, source]));
|
||||
for (const check of rowChecks(row)) violations.push(...check(sources));
|
||||
return violations;
|
||||
}
|
||||
|
||||
function rowChecks(row: MigratedCommandCutover): CutoverCheck[] {
|
||||
return [
|
||||
...(row.execution === 'inventory' ? [row.singularExecution.gatewayProof] : []),
|
||||
...(row.lifecycleProof === undefined ? [] : [row.lifecycleProof]),
|
||||
...(row.extensions ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The legacy adapter is gone: no retired module, import, or executable name.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function retiredModuleViolations(
|
||||
row: MigratedCommandCutover,
|
||||
file: ProductionSource,
|
||||
): UnruledViolation[] {
|
||||
const retired =
|
||||
(row.legacyRetirement.modulePaths ?? []).includes(file.path) ||
|
||||
(row.legacyRetirement.modulePathPatterns ?? []).some((pattern) => pattern.test(file.path));
|
||||
return retired
|
||||
? [{ file: file.path, line: 1, message: `retired ${row.subject} module remains` }]
|
||||
: [];
|
||||
}
|
||||
|
||||
function legacyRouteViolations(
|
||||
row: MigratedCommandCutover,
|
||||
file: ProductionSource,
|
||||
program: AstNode,
|
||||
): UnruledViolation[] {
|
||||
const violations: UnruledViolation[] = [];
|
||||
const seen = new Set<string>();
|
||||
const isDaemon = file.path.startsWith('src/daemon/');
|
||||
const scoped = new Set([
|
||||
...(row.legacyRetirement.routeNames ?? []),
|
||||
...(isDaemon ? (row.legacyRetirement.daemonOnlyRouteNames ?? []) : []),
|
||||
]);
|
||||
visitAst(program, (node) => {
|
||||
const specifier = retiredImportSpecifier(row, node);
|
||||
if (specifier !== undefined) {
|
||||
push(
|
||||
violations,
|
||||
seen,
|
||||
file,
|
||||
node,
|
||||
`production source imports retired ${row.subject} module '${specifier}'`,
|
||||
);
|
||||
}
|
||||
const route = executableName(node, scoped);
|
||||
if (route !== undefined) {
|
||||
push(violations, seen, file, node, `legacy ${row.subject} route ${route}`);
|
||||
}
|
||||
if (
|
||||
isDaemon &&
|
||||
isProviderMethodCall(node, row.legacyRetirement.daemonOnlyProviderMethods ?? [])
|
||||
) {
|
||||
push(
|
||||
violations,
|
||||
seen,
|
||||
file,
|
||||
node,
|
||||
`daemon invokes legacy provider method ${memberName(node['callee'] as AstNode) ?? ''}`,
|
||||
);
|
||||
}
|
||||
if (isPlatformPluginFacet(node, row.legacyRetirement.pluginFacetKeys ?? [])) {
|
||||
push(violations, seen, file, node, `legacy PlatformPlugin ${facetKeyList(row)} facet`);
|
||||
}
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
function facetKeyList(row: MigratedCommandCutover): string {
|
||||
return (row.legacyRetirement.pluginFacetKeys ?? []).join('/');
|
||||
}
|
||||
|
||||
/** Identifier, property key (computed or not), and computed member access all count. */
|
||||
function executableName(node: AstNode, names: ReadonlySet<string>): string | undefined {
|
||||
if (names.size === 0) return undefined;
|
||||
if (node['type'] === 'Identifier' && names.has(String(node['name']))) return String(node['name']);
|
||||
if (node['type'] === 'Property' || node['type'] === 'TSPropertySignature') {
|
||||
const key = propertyName(node['key']);
|
||||
return key !== undefined && names.has(key) ? key : undefined;
|
||||
}
|
||||
if (node['type'] === 'MemberExpression' && node['computed'] === true) {
|
||||
const key = propertyName(node['property']);
|
||||
return key !== undefined && names.has(key) ? key : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function retiredImportSpecifier(row: MigratedCommandCutover, node: AstNode): string | undefined {
|
||||
const patterns = row.legacyRetirement.importPatterns ?? [];
|
||||
if (patterns.length === 0) return undefined;
|
||||
if (
|
||||
node['type'] !== 'ImportDeclaration' &&
|
||||
node['type'] !== 'ImportExpression' &&
|
||||
node['type'] !== 'ExportNamedDeclaration' &&
|
||||
node['type'] !== 'ExportAllDeclaration'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// `export const x = 1` is an ExportNamedDeclaration with a null source.
|
||||
const source = node['source'] as AstNode | null | undefined;
|
||||
const specifier = source === null || source === undefined ? undefined : source['value'];
|
||||
if (typeof specifier !== 'string') return undefined;
|
||||
return patterns.some((pattern) => pattern.test(specifier)) ? specifier : undefined;
|
||||
}
|
||||
|
||||
function isProviderMethodCall(node: AstNode, methods: readonly string[]): boolean {
|
||||
if (methods.length === 0 || node['type'] !== 'CallExpression') return false;
|
||||
const callee = node['callee'] as AstNode | undefined;
|
||||
if (callee === undefined || callee['type'] !== 'MemberExpression') return false;
|
||||
const name = memberName(callee);
|
||||
return name !== undefined && methods.includes(name);
|
||||
}
|
||||
|
||||
function isPlatformPluginFacet(node: AstNode, facetKeys: readonly string[]): boolean {
|
||||
if (facetKeys.length === 0) return false;
|
||||
if (node['type'] === 'TSTypeAliasDeclaration' || node['type'] === 'TSInterfaceDeclaration') {
|
||||
const id = node['id'] as AstNode | undefined;
|
||||
return (
|
||||
id?.['type'] === 'Identifier' &&
|
||||
id['name'] === 'PlatformPlugin' &&
|
||||
declaresFacetKey(node, facetKeys)
|
||||
);
|
||||
}
|
||||
if (node['type'] === 'TSSatisfiesExpression' || node['type'] === 'TSAsExpression') {
|
||||
return (
|
||||
isNamedType(node['typeAnnotation'], 'PlatformPlugin') &&
|
||||
declaresFacetKey(node['expression'], facetKeys)
|
||||
);
|
||||
}
|
||||
if (node['type'] !== 'VariableDeclarator') return false;
|
||||
const id = node['id'] as AstNode | undefined;
|
||||
return (
|
||||
isNamedType(id?.['typeAnnotation'], 'PlatformPlugin') &&
|
||||
declaresFacetKey(node['init'], facetKeys)
|
||||
);
|
||||
}
|
||||
|
||||
function declaresFacetKey(node: unknown, facetKeys: readonly string[]): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (
|
||||
(candidate['type'] === 'Property' || candidate['type'] === 'TSPropertySignature') &&
|
||||
candidate['computed'] !== true &&
|
||||
facetKeys.includes(propertyName(candidate['key']) ?? '')
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function isNamedType(node: unknown, expected: string): boolean {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
const record = node as AstNode;
|
||||
if (record['type'] === 'TSTypeAnnotation') return isNamedType(record['typeAnnotation'], expected);
|
||||
if (record['type'] !== 'TSTypeReference') return false;
|
||||
const name = record['typeName'] as AstNode | undefined;
|
||||
return name?.['type'] === 'Identifier' && name['name'] === expected;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admission comes from facts: no capability bucket, no static command set.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function admissionViolations(
|
||||
row: MigratedCommandCutover,
|
||||
file: ProductionSource,
|
||||
program: AstNode,
|
||||
): UnruledViolation[] {
|
||||
const violations: UnruledViolation[] = [];
|
||||
const member = row.admissionMember;
|
||||
const memberInScope = member !== undefined && (member.files?.includes(file.path) ?? true);
|
||||
visitAst(program, (node) => {
|
||||
if (isLegacyAdmissionCall(node, row.command)) {
|
||||
violations.push(
|
||||
at(file, node, `legacy ${row.command} capability admission requireCommandSupported`),
|
||||
);
|
||||
}
|
||||
if (isDescriptorWithCapability(node, row.command)) {
|
||||
violations.push(
|
||||
at(file, node, `${row.command} descriptor retains legacy capability admission`),
|
||||
);
|
||||
}
|
||||
if (isStaticCommandSet(node, row.command)) {
|
||||
violations.push(
|
||||
at(file, node, `static platform command set retains ${row.command} admission`),
|
||||
);
|
||||
}
|
||||
if (memberInScope && isAdmissionMember(node, row.command, member.forms)) {
|
||||
violations.push(at(file, node, member.message));
|
||||
}
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
function isLegacyAdmissionCall(node: AstNode, command: string): boolean {
|
||||
if (node['type'] !== 'CallExpression') return false;
|
||||
const callee = node['callee'] as AstNode | undefined;
|
||||
const args = node['arguments'] as readonly AstNode[] | undefined;
|
||||
return (
|
||||
callee?.['type'] === 'Identifier' &&
|
||||
callee['name'] === 'requireCommandSupported' &&
|
||||
isCommandExpression(args?.[0], command)
|
||||
);
|
||||
}
|
||||
|
||||
/** `'logs'` and `PUBLIC_COMMANDS.logs` name the same command. */
|
||||
function isCommandExpression(node: AstNode | undefined, command: string): boolean {
|
||||
if (node?.['type'] === 'Literal') return node['value'] === command;
|
||||
if (node?.['type'] !== 'MemberExpression') return false;
|
||||
const object = node['object'] as AstNode | undefined;
|
||||
return (
|
||||
object?.['type'] === 'Identifier' &&
|
||||
object['name'] === 'PUBLIC_COMMANDS' &&
|
||||
memberName(node) === camelCommandKey(command)
|
||||
);
|
||||
}
|
||||
|
||||
/** `PUBLIC_COMMANDS` keys are camelCase (`react-native` -> `reactNative`). */
|
||||
function camelCommandKey(command: string): string {
|
||||
return command.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function isDescriptorWithCapability(node: AstNode, command: string): boolean {
|
||||
if (node['type'] !== 'ObjectExpression' || !Array.isArray(node['properties'])) return false;
|
||||
let named = false;
|
||||
let capability = false;
|
||||
for (const property of node['properties'] as AstNode[]) {
|
||||
if (property['type'] !== 'Property' || property['computed'] === true) continue;
|
||||
const key = propertyName(property['key']);
|
||||
const value = property['value'] as AstNode | undefined;
|
||||
if (key === 'name' && value?.['type'] === 'Literal' && value['value'] === command) named = true;
|
||||
if (key === 'capability') capability = true;
|
||||
}
|
||||
return named && capability;
|
||||
}
|
||||
|
||||
function isStaticCommandSet(node: AstNode, command: string): boolean {
|
||||
if (node['type'] !== 'VariableDeclarator') return false;
|
||||
const id = node['id'] as AstNode | undefined;
|
||||
return (
|
||||
id?.['type'] === 'Identifier' &&
|
||||
/(?:WEB|HARMONY).*COMMANDS/.test(String(id['name'])) &&
|
||||
containsStringLiteral(node['init'], command)
|
||||
);
|
||||
}
|
||||
|
||||
function isAdmissionMember(
|
||||
node: AstNode,
|
||||
command: string,
|
||||
forms: readonly ('computed-property' | 'public-commands-member')[],
|
||||
): boolean {
|
||||
if (
|
||||
forms.includes('computed-property') &&
|
||||
node['type'] === 'Property' &&
|
||||
node['computed'] === true &&
|
||||
isCommandExpression(node['key'] as AstNode | undefined, command)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
forms.includes('public-commands-member') &&
|
||||
node['type'] === 'MemberExpression' &&
|
||||
isCommandExpression(node, command)
|
||||
);
|
||||
}
|
||||
|
||||
function containsStringLiteral(node: unknown, expected: string): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (candidate['type'] === 'Literal' && candidate['value'] === expected) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The runtime route stays narrowed: no manufactured proof, no widened access.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function narrowingViolations(
|
||||
row: MigratedCommandCutover,
|
||||
file: ProductionSource,
|
||||
program: AstNode,
|
||||
): UnruledViolation[] {
|
||||
// An inventory row binds no device runtime, so it has nothing to re-widen.
|
||||
if (!file.path.startsWith('src/daemon/') || row.execution !== 'device-runtime') return [];
|
||||
const violations: UnruledViolation[] = [];
|
||||
const runtimeTypes = new Set([SHARED_RUNTIME_TYPE_NAME, ...row.runtimeTypeNames]);
|
||||
visitAst(program, (node) => {
|
||||
if (
|
||||
(node['type'] === 'TSAsExpression' || node['type'] === 'TSTypeAssertion') &&
|
||||
containsTypeName(node['typeAnnotation'], runtimeTypes)
|
||||
) {
|
||||
violations.push(at(file, node, `widened ${row.subject} runtime type assertion`));
|
||||
}
|
||||
if (node['type'] === 'TSNonNullExpression' && isNonNullRepair(row, node['expression'])) {
|
||||
violations.push(at(file, node, `non-null repair of a narrowed ${row.subject} operation`));
|
||||
}
|
||||
if (isBracketedOperationAccess(row, node)) {
|
||||
violations.push(at(file, node, `bracketed ${row.subject} operation access`));
|
||||
}
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
function isNonNullRepair(row: DeviceRuntimeCutover, node: unknown): boolean {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
const member = node as AstNode;
|
||||
if (member['type'] !== 'MemberExpression') return false;
|
||||
if (row.nonNullRepairScope === 'any-operation') {
|
||||
const object = member['object'] as AstNode | undefined;
|
||||
return object?.['type'] === 'MemberExpression' && memberName(object) === 'operations';
|
||||
}
|
||||
return isRowOperation(row, memberName(member));
|
||||
}
|
||||
|
||||
function isBracketedOperationAccess(row: DeviceRuntimeCutover, node: AstNode): boolean {
|
||||
if (node['type'] !== 'MemberExpression' || node['computed'] !== true) return false;
|
||||
const object = node['object'] as AstNode | undefined;
|
||||
return (
|
||||
isRowOperation(row, memberName(node)) &&
|
||||
object?.['type'] === 'MemberExpression' &&
|
||||
memberName(object) === 'operations'
|
||||
);
|
||||
}
|
||||
|
||||
function isRowOperation(row: DeviceRuntimeCutover, name: string | undefined): boolean {
|
||||
if (name === undefined) return false;
|
||||
return (
|
||||
(row.operations.names ?? []).includes(name) || (row.operations.pattern?.test(name) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
function containsTypeName(node: unknown, names: ReadonlySet<string>): boolean {
|
||||
let found = false;
|
||||
visitAst(node, (candidate) => {
|
||||
if (candidate['type'] === 'Identifier' && names.has(String(candidate['name']))) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The route is singular: one daemon orchestration call, one operation call.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function exactCallViolations(
|
||||
row: MigratedCommandCutover,
|
||||
files: readonly ProductionSource[],
|
||||
programs: ReadonlyMap<string, AstNode>,
|
||||
): UnruledViolation[] {
|
||||
if (row.execution !== 'device-runtime') return [];
|
||||
const routes: readonly string[] = row.singularExecution.routes;
|
||||
const operations: readonly string[] = row.singularExecution.operations ?? [];
|
||||
const routeCounts = new Map<string, number>(routes.map((name) => [name, 0]));
|
||||
const ownerNodes = new Map<string, AstNode[]>();
|
||||
for (const file of files.filter(({ path }) => path.startsWith('src/daemon/'))) {
|
||||
const program = programs.get(file.path);
|
||||
if (!program) continue;
|
||||
visitAst(program, (node) => {
|
||||
const owner = lexicalFunctionOwnerName(node);
|
||||
if (owner !== undefined) {
|
||||
const nodes = ownerNodes.get(owner) ?? [];
|
||||
nodes.push(node);
|
||||
ownerNodes.set(owner, nodes);
|
||||
}
|
||||
if (node['type'] !== 'CallExpression') return;
|
||||
const callee = node['callee'] as AstNode | undefined;
|
||||
if (callee?.['type'] === 'Identifier' && routeCounts.has(String(callee['name']))) {
|
||||
bump(routeCounts, String(callee['name']));
|
||||
}
|
||||
});
|
||||
}
|
||||
const operationCounts = new Map(
|
||||
operations.map((operation) => [
|
||||
operation,
|
||||
countOwnedOperationCalls(
|
||||
operation,
|
||||
row.singularExecution.operationOwners[operation] ?? [],
|
||||
ownerNodes,
|
||||
),
|
||||
]),
|
||||
);
|
||||
const pseudoFile = `(${row.command} runtime)`;
|
||||
return [
|
||||
...routes.map((name) => ({ found: routeCounts.get(name) ?? 0, what: `${name} route` })),
|
||||
...operations.map((name) => ({
|
||||
found: operationCounts.get(name) ?? 0,
|
||||
what: `narrowed ${name} call`,
|
||||
})),
|
||||
]
|
||||
.filter(({ found }) => found !== 1)
|
||||
.map(({ what, found }) => ({
|
||||
file: pseudoFile,
|
||||
line: 1,
|
||||
message: `expected one ${what}, found ${found}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function lexicalFunctionOwnerName(node: AstNode): string | undefined {
|
||||
if (node['type'] === 'FunctionDeclaration') {
|
||||
const id = node['id'] as AstNode | null | undefined;
|
||||
return id?.['type'] === 'Identifier' ? String(id['name']) : undefined;
|
||||
}
|
||||
if (node['type'] !== 'VariableDeclarator') return undefined;
|
||||
const id = node['id'] as AstNode | undefined;
|
||||
const init = node['init'] as AstNode | null | undefined;
|
||||
if (id?.['type'] !== 'Identifier') return undefined;
|
||||
if (init?.['type'] !== 'ArrowFunctionExpression' && init?.['type'] !== 'FunctionExpression') {
|
||||
return undefined;
|
||||
}
|
||||
return String(id['name']);
|
||||
}
|
||||
|
||||
function countOwnedOperationCalls(
|
||||
operation: string,
|
||||
owners: readonly string[],
|
||||
ownerNodes: ReadonlyMap<string, readonly AstNode[]>,
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const owner of owners) {
|
||||
for (const node of ownerNodes.get(owner) ?? []) {
|
||||
visitAst(node, (candidate) => {
|
||||
if (candidate['type'] !== 'CallExpression') return;
|
||||
const callee = candidate['callee'] as AstNode | undefined;
|
||||
if (
|
||||
callee?.['type'] === 'MemberExpression' &&
|
||||
isRuntimeOperationCall(callee) &&
|
||||
memberName(callee) === operation
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function isRuntimeOperationCall(callee: AstNode): boolean {
|
||||
const object = callee['object'] as AstNode | undefined;
|
||||
return object?.['type'] === 'MemberExpression' && memberName(object) === 'operations';
|
||||
}
|
||||
|
||||
function bump(counts: Map<string, number>, name: string): void {
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function push(
|
||||
violations: UnruledViolation[],
|
||||
seen: Set<string>,
|
||||
file: ProductionSource,
|
||||
node: AstNode,
|
||||
message: string,
|
||||
): void {
|
||||
const identity = `${String(node['start'] ?? '')}:${message}`;
|
||||
if (seen.has(identity)) return;
|
||||
seen.add(identity);
|
||||
violations.push(at(file, node, message));
|
||||
}
|
||||
|
||||
function at(file: ProductionSource, node: AstNode, message: string): UnruledViolation {
|
||||
const offset = typeof node['start'] === 'number' ? node['start'] : 0;
|
||||
return { file: file.path, line: file.source.slice(0, offset).split('\n').length, message };
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { checkRuntimeCommandCutover } from './runtime-command-cutover-policy.ts';
|
||||
import { rowFor, sources, summariesFor } from './runtime-command-cutover-fixtures.ts';
|
||||
|
||||
// Per-row acceptance for the four migrated commands. Every case here was carried over
|
||||
// from the per-command policy tests these rows replaced; the mechanism itself is proven
|
||||
// in runtime-command-cutover-policy.test.ts.
|
||||
|
||||
const DEVICES_HANDLER = [
|
||||
"import { listDeviceInventory as discoverInventory } from '../../core/dispatch-resolve.ts';",
|
||||
'export async function handleDevices(request: Request) { return await discoverInventory(request); }',
|
||||
].join('\n');
|
||||
|
||||
const DEVICES_HANDLER_FILE = 'src/daemon/handlers/session-inventory.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// devices (R13)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('devices rejects an exact legacy inventory module path', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R17 device-inventory-cutover', [
|
||||
[DEVICES_HANDLER_FILE, DEVICES_HANDLER],
|
||||
['src/platforms/apple/devices.ts', 'export const staleInventoryRoute = true;'],
|
||||
]),
|
||||
['src/platforms/apple/devices.ts: retired device inventory module remains'],
|
||||
);
|
||||
});
|
||||
|
||||
test('devices rejects executable legacy imports and identifiers', () => {
|
||||
const legacy = [
|
||||
"import { listAppleDevices } from './platforms/apple/devices.ts';",
|
||||
'export async function discover() { return await listAppleDevices(); }',
|
||||
].join('\n');
|
||||
const found = summariesFor('R17 device-inventory-cutover', [
|
||||
[DEVICES_HANDLER_FILE, DEVICES_HANDLER],
|
||||
['src/legacy-inventory.ts', legacy],
|
||||
]).join('\n');
|
||||
|
||||
assert.match(found, /imports retired device inventory module/);
|
||||
assert.match(found, /legacy device inventory route listAppleDevices/);
|
||||
});
|
||||
|
||||
test('devices rejects dynamic imports and re-exports of legacy inventory modules', () => {
|
||||
for (const route of [
|
||||
"export const legacy = import('./platforms/apple/devices.ts');",
|
||||
"export { staleInventory } from './core/platform-inventory.ts';",
|
||||
]) {
|
||||
assert.match(
|
||||
summariesFor('R17 device-inventory-cutover', [
|
||||
[DEVICES_HANDLER_FILE, DEVICES_HANDLER],
|
||||
['src/legacy-inventory.ts', route],
|
||||
]).join('\n'),
|
||||
/imports retired device inventory module/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('devices ignores comments, strings, and similarly named non-route modules', () => {
|
||||
const prose = [
|
||||
"const note = 'listAppleDevices from core/platform-inventory';",
|
||||
'// discoverDevices used to live in platforms/apple/devices.ts',
|
||||
'export const cacheKey = note;',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
summariesFor('R17 device-inventory-cutover', [
|
||||
[DEVICES_HANDLER_FILE, DEVICES_HANDLER],
|
||||
['src/platforms/apple/devices-cache.ts', prose],
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// logs (R14)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('legacy logs scan catches a planted provider route and plugin facet', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R14 logs-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/planted.ts',
|
||||
'await withAppLogProvider(provider, task); await startAppLog(request); handleLogsCommand(params);',
|
||||
],
|
||||
[
|
||||
'src/platforms/planted/plugin.ts',
|
||||
'export const plugin = { appLog: { resolveBackend() {} } } satisfies PlatformPlugin;',
|
||||
],
|
||||
]),
|
||||
[
|
||||
'src/daemon/planted.ts: legacy logs route withAppLogProvider',
|
||||
'src/daemon/planted.ts: legacy logs route startAppLog',
|
||||
'src/platforms/planted/plugin.ts: legacy PlatformPlugin appLog facet',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy logs scan ignores retired names in comments and string data', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R14 logs-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/planted.ts',
|
||||
`
|
||||
// AppLogProvider and startAppLog were removed.
|
||||
const migrationNote = 'withAppLogProvider and requireCommandSupported("logs")';
|
||||
const metadata = { note: 'appLogProvider' };
|
||||
handleLogsCommand(params);
|
||||
`,
|
||||
],
|
||||
['src/platforms/apple/plugin.ts', `const note = 'PUBLIC_COMMANDS.logs';`],
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy logs scan retains type-import and computed executable route coverage', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R14 logs-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/planted.ts',
|
||||
`
|
||||
import type { AppLogProvider as LegacyProvider } from './old-app-log.ts';
|
||||
const route = handlers['startAppLog'];
|
||||
handleLogsCommand(params);
|
||||
`,
|
||||
],
|
||||
]),
|
||||
[
|
||||
'src/daemon/planted.ts: legacy logs route AppLogProvider',
|
||||
'src/daemon/planted.ts: legacy logs route startAppLog',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy logs scan catches planted capability and dual-admission routes', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R14 logs-runtime-cutover', [
|
||||
[
|
||||
'src/core/command-descriptor/registry.ts',
|
||||
`
|
||||
const registry = [
|
||||
{ name: 'logs', capability: { apple: {} }, platformExecution: runtime },
|
||||
{ name: 'events', capability: { apple: {} } },
|
||||
];
|
||||
`,
|
||||
],
|
||||
[
|
||||
'src/daemon/handlers/planted.ts',
|
||||
`
|
||||
const literal = requireCommandSupported('logs', device);
|
||||
const symbolic = requireCommandSupported(PUBLIC_COMMANDS.logs, device);
|
||||
handleLogsCommand(params);
|
||||
`,
|
||||
],
|
||||
[
|
||||
'src/platforms/apple/plugin.ts',
|
||||
`const supports = { [PUBLIC_COMMANDS.logs]: supportsCoreDevice };`,
|
||||
],
|
||||
[
|
||||
'src/core/capabilities.ts',
|
||||
`const HARMONYOS_SUPPORTED_COMMANDS = new Set(['open', 'logs']);`,
|
||||
],
|
||||
]),
|
||||
[
|
||||
'src/core/command-descriptor/registry.ts: logs descriptor retains legacy capability admission',
|
||||
'src/daemon/handlers/planted.ts: legacy logs capability admission requireCommandSupported',
|
||||
'src/daemon/handlers/planted.ts: legacy logs capability admission requireCommandSupported',
|
||||
'src/platforms/apple/plugin.ts: Apple plugin retains legacy logs support or hint closure',
|
||||
'src/core/capabilities.ts: static platform command set retains logs admission',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy logs scan follows declaration roles after files move', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R14 logs-runtime-cutover', [
|
||||
[
|
||||
'src/moved/command-declarations.ts',
|
||||
`const descriptor = { name: 'logs', capability: { apple: {} } };`,
|
||||
],
|
||||
[
|
||||
'src/moved/platform-contract.ts',
|
||||
`export type PlatformPlugin = { readonly appLog?: { inspect(): void } };`,
|
||||
],
|
||||
[
|
||||
'src/moved/apple-family.ts',
|
||||
`const plugin = { appLog: { inspect() {} } } as const satisfies PlatformPlugin;`,
|
||||
],
|
||||
['src/moved/typed-plugin.ts', `const plugin: PlatformPlugin = { appLog: { inspect() {} } };`],
|
||||
['src/moved/capability-data.ts', `const HARMONYOS_SUPPORTED_COMMANDS = new Set(['logs']);`],
|
||||
['src/daemon/logs-route.ts', 'handleLogsCommand(params);'],
|
||||
]),
|
||||
[
|
||||
'src/moved/command-declarations.ts: logs descriptor retains legacy capability admission',
|
||||
'src/moved/platform-contract.ts: legacy PlatformPlugin appLog facet',
|
||||
'src/moved/apple-family.ts: legacy PlatformPlugin appLog facet',
|
||||
'src/moved/typed-plugin.ts: legacy PlatformPlugin appLog facet',
|
||||
'src/moved/capability-data.ts: static platform command set retains logs admission',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('R14 violations retain their exact source line', () => {
|
||||
const [violation] = checkRuntimeCommandCutover(
|
||||
sources([
|
||||
[
|
||||
'src/daemon/moved-handler.ts',
|
||||
`
|
||||
const note = 'reason: retained';
|
||||
const widened = runtime as BoundDeviceRuntime<typeof use>;
|
||||
`,
|
||||
],
|
||||
]),
|
||||
[rowFor('logs')],
|
||||
);
|
||||
|
||||
assert.deepEqual(violation, {
|
||||
rule: 'R14 logs-runtime-cutover',
|
||||
file: 'src/daemon/moved-handler.ts',
|
||||
line: 3,
|
||||
message: 'widened logs runtime type assertion',
|
||||
});
|
||||
});
|
||||
|
||||
test('logs narrowing scan catches planted assertions, non-null repair, and bracket access', () => {
|
||||
const found = summariesFor('R14 logs-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/handlers/planted.ts',
|
||||
`
|
||||
const widened = runtime as BoundDeviceRuntime<typeof use>;
|
||||
widened.operations.appLogStart!({});
|
||||
widened.operations['appLogDoctor']({});
|
||||
handleLogsCommand(params);
|
||||
`,
|
||||
],
|
||||
]);
|
||||
|
||||
assert.equal(found.length, 3);
|
||||
assert.match(found.join('\n'), /widened logs runtime type assertion/);
|
||||
assert.match(found.join('\n'), /non-null repair of a narrowed logs operation/);
|
||||
assert.match(found.join('\n'), /bracketed logs operation access/);
|
||||
});
|
||||
|
||||
test('logs keeps the broader non-null scan over every daemon runtime operation', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R14 logs-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/handlers/planted.ts',
|
||||
'runtime.operations.somethingElse!({}); handleLogsCommand(params);',
|
||||
],
|
||||
]),
|
||||
['src/daemon/handlers/planted.ts: non-null repair of a narrowed logs operation'],
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// network (R15)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('network cutover scan catches planted legacy execution and dual admission', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R15 network-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/handlers/planted.ts',
|
||||
`
|
||||
await readSessionNetworkCapture(input);
|
||||
await provider.dumpNetwork({ include: 'all' });
|
||||
requireCommandSupported('network', device);
|
||||
function handleNetworkCommand() { runtime.operations.networkDump(input); }
|
||||
handleNetworkCommand(request);
|
||||
`,
|
||||
],
|
||||
[
|
||||
'src/core/command-descriptor/registry.ts',
|
||||
`const commands = [{ name: 'network', capability: { apple: {} } }];`,
|
||||
],
|
||||
['src/core/capabilities.ts', `const WEB_QUERY_COMMANDS = ['find', 'network'];`],
|
||||
[
|
||||
'src/platforms/apple/plugin.ts',
|
||||
`const supports = { [PUBLIC_COMMANDS.network]: supportsDevice };`,
|
||||
],
|
||||
]),
|
||||
[
|
||||
'src/daemon/handlers/planted.ts: legacy network route readSessionNetworkCapture',
|
||||
'src/daemon/handlers/planted.ts: daemon invokes legacy provider method dumpNetwork',
|
||||
'src/daemon/handlers/planted.ts: legacy network capability admission requireCommandSupported',
|
||||
'src/core/command-descriptor/registry.ts: network descriptor retains legacy capability admission',
|
||||
'src/core/capabilities.ts: static platform command set retains network admission',
|
||||
'src/platforms/apple/plugin.ts: Apple plugin retains legacy network support or hint closure',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('network cutover scan rejects a retired implementation by committed path', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R15 network-runtime-cutover', [
|
||||
['src/daemon/network-log.ts', `export const parser = () => [];`],
|
||||
[
|
||||
'src/daemon/handlers/ok.ts',
|
||||
'function handleNetworkCommand() { runtime.operations.networkDump(i); } handleNetworkCommand(r);',
|
||||
],
|
||||
]),
|
||||
['src/daemon/network-log.ts: retired network module remains'],
|
||||
);
|
||||
});
|
||||
|
||||
test('network cutover scan ignores retired names in comments and string data', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R15 network-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/planted.ts',
|
||||
`
|
||||
// readSessionNetworkCapture and requireCommandSupported('network') were removed.
|
||||
const note = 'provider.dumpNetwork and PUBLIC_COMMANDS.network';
|
||||
function handleNetworkCommand() { runtime.operations.networkDump(input); }
|
||||
handleNetworkCommand(request);
|
||||
`,
|
||||
],
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('network narrowing scan catches planted casts, non-null repair, and bracket access', () => {
|
||||
const found = summariesFor('R15 network-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/handlers/planted.ts',
|
||||
`
|
||||
const widened = runtime as BoundDeviceRuntime<typeof use>;
|
||||
widened.operations.networkDump!({});
|
||||
widened.operations['networkDump']({});
|
||||
`,
|
||||
],
|
||||
]);
|
||||
const messages = found.join('\n');
|
||||
assert.match(messages, /widened network runtime type assertion/);
|
||||
assert.match(messages, /non-null repair of a narrowed network operation/);
|
||||
assert.match(messages, /bracketed network operation access/);
|
||||
});
|
||||
|
||||
test('network route scan rejects planted neither and duplicate routes', () => {
|
||||
assert.deepEqual(summariesFor('R15 network-runtime-cutover', []), [
|
||||
'(network runtime): expected one handleNetworkCommand route, found 0',
|
||||
'(network runtime): expected one narrowed networkDump call, found 0',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
summariesFor('R15 network-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/planted.ts',
|
||||
`
|
||||
function handleNetworkCommand() {
|
||||
runtime.operations.networkDump(input);
|
||||
fallback.operations.networkDump(input);
|
||||
}
|
||||
handleNetworkCommand(first);
|
||||
handleNetworkCommand(second);
|
||||
`,
|
||||
],
|
||||
]),
|
||||
[
|
||||
'(network runtime): expected one handleNetworkCommand route, found 2',
|
||||
'(network runtime): expected one narrowed networkDump call, found 2',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// record (R16)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('R16 catches a planted legacy recording backend, admission, and plugin facet', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R16 record-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/handlers/planted.ts',
|
||||
`
|
||||
await resolveRecordingBackendForDevice(device).start(input);
|
||||
requireCommandSupported('record', device);
|
||||
requireCommandSupported(PUBLIC_COMMANDS.record, device);
|
||||
const backend = RECORDING_BACKENDS_BY_TAG.android;
|
||||
type LegacyTag = RecordingBackendTag;
|
||||
function startRecording() { runtime.operations.screenRecordingStart(input); }
|
||||
function createScreenRecordingRecoveryControl() {
|
||||
runtime.operations.screenRecordingReattach(input);
|
||||
runtime.operations.screenRecordingCleanup(input);
|
||||
}
|
||||
handleRecordTraceCommands(input);
|
||||
`,
|
||||
],
|
||||
[
|
||||
'src/core/command-descriptor/registry.ts',
|
||||
`const commands = [{ name: 'record', capability: { apple: {} } }];`,
|
||||
],
|
||||
[
|
||||
'src/platforms/apple/plugin.ts',
|
||||
`const plugin = { recording: { resolveBackendTag() {} } } satisfies PlatformPlugin;`,
|
||||
],
|
||||
]),
|
||||
[
|
||||
'src/daemon/handlers/planted.ts: legacy recording route resolveRecordingBackendForDevice',
|
||||
'src/daemon/handlers/planted.ts: legacy recording route RECORDING_BACKENDS_BY_TAG',
|
||||
'src/daemon/handlers/planted.ts: legacy recording route RecordingBackendTag',
|
||||
'src/daemon/handlers/planted.ts: legacy record capability admission requireCommandSupported',
|
||||
'src/daemon/handlers/planted.ts: legacy record capability admission requireCommandSupported',
|
||||
'src/core/command-descriptor/registry.ts: record descriptor retains legacy capability admission',
|
||||
'src/platforms/apple/plugin.ts: legacy PlatformPlugin recording facet',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('R16 excludes trace-only mechanics from the record cutover scan', () => {
|
||||
assert.deepEqual(
|
||||
summariesFor('R16 record-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/handlers/record-trace.ts',
|
||||
`
|
||||
const traceRecording = { startTrace() {}, stopTrace() {} };
|
||||
traceRecording.startTrace();
|
||||
function startRecording() { runtime.operations.screenRecordingStart(input); }
|
||||
function createScreenRecordingRecoveryControl() {
|
||||
runtime.operations.screenRecordingReattach(input);
|
||||
runtime.operations.screenRecordingCleanup(input);
|
||||
}
|
||||
handleRecordTraceCommands(input);
|
||||
`,
|
||||
],
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('R16 rejects the retired recording-provider scope while allowing the focused transport', () => {
|
||||
const found = summariesFor('R16 record-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/request-platform-providers.ts',
|
||||
`
|
||||
import type { RecordingProvider } from './recording-provider.ts';
|
||||
const recordingProvider = resolveRecordingProvider();
|
||||
await withRecordingProvider(undefined, task);
|
||||
`,
|
||||
],
|
||||
[
|
||||
'src/platform-runtime-screen-recording-apple-transport.ts',
|
||||
`
|
||||
// recordingProvider and withRecordingProvider are retired prose.
|
||||
const prose = 'src/daemon/recording-provider.ts';
|
||||
withAppleSimulatorScreenRecordingTransport(transport, task);
|
||||
`,
|
||||
],
|
||||
['src/daemon/recording-provider.ts', `export const retired = true;`],
|
||||
[
|
||||
'src/daemon/dynamic-provider.ts',
|
||||
`
|
||||
const providers = { ['recordingProvider']: resolver };
|
||||
await import('./recording-provider.ts');
|
||||
`,
|
||||
],
|
||||
]);
|
||||
const messages = found.join('\n');
|
||||
assert.match(messages, /retired recording module remains/);
|
||||
assert.match(messages, /imports retired recording module/);
|
||||
assert.match(messages, /legacy recording route RecordingProvider/);
|
||||
assert.match(messages, /legacy recording route recordingProvider/);
|
||||
assert.match(messages, /legacy recording route resolveRecordingProvider/);
|
||||
assert.match(messages, /legacy recording route withRecordingProvider/);
|
||||
assert.doesNotMatch(
|
||||
messages,
|
||||
/src\/platform-runtime-screen-recording-apple-transport\.ts/,
|
||||
'prose in the focused transport must stay allowed',
|
||||
);
|
||||
});
|
||||
|
||||
test('R16 rejects proof repair for a narrowed screen-recording operation', () => {
|
||||
const found = summariesFor('R16 record-runtime-cutover', [
|
||||
[
|
||||
'src/daemon/handlers/planted.ts',
|
||||
`
|
||||
const widened = runtime as BoundDeviceRuntime<typeof use>;
|
||||
widened.operations.screenRecordingStart!({});
|
||||
widened.operations['screenRecordingReattach']({});
|
||||
`,
|
||||
],
|
||||
]);
|
||||
const messages = found.join('\n');
|
||||
assert.match(messages, /widened recording runtime type assertion/);
|
||||
assert.match(messages, /non-null repair of a narrowed recording operation/);
|
||||
assert.match(messages, /bracketed recording operation access/);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { MigratedCommandCutover, UnruledViolation } from './runtime-command-cutover-model.ts';
|
||||
import {
|
||||
appLogSessionStateOwnershipViolations,
|
||||
devicesGatewayBindingViolations,
|
||||
sourceExecutedUsingDeclarationViolations,
|
||||
} from './runtime-command-cutover-extensions.ts';
|
||||
import { recordRuntimeDaemonMechanicsViolations } from './record-runtime-mechanics-policy.ts';
|
||||
|
||||
/**
|
||||
* One row per migrated command (ADR 0019 §8). A new command unit adds a row here; the
|
||||
* mechanism in `runtime-command-cutover-policy.ts` carries one planted-red proof for
|
||||
* every row, and `cutoverRowDefects` rejects a row that leaves its claims unstated.
|
||||
*
|
||||
* Rule ids are per row: the layering report groups violations under R17 devices,
|
||||
* R14 logs, R15 network, R16 record.
|
||||
*/
|
||||
export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [
|
||||
{
|
||||
rule: 'R17 device-inventory-cutover',
|
||||
command: 'devices',
|
||||
subject: 'device inventory',
|
||||
tier: 'request-scoped',
|
||||
execution: 'inventory',
|
||||
legacyRetirement: {
|
||||
modulePaths: ['src/core/platform-inventory.ts'],
|
||||
modulePathPatterns: [/^src\/platforms\/(?:[^/]+\/)*devices\.ts$/],
|
||||
importPatterns: [
|
||||
/(?:^|\/)core\/platform-inventory(?:\.[cm]?[jt]s)?$/,
|
||||
/(?:^|\/)platforms\/(?:[^/]+\/)*devices(?:\.[cm]?[jt]s)?$/,
|
||||
],
|
||||
routeNames: [
|
||||
'discoverDevices',
|
||||
'listAndroidDevices',
|
||||
'listAppleDevices',
|
||||
'listHarmonyDevices',
|
||||
'listHarmonyOsDevices',
|
||||
'listLinuxDevices',
|
||||
'listMacosDevices',
|
||||
'listVegaDevices',
|
||||
'listWebDevices',
|
||||
],
|
||||
},
|
||||
singularExecution: { gatewayProof: devicesGatewayBindingViolations },
|
||||
},
|
||||
{
|
||||
rule: 'R14 logs-runtime-cutover',
|
||||
command: 'logs',
|
||||
subject: 'logs',
|
||||
tier: 'durable-resource',
|
||||
execution: 'device-runtime',
|
||||
legacyRetirement: {
|
||||
routeNames: [
|
||||
'startAppLog',
|
||||
'stopAppLog',
|
||||
'runAppLogDoctor',
|
||||
'resolveLogBackend',
|
||||
'withAppLogProvider',
|
||||
'appLogProvider',
|
||||
'AppLogProviderResolver',
|
||||
'AppLogProvider',
|
||||
],
|
||||
pluginFacetKeys: ['appLog'],
|
||||
},
|
||||
// Apple logs admission is a computed property key; `PUBLIC_COMMANDS.logs` as a plain
|
||||
// member is not an admission signal for this command.
|
||||
admissionMember: {
|
||||
forms: ['computed-property'],
|
||||
message: 'Apple plugin retains legacy logs support or hint closure',
|
||||
},
|
||||
runtimeTypeNames: ['AppLogRuntimeOperations', 'AppLogLiveHandle'],
|
||||
// Input-dependent plan: the logs use narrows to a family, not a fixed operation list.
|
||||
operations: { pattern: /^appLog[A-Za-z]+$/ },
|
||||
nonNullRepairScope: 'any-operation',
|
||||
// The app-log operation set is input-dependent, so only the daemon route is singular.
|
||||
singularExecution: { routes: ['handleLogsCommand'] },
|
||||
lifecycleProof: appLogSessionStateOwnershipViolations,
|
||||
extensions: [sourceExecutedUsingDeclarationViolations],
|
||||
},
|
||||
{
|
||||
rule: 'R15 network-runtime-cutover',
|
||||
command: 'network',
|
||||
subject: 'network',
|
||||
tier: 'request-scoped',
|
||||
execution: 'device-runtime',
|
||||
legacyRetirement: {
|
||||
modulePaths: [
|
||||
'src/daemon/network-log.ts',
|
||||
'src/daemon/app-log-network-recovery.ts',
|
||||
'src/daemon/network-log-android-recovery.ts',
|
||||
'src/daemon/network-log-ios-simulator-recovery.ts',
|
||||
],
|
||||
daemonOnlyRouteNames: [
|
||||
'handleWebNetworkCommand',
|
||||
'readSessionNetworkCapture',
|
||||
'readRecentNetworkTraffic',
|
||||
'readRecentAndroidLogcatForPackage',
|
||||
'readRecentIosSimulatorLogShowForBundle',
|
||||
],
|
||||
daemonOnlyProviderMethods: ['dumpNetwork'],
|
||||
},
|
||||
// Scoped to the Apple plugin: elsewhere `PUBLIC_COMMANDS.network` is an identifier,
|
||||
// not an admission decision.
|
||||
admissionMember: {
|
||||
forms: ['public-commands-member'],
|
||||
files: ['src/platforms/apple/plugin.ts'],
|
||||
message: 'Apple plugin retains legacy network support or hint closure',
|
||||
},
|
||||
runtimeTypeNames: ['NetworkRuntimeOperations'],
|
||||
operations: { names: ['networkDump'] },
|
||||
singularExecution: {
|
||||
routes: ['handleNetworkCommand'],
|
||||
operations: ['networkDump'],
|
||||
operationOwners: { networkDump: ['handleNetworkCommand'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
rule: 'R16 record-runtime-cutover',
|
||||
command: 'record',
|
||||
subject: 'recording',
|
||||
tier: 'durable-resource',
|
||||
execution: 'device-runtime',
|
||||
legacyRetirement: {
|
||||
modulePaths: ['src/daemon/recording-provider.ts'],
|
||||
importPatterns: [/(?:^|\/)recording-provider(?:\.[cm]?[jt]s)?$/],
|
||||
routeNames: [
|
||||
'resolveRecordingBackendForDevice',
|
||||
'stopActiveRecording',
|
||||
'RecordingBackend',
|
||||
'RecordingStartBackend',
|
||||
'RECORDING_BACKENDS_BY_TAG',
|
||||
'RecordingBackendTag',
|
||||
'RecordingProvider',
|
||||
'RecordingProviderResolver',
|
||||
'recordingProvider',
|
||||
'resolveRecordingProvider',
|
||||
'withRecordingProvider',
|
||||
'createLocalRecordingProvider',
|
||||
],
|
||||
pluginFacetKeys: ['recording'],
|
||||
},
|
||||
// No admission-member claim: `PUBLIC_COMMANDS.record` is live identifier-only data in
|
||||
// the daemon session-event tables, so the member form cannot discriminate here.
|
||||
runtimeTypeNames: ['ScreenRecordingRuntimeOperations', 'ScreenRecordingLiveHandle'],
|
||||
operations: {
|
||||
names: ['screenRecordingStart', 'screenRecordingReattach', 'screenRecordingCleanup'],
|
||||
},
|
||||
singularExecution: {
|
||||
routes: ['handleRecordTraceCommands'],
|
||||
operations: ['screenRecordingStart', 'screenRecordingReattach', 'screenRecordingCleanup'],
|
||||
operationOwners: {
|
||||
screenRecordingStart: ['startRecording'],
|
||||
screenRecordingReattach: ['createScreenRecordingRecoveryControl'],
|
||||
screenRecordingCleanup: ['createScreenRecordingRecoveryControl'],
|
||||
},
|
||||
},
|
||||
lifecycleProof: recordDaemonMechanicsProof,
|
||||
},
|
||||
];
|
||||
|
||||
/** The record mechanics policy predates the row model and reports `path: message`. */
|
||||
function recordDaemonMechanicsProof(sources: ReadonlyMap<string, string>): UnruledViolation[] {
|
||||
const production = [...sources].map(([path, source]) => ({ path, source }));
|
||||
return recordRuntimeDaemonMechanicsViolations(production).map((violation) => {
|
||||
const separator = violation.indexOf(': ');
|
||||
return {
|
||||
file: separator < 0 ? '(record runtime)' : violation.slice(0, separator),
|
||||
line: 1,
|
||||
message: separator < 0 ? violation : violation.slice(separator + 2),
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user