From ec8c515a0776d81aa2afebaaedb4ebfeab4f5952 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:41:56 +0200 Subject: [PATCH 1/8] refactor(layering): generalize package entry pin --- .../{test-kit-isolation.test.ts => package-entry-surface.test.ts} | 0 .../layering/{test-kit-isolation.ts => package-entry-surface.ts} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename scripts/layering/{test-kit-isolation.test.ts => package-entry-surface.test.ts} (100%) rename scripts/layering/{test-kit-isolation.ts => package-entry-surface.ts} (100%) diff --git a/scripts/layering/test-kit-isolation.test.ts b/scripts/layering/package-entry-surface.test.ts similarity index 100% rename from scripts/layering/test-kit-isolation.test.ts rename to scripts/layering/package-entry-surface.test.ts diff --git a/scripts/layering/test-kit-isolation.ts b/scripts/layering/package-entry-surface.ts similarity index 100% rename from scripts/layering/test-kit-isolation.ts rename to scripts/layering/package-entry-surface.ts From 15bd484956d3f8fb52ffbe374275acbaccccc6a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:44:49 +0200 Subject: [PATCH 2/8] refactor(daemon): pin single package entry --- docs/adr/0033-collocation-decision-table.md | 14 ++-- packages/daemon/package.json | 72 ------------------- scripts/layering/check.ts | 4 +- .../layering/package-entry-surface.test.ts | 52 +++++++++++++- scripts/layering/package-entry-surface.ts | 66 +++++++++-------- 5 files changed, 93 insertions(+), 115 deletions(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index dd0a854062..30cc76196e 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -201,16 +201,10 @@ its owner: the route owner-file claims sit beside the per-command claims in `@agent-device/command-registry/owner-files`, and the wire request and response types come from `@agent-device/contracts/command` and `@agent-device/kernel/contracts`. -Root tests that still drive daemon internals reach them through transitional `./testing/` -entries: named `./testing/` entries for production modules, named `./testing/` -entries, and per fixture folder a `./testing//*` pattern. Each batch of tests that moves into -`packages/daemon/src`, where they import their subject relatively, deletes the entries it frees. -R11 refuses any `./testing/` import from a non-test file, so root production code cannot import -a module the package publishes for tests (a package test double reached through the boundary is -production code depending on a fake). `resolveWorkspaceSpecifier` (layering model) owns Node's -exports semantics — the layering edges, the depgraph, mutation ownership, and R11's named-surface -check call it, and the Stryker alias table orders its Vite first-match list to resolve in exactly -its order — so every lane gives a specifier the module id Node's exports resolution gives it. +The package now exports exactly `.`. The layering entry-surface rule pins that export to +`src/index.ts` and rejects `export *` there; R83 reports no daemon test-only exports. Package tests +import internal subjects relatively, while composed root tests reach the daemon through +`startDaemonRuntime` from the package entry. R11 owns resolution of workspace package specifiers. ## Refuted alternatives diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 0a76e7357c..419c2a6cf0 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -35,78 +35,6 @@ ".": { "types": "./src/index.ts", "default": "./src/index.ts" - }, - "./testing/__tests__/support/session-factories": { - "types": "./src/__tests__/support/session-factories.ts", - "default": "./src/__tests__/support/session-factories.ts" - }, - "./testing/__tests__/support/store-factory": { - "types": "./src/__tests__/support/store-factory.ts", - "default": "./src/__tests__/support/store-factory.ts" - }, - "./testing/__tests__/support/unavailable-device-runtime-gateway": { - "types": "./src/__tests__/support/unavailable-device-runtime-gateway.ts", - "default": "./src/__tests__/support/unavailable-device-runtime-gateway.ts" - }, - "./testing/artifact-tracking": { - "types": "./src/resources/artifact-tracking.ts", - "default": "./src/resources/artifact-tracking.ts" - }, - "./testing/context": { - "types": "./src/core/context.ts", - "default": "./src/core/context.ts" - }, - "./testing/interaction/index": { - "types": "./src/execution/interaction/index.ts", - "default": "./src/execution/interaction/index.ts" - }, - "./testing/lease-registry": { - "types": "./src/resources/lease-registry.ts", - "default": "./src/resources/lease-registry.ts" - }, - "./testing/provider-device-admission": { - "types": "./src/core/provider-device-admission.ts", - "default": "./src/core/provider-device-admission.ts" - }, - "./testing/provider-lease-expiry": { - "types": "./src/server/provider-lease-expiry.ts", - "default": "./src/server/provider-lease-expiry.ts" - }, - "./testing/ref-frame": { - "types": "./src/core/ref-frame.ts", - "default": "./src/core/ref-frame.ts" - }, - "./testing/request-dispatch-ledger": { - "types": "./src/core/request-dispatch-ledger.ts", - "default": "./src/core/request-dispatch-ledger.ts" - }, - "./testing/request-finalization": { - "types": "./src/server/request-finalization.ts", - "default": "./src/server/request-finalization.ts" - }, - "./testing/request-runtime-binding": { - "types": "./src/core/request-runtime-binding.ts", - "default": "./src/core/request-runtime-binding.ts" - }, - "./testing/server/http-server": { - "types": "./src/server/http/http-server.ts", - "default": "./src/server/http/http-server.ts" - }, - "./testing/session-action-recorder": { - "types": "./src/core/session-action-recorder.ts", - "default": "./src/core/session-action-recorder.ts" - }, - "./testing/session-snapshot": { - "types": "./src/execution/session-snapshot.ts", - "default": "./src/execution/session-snapshot.ts" - }, - "./testing/session-state": { - "types": "./src/core/session-state.ts", - "default": "./src/core/session-state.ts" - }, - "./testing/session-store": { - "types": "./src/core/session-store.ts", - "default": "./src/core/session-store.ts" } } } diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index dd1f39ef20..45bc916381 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -144,7 +144,7 @@ import { checkPackageExportSurface, packageExportSurfaceSummary, } from './package-export-surface.ts'; -import { checkTestKitIsolation } from './test-kit-isolation.ts'; +import { checkPackageEntrySurfaces } from './package-entry-surface.ts'; import { checkTestColocation, testColocationSummary } from './test-colocation.ts'; import { checkDaemonLayers, DAEMON_ROOT } from './daemon-layers.ts'; import { checkSessionAuthorityOverlay, handlerOwnedOverlay } from './session-authority-overlay.ts'; @@ -624,7 +624,7 @@ export const LAYERING_RULES: Readonly> = { context.ratchets.testOnlyExports, context.reference.testOnlyExports, ), - ...checkTestKitIsolation( + ...checkPackageEntrySurfaces( parseManifests(readTrackedPackageManifests(repoRoot)), context.allTypeScriptSources, ), diff --git a/scripts/layering/package-entry-surface.test.ts b/scripts/layering/package-entry-surface.test.ts index 59aa0a4944..54354758ca 100644 --- a/scripts/layering/package-entry-surface.test.ts +++ b/scripts/layering/package-entry-surface.test.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { parseManifests } from './package-isolation.ts'; -import { checkTestKitIsolation } from './test-kit-isolation.ts'; +import { checkPackageEntrySurfaces } from './package-entry-surface.ts'; type Manifest = Record; @@ -25,7 +25,10 @@ function workspace(overrides: Record = {}): Map = {}): Map = {}, sources = SOURCE) { - return checkTestKitIsolation(parseManifests(workspace(overrides)), sources); + return checkPackageEntrySurfaces(parseManifests(workspace(overrides)), sources); } test('the test-kit shape holds: one entry, built only on packages below it', () => { @@ -63,6 +70,35 @@ test('test-kit exports exactly "." and nothing else', () => { assert.equal(check({ 'test-kit': { exports: { './tmp-dir': entry('./x.ts') } } }).length, 1); }); +test('daemon exports exactly "." and nothing else', () => { + const extra = check({ + daemon: { + exports: { + '.': entry('./src/index.ts'), + './testing/context': entry('./src/core/context.ts'), + }, + }, + }); + assert.equal(extra.length, 1); + assert.match(extra[0]!.file, /packages\/daemon\/package\.json/); + assert.match(extra[0]!.message, /exports exactly "\."/); + assert.deepEqual(check(), []); +}); + +test('every daemon condition of "." points at src/index.ts', () => { + for (const exports of [ + { '.': entry('./src/other.ts') }, + { '.': { types: './src/index.ts', default: './src/other.ts' } }, + { '.': './src/other.ts' }, + ]) { + const found = check({ daemon: { exports } }); + assert.equal(found.length, 1, JSON.stringify(exports)); + assert.match(found[0]!.file, /packages\/daemon\/package\.json/); + assert.match(found[0]!.message, /every condition of it is \.\/src\/index\.ts/); + } + assert.deepEqual(check({ daemon: { exports: { '.': './src/index.ts' } } }), []); +}); + test('the entry names its exports: export * is refused', () => { for (const body of [ "export * from './a.ts';", @@ -78,6 +114,16 @@ test('the entry names its exports: export * is refused', () => { ); }); +test('the daemon entry names its exports: export * is refused', () => { + const sources = new Map(SOURCE); + sources.set('packages/daemon/src/index.ts', "export * from './server/http/daemon-runtime.ts';"); + const found = check({}, sources); + assert.equal(found.length, 1); + assert.equal(found[0]!.file, 'packages/daemon/src/index.ts'); + assert.match(found[0]!.message, /names every export explicitly/); + assert.deepEqual(check(), []); +}); + test('every condition of the "." entry must point at src/index.ts', () => { for (const exports of [ { '.': entry('./src/other.ts') }, diff --git a/scripts/layering/package-entry-surface.ts b/scripts/layering/package-entry-surface.ts index 42b760c020..3bde6567a3 100644 --- a/scripts/layering/package-entry-surface.ts +++ b/scripts/layering/package-entry-surface.ts @@ -1,12 +1,4 @@ -// Catches: `@agent-device/test-kit` growing a second entry, a star re-export, or a dependency on -// the code it exists to test. test-kit is the one home of test helpers shared across packages and -// every helper is a named export of its single entry, so a fixture that needs a platform, -// provider, daemon or root module's code is a test of that package and belongs in it. -// Evidence: the single-entry test-kit loads every helper eagerly, so a second entry or a star -// re-export would publish a module's whole surface into every test's import graph. -// Cost: one pass over the tracked manifests and test-kit's entry. -// Kill criterion: a maintainer decision that test-kit may expose subpath entries or depend on -// platform, provider, daemon or root code. +// Catches extra package entries and star re-exports on test-kit and daemon. // // Part of R83 package-export-surface. The dependency, import and reference-cycle half is the // shared walk in package-isolation.ts under TEST_KIT_ISOLATION. @@ -21,6 +13,13 @@ import { const TEST_KIT_DIR = TEST_KIT_ISOLATION.dir; const TEST_KIT_ENTRY = './src/index.ts'; +const DAEMON_DIR = 'packages/daemon'; +const DAEMON_PACKAGE = '@agent-device/daemon'; + +const ENTRY_SURFACES = [ + { packageName: TEST_KIT_PACKAGE, dir: TEST_KIT_DIR, entry: TEST_KIT_ENTRY }, + { packageName: DAEMON_PACKAGE, dir: DAEMON_DIR, entry: './src/index.ts' }, +] as const; function stringLeaves(value: unknown): string[] { if (typeof value === 'string') return [value]; @@ -32,48 +31,59 @@ function violation(file: string, message: string, line = 1): LayeringViolation { return { rule: PACKAGE_EXPORT_SURFACE_RULE, file, line, message }; } -/** - * The shape of the one shared test package: a single `.` entry that names its exports, plus the - * shared isolation walk (no platform, provider, daemon or root package reached directly, through - * other packages or by import; no package it reaches declaring it back). - */ -export function checkTestKitIsolation( +/** Keeps test-kit and daemon on one named public entry; test-kit also runs the isolation walk. */ +export function checkPackageEntrySurfaces( manifests: ParsedManifests, sources: ReadonlyMap, ): LayeringViolation[] { - const violations: LayeringViolation[] = []; const testKitFile = `${TEST_KIT_DIR}/package.json`; - const testKit = manifests.get(testKitFile); - if (!testKit) return violations; + const violations = ENTRY_SURFACES.flatMap((entrySurface) => + checkEntrySurface(entrySurface, manifests, sources), + ); + if (manifests.has(testKitFile)) { + violations.push(...checkPackageIsolation(manifests, sources, TEST_KIT_ISOLATION)); + } + return violations; +} - const exported = (testKit.exports as Record | undefined) ?? {}; +function checkEntrySurface( + entrySurface: (typeof ENTRY_SURFACES)[number], + manifests: ParsedManifests, + sources: ReadonlyMap, +): LayeringViolation[] { + const manifestFile = `${entrySurface.dir}/package.json`; + const manifest = manifests.get(manifestFile); + if (!manifest) return []; + + const violations: LayeringViolation[] = []; + const exported = (manifest.exports as Record | undefined) ?? {}; const exportKeys = Object.keys(exported); const entryTargets = stringLeaves(exported['.']); if ( exportKeys.length !== 1 || exportKeys[0] !== '.' || entryTargets.length === 0 || - entryTargets.some((target) => target !== TEST_KIT_ENTRY) + entryTargets.some((target) => target !== entrySurface.entry) ) { violations.push( violation( - testKitFile, - `${TEST_KIT_PACKAGE} exports exactly "." and every condition of it is ${TEST_KIT_ENTRY} ` + - `(found ${exportKeys.join(', ') || 'none'} -> ${entryTargets.join(', ') || 'nothing'}): a ` + - `shared helper is a named export of src/index.ts, not a subpath.`, + manifestFile, + `${entrySurface.packageName} exports exactly "." and every condition of it is ` + + `${entrySurface.entry} (found ${exportKeys.join(', ') || 'none'} -> ` + + `${entryTargets.join(', ') || 'nothing'}).`, ), ); } - violations.push(...checkPackageIsolation(manifests, sources, TEST_KIT_ISOLATION)); - const entrySource = sources.get(`${TEST_KIT_DIR}/src/index.ts`); + const entryFile = `${entrySurface.dir}/src/index.ts`; + const entrySource = sources.get(entryFile); const starExport = entrySource === undefined ? null : /^[ \t]*export\s*\*/m.exec(entrySource); if (entrySource !== undefined && starExport) { const line = entrySource.slice(0, starExport.index).split('\n').length; violations.push( violation( - `${TEST_KIT_DIR}/src/index.ts`, - `${TEST_KIT_PACKAGE} names every export explicitly: \`export *\` would publish a module's whole surface.`, + entryFile, + `${entrySurface.packageName} names every export explicitly: \`export *\` is forbidden.`, line, ), ); From 96f3abe729445b252e67c1bf5cfb98d85499d543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:48:30 +0200 Subject: [PATCH 3/8] test(capture): pin crop before screenshot scaling --- .../capture/runtime/screenshot.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/commands/capture/runtime/screenshot.test.ts b/src/commands/capture/runtime/screenshot.test.ts index 49efcb169b..147122abc0 100644 --- a/src/commands/capture/runtime/screenshot.test.ts +++ b/src/commands/capture/runtime/screenshot.test.ts @@ -34,8 +34,53 @@ test('runtime screenshot applies scale and forwards the capture rotation', async assert.equal(result.displayRotation, 'landscape-left'); }); +test('runtime screenshot scales the already-cropped backend capture', async () => { + const outPath = path.join(mkdtempForTestSync('runtime-screenshot-crop-order-'), 'cropped.png'); + const device = createAgentDevice({ + backend: { + platform: 'ios', + captureScreenshot: async (_context, requestedPath) => { + const source = solidPngBuffer(100, 50); + fs.writeFileSync( + requestedPath, + cropPngBuffer(source, { x: 10, y: 10, width: 40, height: 20 }), + ); + }, + } satisfies AgentDeviceBackend, + artifacts: createLocalArtifactAdapter(), + sessions: { get: () => undefined, set: () => {} }, + policy: localCommandPolicy(), + }); + + fs.writeFileSync(outPath, solidPngBuffer(100, 50)); + await device.capture.screenshot({ + out: { kind: 'path', path: outPath }, + scale: 0.5, + }); + const scaledCrop = PNG.sync.read(fs.readFileSync(outPath)); + + assert.deepEqual( + { width: scaledCrop.width, height: scaledCrop.height }, + { width: 20, height: 10 }, + ); +}); + function solidPngBuffer(width: number, height: number): Buffer { const png = new PNG({ width, height }); for (let index = 3; index < png.data.length; index += 4) png.data[index] = 255; return PNG.sync.write(png); } + +function cropPngBuffer( + source: Buffer, + crop: Readonly<{ x: number; y: number; width: number; height: number }>, +): Buffer { + const image = PNG.sync.read(source); + const cropped = new PNG({ width: crop.width, height: crop.height }); + for (let row = 0; row < crop.height; row += 1) { + const sourceStart = ((crop.y + row) * image.width + crop.x) * 4; + const targetStart = row * crop.width * 4; + image.data.copy(cropped.data, targetStart, sourceStart, sourceStart + crop.width * 4); + } + return PNG.sync.write(cropped); +} From f902cf15e93e01d46a8f121589c0ae1d494522a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:51:45 +0200 Subject: [PATCH 4/8] test(daemon): clarify scale metadata screenshot case --- .../src/server/__tests__/request-router-screenshot.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts index 9c6c77165c..286bab4bad 100644 --- a/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts +++ b/packages/daemon/src/server/__tests__/request-router-screenshot.test.ts @@ -341,7 +341,7 @@ test('non-iOS screenshot response tolerates malformed PNG metadata', async () => } }); -test('iOS simulator screenshot omits logical density metadata after --scale downscale', async () => { +test('iOS simulator screenshot suppresses logical density metadata when --scale is set', async () => { const screenshotPath = path.join( mkdtempForTestSync('agent-device-ios-scale'), `agent-device-ios-scale-${Date.now()}.png`, From 40618c28b1ae55d9d5c3ad93de050b4ed27a9de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 10:53:02 +0200 Subject: [PATCH 5/8] test(provider-scenarios): share rpc envelope conversion --- .../provider-scenarios/harness-proxy.ts | 22 +++++------------- .../provider-scenarios/harness-rpc.ts | 21 +++++++++++++++++ .../integration/provider-scenarios/harness.ts | 23 +++++-------------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/test/integration/provider-scenarios/harness-proxy.ts b/test/integration/provider-scenarios/harness-proxy.ts index 4d8bfc4a70..7c118eca51 100644 --- a/test/integration/provider-scenarios/harness-proxy.ts +++ b/test/integration/provider-scenarios/harness-proxy.ts @@ -1,11 +1,14 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; -import type { DaemonResponse } from '@agent-device/kernel/contracts'; import http from 'node:http'; import { createDaemonProxyServer, type DaemonProxyAuthenticate } from '@agent-device/proxy'; import { buildHttpRpcPayload } from '../../../src/daemon-client/daemon-client-rpc.ts'; import { withClientReplayScriptSources } from '../../../src/__tests__/test-utils/replay-script-source.fixtures.ts'; import { closeLoopbackServer, listenOnLoopback } from '@agent-device/test-kit'; -import { commandRequest, responseToRpcResult } from './harness-rpc.ts'; +import { + commandRequest, + envelopeToRpcResult, + type JsonRpcResponseEnvelope, +} from './harness-rpc.ts'; import { type ProviderScenarioHarness, type ProviderScenarioRpcResult } from './harness.ts'; export type ProviderScenarioForwardedRequest = { @@ -46,11 +49,6 @@ export type ProviderScenarioProxyOptions = | { clientToken: string; authenticate?: never } | { clientToken?: never; authenticate: DaemonProxyAuthenticate }; -type JsonRpcResponseEnvelope = { - result?: DaemonResponse; - error?: { code: number; message: string; data?: Record }; -}; - export async function createProviderScenarioProxy( daemon: ProviderScenarioHarness, options: ProviderScenarioProxyOptions, @@ -98,15 +96,7 @@ export async function createProviderScenarioProxy( const result = await requestRpc(authToken, payload); const envelope = result.json as JsonRpcResponseEnvelope; const id = String(payload.id); - if (envelope.result) return responseToRpcResult(envelope.result, id, result.statusCode); - return { - statusCode: result.statusCode, - json: { - jsonrpc: '2.0', - id, - error: envelope.error ?? { code: result.statusCode, message: '' }, - }, - }; + return envelopeToRpcResult(envelope, id, result.statusCode); }, close: async () => { if (closed) return; diff --git a/test/integration/provider-scenarios/harness-rpc.ts b/test/integration/provider-scenarios/harness-rpc.ts index 0fbcf68b77..d988a551f0 100644 --- a/test/integration/provider-scenarios/harness-rpc.ts +++ b/test/integration/provider-scenarios/harness-rpc.ts @@ -2,6 +2,11 @@ import type { DaemonWireRequest } from '@agent-device/contracts/command'; import type { DaemonResponse } from '@agent-device/kernel/contracts'; import type { ProviderScenarioRpcResult } from './harness.ts'; +export type JsonRpcResponseEnvelope = { + result?: DaemonResponse; + error?: { code: number; message: string; data?: Record }; +}; + export function commandRequest( token: string, command: string, @@ -50,3 +55,19 @@ export function responseToRpcResult( }, }; } + +export function envelopeToRpcResult( + envelope: JsonRpcResponseEnvelope, + id: string | number, + statusCode: number, +): ProviderScenarioRpcResult { + if (envelope.result) return responseToRpcResult(envelope.result, String(id), statusCode); + return { + statusCode, + json: { + jsonrpc: '2.0', + id, + error: envelope.error ?? { code: statusCode, message: '' }, + }, + }; +} diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 9f76509ef9..187f41b587 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -21,7 +21,11 @@ import { buildHttpRpcPayload, handleDaemonHttpResponseBody, } from '../../../src/daemon-client/daemon-client-rpc.ts'; -import { commandRequest, responseToRpcResult } from './harness-rpc.ts'; +import { + commandRequest, + envelopeToRpcResult, + type JsonRpcResponseEnvelope, +} from './harness-rpc.ts'; import { createDaemonRuntimeDeps } from '../../../src/daemon-composition.ts'; import { createPlatformRuntimeGateway, @@ -52,11 +56,6 @@ const PROVIDER_SCENARIO_TEMP_REMOVE_OPTIONS = { export type ProviderScenarioRpcResult = { statusCode: number; json: any }; -type JsonRpcResponseEnvelope = { - result?: DaemonResponse; - error?: { code: number; message: string; data?: Record }; -}; - export type ProviderScenarioHarness = { callCommand: ( command: string, @@ -264,17 +263,7 @@ export async function createProviderScenarioHarness( }); const envelope = JSON.parse(body) as JsonRpcResponseEnvelope; if (envelope.result) assertNoInternalChromeProvenance(envelope.result); - if (envelope.result) { - return responseToRpcResult(envelope.result, String(payload.id), statusCode); - } - return { - statusCode, - json: { - jsonrpc: '2.0', - id: payload.id, - error: envelope.error ?? { code: statusCode, message: '' }, - }, - }; + return envelopeToRpcResult(envelope, payload.id, statusCode); }, client: () => client, hasSession: async (name = 'default') => From 0d7c8f14fc3c7a2d3636a2b28e99850f25b7594c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:15:09 +0200 Subject: [PATCH 6/8] docs(adr): record the daemon's closed testing surface ADR 0033's daemon section described the transitional `./testing/` entries as still standing. Record the final state and name the owner of the surface instead of restating what R83 now enforces, and restore the rule header package-entry-surface.ts lost when it was generalised. Co-Authored-By: Claude Opus 5.5 --- docs/adr/0033-collocation-decision-table.md | 11 +++++++---- scripts/layering/package-entry-surface.ts | 12 +++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/adr/0033-collocation-decision-table.md b/docs/adr/0033-collocation-decision-table.md index 30cc76196e..5a1dd88ec6 100644 --- a/docs/adr/0033-collocation-decision-table.md +++ b/docs/adr/0033-collocation-decision-table.md @@ -201,10 +201,13 @@ its owner: the route owner-file claims sit beside the per-command claims in `@agent-device/command-registry/owner-files`, and the wire request and response types come from `@agent-device/contracts/command` and `@agent-device/kernel/contracts`. -The package now exports exactly `.`. The layering entry-surface rule pins that export to -`src/index.ts` and rejects `export *` there; R83 reports no daemon test-only exports. Package tests -import internal subjects relatively, while composed root tests reach the daemon through -`startDaemonRuntime` from the package entry. R11 owns resolution of workspace package specifiers. +The transitional `./testing/` entries are gone, last of them behind the consumers that moved off +them in [#3728](https://github.com/callstack/agent-device/pull/3728), +[#3733](https://github.com/callstack/agent-device/pull/3733), and +[#3742](https://github.com/callstack/agent-device/pull/3742). +`scripts/layering/package-entry-surface.ts` owns the surface under R83, so this document states the +decision and not the check. Package tests import their subject relatively; composed root tests reach +the daemon through `startDaemonRuntime` from the entry. ## Refuted alternatives diff --git a/scripts/layering/package-entry-surface.ts b/scripts/layering/package-entry-surface.ts index 3bde6567a3..5fcadd78ba 100644 --- a/scripts/layering/package-entry-surface.ts +++ b/scripts/layering/package-entry-surface.ts @@ -1,4 +1,14 @@ -// Catches extra package entries and star re-exports on test-kit and daemon. +// Catches: a pinned package entry growing a sibling subpath, pointing somewhere other than its +// declared entry file, or re-exporting a module's whole surface with `export *`. test-kit is the +// one home of test helpers shared across packages, so a second entry there publishes a fixture's +// surface into every test's import graph. The daemon's entry is its package boundary and the one +// place a barrel is allowed, so a subpath or a star re-export there publishes the daemon graph +// under an unowned name (ADR 0033). +// Evidence: both packages held the shape by hand until now; test-kit's single entry and the +// daemon's `.` are each asserted by the same declaration. +// Cost: one pass over the pinned manifests and their entry sources. +// Kill criterion: a maintainer decision that a pinned package may expose subpath entries or a star +// re-export from its entry. // // Part of R83 package-export-surface. The dependency, import and reference-cycle half is the // shared walk in package-isolation.ts under TEST_KIT_ISOLATION. From 8d652e315b8ae0fe3a7e39eea701adbfa5d0f1af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:17:39 +0200 Subject: [PATCH 7/8] test(provider-scenarios): keep the rpc envelope conversion's leaf private Both callCommand functions now convert through envelopeToRpcResult, so responseToRpcResult has no caller outside harness-rpc.ts. Fallow flagged the export as dead surface; the conversion stays one function with one entry. Co-Authored-By: Claude Opus 5.5 --- test/integration/provider-scenarios/harness-rpc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/provider-scenarios/harness-rpc.ts b/test/integration/provider-scenarios/harness-rpc.ts index d988a551f0..34d3e86505 100644 --- a/test/integration/provider-scenarios/harness-rpc.ts +++ b/test/integration/provider-scenarios/harness-rpc.ts @@ -31,7 +31,7 @@ export function commandRequest( }; } -export function responseToRpcResult( +function responseToRpcResult( response: DaemonResponse, id: string, statusCode: number, From ac076f13a1295dd5e216e3e9d07f3df929f9dbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 17 Oct 2026 11:35:58 +0200 Subject: [PATCH 8/8] refactor(layering): read the entry's star exports as syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin matched `^[ \t]*export\s*\*` against the entry's text, so it refused a comment that mentions a star and accepted `export type * from`, which publishes a module's whole type surface just the same. Read the parsed AST instead: `ExportAllDeclaration` is the one node every star shape builds — `export *`, `export * as ns`, and both `export type` forms — and prose builds none. facade-exports.ts records the same failure mode from #1555: a hand-enumerated export-form scanner misses exactly the form that widens the surface. Co-Authored-By: Claude Opus 5.5 --- .../layering/package-entry-surface.test.ts | 9 +++++++ scripts/layering/package-entry-surface.ts | 27 ++++++++++++++++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/scripts/layering/package-entry-surface.test.ts b/scripts/layering/package-entry-surface.test.ts index 54354758ca..3efb9d23a4 100644 --- a/scripts/layering/package-entry-surface.test.ts +++ b/scripts/layering/package-entry-surface.test.ts @@ -103,6 +103,8 @@ test('the entry names its exports: export * is refused', () => { for (const body of [ "export * from './a.ts';", "export { a } from './a.ts';\nexport * as ns from './b.ts';", + "export type * from './a.ts';", + "export { a } from './a.ts';\nexport type * as ns from './b.ts';", ]) { const found = check({}, new Map([['packages/test-kit/src/index.ts', body]])); assert.equal(found.length, 1, body); @@ -114,6 +116,13 @@ test('the entry names its exports: export * is refused', () => { ); }); +test('text that only mentions a star is not an export', () => { + const body = + "// export * from './a.ts' is forbidden here\n" + + "const note = `export * from './b.ts'`;\nexport { note };\nexport { a } from './a.ts';"; + assert.deepEqual(check({}, new Map([['packages/test-kit/src/index.ts', body]])), []); +}); + test('the daemon entry names its exports: export * is refused', () => { const sources = new Map(SOURCE); sources.set('packages/daemon/src/index.ts', "export * from './server/http/daemon-runtime.ts';"); diff --git a/scripts/layering/package-entry-surface.ts b/scripts/layering/package-entry-surface.ts index 5fcadd78ba..ea83d44a16 100644 --- a/scripts/layering/package-entry-surface.ts +++ b/scripts/layering/package-entry-surface.ts @@ -13,6 +13,8 @@ // Part of R83 package-export-surface. The dependency, import and reference-cycle half is the // shared walk in package-isolation.ts under TEST_KIT_ISOLATION. +import { parseSync } from 'oxc-parser'; +import { visitAst } from './layering-ast.ts'; import type { LayeringViolation } from './model.ts'; import { PACKAGE_EXPORT_SURFACE_RULE, TEST_KIT_PACKAGE } from './package-export-surface.ts'; import { @@ -87,16 +89,33 @@ function checkEntrySurface( const entryFile = `${entrySurface.dir}/src/index.ts`; const entrySource = sources.get(entryFile); - const starExport = entrySource === undefined ? null : /^[ \t]*export\s*\*/m.exec(entrySource); - if (entrySource !== undefined && starExport) { - const line = entrySource.slice(0, starExport.index).split('\n').length; + const starLine = entrySource === undefined ? null : findStarExportLine(entryFile, entrySource); + if (starLine !== null) { violations.push( violation( entryFile, `${entrySurface.packageName} names every export explicitly: \`export *\` is forbidden.`, - line, + starLine, ), ); } return violations; } + +/** + * The line of the first star re-export in an entry's source, or null when it names everything. + * Read as syntax rather than text, for the reason `facade-exports.ts` records from #1555: a form + * scanner has to enumerate every export FORM by hand and the one it forgets slips through. + * `ExportAllDeclaration` is the one node every star shape builds — `export *`, `export * as ns`, + * and their `export type` forms, which publish a module's surface under one or zero names — while a + * comment or template literal that merely mentions a star declares no node at all. + */ +function findStarExportLine(entryFile: string, source: string): number | null { + let start: number | null = null; + visitAst(parseSync(entryFile, source).program, (node) => { + if (start === null && node.type === 'ExportAllDeclaration' && typeof node.start === 'number') { + start = node.start; + } + }); + return start === null ? null : source.slice(0, start).split('\n').length; +}