Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions docs/adr/0033-collocation-decision-table.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,16 +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`.

Root tests that still drive daemon internals reach them through transitional `./testing/`
entries: named `./testing/<module>` entries for production modules, named `./testing/<fixture>`
entries, and per fixture folder a `./testing/<dir>/*` 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 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

Expand Down
72 changes: 0 additions & 72 deletions packages/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
4 changes: 2 additions & 2 deletions scripts/layering/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -624,7 +624,7 @@ export const LAYERING_RULES: Readonly<Record<LayeringRuleId, LayeringRule>> = {
context.ratchets.testOnlyExports,
context.reference.testOnlyExports,
),
...checkTestKitIsolation(
...checkPackageEntrySurfaces(
parseManifests(readTrackedPackageManifests(repoRoot)),
context.allTypeScriptSources,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

Expand All @@ -25,7 +25,10 @@ function workspace(overrides: Record<string, Manifest> = {}): Map<string, string
'platform-apple': { name: '@agent-device/platform-apple' },
'provider-testmu': { name: '@agent-device/testmu' },
'provider-limrun': { name: '@agent-device/provider-limrun' },
daemon: { name: '@agent-device/daemon' },
daemon: {
name: '@agent-device/daemon',
exports: { '.': entry('./src/index.ts') },
},
proxy: { name: '@agent-device/proxy' },
contracts: { name: '@agent-device/contracts' },
};
Expand All @@ -44,10 +47,14 @@ function workspace(overrides: Record<string, Manifest> = {}): Map<string, string
const SOURCE = new Map([
['packages/test-kit/src/index.ts', "export { a } from './a.ts';"],
['packages/test-kit/src/a.ts', "import { x } from '@agent-device/kernel/errors';"],
[
'packages/daemon/src/index.ts',
"export { startDaemonRuntime } from './server/http/daemon-runtime.ts';",
],
]);

function check(overrides: Record<string, Manifest> = {}, 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', () => {
Expand All @@ -63,10 +70,41 @@ 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';",
"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);
Expand All @@ -78,6 +116,23 @@ 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';");
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') },
Expand Down
121 changes: 121 additions & 0 deletions scripts/layering/package-entry-surface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// 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.

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 {
checkPackageIsolation,
TEST_KIT_ISOLATION,
type ParsedManifests,
} from './package-isolation.ts';

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];
if (value && typeof value === 'object') return Object.values(value).flatMap(stringLeaves);
return [];
}

function violation(file: string, message: string, line = 1): LayeringViolation {
return { rule: PACKAGE_EXPORT_SURFACE_RULE, file, line, message };
}

/** Keeps test-kit and daemon on one named public entry; test-kit also runs the isolation walk. */
export function checkPackageEntrySurfaces(
manifests: ParsedManifests,
sources: ReadonlyMap<string, string>,
): LayeringViolation[] {
const testKitFile = `${TEST_KIT_DIR}/package.json`;
const violations = ENTRY_SURFACES.flatMap((entrySurface) =>
checkEntrySurface(entrySurface, manifests, sources),
);
if (manifests.has(testKitFile)) {
violations.push(...checkPackageIsolation(manifests, sources, TEST_KIT_ISOLATION));
}
return violations;
}

function checkEntrySurface(
entrySurface: (typeof ENTRY_SURFACES)[number],
manifests: ParsedManifests,
sources: ReadonlyMap<string, string>,
): LayeringViolation[] {
const manifestFile = `${entrySurface.dir}/package.json`;
const manifest = manifests.get(manifestFile);
if (!manifest) return [];

const violations: LayeringViolation[] = [];
const exported = (manifest.exports as Record<string, unknown> | undefined) ?? {};
const exportKeys = Object.keys(exported);
const entryTargets = stringLeaves(exported['.']);
if (
exportKeys.length !== 1 ||
exportKeys[0] !== '.' ||
entryTargets.length === 0 ||
entryTargets.some((target) => target !== entrySurface.entry)
) {
violations.push(
violation(
manifestFile,
`${entrySurface.packageName} exports exactly "." and every condition of it is ` +
`${entrySurface.entry} (found ${exportKeys.join(', ') || 'none'} -> ` +
`${entryTargets.join(', ') || 'nothing'}).`,
),
);
}

const entryFile = `${entrySurface.dir}/src/index.ts`;
const entrySource = sources.get(entryFile);
const starLine = entrySource === undefined ? null : findStarExportLine(entryFile, entrySource);
if (starLine !== null) {
violations.push(
violation(
entryFile,
`${entrySurface.packageName} names every export explicitly: \`export *\` is forbidden.`,
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;
}
Loading
Loading