Skip to content

Commit 9a804db

Browse files
committed
fix(layering): parse static imports from OXC AST
1 parent cea992e commit 9a804db

3 files changed

Lines changed: 212 additions & 109 deletions

File tree

‎scripts/layering/model.test.ts‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
33
import path from 'node:path';
44
import { test } from 'node:test';
55
import { listSourceFiles } from './check.ts';
6+
import { workspaceSpecifierTargets } from './package-boundaries.ts';
67
import {
78
fieldClassificationDrift,
89
findSessionStateWrites,
@@ -29,6 +30,7 @@ test('parseImports distinguishes value, type-only, dynamic, and value re-export
2930
const edges = parseImports(
3031
[
3132
"import value from './value.ts';",
33+
"import './side-effect.ts';",
3234
"import type { TypeA } from './types.ts';",
3335
"import { type TypeB, type TypeC } from './more-types.ts';",
3436
"import { type TypeD, runtime } from './mixed.ts';",
@@ -42,6 +44,7 @@ test('parseImports distinguishes value, type-only, dynamic, and value re-export
4244
edges.map(({ spec, dynamic, typeOnly }) => ({ spec, dynamic, typeOnly })),
4345
[
4446
{ spec: './value.ts', dynamic: false, typeOnly: false },
47+
{ spec: './side-effect.ts', dynamic: false, typeOnly: false },
4548
{ spec: './types.ts', dynamic: false, typeOnly: true },
4649
{ spec: './more-types.ts', dynamic: false, typeOnly: true },
4750
{ spec: './mixed.ts', dynamic: false, typeOnly: false },
@@ -52,6 +55,111 @@ test('parseImports distinguishes value, type-only, dynamic, and value re-export
5255
);
5356
});
5457

58+
test('parseImports records TSImportType edges separately from dynamic imports', () => {
59+
const edges = parseImports(
60+
[
61+
"type Imported = import('./type-only.ts').Imported;",
62+
"type Queried = typeof import('./type-query.ts').value;",
63+
"type SameTarget = import('./same-target.ts').Target; void import('./same-target.ts');",
64+
].join('\n'),
65+
);
66+
67+
assert.deepEqual(
68+
edges.map(({ spec, dynamic, typeOnly, line, symbols }) => ({
69+
spec,
70+
dynamic,
71+
typeOnly,
72+
line,
73+
symbols,
74+
})),
75+
[
76+
{
77+
spec: './type-only.ts',
78+
dynamic: false,
79+
typeOnly: true,
80+
line: 1,
81+
symbols: [],
82+
},
83+
{
84+
spec: './type-query.ts',
85+
dynamic: false,
86+
typeOnly: true,
87+
line: 2,
88+
symbols: [],
89+
},
90+
{
91+
spec: './same-target.ts',
92+
dynamic: true,
93+
typeOnly: false,
94+
line: 3,
95+
symbols: [],
96+
},
97+
{
98+
spec: './same-target.ts',
99+
dynamic: false,
100+
typeOnly: true,
101+
line: 3,
102+
symbols: [],
103+
},
104+
],
105+
);
106+
107+
const resolved = resolveImportEdges(
108+
new Map([
109+
[
110+
'src/core/consumer.ts',
111+
"type Imported = import('../commands/target.ts').Target; void import('../commands/target.ts');",
112+
],
113+
['src/commands/target.ts', 'export type Target = unknown;'],
114+
]),
115+
);
116+
assert.deepEqual(
117+
resolved.map(({ file, target, dynamic, typeOnly }) => ({ file, target, dynamic, typeOnly })),
118+
[
119+
{
120+
file: 'src/core/consumer.ts',
121+
target: 'src/commands/target.ts',
122+
dynamic: true,
123+
typeOnly: false,
124+
},
125+
{
126+
file: 'src/core/consumer.ts',
127+
target: 'src/commands/target.ts',
128+
dynamic: false,
129+
typeOnly: true,
130+
},
131+
],
132+
);
133+
});
134+
135+
test('the nine recorded TSImportType file pairs resolve as type edges', () => {
136+
const expected = [
137+
'packages/contracts/src/platform-runtime.ts\0packages/contracts/src/device-shutdown-runtime.ts',
138+
'packages/platform-apple/src/runner/runner-provider.ts\0packages/platform-apple/src/runner/runner-artifact.ts',
139+
'src/daemon/interaction/index.ts\0src/daemon/gesture-runtime.ts',
140+
'src/daemon/interaction/index.ts\0src/daemon/touch-runtime.ts',
141+
'src/daemon/interaction/internal/interaction.ts\0packages/contracts/src/android-observation.ts',
142+
'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/focus-runtime.ts',
143+
'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/interactor-types.ts',
144+
'src/daemon/snapshot-runtime-binding.ts\0packages/contracts/src/type-text-runtime.ts',
145+
'src/sdk/artifacts.ts\0packages/platform-android/src/mechanics.ts',
146+
].sort();
147+
const expectedPairs = new Set(expected);
148+
const sources = new Map(
149+
listSourceFiles().map((file) => [file, readFileSync(path.resolve(file), 'utf8')]),
150+
);
151+
const actual = [
152+
...new Set(
153+
resolveImportEdges(sources, workspaceSpecifierTargets(process.cwd()))
154+
.filter((edge) => !edge.dynamic && edge.typeOnly)
155+
.map((edge) => `${edge.file}\0${edge.target}`)
156+
.filter((pair) => expectedPairs.has(pair)),
157+
),
158+
].sort();
159+
160+
assert.deepEqual(actual, expected);
161+
});
162+
55163
test('parseImports detects multiline dynamic imports', () => {
56164
const edges = parseImports(['void import(', " '../multiline.ts'", ');'].join('\n'));
57165

‎scripts/layering/model.ts‎

Lines changed: 103 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -168,127 +168,124 @@ function literalSpecifier(node: unknown): string | undefined {
168168
return undefined;
169169
}
170170

171-
function scanDynamicImports(source: string): ImportEdge[] {
172-
const edges: ImportEdge[] = [];
173-
const parsed = parseSync('layering-imports.ts', source);
174-
const destructured = destructuredDynamicImportBindings(parsed.program);
175-
visitAst(parsed.program, (node) => {
176-
if (node.type !== 'ImportExpression') return;
177-
const spec = literalSpecifier(node.source);
178-
if (spec === undefined) return;
179-
const start = node.start as number | undefined;
180-
const capture = typeof start === 'number' ? destructured.get(start) : undefined;
181-
edges.push({
182-
spec,
183-
dynamic: true,
184-
typeOnly: false,
185-
line: sourceLine(source, start),
186-
symbols: capture ? [...capture.symbols] : [],
187-
bindingResidue: capture?.residue ?? false,
188-
});
189-
});
190-
return edges;
191-
}
192-
193-
function scanSideEffectImport(line: string, lineNo: number): ImportEdge | null {
194-
const match = /^\s*import\s+['"]([^'"]+)['"]/.exec(line);
195-
return match
196-
? {
197-
spec: match[1]!,
198-
dynamic: false,
199-
typeOnly: false,
200-
line: lineNo,
201-
symbols: [],
202-
bindingResidue: false,
203-
}
204-
: null;
205-
}
171+
type LocatedImportEdge = { edge: ImportEdge; start: number; order: number };
206172

207-
function withoutImportComments(statement: string): string {
208-
return statement.replaceAll(
209-
/(["'])(?:\\.|(?!\1)[^\\\r\n])*?\1|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g,
210-
(match) => (match.startsWith('/*') ? ' ' : match.startsWith('//') ? '\n' : match),
211-
);
212-
}
213-
214-
type NamedSpecifier = { name: string; typeOnly: boolean };
215-
type ParsedNamedSpecifiers = { index: number; specifiers: NamedSpecifier[] };
216-
217-
function parseNamedSpecifiers(statement: string): ParsedNamedSpecifiers | null {
218-
const named = /\{([\s\S]*?)\}/.exec(statement);
219-
if (!named) return null;
220-
221-
const specifiers: NamedSpecifier[] = [];
222-
for (const specifier of named[1]!.split(',')) {
223-
const trimmed = specifier.trim();
224-
const typeOnly = /^type\b/.test(trimmed);
225-
const sourceName = trimmed.replace(/^type\s+/, '');
226-
const name = /^[A-Za-z_$][\w$]*/.exec(sourceName)?.[0];
227-
if (name) specifiers.push({ name, typeOnly });
228-
}
229-
return { index: named.index, specifiers };
173+
function importedName(node: unknown): string | undefined {
174+
if (node === null || typeof node !== 'object') return undefined;
175+
const record = node as Record<string, unknown>;
176+
return record.type === 'Identifier' && typeof record.name === 'string' ? record.name : undefined;
230177
}
231178

232-
function importedSymbols(statement: string): string[] {
233-
const parsed = parseNamedSpecifiers(statement);
234-
return [...new Set(parsed?.specifiers.map(({ name }) => name) ?? [])];
179+
function namedSymbols(
180+
specifiers: readonly Record<string, unknown>[],
181+
importedField: 'imported' | 'local',
182+
): string[] {
183+
const symbols = specifiers.flatMap((specifier) => {
184+
const name = importedName(specifier[importedField]);
185+
return specifier.type ===
186+
(importedField === 'imported' ? 'ImportSpecifier' : 'ExportSpecifier') && name !== undefined
187+
? [name]
188+
: [];
189+
});
190+
return [...new Set(symbols)];
235191
}
236192

237-
function statementIsTypeOnly(statement: string): boolean {
238-
if (/^\s*(?:import|export)\s+type\b/.test(statement)) return true;
239-
const parsed = parseNamedSpecifiers(statement);
240-
if (!parsed) return false;
241-
const prefix = statement
242-
.slice(0, parsed.index)
243-
.replace(/^\s*(?:import|export)\s+/, '')
244-
.trim()
245-
.replace(/,$/, '')
246-
.trim();
247-
if (prefix.length > 0) return false;
248-
return parsed.specifiers.length > 0 && parsed.specifiers.every(({ typeOnly }) => typeOnly);
193+
function specifierTypeOnly(
194+
declaration: Record<string, unknown>,
195+
specifiers: readonly Record<string, unknown>[],
196+
kindField: 'importKind' | 'exportKind',
197+
): boolean {
198+
if (declaration[kindField] === 'type') return true;
199+
return specifiers.length > 0 && specifiers.every((specifier) => specifier[kindField] === 'type');
249200
}
250201

251-
function scanFromImport(lines: string[], index: number): ImportEdge | null {
252-
const fromMatch = /(?:^|[\s;}])from\s+['"]([^'"]+)['"]/.exec(lines[index]!);
253-
if (!fromMatch) return null;
254-
255-
let start = index;
256-
while (start >= 0 && !/^\s*(?:import|export)\b/.test(lines[start]!)) start--;
257-
if (start < 0) return null;
258-
259-
const statement = lines.slice(start, index + 1).join('\n');
260-
const normalizedStatement = withoutImportComments(statement);
202+
function staticImportEdge(
203+
source: string,
204+
node: Record<string, unknown>,
205+
specifierNode: unknown,
206+
typeOnly: boolean,
207+
symbols: string[],
208+
): ImportEdge | null {
209+
const spec = literalSpecifier(specifierNode);
210+
if (spec === undefined) return null;
211+
const start = node.start as number | undefined;
261212
return {
262-
spec: fromMatch[1]!,
213+
spec,
263214
dynamic: false,
264-
typeOnly: statementIsTypeOnly(normalizedStatement),
265-
line: start + 1,
266-
symbols: importedSymbols(normalizedStatement),
215+
typeOnly,
216+
line: sourceLine(source, start),
217+
symbols,
267218
bindingResidue: false,
268219
};
269220
}
270221

271222
export function parseImports(source: string): ImportEdge[] {
272-
const lines = source.split('\n');
273-
const dynamicImports = scanDynamicImports(source);
274-
const dynamicImportsByLine = new Map<number, ImportEdge[]>();
275-
for (const edge of dynamicImports) {
276-
const lineEdges = dynamicImportsByLine.get(edge.line) ?? [];
277-
lineEdges.push(edge);
278-
dynamicImportsByLine.set(edge.line, lineEdges);
279-
}
280-
const edges: ImportEdge[] = [];
281-
for (let index = 0; index < lines.length; index++) {
282-
edges.push(...(dynamicImportsByLine.get(index + 1) ?? []));
283-
const sideEffect = scanSideEffectImport(lines[index]!, index + 1);
284-
if (sideEffect) {
285-
edges.push(sideEffect);
286-
continue;
223+
const parsed = parseSync('layering-imports.ts', source);
224+
const destructured = destructuredDynamicImportBindings(parsed.program);
225+
const located: LocatedImportEdge[] = [];
226+
let order = 0;
227+
228+
visitAst(parsed.program, (node) => {
229+
const start = typeof node.start === 'number' ? node.start : 0;
230+
if (node.type === 'ImportExpression') {
231+
const spec = literalSpecifier(node.source);
232+
if (spec === undefined) return;
233+
const capture = destructured.get(start);
234+
located.push({
235+
start,
236+
order: order++,
237+
edge: {
238+
spec,
239+
dynamic: true,
240+
typeOnly: false,
241+
line: sourceLine(source, start),
242+
symbols: capture ? [...capture.symbols] : [],
243+
bindingResidue: capture?.residue ?? false,
244+
},
245+
});
246+
return;
287247
}
288-
const fromImport = scanFromImport(lines, index);
289-
if (fromImport) edges.push(fromImport);
290-
}
291-
return edges;
248+
249+
let edge: ImportEdge | null = null;
250+
if (node.type === 'ImportDeclaration') {
251+
const specifiers = Array.isArray(node.specifiers)
252+
? (node.specifiers as Record<string, unknown>[])
253+
: [];
254+
edge = staticImportEdge(
255+
source,
256+
node,
257+
node.source,
258+
specifierTypeOnly(node, specifiers, 'importKind'),
259+
namedSymbols(specifiers, 'imported'),
260+
);
261+
} else if (node.type === 'ExportNamedDeclaration' && node.source) {
262+
const specifiers = Array.isArray(node.specifiers)
263+
? (node.specifiers as Record<string, unknown>[])
264+
: [];
265+
edge = staticImportEdge(
266+
source,
267+
node,
268+
node.source,
269+
specifierTypeOnly(node, specifiers, 'exportKind'),
270+
namedSymbols(specifiers, 'local'),
271+
);
272+
} else if (node.type === 'ExportAllDeclaration') {
273+
edge = staticImportEdge(source, node, node.source, node.exportKind === 'type', []);
274+
} else if (node.type === 'TSImportType') {
275+
edge = staticImportEdge(source, node, node.source, true, []);
276+
}
277+
if (edge) located.push({ edge, start, order: order++ });
278+
});
279+
280+
return located
281+
.sort(
282+
(left, right) =>
283+
left.edge.line - right.edge.line ||
284+
Number(right.edge.dynamic) - Number(left.edge.dynamic) ||
285+
left.start - right.start ||
286+
left.order - right.order,
287+
)
288+
.map(({ edge }) => edge);
292289
}
293290

294291
export function topFolder(file: string): string {

‎src/platform-runtime-device-inventory.test.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ const simctl = vi.hoisted(() => ({
2323
}));
2424

2525
vi.mock('../packages/platform-apple/src/core/tool-provider.ts', async (importOriginal) => ({
26-
...(await importOriginal<
27-
typeof import('../packages/platform-apple/src/core/tool-provider.ts')
28-
>()),
26+
...(await importOriginal<typeof import('@agent-device/platform-apple/tool-provider')>()),
2927
runXcrun: async (args: string[]) => {
3028
const idx = args.indexOf('listapps');
3129
const deviceId = idx >= 0 ? args[idx + 1] : undefined;

0 commit comments

Comments
 (0)