-
Notifications
You must be signed in to change notification settings - Fork 16
fix: credit destructured dynamic import() bindings as exports consumers #1921
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 55 commits
2b6a00d
b28051a
17fbcba
26918bb
be6432c
4ead840
f6326bf
590f560
d3ea4a4
c306812
8bb5893
46037b1
6a84cf9
8d936d1
11c84be
963301a
3e8035d
879635e
0fe9fc3
ccf3c19
d5b1162
8971017
056c5e4
0738171
6f2423c
4c02378
0698e16
4ac3b99
6767f09
5f6f30a
b479318
c39ac40
067674f
f6807b4
1c07a54
bf82aa2
cd02d27
980b5dc
4a873a3
8f3348a
acf804c
986c851
71e9174
d2935cc
6515186
30c491a
9db911b
70559fc
822e0eb
fcdfe7d
ee6362c
d4e6212
aec5d2b
d250f8d
82caa41
ad1396e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4051,22 +4051,41 @@ function extractImportNames( | |||||||||||||||||||||||
| return names; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Wrapper node types that can sit between a dynamic `import()` call and its | ||||||||||||||||||||||||
| * enclosing `variable_declarator` without changing which value gets bound — | ||||||||||||||||||||||||
| * `await`, redundant parentheses, and TypeScript `as` casts. Real-world | ||||||||||||||||||||||||
| * dynamic-import call sites often combine several of these, e.g. | ||||||||||||||||||||||||
| * `const { X } = (await import('./mod.js')) as { X: Fn }` nests | ||||||||||||||||||||||||
| * await_expression → parenthesized_expression → as_expression before | ||||||||||||||||||||||||
| * reaching the declarator (#1781). | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| const DYNAMIC_IMPORT_WRAPPER_TYPES = new Set([ | ||||||||||||||||||||||||
| 'await_expression', | ||||||||||||||||||||||||
| 'parenthesized_expression', | ||||||||||||||||||||||||
| 'as_expression', | ||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||
|
Comment on lines
+4065
to
+4070
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
TypeScript 4.9+ allows
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — added
Comment on lines
+4065
to
+4070
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Extract destructured names from a dynamic import() call expression. | ||||||||||||||||||||||||
| * | ||||||||||||||||||||||||
| * Handles: | ||||||||||||||||||||||||
| * const { a, b } = await import('./foo.js') → ['a', 'b'] | ||||||||||||||||||||||||
| * const mod = await import('./foo.js') → ['mod'] | ||||||||||||||||||||||||
| * import('./foo.js') → [] (no names extractable) | ||||||||||||||||||||||||
| * const { a, b } = await import('./foo.js') → ['a', 'b'] | ||||||||||||||||||||||||
| * const mod = await import('./foo.js') → ['mod'] | ||||||||||||||||||||||||
| * const { a } = (await import('./foo.js')) as { a: Fn } → ['a'] | ||||||||||||||||||||||||
| * import('./foo.js') → [] (no names extractable) | ||||||||||||||||||||||||
| * | ||||||||||||||||||||||||
| * Walks up the AST from the call_expression to find the enclosing | ||||||||||||||||||||||||
| * Walks up the AST from the call_expression — through any nesting of | ||||||||||||||||||||||||
| * await/parenthesized/as-cast wrappers — to find the enclosing | ||||||||||||||||||||||||
| * variable_declarator and reads the name/object_pattern. | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| function extractDynamicImportNames(callNode: TreeSitterNode): string[] { | ||||||||||||||||||||||||
| // Walk up: call_expression → await_expression → variable_declarator | ||||||||||||||||||||||||
| // Walk up through await_expression / parenthesized_expression / as_expression | ||||||||||||||||||||||||
| // wrappers, in any combination or order, to reach the variable_declarator. | ||||||||||||||||||||||||
| let current = callNode.parent; | ||||||||||||||||||||||||
| // Skip await_expression wrapper if present | ||||||||||||||||||||||||
| if (current && current.type === 'await_expression') current = current.parent; | ||||||||||||||||||||||||
| while (current && DYNAMIC_IMPORT_WRAPPER_TYPES.has(current.type)) { | ||||||||||||||||||||||||
| current = current.parent; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| // We should now be at a variable_declarator (or not, if standalone import()) | ||||||||||||||||||||||||
| if (current?.type !== 'variable_declarator') return []; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| /** | ||
| * Integration test for #1781: `codegraph exports` did not credit consumers | ||
| * reached via a dynamic `import()` expression whose destructured result is | ||
| * wrapped in redundant parentheses and/or a TypeScript `as {...}` type | ||
| * assertion — the exact shape used throughout | ||
| * `src/domain/graph/builder/stages/native-orchestrator.ts`: | ||
| * | ||
| * const { buildDataflowVerticesFromMap, ... } = | ||
| * (await import('../../../../features/dataflow.js')) as {...}; | ||
| * | ||
| * Root cause: `extractDynamicImportNames` (TS) / `extract_dynamic_import_names` | ||
| * (Rust) walked up from the `import()` call through at most one optional | ||
| * `await_expression` before requiring the immediate parent to be a | ||
| * `variable_declarator`. The extra `parenthesized_expression` and/or | ||
| * `as_expression` wrapper layers introduced by parens and a type assertion | ||
| * broke that walk-up, so no destructured names were ever extracted — the | ||
| * import was recorded with `names: []`, `importedNames` never gained an | ||
| * entry for the destructured bindings, and the later call through the | ||
| * destructured local name never resolved to a `calls` edge. Both `codegraph | ||
| * exports` (consumer count) and `codegraph roles --role dead` therefore | ||
| * treated a genuinely-consumed export as unreferenced. | ||
| * | ||
| * Fix: the walk-up now skips any nesting/combination of `await_expression`, | ||
| * `parenthesized_expression`, and `as_expression` wrappers before checking | ||
| * for the enclosing `variable_declarator`, in both engines. | ||
| * | ||
| * Two consumer shapes are exercised against the same target module: | ||
| * - `usesPlain`: bare `const { X } = await import(...)` (no cast) — the | ||
| * pre-existing baseline that must keep working. | ||
| * - `usesCast`: `const { Y } = (await import(...)) as {...}` — the exact | ||
| * regression shape from native-orchestrator.ts. | ||
| * | ||
| * The target module and its consumer are deliberately placed in *different* | ||
| * directories (`features/` vs `domain/stages/`, mirroring the real repo | ||
| * layout) rather than side by side. A same-directory fixture does not | ||
| * discriminate this bug at all: the resolver's directory-scoped fallback | ||
| * tier (a same-directory, name-only match — one of several confidence tiers | ||
| * below the import-aware match) coincidentally "rescues" the call even when | ||
| * `extractDynamicImportNames` returns no names, producing a `calls` edge | ||
| * regardless of whether the fix is present. Cross-directory placement is | ||
| * required so the only way to resolve `usesCast`'s call is through the | ||
| * import-aware path this bug actually breaks. | ||
| */ | ||
|
|
||
| import fs from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| import Database from 'better-sqlite3'; | ||
| import { afterAll, beforeAll, describe, expect, it } from 'vitest'; | ||
| import { buildGraph } from '../../src/domain/graph/builder.js'; | ||
| import { exportsData } from '../../src/domain/queries.js'; | ||
| import { isNativeAvailable } from '../../src/infrastructure/native.js'; | ||
|
|
||
| const DEAD_ROLES = new Set(['dead-unresolved', 'dead-leaf', 'dead-entry', 'dead-ffi']); | ||
|
|
||
| const TARGET_FILE = 'features/dataflow.ts'; | ||
|
|
||
| const FIXTURE = { | ||
| [TARGET_FILE]: ` | ||
| export function buildDataflowVerticesFromMap(): number { | ||
| return 42; | ||
| } | ||
|
|
||
| export function buildDataflowP4ForNative(): number { | ||
| return 7; | ||
| } | ||
| `, | ||
| 'domain/stages/consumer.ts': ` | ||
| export async function usesPlain() { | ||
| const { buildDataflowVerticesFromMap } = await import('../../features/dataflow.js'); | ||
| return buildDataflowVerticesFromMap(); | ||
| } | ||
|
|
||
| export async function usesCast() { | ||
| const { buildDataflowP4ForNative } = (await import('../../features/dataflow.js')) as { | ||
| buildDataflowP4ForNative: () => number; | ||
| }; | ||
| return buildDataflowP4ForNative(); | ||
| } | ||
| `, | ||
| }; | ||
|
|
||
| function readNodesWithRoles(dbPath: string) { | ||
| const db = new Database(dbPath, { readonly: true }); | ||
| try { | ||
| return db.prepare('SELECT name, kind, role FROM nodes ORDER BY name').all() as Array<{ | ||
| name: string; | ||
| kind: string; | ||
| role: string | null; | ||
| }>; | ||
| } finally { | ||
| db.close(); | ||
| } | ||
| } | ||
|
|
||
| function getCallEdges(dbPath: string) { | ||
| const db = new Database(dbPath, { readonly: true }); | ||
| try { | ||
| return db | ||
| .prepare( | ||
| `SELECT n1.name AS src, n2.name AS tgt, n2.file AS tgt_file | ||
| FROM edges e | ||
| JOIN nodes n1 ON e.source_id = n1.id | ||
| JOIN nodes n2 ON e.target_id = n2.id | ||
| WHERE e.kind = 'calls' | ||
| ORDER BY n1.name, n2.name`, | ||
| ) | ||
| .all() as Array<{ src: string; tgt: string; tgt_file: string }>; | ||
| } finally { | ||
| db.close(); | ||
| } | ||
| } | ||
|
|
||
| function writeFixture(rootDir: string) { | ||
|
Comment on lines
+107
to
+114
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The native suite's Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed — added a matching 60s timeout to the WASM |
||
| for (const [rel, content] of Object.entries(FIXTURE)) { | ||
| const abs = path.join(rootDir, rel); | ||
| fs.mkdirSync(path.dirname(abs), { recursive: true }); | ||
| fs.writeFileSync(abs, content); | ||
| } | ||
| } | ||
|
|
||
| describe('dynamic import() + destructure consumer crediting (#1781) — WASM', () => { | ||
| let tmpDir: string; | ||
|
|
||
| beforeAll(async () => { | ||
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1781-wasm-')); | ||
| writeFixture(tmpDir); | ||
| await buildGraph(tmpDir, { engine: 'wasm', incremental: false, skipRegistry: true }); | ||
| }); | ||
|
|
||
| afterAll(() => { | ||
| fs.rmSync(tmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('creates a calls edge for the bare (uncast) destructured binding', () => { | ||
| const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); | ||
| const edges = getCallEdges(dbPath); | ||
| const edge = edges.find( | ||
| (e) => e.src === 'usesPlain' && e.tgt === 'buildDataflowVerticesFromMap', | ||
| ); | ||
| expect( | ||
| edge, | ||
| `Expected usesPlain -> buildDataflowVerticesFromMap; got: ${JSON.stringify(edges)}`, | ||
| ).toBeDefined(); | ||
| expect(edge?.tgt_file).toBe(TARGET_FILE); | ||
| }); | ||
|
|
||
| it('creates a calls edge for the parenthesized + `as`-cast destructured binding', () => { | ||
| const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); | ||
| const edges = getCallEdges(dbPath); | ||
| const edge = edges.find((e) => e.src === 'usesCast' && e.tgt === 'buildDataflowP4ForNative'); | ||
| expect( | ||
| edge, | ||
| `Expected usesCast -> buildDataflowP4ForNative; got: ${JSON.stringify(edges)}`, | ||
| ).toBeDefined(); | ||
| expect(edge?.tgt_file).toBe(TARGET_FILE); | ||
| }); | ||
|
|
||
| it('codegraph exports credits both dynamically-imported functions with real consumers', () => { | ||
| const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); | ||
| const data = exportsData(TARGET_FILE, dbPath); | ||
|
|
||
| const vertices = data.results.find( | ||
| (r: { name: string }) => r.name === 'buildDataflowVerticesFromMap', | ||
| ); | ||
| expect(vertices).toBeDefined(); | ||
| expect(vertices.consumerCount).toBeGreaterThanOrEqual(1); | ||
| expect(vertices.consumers.map((c: { name: string }) => c.name)).toContain('usesPlain'); | ||
|
|
||
| const p4 = data.results.find((r: { name: string }) => r.name === 'buildDataflowP4ForNative'); | ||
| expect(p4).toBeDefined(); | ||
| expect(p4.consumerCount).toBeGreaterThanOrEqual(1); | ||
| expect(p4.consumers.map((c: { name: string }) => c.name)).toContain('usesCast'); | ||
| }); | ||
|
|
||
| it('does not classify either dynamically-imported export as dead', () => { | ||
| const dbPath = path.join(tmpDir, '.codegraph', 'graph.db'); | ||
| const nodes = readNodesWithRoles(dbPath); | ||
| for (const name of ['buildDataflowVerticesFromMap', 'buildDataflowP4ForNative']) { | ||
| const node = nodes.find((n) => n.name === name && n.kind === 'function'); | ||
| expect(node, `${name} node not found`).toBeDefined(); | ||
| expect( | ||
| DEAD_ROLES.has(node!.role ?? ''), | ||
| `${name} was classified as ${node!.role} — expected a non-dead role now that a real edge exists`, | ||
| ).toBe(false); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| // ── Native engine parity ──────────────────────────────────────────────────── | ||
| // Skipped when the native addon is not installed. | ||
|
|
||
| describe.skipIf(!isNativeAvailable())( | ||
| 'dynamic import() + destructure consumer crediting (#1781) — native', | ||
| () => { | ||
| let nativeTmpDir: string; | ||
|
|
||
| beforeAll(async () => { | ||
| nativeTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-1781-native-')); | ||
| writeFixture(nativeTmpDir); | ||
| await buildGraph(nativeTmpDir, { engine: 'native', incremental: false, skipRegistry: true }); | ||
| }, 60_000); | ||
|
|
||
| afterAll(() => { | ||
| fs.rmSync(nativeTmpDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('creates a calls edge for the bare (uncast) destructured binding', () => { | ||
| const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); | ||
| const edges = getCallEdges(dbPath); | ||
| const edge = edges.find( | ||
| (e) => e.src === 'usesPlain' && e.tgt === 'buildDataflowVerticesFromMap', | ||
| ); | ||
| expect( | ||
| edge, | ||
| `Expected native usesPlain -> buildDataflowVerticesFromMap; got: ${JSON.stringify(edges)}`, | ||
| ).toBeDefined(); | ||
| expect(edge?.tgt_file).toBe(TARGET_FILE); | ||
| }); | ||
|
|
||
| it('creates a calls edge for the parenthesized + `as`-cast destructured binding', () => { | ||
| const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); | ||
| const edges = getCallEdges(dbPath); | ||
| const edge = edges.find((e) => e.src === 'usesCast' && e.tgt === 'buildDataflowP4ForNative'); | ||
| expect( | ||
| edge, | ||
| `Expected native usesCast -> buildDataflowP4ForNative; got: ${JSON.stringify(edges)}`, | ||
| ).toBeDefined(); | ||
| expect(edge?.tgt_file).toBe(TARGET_FILE); | ||
| }); | ||
|
|
||
| it('codegraph exports credits both dynamically-imported functions with real consumers', () => { | ||
| const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); | ||
| const data = exportsData(TARGET_FILE, dbPath); | ||
|
|
||
| const vertices = data.results.find( | ||
| (r: { name: string }) => r.name === 'buildDataflowVerticesFromMap', | ||
| ); | ||
| expect(vertices).toBeDefined(); | ||
| expect(vertices.consumerCount).toBeGreaterThanOrEqual(1); | ||
|
|
||
| const p4 = data.results.find((r: { name: string }) => r.name === 'buildDataflowP4ForNative'); | ||
| expect(p4).toBeDefined(); | ||
| expect(p4.consumerCount).toBeGreaterThanOrEqual(1); | ||
| }); | ||
|
|
||
| it('does not classify either dynamically-imported export as dead', () => { | ||
| const dbPath = path.join(nativeTmpDir, '.codegraph', 'graph.db'); | ||
| const nodes = readNodesWithRoles(dbPath); | ||
| for (const name of ['buildDataflowVerticesFromMap', 'buildDataflowP4ForNative']) { | ||
| const node = nodes.find((n) => n.name === name && n.kind === 'function'); | ||
| expect(node, `${name} node not found (native)`).toBeDefined(); | ||
| expect(DEAD_ROLES.has(node!.role ?? '')).toBe(false); | ||
| } | ||
| }); | ||
| }, | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
non_null_expression(tree-sitter TypeScript grammar node for the!non-null assertion suffix) is absent from the wrapper-kinds slice.const { x } = (await import('./foo.js'))!inserts anon_null_expressionabove theparenthesized_expression, which stops the Rust walk-up before it reaches thevariable_declarator.