From a4ec5b459dbe8794a28aeb69c5f10ebaf79f3039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 10:59:38 +0200 Subject: [PATCH 1/4] fix(layering): parse static imports from OXC AST --- scripts/layering/model.test.ts | 108 +++++++++ scripts/layering/model.ts | 209 +++++++++--------- src/platform-runtime-device-inventory.test.ts | 4 +- 3 files changed, 212 insertions(+), 109 deletions(-) diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index a0e14bf2c2..679d6c00d5 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import { listSourceFiles } from './check.ts'; +import { workspaceSpecifierTargets } from './package-boundaries.ts'; import { fieldClassificationDrift, findSessionStateWrites, @@ -29,6 +30,7 @@ test('parseImports distinguishes value, type-only, dynamic, and value re-export const edges = parseImports( [ "import value from './value.ts';", + "import './side-effect.ts';", "import type { TypeA } from './types.ts';", "import { type TypeB, type TypeC } from './more-types.ts';", "import { type TypeD, runtime } from './mixed.ts';", @@ -42,6 +44,7 @@ test('parseImports distinguishes value, type-only, dynamic, and value re-export edges.map(({ spec, dynamic, typeOnly }) => ({ spec, dynamic, typeOnly })), [ { spec: './value.ts', dynamic: false, typeOnly: false }, + { spec: './side-effect.ts', dynamic: false, typeOnly: false }, { spec: './types.ts', dynamic: false, typeOnly: true }, { spec: './more-types.ts', dynamic: false, typeOnly: true }, { spec: './mixed.ts', dynamic: false, typeOnly: false }, @@ -52,6 +55,111 @@ test('parseImports distinguishes value, type-only, dynamic, and value re-export ); }); +test('parseImports records TSImportType edges separately from dynamic imports', () => { + const edges = parseImports( + [ + "type Imported = import('./type-only.ts').Imported;", + "type Queried = typeof import('./type-query.ts').value;", + "type SameTarget = import('./same-target.ts').Target; void import('./same-target.ts');", + ].join('\n'), + ); + + assert.deepEqual( + edges.map(({ spec, dynamic, typeOnly, line, symbols }) => ({ + spec, + dynamic, + typeOnly, + line, + symbols, + })), + [ + { + spec: './type-only.ts', + dynamic: false, + typeOnly: true, + line: 1, + symbols: [], + }, + { + spec: './type-query.ts', + dynamic: false, + typeOnly: true, + line: 2, + symbols: [], + }, + { + spec: './same-target.ts', + dynamic: true, + typeOnly: false, + line: 3, + symbols: [], + }, + { + spec: './same-target.ts', + dynamic: false, + typeOnly: true, + line: 3, + symbols: [], + }, + ], + ); + + const resolved = resolveImportEdges( + new Map([ + [ + 'src/core/consumer.ts', + "type Imported = import('../commands/target.ts').Target; void import('../commands/target.ts');", + ], + ['src/commands/target.ts', 'export type Target = unknown;'], + ]), + ); + assert.deepEqual( + resolved.map(({ file, target, dynamic, typeOnly }) => ({ file, target, dynamic, typeOnly })), + [ + { + file: 'src/core/consumer.ts', + target: 'src/commands/target.ts', + dynamic: true, + typeOnly: false, + }, + { + file: 'src/core/consumer.ts', + target: 'src/commands/target.ts', + dynamic: false, + typeOnly: true, + }, + ], + ); +}); + +test('the nine recorded TSImportType file pairs resolve as type edges', () => { + const expected = [ + 'packages/contracts/src/platform-runtime.ts\0packages/contracts/src/device-shutdown-runtime.ts', + 'packages/platform-apple/src/runner/runner-provider.ts\0packages/platform-apple/src/runner/runner-artifact.ts', + 'src/daemon/interaction/index.ts\0src/daemon/gesture-runtime.ts', + 'src/daemon/interaction/index.ts\0src/daemon/touch-runtime.ts', + 'src/daemon/interaction/internal/interaction.ts\0packages/contracts/src/android-observation.ts', + 'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/focus-runtime.ts', + 'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/interactor-types.ts', + 'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/type-text-runtime.ts', + 'src/sdk/artifacts.ts\0packages/platform-android/src/mechanics.ts', + ].sort(); + const expectedPairs = new Set(expected); + const sources = new Map( + listSourceFiles().map((file) => [file, readFileSync(path.resolve(file), 'utf8')]), + ); + const actual = [ + ...new Set( + resolveImportEdges(sources, workspaceSpecifierTargets(process.cwd())) + .filter((edge) => !edge.dynamic && edge.typeOnly) + .map((edge) => `${edge.file}\0${edge.target}`) + .filter((pair) => expectedPairs.has(pair)), + ), + ].sort(); + + assert.deepEqual(actual, expected); +}); + test('parseImports detects multiline dynamic imports', () => { const edges = parseImports(['void import(', " '../multiline.ts'", ');'].join('\n')); diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 4f44ee1346..80da3e94a6 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -168,127 +168,124 @@ function literalSpecifier(node: unknown): string | undefined { return undefined; } -function scanDynamicImports(source: string): ImportEdge[] { - const edges: ImportEdge[] = []; - const parsed = parseSync('layering-imports.ts', source); - const destructured = destructuredDynamicImportBindings(parsed.program); - visitAst(parsed.program, (node) => { - if (node.type !== 'ImportExpression') return; - const spec = literalSpecifier(node.source); - if (spec === undefined) return; - const start = node.start as number | undefined; - const capture = typeof start === 'number' ? destructured.get(start) : undefined; - edges.push({ - spec, - dynamic: true, - typeOnly: false, - line: sourceLine(source, start), - symbols: capture ? [...capture.symbols] : [], - bindingResidue: capture?.residue ?? false, - }); - }); - return edges; -} - -function scanSideEffectImport(line: string, lineNo: number): ImportEdge | null { - const match = /^\s*import\s+['"]([^'"]+)['"]/.exec(line); - return match - ? { - spec: match[1]!, - dynamic: false, - typeOnly: false, - line: lineNo, - symbols: [], - bindingResidue: false, - } - : null; -} - -function withoutImportComments(statement: string): string { - return statement.replaceAll( - /(["'])(?:\\.|(?!\1)[^\\\r\n])*?\1|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, - (match) => (match.startsWith('/*') ? ' ' : match.startsWith('//') ? '\n' : match), - ); -} +type LocatedImportEdge = { edge: ImportEdge; start: number; order: number }; -type NamedSpecifier = { name: string; typeOnly: boolean }; -type ParsedNamedSpecifiers = { index: number; specifiers: NamedSpecifier[] }; - -function parseNamedSpecifiers(statement: string): ParsedNamedSpecifiers | null { - const named = /\{([\s\S]*?)\}/.exec(statement); - if (!named) return null; - - const specifiers: NamedSpecifier[] = []; - for (const specifier of named[1]!.split(',')) { - const trimmed = specifier.trim(); - const typeOnly = /^type\b/.test(trimmed); - const sourceName = trimmed.replace(/^type\s+/, ''); - const name = /^[A-Za-z_$][\w$]*/.exec(sourceName)?.[0]; - if (name) specifiers.push({ name, typeOnly }); - } - return { index: named.index, specifiers }; +function importedName(node: unknown): string | undefined { + if (node === null || typeof node !== 'object') return undefined; + const record = node as Record; + return record.type === 'Identifier' && typeof record.name === 'string' ? record.name : undefined; } -function importedSymbols(statement: string): string[] { - const parsed = parseNamedSpecifiers(statement); - return [...new Set(parsed?.specifiers.map(({ name }) => name) ?? [])]; +function namedSymbols( + specifiers: readonly Record[], + importedField: 'imported' | 'local', +): string[] { + const symbols = specifiers.flatMap((specifier) => { + const name = importedName(specifier[importedField]); + return specifier.type === + (importedField === 'imported' ? 'ImportSpecifier' : 'ExportSpecifier') && name !== undefined + ? [name] + : []; + }); + return [...new Set(symbols)]; } -function statementIsTypeOnly(statement: string): boolean { - if (/^\s*(?:import|export)\s+type\b/.test(statement)) return true; - const parsed = parseNamedSpecifiers(statement); - if (!parsed) return false; - const prefix = statement - .slice(0, parsed.index) - .replace(/^\s*(?:import|export)\s+/, '') - .trim() - .replace(/,$/, '') - .trim(); - if (prefix.length > 0) return false; - return parsed.specifiers.length > 0 && parsed.specifiers.every(({ typeOnly }) => typeOnly); +function specifierTypeOnly( + declaration: Record, + specifiers: readonly Record[], + kindField: 'importKind' | 'exportKind', +): boolean { + if (declaration[kindField] === 'type') return true; + return specifiers.length > 0 && specifiers.every((specifier) => specifier[kindField] === 'type'); } -function scanFromImport(lines: string[], index: number): ImportEdge | null { - const fromMatch = /(?:^|[\s;}])from\s+['"]([^'"]+)['"]/.exec(lines[index]!); - if (!fromMatch) return null; - - let start = index; - while (start >= 0 && !/^\s*(?:import|export)\b/.test(lines[start]!)) start--; - if (start < 0) return null; - - const statement = lines.slice(start, index + 1).join('\n'); - const normalizedStatement = withoutImportComments(statement); +function staticImportEdge( + source: string, + node: Record, + specifierNode: unknown, + typeOnly: boolean, + symbols: string[], +): ImportEdge | null { + const spec = literalSpecifier(specifierNode); + if (spec === undefined) return null; + const start = node.start as number | undefined; return { - spec: fromMatch[1]!, + spec, dynamic: false, - typeOnly: statementIsTypeOnly(normalizedStatement), - line: start + 1, - symbols: importedSymbols(normalizedStatement), + typeOnly, + line: sourceLine(source, start), + symbols, bindingResidue: false, }; } export function parseImports(source: string): ImportEdge[] { - const lines = source.split('\n'); - const dynamicImports = scanDynamicImports(source); - const dynamicImportsByLine = new Map(); - for (const edge of dynamicImports) { - const lineEdges = dynamicImportsByLine.get(edge.line) ?? []; - lineEdges.push(edge); - dynamicImportsByLine.set(edge.line, lineEdges); - } - const edges: ImportEdge[] = []; - for (let index = 0; index < lines.length; index++) { - edges.push(...(dynamicImportsByLine.get(index + 1) ?? [])); - const sideEffect = scanSideEffectImport(lines[index]!, index + 1); - if (sideEffect) { - edges.push(sideEffect); - continue; + const parsed = parseSync('layering-imports.ts', source); + const destructured = destructuredDynamicImportBindings(parsed.program); + const located: LocatedImportEdge[] = []; + let order = 0; + + visitAst(parsed.program, (node) => { + const start = typeof node.start === 'number' ? node.start : 0; + if (node.type === 'ImportExpression') { + const spec = literalSpecifier(node.source); + if (spec === undefined) return; + const capture = destructured.get(start); + located.push({ + start, + order: order++, + edge: { + spec, + dynamic: true, + typeOnly: false, + line: sourceLine(source, start), + symbols: capture ? [...capture.symbols] : [], + bindingResidue: capture?.residue ?? false, + }, + }); + return; } - const fromImport = scanFromImport(lines, index); - if (fromImport) edges.push(fromImport); - } - return edges; + + let edge: ImportEdge | null = null; + if (node.type === 'ImportDeclaration') { + const specifiers = Array.isArray(node.specifiers) + ? (node.specifiers as Record[]) + : []; + edge = staticImportEdge( + source, + node, + node.source, + specifierTypeOnly(node, specifiers, 'importKind'), + namedSymbols(specifiers, 'imported'), + ); + } else if (node.type === 'ExportNamedDeclaration' && node.source) { + const specifiers = Array.isArray(node.specifiers) + ? (node.specifiers as Record[]) + : []; + edge = staticImportEdge( + source, + node, + node.source, + specifierTypeOnly(node, specifiers, 'exportKind'), + namedSymbols(specifiers, 'local'), + ); + } else if (node.type === 'ExportAllDeclaration') { + edge = staticImportEdge(source, node, node.source, node.exportKind === 'type', []); + } else if (node.type === 'TSImportType') { + edge = staticImportEdge(source, node, node.source, true, []); + } + if (edge) located.push({ edge, start, order: order++ }); + }); + + return located + .sort( + (left, right) => + left.edge.line - right.edge.line || + Number(right.edge.dynamic) - Number(left.edge.dynamic) || + left.start - right.start || + left.order - right.order, + ) + .map(({ edge }) => edge); } export function topFolder(file: string): string { diff --git a/src/platform-runtime-device-inventory.test.ts b/src/platform-runtime-device-inventory.test.ts index f096122288..f4e0378688 100644 --- a/src/platform-runtime-device-inventory.test.ts +++ b/src/platform-runtime-device-inventory.test.ts @@ -23,9 +23,7 @@ const simctl = vi.hoisted(() => ({ })); vi.mock('../packages/platform-apple/src/core/tool-provider.ts', async (importOriginal) => ({ - ...(await importOriginal< - typeof import('../packages/platform-apple/src/core/tool-provider.ts') - >()), + ...(await importOriginal()), runXcrun: async (args: string[]) => { const idx = args.indexOf('listapps'); const deviceId = idx >= 0 ? args[idx + 1] : undefined; From 903d5f129a20b65dbdb58770c0e96b14e6bf55e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 11:19:04 +0200 Subject: [PATCH 2/4] fix(layering): preserve import-type source symbols --- scripts/layering/model.test.ts | 22 +++++++++++++++++-- scripts/layering/model.ts | 16 +++++++++++++- .../session-authority-overlay.test.ts | 20 +++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index 679d6c00d5..bf489a6d7d 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -61,6 +61,8 @@ test('parseImports records TSImportType edges separately from dynamic imports', "type Imported = import('./type-only.ts').Imported;", "type Queried = typeof import('./type-query.ts').value;", "type SameTarget = import('./same-target.ts').Target; void import('./same-target.ts');", + "type Nested = import('./nested.ts').namespace.SessionState;", + "type WholeModule = import('./whole-module.ts');", ].join('\n'), ); @@ -78,14 +80,14 @@ test('parseImports records TSImportType edges separately from dynamic imports', dynamic: false, typeOnly: true, line: 1, - symbols: [], + symbols: ['Imported'], }, { spec: './type-query.ts', dynamic: false, typeOnly: true, line: 2, - symbols: [], + symbols: ['value'], }, { spec: './same-target.ts', @@ -99,6 +101,20 @@ test('parseImports records TSImportType edges separately from dynamic imports', dynamic: false, typeOnly: true, line: 3, + symbols: ['Target'], + }, + { + spec: './nested.ts', + dynamic: false, + typeOnly: true, + line: 4, + symbols: ['namespace'], + }, + { + spec: './whole-module.ts', + dynamic: false, + typeOnly: true, + line: 5, symbols: [], }, ], @@ -220,6 +236,7 @@ test('parseImports retains named source symbols without changing edge-kind detec "import type { TypeB as RenamedType } from './types.ts';", "export { reExport as publicName } from './re-export.ts';", "export type { ExportedType } from './exported-types.ts';", + "export * from './re-export-all.ts';", "import * as namespace from './namespace.ts';", "void import('./dynamic.ts');", ].join('\n'), @@ -237,6 +254,7 @@ test('parseImports retains named source symbols without changing edge-kind detec { spec: './types.ts', dynamic: false, typeOnly: true, symbols: ['TypeB'] }, { spec: './re-export.ts', dynamic: false, typeOnly: false, symbols: ['reExport'] }, { spec: './exported-types.ts', dynamic: false, typeOnly: true, symbols: ['ExportedType'] }, + { spec: './re-export-all.ts', dynamic: false, typeOnly: false, symbols: [] }, { spec: './namespace.ts', dynamic: false, typeOnly: false, symbols: [] }, { spec: './dynamic.ts', dynamic: true, typeOnly: false, symbols: [] }, ], diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 80da3e94a6..5de315a404 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -190,6 +190,13 @@ function namedSymbols( return [...new Set(symbols)]; } +function importTypeSourceName(qualifier: unknown): string | undefined { + if (qualifier === null || typeof qualifier !== 'object') return undefined; + const record = qualifier as Record; + if (record.type === 'Identifier') return importedName(qualifier); + return record.type === 'TSQualifiedName' ? importTypeSourceName(record.left) : undefined; +} + function specifierTypeOnly( declaration: Record, specifiers: readonly Record[], @@ -272,7 +279,14 @@ export function parseImports(source: string): ImportEdge[] { } else if (node.type === 'ExportAllDeclaration') { edge = staticImportEdge(source, node, node.source, node.exportKind === 'type', []); } else if (node.type === 'TSImportType') { - edge = staticImportEdge(source, node, node.source, true, []); + const sourceName = importTypeSourceName(node.qualifier); + edge = staticImportEdge( + source, + node, + node.source, + true, + sourceName === undefined ? [] : [sourceName], + ); } if (edge) located.push({ edge, start, order: order++ }); }); diff --git a/scripts/layering/session-authority-overlay.test.ts b/scripts/layering/session-authority-overlay.test.ts index aca280a927..68af64910a 100644 --- a/scripts/layering/session-authority-overlay.test.ts +++ b/scripts/layering/session-authority-overlay.test.ts @@ -135,3 +135,23 @@ test('the end-to-end measurement feeds the ratchet: a new handler importer is re assert.equal(violations.length, 1); assert.equal(violations[0]!.file, 'src/daemon/handlers/new.ts'); }); + +test('R75 catches a new handler shape edge expressed as an import type', () => { + const base = { + 'src/daemon/session-state.ts': STATE_STUB, + 'src/daemon/session-store.ts': STORE_STUB, + }; + const reference = handlerOwnedOverlay(overlayOf(base)); + const measured = handlerOwnedOverlay( + overlayOf({ + ...base, + 'src/daemon/handlers/new.ts': "type State = import('../session-state.ts').SessionState;\n", + }), + ); + + const violations = checkSessionAuthorityOverlay(measured, reference); + assert.equal(violations.length, 1); + assert.equal(violations[0]!.rule, SESSION_AUTHORITY_OVERLAY_RULE); + assert.equal(violations[0]!.file, 'src/daemon/handlers/new.ts'); + assert.match(violations[0]!.message, /new handler-owned SessionState shape edge/); +}); From 0ba03877833ff7ac6779b0592c8d6fd2eb5fb60f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 15:37:25 +0200 Subject: [PATCH 3/4] fix(layering): reject incomplete AST parses and isolate parity fixtures --- scripts/layering/model.test.ts | 131 ++++++++++++++++++++++++++------- scripts/layering/model.ts | 14 ++-- 2 files changed, 112 insertions(+), 33 deletions(-) diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index bf489a6d7d..1351ac2a14 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -3,7 +3,6 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import { listSourceFiles } from './check.ts'; -import { workspaceSpecifierTargets } from './package-boundaries.ts'; import { fieldClassificationDrift, findSessionStateWrites, @@ -26,6 +25,26 @@ import { unclassifiedZones, } from './model.ts'; +test('parseImports rejects malformed source instead of returning an incomplete graph', () => { + assert.throws( + () => parseImports("import { Broken from './broken.ts';"), + /Cannot parse imports.*Expected/, + ); +}); + +test('parseImports ignores import-like declarations inside comments and template text', () => { + const source = [ + 'const documentation = `', + "import { phantom } from './phantom.ts';", + "export { phantom } from './also-phantom.ts';", + '`;', + '/*', + "import './comment.ts';", + '*/', + ].join('\n'); + assert.deepEqual(parseImports(source), []); +}); + test('parseImports distinguishes value, type-only, dynamic, and value re-export edges', () => { const edges = parseImports( [ @@ -148,32 +167,90 @@ test('parseImports records TSImportType edges separately from dynamic imports', ); }); -test('the nine recorded TSImportType file pairs resolve as type edges', () => { - const expected = [ - 'packages/contracts/src/platform-runtime.ts\0packages/contracts/src/device-shutdown-runtime.ts', - 'packages/platform-apple/src/runner/runner-provider.ts\0packages/platform-apple/src/runner/runner-artifact.ts', - 'src/daemon/interaction/index.ts\0src/daemon/gesture-runtime.ts', - 'src/daemon/interaction/index.ts\0src/daemon/touch-runtime.ts', - 'src/daemon/interaction/internal/interaction.ts\0packages/contracts/src/android-observation.ts', - 'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/focus-runtime.ts', - 'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/interactor-types.ts', - 'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/type-text-runtime.ts', - 'src/sdk/artifacts.ts\0packages/platform-android/src/mechanics.ts', - ].sort(); - const expectedPairs = new Set(expected); - const sources = new Map( - listSourceFiles().map((file) => [file, readFileSync(path.resolve(file), 'utf8')]), - ); - const actual = [ - ...new Set( - resolveImportEdges(sources, workspaceSpecifierTargets(process.cwd())) - .filter((edge) => !edge.dynamic && edge.typeOnly) - .map((edge) => `${edge.file}\0${edge.target}`) - .filter((pair) => expectedPairs.has(pair)), - ), - ].sort(); - - assert.deepEqual(actual, expected); +test('the nine recorded TSImportType shapes resolve with source-side symbols', () => { + const cases = [ + [ + 'packages/contracts/src/platform-runtime.ts', + 'packages/contracts/src/device-shutdown-runtime.ts', + './device-shutdown-runtime.ts', + 'DeviceShutdownRuntime', + ], + [ + 'packages/platform-apple/src/runner/runner-provider.ts', + 'packages/platform-apple/src/runner/runner-artifact.ts', + './runner-artifact.ts', + 'RunnerStartAdmission', + ], + [ + 'src/daemon/interaction/index.ts', + 'src/daemon/gesture-runtime.ts', + '../gesture-runtime.ts', + 'BoundGestureExecutor', + ], + [ + 'src/daemon/interaction/index.ts', + 'src/daemon/touch-runtime.ts', + '../touch-runtime.ts', + 'BoundTouchExecutor', + ], + [ + 'src/daemon/interaction/internal/interaction.ts', + 'packages/contracts/src/android-observation.ts', + '@agent-device/contracts/android-observation', + 'AndroidObservationAdapter', + ], + [ + 'src/daemon/snapshot-runtime-binding.ts', + 'packages/contracts/src/focus-runtime.ts', + '@agent-device/contracts/focus-runtime', + 'FocusPointInput', + ], + [ + 'src/daemon/snapshot-runtime-binding.ts', + 'packages/contracts/src/interactor-types.ts', + '@agent-device/contracts/interactor-types', + 'TypeTextBackendResult', + ], + [ + 'src/daemon/snapshot-runtime-binding.ts', + 'packages/contracts/src/type-text-runtime.ts', + '@agent-device/contracts/type-text-runtime', + 'TypeTextInput', + ], + [ + 'src/sdk/artifacts.ts', + 'packages/platform-android/src/mechanics.ts', + '@agent-device/platform-android/mechanics', + 'resolveAndroidArchivePackageName', + ], + ] as const; + const sources = new Map(); + const exports = new Map(); + for (const [index, [file, target, spec, symbol]] of cases.entries()) { + const query = file === 'src/sdk/artifacts.ts' ? 'typeof ' : ''; + sources.set( + file, + `${sources.get(file) ?? ''}type Dependency${index} = ${query}import('${spec}').${symbol};\n`, + ); + sources.set(target, `export type ${symbol} = unknown;`); + if (spec.startsWith('@')) exports.set(spec, target); + } + assert.deepEqual( + resolveImportEdges(sources, exports).map(({ file, target, dynamic, typeOnly, symbols }) => ({ + file, + target, + dynamic, + typeOnly, + symbols, + })), + cases.map(([file, target, , symbol]) => ({ + file, + target, + dynamic: false, + typeOnly: true, + symbols: [symbol], + })), + ); }); test('parseImports detects multiline dynamic imports', () => { diff --git a/scripts/layering/model.ts b/scripts/layering/model.ts index 5de315a404..ef0d18011a 100644 --- a/scripts/layering/model.ts +++ b/scripts/layering/model.ts @@ -168,7 +168,7 @@ function literalSpecifier(node: unknown): string | undefined { return undefined; } -type LocatedImportEdge = { edge: ImportEdge; start: number; order: number }; +type LocatedImportEdge = { edge: ImportEdge; start: number }; function importedName(node: unknown): string | undefined { if (node === null || typeof node !== 'object') return undefined; @@ -228,9 +228,13 @@ function staticImportEdge( export function parseImports(source: string): ImportEdge[] { const parsed = parseSync('layering-imports.ts', source); + if (parsed.errors.length > 0) { + throw new SyntaxError( + `Cannot parse imports: ${parsed.errors.map(({ message }) => message).join('; ')}`, + ); + } const destructured = destructuredDynamicImportBindings(parsed.program); const located: LocatedImportEdge[] = []; - let order = 0; visitAst(parsed.program, (node) => { const start = typeof node.start === 'number' ? node.start : 0; @@ -240,7 +244,6 @@ export function parseImports(source: string): ImportEdge[] { const capture = destructured.get(start); located.push({ start, - order: order++, edge: { spec, dynamic: true, @@ -288,7 +291,7 @@ export function parseImports(source: string): ImportEdge[] { sourceName === undefined ? [] : [sourceName], ); } - if (edge) located.push({ edge, start, order: order++ }); + if (edge) located.push({ edge, start }); }); return located @@ -296,8 +299,7 @@ export function parseImports(source: string): ImportEdge[] { (left, right) => left.edge.line - right.edge.line || Number(right.edge.dynamic) - Number(left.edge.dynamic) || - left.start - right.start || - left.order - right.order, + left.start - right.start, ) .map(({ edge }) => edge); } From 5f411172f460e2d16eabf97e94ca749f565ca554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 8 Oct 2026 15:49:08 +0200 Subject: [PATCH 4/4] test(layering): verify recorded production type dependencies --- scripts/layering/model.test.ts | 141 ++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 56 deletions(-) diff --git a/scripts/layering/model.test.ts b/scripts/layering/model.test.ts index 1351ac2a14..a7a693fb8c 100644 --- a/scripts/layering/model.test.ts +++ b/scripts/layering/model.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import { listSourceFiles } from './check.ts'; +import { workspaceSpecifierTargets } from './package-boundaries.ts'; import { fieldClassificationDrift, findSessionStateWrites, @@ -167,63 +168,65 @@ test('parseImports records TSImportType edges separately from dynamic imports', ); }); +const RECORDED_IMPORT_TYPE_CASES = [ + [ + 'packages/contracts/src/platform-runtime.ts', + 'packages/contracts/src/device-shutdown-runtime.ts', + './device-shutdown-runtime.ts', + 'DeviceShutdownRuntime', + ], + [ + 'packages/platform-apple/src/runner/runner-provider.ts', + 'packages/platform-apple/src/runner/runner-artifact.ts', + './runner-artifact.ts', + 'RunnerStartAdmission', + ], + [ + 'src/daemon/interaction/index.ts', + 'src/daemon/gesture-runtime.ts', + '../gesture-runtime.ts', + 'BoundGestureExecutor', + ], + [ + 'src/daemon/interaction/index.ts', + 'src/daemon/touch-runtime.ts', + '../touch-runtime.ts', + 'BoundTouchExecutor', + ], + [ + 'src/daemon/interaction/internal/interaction.ts', + 'packages/contracts/src/android-observation.ts', + '@agent-device/contracts/android-observation', + 'AndroidObservationAdapter', + ], + [ + 'src/daemon/snapshot-runtime-binding.ts', + 'packages/contracts/src/focus-runtime.ts', + '@agent-device/contracts/focus-runtime', + 'FocusPointInput', + ], + [ + 'src/daemon/snapshot-runtime-binding.ts', + 'packages/contracts/src/interactor-types.ts', + '@agent-device/contracts/interactor-types', + 'TypeTextBackendResult', + ], + [ + 'src/daemon/snapshot-runtime-binding.ts', + 'packages/contracts/src/type-text-runtime.ts', + '@agent-device/contracts/type-text-runtime', + 'TypeTextInput', + ], + [ + 'src/sdk/artifacts.ts', + 'packages/platform-android/src/mechanics.ts', + '@agent-device/platform-android/mechanics', + 'resolveAndroidArchivePackageName', + ], +] as const; + test('the nine recorded TSImportType shapes resolve with source-side symbols', () => { - const cases = [ - [ - 'packages/contracts/src/platform-runtime.ts', - 'packages/contracts/src/device-shutdown-runtime.ts', - './device-shutdown-runtime.ts', - 'DeviceShutdownRuntime', - ], - [ - 'packages/platform-apple/src/runner/runner-provider.ts', - 'packages/platform-apple/src/runner/runner-artifact.ts', - './runner-artifact.ts', - 'RunnerStartAdmission', - ], - [ - 'src/daemon/interaction/index.ts', - 'src/daemon/gesture-runtime.ts', - '../gesture-runtime.ts', - 'BoundGestureExecutor', - ], - [ - 'src/daemon/interaction/index.ts', - 'src/daemon/touch-runtime.ts', - '../touch-runtime.ts', - 'BoundTouchExecutor', - ], - [ - 'src/daemon/interaction/internal/interaction.ts', - 'packages/contracts/src/android-observation.ts', - '@agent-device/contracts/android-observation', - 'AndroidObservationAdapter', - ], - [ - 'src/daemon/snapshot-runtime-binding.ts', - 'packages/contracts/src/focus-runtime.ts', - '@agent-device/contracts/focus-runtime', - 'FocusPointInput', - ], - [ - 'src/daemon/snapshot-runtime-binding.ts', - 'packages/contracts/src/interactor-types.ts', - '@agent-device/contracts/interactor-types', - 'TypeTextBackendResult', - ], - [ - 'src/daemon/snapshot-runtime-binding.ts', - 'packages/contracts/src/type-text-runtime.ts', - '@agent-device/contracts/type-text-runtime', - 'TypeTextInput', - ], - [ - 'src/sdk/artifacts.ts', - 'packages/platform-android/src/mechanics.ts', - '@agent-device/platform-android/mechanics', - 'resolveAndroidArchivePackageName', - ], - ] as const; + const cases = RECORDED_IMPORT_TYPE_CASES; const sources = new Map(); const exports = new Map(); for (const [index, [file, target, spec, symbol]] of cases.entries()) { @@ -253,6 +256,32 @@ test('the nine recorded TSImportType shapes resolve with source-side symbols', ( ); }); +test('the recorded production type dependencies resolve from their owning declarations', () => { + const sources = new Map( + listSourceFiles().map((file) => [file, readFileSync(path.resolve(file), 'utf8')]), + ); + const edges = resolveImportEdges(sources, workspaceSpecifierTargets(process.cwd())); + for (const [recordedFile, target, , recordedSymbol] of RECORDED_IMPORT_TYPE_CASES) { + // #3304 moved the shutdown declaration to an ordinary type import in the operations module. + const movedShutdown = recordedFile === 'packages/contracts/src/platform-runtime.ts'; + const file = movedShutdown + ? 'packages/contracts/src/platform-runtime-operations.ts' + : recordedFile; + const symbol = movedShutdown ? 'DeviceShutdownRuntimeOperations' : recordedSymbol; + assert.ok( + edges.some( + (edge) => + edge.file === file && + edge.target === target && + !edge.dynamic && + edge.typeOnly && + edge.symbols.includes(symbol), + ), + `missing production type dependency: ${file} -> ${target} (${symbol})`, + ); + } +}); + test('parseImports detects multiline dynamic imports', () => { const edges = parseImports(['void import(', " '../multiline.ts'", ');'].join('\n'));