diff --git a/packages/platform-apple/src/runner/host.ts b/packages/platform-apple/src/runner/host.ts index 6e17e8fb6..789945169 100644 --- a/packages/platform-apple/src/runner/host.ts +++ b/packages/platform-apple/src/runner/host.ts @@ -17,6 +17,22 @@ import type { XmlNode } from '@agent-device/xml'; * runner uses; the composition-root assignment is the conformance check, so a * root signature drifting incompatibly fails typecheck there rather than at * runtime. + * + * A host-kit symbol the runner needs is added HERE, on {@link AppleRunnerHost}, and bound to the + * real implementation in `core/runner-host.ts` -- never imported directly from a `runner/*` + * module. The reason: `packages/platform-apple/src/runner/` sits in the eager import closure of + * seven Apple facade entries (`app-lifecycle-facade.ts`, `app-resolution-facade.ts`, + * `doctor-facade.ts`, `perf-facade.ts`, `physical-device-facade.ts`, `runner-operations-facade.ts`, + * `runner/index.ts`) that `scripts/__tests__/eager-closure-budgets.ts` holds at a fixed size (no + * growth against the merge-base); a static `@agent-device/host-kit/*` value import from a runner + * module adds every module on its own import path to all seven closures at once (#2423 measured + * one candidate import adding 5 modules to `runner/index.ts`'s closure, 13 -> 18, after two review + * rounds spent rediscovering this). `scripts/layering/` enforces the port at the import-graph + * level (R77 apple-runner-host-port): a `runner/**` file may hold a type-only + * `@agent-device/host-kit/*` import, which evaluates nothing, but never a value one. A pure + * constant that both the runner and another package need is not a host-kit exception to this -- it + * belongs in a runner module already inside every facade closure (e.g. + * `runner/apple-runner-platform.ts`), imported directly from there. */ export type ExecResult = { diff --git a/scripts/layering/apple-runner-host-port-policy.test.ts b/scripts/layering/apple-runner-host-port-policy.test.ts new file mode 100644 index 000000000..909ef0b02 --- /dev/null +++ b/scripts/layering/apple-runner-host-port-policy.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { appleRunnerHostPortViolations, RUNNER_SUBTREE } from './apple-runner-host-port-policy.ts'; + +function sources(entries: Record): ReadonlyMap { + return new Map(Object.entries(entries)); +} + +test('a runner module value-importing host-kit directly is refused', () => { + const violations = appleRunnerHostPortViolations( + sources({ + [`${RUNNER_SUBTREE}runner-planted.ts`]: + "import { runCmdSync } from '@agent-device/host-kit/command';\n", + }), + ); + assert.equal(violations.length, 1); + const [violation] = violations; + assert.equal(violation!.rule, 'R77 apple-runner-host-port'); + assert.equal(violation!.file, `${RUNNER_SUBTREE}runner-planted.ts`); + assert.equal(violation!.line, 1); + assert.match(violation!.message, /runner host port/); + assert.match(violation!.message, /runner\/host\.ts, bound in core\/runner-host\.ts/); + assert.match(violation!.message, /eager-closure-budgets/); +}); + +test('every value-import form that reaches host-kit is refused', () => { + for (const source of [ + "import { runCmdSync } from '@agent-device/host-kit/command';", + "import * as command from '@agent-device/host-kit/command';", + "const command = await import('@agent-device/host-kit/command');", + "export { runCmdSync } from '@agent-device/host-kit/command';", + "export * from '@agent-device/host-kit/command';", + "import '@agent-device/host-kit/command';", + ]) { + const violations = appleRunnerHostPortViolations( + sources({ [`${RUNNER_SUBTREE}runner-planted.ts`]: source }), + ); + assert.equal(violations.length, 1, source); + } +}); + +test('a type-only host-kit import is exempt, in the runner subtree and on the port itself', () => { + assert.deepEqual( + appleRunnerHostPortViolations( + sources({ + [`${RUNNER_SUBTREE}runner-planted.ts`]: + "import type { ExecResult } from '@agent-device/host-kit/command';\n", + [`${RUNNER_SUBTREE}host.ts`]: + "import type { ExecOptions } from '@agent-device/host-kit/command';\n", + }), + ), + [], + ); +}); + +test('a host-kit import outside the runner subtree is not this rule’s concern', () => { + assert.deepEqual( + appleRunnerHostPortViolations( + sources({ + 'packages/platform-apple/src/core/runner-host.ts': + "import { runCmdSync } from '@agent-device/host-kit/command';\n", + }), + ), + [], + ); +}); + +test('a runner import of anything other than host-kit is not this rule’s concern', () => { + assert.deepEqual( + appleRunnerHostPortViolations( + sources({ + [`${RUNNER_SUBTREE}runner-planted.ts`]: [ + "import { PLATFORMS } from '@agent-device/kernel/device';", + "import { runCmdSync } from './host.ts';", + ].join('\n'), + }), + ), + [], + ); +}); diff --git a/scripts/layering/apple-runner-host-port-policy.ts b/scripts/layering/apple-runner-host-port-policy.ts new file mode 100644 index 000000000..65435a9f4 --- /dev/null +++ b/scripts/layering/apple-runner-host-port-policy.ts @@ -0,0 +1,61 @@ +// Catches: a runner module reaching `@agent-device/host-kit/*` directly instead of through the +// Apple runner host port (`runner/host.ts`, bound in `core/runner-host.ts`) -- invisible to R13's +// general platform-package import rule, which admits host-kit to every platform-apple file, and +// invisible to typecheck, because the direct import and the port delegator have the same call +// shape. `packages/platform-apple/src/runner/` sits in the eager import closure of seven Apple +// facade entries (`app-lifecycle-facade.ts`, `app-resolution-facade.ts`, `doctor-facade.ts`, +// `perf-facade.ts`, `physical-device-facade.ts`, `runner-operations-facade.ts`, `runner/index.ts`) +// held at a fixed size by `scripts/__tests__/eager-closure-budgets.ts`: a direct host-kit value +// import from a runner module adds every module on its own import path to all seven closures at +// once. +// Evidence: #2423 measured a candidate direct `@agent-device/host-kit/command` import from +// `runner-cache-metadata.ts` adding 5 modules to `runner/index.ts`'s closure (13 -> 18); the +// review spent two rounds rediscovering the port requirement before the symbol was routed back +// through `runner/host.ts`, which is the gap this rule closes. +// Cost: 141 LOC (61 rule + 80 test). +// Kill criterion: none enforced today; retire only by maintainer decision that the eager-closure +// budgets no longer bind the runner subtree, or that the port itself is retired in favor of some +// other seam that keeps the same property. + +import { parseImports, type LayeringViolation } from './model.ts'; + +const RULE = 'R77 apple-runner-host-port'; + +/** Every file this rule polices, production and test alike -- the port has no test exception. */ +export const RUNNER_SUBTREE = 'packages/platform-apple/src/runner/'; + +const HOST_KIT_PREFIX = '@agent-device/host-kit/'; + +function violation(file: string, line: number, spec: string): LayeringViolation { + return { + rule: RULE, + file, + line, + message: + `imports '${spec}' directly. Reach host-kit through the runner host port ` + + `(runner/host.ts, bound in core/runner-host.ts); a direct import grows the Apple facade ` + + `eager closures (eager-closure-budgets).`, + }; +} + +/** + * `runner/**` (every file, `host.ts` included -- it holds none today, and a value import there + * would defeat the port it defines) may not VALUE-import `@agent-device/host-kit/*`. A type-only + * import of the same specifier (an `import type` declaration, or a named `type` specifier) is + * exempt everywhere: it evaluates nothing, so it cannot add a module to a closure the + * eager-closure gate measures at runtime. + */ +export function appleRunnerHostPortViolations( + sources: ReadonlyMap, +): LayeringViolation[] { + const violations: LayeringViolation[] = []; + for (const [file, source] of sources) { + if (!file.startsWith(RUNNER_SUBTREE)) continue; + for (const site of parseImports(source)) { + if (site.typeOnly) continue; + if (!site.spec.startsWith(HOST_KIT_PREFIX)) continue; + violations.push(violation(file, site.line, site.spec)); + } + } + return violations; +} diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 639f79e27..55ca538d9 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -32,6 +32,10 @@ // an exports map, and every workspace specifier declared + exports-named (R11). // - 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 THE APPLE RUNNER SUBTREE: `runner/**` may not value-import `@agent-device/host-kit/*` +// directly (R77) — the subtree sits in the eager closure of seven Apple façade entries the +// eager-closure-budgets gate holds at a fixed size, so a direct host-kit edge grows all seven; +// host-kit reaches the runner only through `runner/host.ts`, bound in `core/runner-host.ts`. // - Over REQUEST-BOUND RUNTIME EXECUTION: facts remain the only admission authority and daemon // code cannot manufacture or repair a narrowed runtime proof (R66). // - Over CONTRACTS PRODUCTION SOURCE: contracts owns vocabulary only — host, process, and timer @@ -90,6 +94,7 @@ import { checkRetiredPlatformsZone, platformPackagePolicySummary, } from './platform-package-policy.ts'; +import { appleRunnerHostPortViolations } from './apple-runner-host-port-policy.ts'; import { listUntrackedProductionTypeScriptFiles, readTrackedPlatformPackageDeclarations, @@ -443,6 +448,7 @@ export const LAYERING_RULE_IDS = [ 'daemon-platform-boundary', 'package-boundaries', 'platform-package-policy', + 'apple-runner-host-port', 'retired-platforms-zone', 'src-utils-retirement', 'replay-ownership', @@ -493,6 +499,8 @@ export const LAYERING_RULES: Readonly> = { readTrackedPlatformPackageDeclarations(repoRoot), { untrackedProductionFiles: listUntrackedProductionTypeScriptFiles(repoRoot) }, ), + 'apple-runner-host-port': (context) => + appleRunnerHostPortViolations(context.allTypeScriptSources), 'retired-platforms-zone': () => checkRetiredPlatformsZone(listTrackedPlatformZoneFiles(repoRoot)), 'src-utils-retirement': (context) => retiredPathRuleViolations('R14', context.trackedSrcUtilsFiles),