Ver Fonte

feat(impact): fix affected/blast-radius and expand cross-file dependency coverage

The file-level dependency queries followed only `imports` edges, which are
same-file in this engine, so `codegraph affected` and getFileDependents returned
0 dependents for every file. Project the symbol-level edge graph instead, then
close the cross-file edge gaps that left real dependencies unlinked per language.

Queries:
- Add getDependentFilePaths / getDependencyFilePaths (one indexed JOIN over all
  cross-file edge kinds except `contains`); getFileDependents/getFileDependencies
  and circular-dependency detection now use them.

Extraction & resolution, by language:
- TypeScript/JS: extract in-body local type annotations; link every named,
  default, re-export (`export {X} from`), and namespace import to its definition
  (incl. tsconfig `@/` aliases), with a namespace-import file backstop.
- Python: link `from module import X` bindings (value imports, aliases, __init__
  re-export barrels); resolve `from . import sub` + `sub.func()` module-member
  access; fix Python dotted-relative import path resolution.
- Go: extract cross-package struct composite literals (`pkg.Type{...}`, incl.
  package-level var registries) and `(*T)(x)` type conversions; implicit
  interface satisfaction — extract interface methods, synthesize `implements`
  edges via method-set matching, bridge interface dispatch to impls (#584).
- C#: index `record` / `record struct` declarations as types (#237).

Measured file-dependent coverage on real repos: this repo (TS) 62.5%->95.8%;
requests (Py) 54%->100%; gin (Go) 62.7%->96.6%; MediatR (C#) 81.5%->85.2%.
Node counts stay stable (edges added, not nodes, aside from real interface/record
symbols). Adds tests and CHANGELOG entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry há 2 semanas atrás
pai
commit
16b5633

+ 7 - 0
CHANGELOG.md

@@ -15,6 +15,13 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too.
+- Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript)
+- The same completeness fix now applies to **Python**: a name brought in with `from module import X` is recorded as a dependency on that module even when `X` is only stored in a list/dict, passed as an argument, used as a decorator, or re-exported through an `__init__.py`. Previously Python linked only imports that were called or instantiated, so a module consumed purely by value — or only re-exported — looked like nothing depended on it.
+- C# `record` types are now indexed. `record`, `record class`, and `record struct` declarations (everywhere in modern C# — DTOs, value objects, CQRS messages, MediatR notifications) were previously skipped entirely, so every reference, generic type argument (`IEnumerable<MyRecord>`), and `new MyRecord(...)` pointed at nothing and the file defining a record looked like it had no callers or dependents. (#237)
+- Go interfaces now connect to their implementations. Go has no `implements` keyword — a type satisfies an interface just by having the right methods — so CodeGraph now infers that link: a struct whose methods cover an interface's method set is treated as implementing it, and a call through the interface (`API.Marshal(...)`) reaches every concrete implementation. This means a type used only via an interface (the common plugin/strategy pattern — e.g. JSON-codec or renderer implementations selected at runtime) is no longer reported as having no callers or no dependents, and impact now flows from an interface method to the implementations behind it. (#584)
+- Go now records cross-package struct creation. A composite literal like `render.XML{...}` or `pkga.Widget{...}` — including ones registered in a package-level `var registry = map[string]R{...}` — now links to the package that defines the type. Cross-package function calls and type references already resolved; this closes struct instantiation, so a package whose types are only *constructed* elsewhere (a common pattern for interface implementations) is no longer reported as having no dependents. Go type conversions such as `(*Wrapped)(x)` now link to the converted-to type as well.
+- Python now follows whole-module imports — `from . import certs` then `certs.where()`, or `from pkg import sub` then `sub.run()`. Calls and attribute access through an imported submodule now resolve to that submodule, and importing a module is recorded as a dependency on it even when the member you use is itself re-exported from a third-party package. This also fixed Python relative-import path resolution generally (`from .sub.mod import x`), so `codegraph affected` and impact see the real module graph of a package.
 - The background file watcher no longer exhausts your machine's file-descriptor budget. On macOS it previously kept **one open file handle per watched file**, so on a large project the running MCP server could pile up tens of thousands of handles and blow past the system-wide limit — at which point *unrelated* apps (your shell, editor, Docker, browser) started failing with "too many open files" until the codegraph process was killed. The watcher now uses a single recursive watch on macOS and Windows, and bounded per-directory watches on Linux, so its cost stays flat no matter how large the project is. (#644, #496, #555, #628, #579)
 - Indexing a project with very symbol-dense files (tens of thousands of functions or methods in a single file) no longer runs out of memory. The step that links dynamic call relationships used to load every function and method into memory at once, which could exhaust the heap and abort indexing with "JavaScript heap out of memory" on large or generated codebases; it now streams them, so memory stays flat no matter how many symbols the project has. (#610)
 - Indexing a very large repository no longer aborts during its first sync with a "too many SQL variables" error. (#540)

+ 319 - 0
__tests__/extraction.test.ts

@@ -237,6 +237,54 @@ interface MethodForm {
     expect(refs.some((r) => r.referenceName === 'IOrderField')).toBe(true);
   });
 
+  it('extracts type references from in-body local variable annotations', () => {
+    // A function that uses a type ONLY in its body — `const items: Foo[] = []` —
+    // still depends on Foo. The body walker used to capture calls but never type
+    // annotations, so impact / `affected` missed the dependency. Must cover
+    // function, class-method, and object-literal-method bodies — and must NOT
+    // turn the locals themselves into graph nodes (that would explode the graph).
+    const code = `
+import { Foo } from './types';
+
+export function build(): void {
+  const items: Foo[] = [];
+  void items;
+}
+
+export class K {
+  run(): void {
+    const a: Foo = { x: 1 };
+    void a;
+  }
+}
+
+export const handler = {
+  handle(): void {
+    const b: Foo = { x: 1 };
+    void b;
+  },
+};
+`;
+    const result = extractFromSource('inbody.ts', code);
+
+    const fooRefs = result.unresolvedReferences.filter(
+      (r) => r.referenceKind === 'references' && r.referenceName === 'Foo'
+    );
+    // One per body scope: build(), K.run(), handler.handle().
+    expect(fooRefs.length).toBeGreaterThanOrEqual(3);
+
+    // Each reference is attributed to its enclosing function/method node — never
+    // to a local-variable node, because locals are intentionally not extracted.
+    const byId = new Map(result.nodes.map((n) => [n.id, n]));
+    for (const ref of fooRefs) {
+      const owner = byId.get(ref.fromNodeId);
+      expect(owner).toBeDefined();
+      expect(['function', 'method']).toContain(owner!.kind);
+    }
+    // The locals (items/a/b) must not leak in as symbols.
+    expect(result.nodes.some((n) => ['items', 'a', 'b'].includes(n.name))).toBe(false);
+  });
+
   it('should track function calls', () => {
     const code = `
 function main() {
@@ -4459,3 +4507,274 @@ func (s Stack[T]) Len() int { return len(s.items) }
     expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined();
   });
 });
+
+describe('Import / re-export dependency linking (blast-radius recall)', () => {
+  // An import IS a dependency, but extraction only emits references for calls,
+  // instantiations, type annotations, and inheritance — so a symbol imported and
+  // then merely re-exported, placed in a registry array, passed as an argument,
+  // or used in JSX produced no cross-file edge, leaving the providing file with a
+  // false "0 dependents". These tests pin the import/re-export binding linking.
+  it('emits an imports reference per named, aliased, and default import binding', () => {
+    const code = `
+import { widget, helper as h } from './foo';
+import Thing from './thing';
+import * as NS from './ns';
+export const registry = [widget];
+`;
+    const result = extractFromSource('bar.ts', code);
+    const names = result.unresolvedReferences
+      .filter((r) => r.referenceKind === 'imports')
+      .map((r) => r.referenceName);
+    expect(names).toContain('widget');   // named import → local name
+    expect(names).toContain('h');        // aliased import → local alias
+    expect(names).toContain('Thing');    // default import
+    expect(names).toContain('NS');       // namespace import → linked to the module file as a dependency
+  });
+
+  it('emits an imports reference per re-exported binding', () => {
+    const result = extractFromSource('barrel.ts', `export { alpha, beta as b } from './source';`);
+    const names = result.unresolvedReferences
+      .filter((r) => r.referenceKind === 'imports')
+      .map((r) => r.referenceName);
+    // Re-export links the SOURCE-side name, not the local alias.
+    expect(names).toContain('alpha');
+    expect(names).toContain('beta');
+  });
+
+  it('a value imported/re-exported but never called still makes the importer a dependent', async () => {
+    const dir = createTempDir();
+    try {
+      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
+      fs.writeFileSync(
+        path.join(dir, 'src', 'foo.ts'),
+        `export const widget = { n: 1 };\nexport function helper(): void {}\n`
+      );
+      // bar uses widget ONLY in an array and re-exports helper — neither is
+      // called/typed, so before import-linking bar had no edge to foo at all.
+      fs.writeFileSync(
+        path.join(dir, 'src', 'bar.ts'),
+        `import { widget } from './foo';\nexport { helper } from './foo';\nexport const registry = [widget];\n`
+      );
+      const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+
+  it('a namespace import touched only via a value-member read still links the module file', async () => {
+    const dir = createTempDir();
+    try {
+      fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
+      fs.writeFileSync(path.join(dir, 'src', 'foo.ts'), `export const SOME_CONST = 42;\n`);
+      // `foo` is imported as a namespace and used ONLY via a value-member read
+      // (no call, no type) — `foo.helper()` would link on its own, but a bare
+      // `foo.SOME_CONST` would not, so the module-import backstop must link it.
+      fs.writeFileSync(path.join(dir, 'src', 'bar.ts'), `import * as foo from './foo';\nexport const x = foo.SOME_CONST;\n`);
+      const cg = CodeGraph.initSync(dir, { config: { include: ['src/**/*.ts'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('src/foo.ts')).toContain('src/bar.ts');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+});
+
+describe('Python import dependency linking (blast-radius recall)', () => {
+  // Same recall gap as TS: Python only linked called/instantiated imports, so a
+  // name brought in with `from module import X` and then merely stored, used as
+  // a decorator/argument, or re-exported through an `__init__.py` produced no
+  // cross-file edge — the providing module showed a false "0 dependents".
+  it('emits an imports reference per name in a `from module import ...` (incl. value/aliased)', () => {
+    const code = [
+      'from foo import helper, widget',
+      'from foo import Thing as T',
+      'from . import sibling',
+      'from bar import *',
+    ].join('\n');
+    const names = extractFromSource('mod.py', code)
+      .unresolvedReferences.filter((r) => r.referenceKind === 'imports')
+      .map((r) => r.referenceName);
+    expect(names).toContain('helper');
+    expect(names).toContain('widget');   // value import
+    expect(names).toContain('T');        // aliased import → local name
+    expect(names).toContain('sibling');  // `from . import <name>`
+    expect(names).not.toContain('*');    // wildcard import has no names
+  });
+
+  it('a Python value imported but never called still makes the importer a dependent', async () => {
+    const dir = createTempDir();
+    try {
+      fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
+      fs.writeFileSync(path.join(dir, 'pkg', 'foo.py'), `widget = {"n": 1}\ndef helper():\n    return 1\n`);
+      // bar imports widget+helper but only stores widget in a list — nothing is
+      // called, so before import-linking bar had no edge to foo.
+      fs.writeFileSync(path.join(dir, 'pkg', 'bar.py'), `from foo import widget, helper\nregistry = [widget]\n`);
+      const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('pkg/foo.py')).toContain('pkg/bar.py');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+
+  it('resolves `from . import submodule` + `submodule.func()` to the submodule', async () => {
+    const dir = createTempDir();
+    try {
+      fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
+      fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
+      fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `def where():\n    return "/ca.pem"\n`);
+      // certs is an imported MODULE (a file), and certs.where() is a qualified
+      // call through it — the receiver isn't a symbol, so plain name-matching
+      // can't link it. Also exercises the Python relative-dot path fix (`.certs`).
+      fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\ndef go():\n    return certs.where()\n`);
+      const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+
+  it('a module import is a dependency even when the used member is re-exported elsewhere', async () => {
+    const dir = createTempDir();
+    try {
+      fs.mkdirSync(path.join(dir, 'pkg'), { recursive: true });
+      fs.writeFileSync(path.join(dir, 'pkg', '__init__.py'), '');
+      // `where` is NOT defined in certs.py (re-exported from a 3rd-party pkg), so
+      // member resolution can't find it — the module-import backstop must still
+      // record utils -> certs. (Mirrors requests' real `certs.where`.)
+      fs.writeFileSync(path.join(dir, 'pkg', 'certs.py'), `from external_ca import where\n`);
+      fs.writeFileSync(path.join(dir, 'pkg', 'utils.py'), `from . import certs\nCA = certs.where()\n`);
+      const cg = CodeGraph.initSync(dir, { config: { include: ['pkg/**/*.py'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('pkg/certs.py')).toContain('pkg/utils.py');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+});
+
+describe('Go cross-package composite literals (blast-radius recall)', () => {
+  // Go function calls and type references across packages already resolved, but
+  // struct composite literals — `render.XML{...}` / `pkga.Widget{...}` — were not
+  // extracted at all, so a package whose types are only INSTANTIATED elsewhere
+  // (gin's render/binding implementations) showed 0 dependents.
+  it('links a cross-package struct composite literal to the defining package', async () => {
+    const dir = createTempDir();
+    try {
+      fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
+      fs.mkdirSync(path.join(dir, 'render'), { recursive: true });
+      fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct { Data any }\n`);
+      fs.writeFileSync(path.join(dir, 'app.go'), `package main\n\nimport "example.com/proj/render"\n\nfunc handle() any { return render.XML{} }\n`);
+      const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('render/xml.go')).toContain('app.go');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+
+  it('links a composite literal in a package-level var registry', async () => {
+    const dir = createTempDir();
+    try {
+      fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
+      fs.mkdirSync(path.join(dir, 'render'), { recursive: true });
+      fs.writeFileSync(path.join(dir, 'render', 'xml.go'), `package render\n\ntype XML struct {}\nfunc (XML) Render() {}\n`);
+      // The implementation is registered only in a top-level `var registry = {...}`
+      // map literal — the body walker doesn't cover top-level declarations, so this
+      // exercises the var-initializer walking added for Go.
+      fs.writeFileSync(path.join(dir, 'reg.go'), `package main\n\nimport "example.com/proj/render"\n\ntype R interface { Render() }\n\nvar registry = map[string]R{ "xml": render.XML{} }\n`);
+      const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('render/xml.go')).toContain('reg.go');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+
+  it('links a parenthesized pointer type conversion `(*T)(x)` to the type', async () => {
+    const dir = createTempDir();
+    try {
+      fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
+      fs.writeFileSync(path.join(dir, 'types.go'), `package main\n\ntype Wrapped struct { N int }\n`);
+      // `(*Wrapped)(x)` parses as a call whose callee is the parenthesized type
+      // `(*Wrapped)` — without normalization it dropped on the floor.
+      fs.writeFileSync(path.join(dir, 'use.go'), `package main\n\nfunc run(x *int) { _ = (*Wrapped)(x) }\n`);
+      const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('types.go')).toContain('use.go');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+
+  it('links an implementation reached only through a Go interface (implicit satisfaction, #584)', async () => {
+    const dir = createTempDir();
+    try {
+      fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/proj\n\ngo 1.21\n');
+      fs.mkdirSync(path.join(dir, 'codec'), { recursive: true });
+      fs.writeFileSync(path.join(dir, 'codec', 'api.go'), `package codec\n\ntype Core interface {\n\tMarshal(v any) ([]byte, error)\n}\n\nvar API Core\n`);
+      // jsonApi satisfies Core structurally (no `implements` keyword) and is
+      // reached ONLY through the interface (API.Marshal). Without implicit
+      // interface satisfaction + dispatch, json.go shows 0 dependents.
+      fs.writeFileSync(path.join(dir, 'codec', 'json.go'), `package codec\n\ntype jsonApi struct{}\n\nfunc (j jsonApi) Marshal(v any) ([]byte, error) { return nil, nil }\n\nfunc init() { API = jsonApi{} }\n`);
+      const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.go'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('codec/json.go')).toContain('codec/api.go');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+});
+
+describe('C# records (blast-radius recall)', () => {
+  // Records are ubiquitous in modern C# (DTOs, value objects, CQRS messages),
+  // but `record` / `record struct` declarations weren't extracted as types — so
+  // every reference, generic-type-argument, and `new` of a record dropped on the
+  // floor and the defining file showed 0 dependents. (#237)
+  it('extracts a record as a graph node (record class + record struct)', () => {
+    const r = extractFromSource('r.cs', `namespace P;\npublic record Box(int N);\npublic record struct Pt(int X);\n`);
+    expect(r.nodes.find((n) => n.name === 'Box' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined();
+    expect(r.nodes.find((n) => n.name === 'Pt' && (n.kind === 'class' || n.kind === 'struct'))).toBeDefined();
+  });
+
+  it('resolves references / instantiations of a record across files', async () => {
+    const dir = createTempDir();
+    try {
+      fs.writeFileSync(path.join(dir, 'types.cs'), `namespace P;\npublic record Box(int N);\n`);
+      // Box is used as a generic type argument and instantiated — both require
+      // Box to be a node to resolve.
+      fs.writeFileSync(
+        path.join(dir, 'use.cs'),
+        `using System.Collections.Generic;\nnamespace P;\npublic class User {\n    public IEnumerable<Box> Boxes { get; }\n    public Box Make() => new Box(1);\n}\n`
+      );
+      const cg = CodeGraph.initSync(dir, { config: { include: ['**/*.cs'], exclude: [] } });
+      await cg.indexAll();
+      cg.resolveReferences();
+      expect(cg.getFileDependents('types.cs')).toContain('use.cs');
+      cg.destroy();
+    } finally {
+      cleanupTempDir(dir);
+    }
+  });
+});

+ 26 - 5
__tests__/graph.test.ts

@@ -388,16 +388,37 @@ export { main };
   });
 
   describe('File dependency analysis', () => {
-    it('should get file dependencies', () => {
+    // Regression: getFileDependents/getFileDependencies used to follow
+    // ONLY `imports` edges, which in this engine are same-file (a file → its
+    // own local import declarations). That made both return [] for EVERY file,
+    // so `codegraph affected` found no dependents on any language/framework.
+    // They must follow the cross-file symbol graph instead (calls / references
+    // / instantiates / extends / implements / ...).
+    it('reports cross-file dependencies via the symbol graph, not just imports', () => {
       const deps = cg.getFileDependencies('src/main.ts');
+      // main() instantiates DerivedClass (derived.ts) and calls
+      // processValue/doubleValue (utils.ts) — both are real dependencies.
+      expect(deps).toContain('src/utils.ts');
+      expect(deps).toContain('src/derived.ts');
+    });
 
-      expect(Array.isArray(deps)).toBe(true);
+    it('reports cross-file dependents via the symbol graph, not just imports', () => {
+      // utils.ts is used by main.ts (processValue/doubleValue calls); the old
+      // imports-only implementation returned [] here.
+      expect(cg.getFileDependents('src/utils.ts')).toContain('src/main.ts');
     });
 
-    it('should get file dependents', () => {
-      const dependents = cg.getFileDependents('src/utils.ts');
+    it('counts extends/implements as a dependency edge', () => {
+      // derived.ts extends BaseClass / implements Printable, both in base.ts.
+      expect(cg.getFileDependencies('src/derived.ts')).toContain('src/base.ts');
+      expect(cg.getFileDependents('src/base.ts')).toContain('src/derived.ts');
+    });
 
-      expect(Array.isArray(dependents)).toBe(true);
+    it('never lists a file as its own dependent or dependency', () => {
+      for (const f of ['src/main.ts', 'src/utils.ts', 'src/base.ts', 'src/derived.ts']) {
+        expect(cg.getFileDependents(f)).not.toContain(f);
+        expect(cg.getFileDependencies(f)).not.toContain(f);
+      }
     });
   });
 

+ 46 - 0
src/db/queries.ts

@@ -1351,6 +1351,52 @@ export class QueryBuilder {
     return rows.map(rowToEdge);
   }
 
+  /**
+   * Distinct file paths that DEPEND ON `filePath`: every file containing a
+   * symbol with a cross-file edge (any kind except `contains`) into a symbol
+   * of this file. This is the file-level projection of the symbol dependency
+   * graph and the basis for blast-radius / `affected` test selection.
+   *
+   * It deliberately does NOT restrict to `imports` edges. In this graph an
+   * `imports` edge connects a file to its own local import declarations
+   * (it is always same-file), so an imports-only lookup returns zero
+   * cross-file dependents for every file. The real cross-file dependency
+   * signal is the resolved call/reference graph — calls, references,
+   * instantiates, extends, implements, overrides, type_of, returns,
+   * decorates — exactly what {@link GraphTraverser.getImpactRadius} traverses.
+   * `contains` is excluded: a parent containing a symbol does not *depend* on
+   * it. One indexed query (idx_nodes_file_path + idx_edges_target_kind).
+   */
+  getDependentFilePaths(filePath: string): string[] {
+    const sql = `SELECT DISTINCT src.file_path AS fp
+      FROM edges e
+      JOIN nodes tgt ON tgt.id = e.target
+      JOIN nodes src ON src.id = e.source
+      WHERE tgt.file_path = ?
+        AND e.kind != 'contains'
+        AND src.file_path != ?`;
+    const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>;
+    return rows.map((r) => r.fp);
+  }
+
+  /**
+   * Distinct file paths that `filePath` DEPENDS ON — the inverse of
+   * {@link getDependentFilePaths}: every file containing a symbol that a
+   * symbol of this file has a cross-file edge into. Same edge-kind rules
+   * (all kinds except `contains`); same reason imports-only is insufficient.
+   */
+  getDependencyFilePaths(filePath: string): string[] {
+    const sql = `SELECT DISTINCT tgt.file_path AS fp
+      FROM edges e
+      JOIN nodes src ON src.id = e.source
+      JOIN nodes tgt ON tgt.id = e.target
+      WHERE src.file_path = ?
+        AND e.kind != 'contains'
+        AND tgt.file_path != ?`;
+    const rows = this.db.prepare(sql).all(filePath, filePath) as Array<{ fp: string }>;
+    return rows.map((r) => r.fp);
+  }
+
   // ===========================================================================
   // File Operations
   // ===========================================================================

+ 6 - 2
src/extraction/languages/csharp.ts

@@ -4,10 +4,14 @@ import type { LanguageExtractor } from '../tree-sitter-types';
 
 export const csharpExtractor: LanguageExtractor = {
   functionTypes: [],
-  classTypes: ['class_declaration'],
+  // Records are first-class type declarations in modern C# (DTOs, value objects,
+  // MediatR/CQRS messages). `record` / `record class` parse as record_declaration
+  // (reference type → class); `record struct` as record_struct_declaration (value
+  // type → struct). Without these, references to a record never resolve (#237).
+  classTypes: ['class_declaration', 'record_declaration'],
   methodTypes: ['method_declaration', 'constructor_declaration'],
   interfaceTypes: ['interface_declaration'],
-  structTypes: ['struct_declaration'],
+  structTypes: ['struct_declaration', 'record_struct_declaration'],
   enumTypes: ['enum_declaration'],
   enumMemberTypes: ['enum_member_declaration'],
   typeAliasTypes: [],

+ 251 - 0
src/extraction/tree-sitter.ts

@@ -124,6 +124,7 @@ const INSTANTIATION_KINDS: ReadonlySet<string> = new Set([
   'new_expression',                  // typescript / javascript / tsx / jsx
   'object_creation_expression',      // java / c#
   'instance_creation_expression',    // some grammars
+  'composite_literal',               // go — `Widget{...}` / `pkga.Widget{...}`
 ]);
 
 /**
@@ -378,6 +379,22 @@ export class TreeSitterExtractor {
     else if (this.extractor.importTypes.includes(nodeType)) {
       this.extractImport(node);
     }
+    // Re-export from another module — `export { X } from './y'` (TS/JS). A
+    // re-export is a dependency on the source module just like an import, but
+    // the export_statement is otherwise only descended into (no declaration to
+    // extract), so a barrel that ONLY re-exports produced zero edges and showed
+    // 0 dependents. Link each re-exported name to its definition. Children are
+    // still visited (a non-re-export `export const X = …` has no `source` and
+    // falls through to its normal declaration extraction).
+    else if (
+      nodeType === 'export_statement' &&
+      (this.language === 'typescript' || this.language === 'tsx' ||
+       this.language === 'javascript' || this.language === 'jsx') &&
+      getChildByField(node, 'source')
+    ) {
+      const parentId = this.nodeStack[this.nodeStack.length - 1];
+      if (parentId) this.emitReExportRefs(node, parentId);
+    }
     // Check for function calls
     else if (this.extractor.callTypes.includes(nodeType)) {
       this.extractCall(node);
@@ -1349,6 +1366,13 @@ export class TreeSitterExtractor {
             signature: initSignature,
           });
         }
+        // Walk the initializer so composite literals and calls in a
+        // package-level `var Query Binding = queryBinding{}` (a registry of
+        // implementations) or `var c = pkg.New()` are extracted as
+        // instantiates/calls dependencies — the body walker only covers
+        // initializers inside functions, not these top-level declarations.
+        const valueField = getChildByField(spec, 'value');
+        if (valueField) this.visitFunctionBody(valueField, '');
       }
 
       // Handle short_var_declaration (:=)
@@ -1485,6 +1509,13 @@ export class TreeSitterExtractor {
       // Extract interface inheritance from the inner type node
       const typeChild = getChildByField(node, 'type');
       if (typeChild) this.extractInheritance(typeChild, interfaceNode.id);
+      // Go: extract the interface's method specs as `method` nodes so implicit
+      // interface satisfaction (a struct's method set ⊇ the interface's) and
+      // impl-navigation can see the contract. Go has no `implements` keyword, so
+      // without the interface's method set there's nothing to match against.
+      if (this.language === 'go' && typeChild) {
+        this.extractGoInterfaceMethods(typeChild, interfaceNode.id);
+      }
       return true;
     }
 
@@ -1512,6 +1543,30 @@ export class TreeSitterExtractor {
     return false;
   }
 
+  /**
+   * Extract the method specs of a Go `interface_type` body as `method` nodes
+   * contained by the interface (e.g. `Marshal`, `Unmarshal` of a `Core`
+   * interface). tree-sitter-go names these `method_elem` (newer) or
+   * `method_spec` (older). Embedded interfaces (`Reader` inside `ReadWriter`)
+   * are `type_identifier`s, not methods, and are left to inheritance extraction.
+   */
+  private extractGoInterfaceMethods(interfaceType: SyntaxNode, ifaceId: string): void {
+    this.nodeStack.push(ifaceId);
+    for (let i = 0; i < interfaceType.namedChildCount; i++) {
+      const m = interfaceType.namedChild(i);
+      if (!m || (m.type !== 'method_elem' && m.type !== 'method_spec')) continue;
+      const nameNode = getChildByField(m, 'name') ?? m.namedChild(0);
+      if (!nameNode) continue;
+      const mname = getNodeText(nameNode, this.source);
+      if (mname) {
+        this.createNode('method', mname, m, {
+          signature: this.extractor?.getSignature?.(m, this.source),
+        });
+      }
+    }
+    this.nodeStack.pop();
+  }
+
   /**
    * Surface the members of a TypeScript `type X = { ... }` (or intersection
    * thereof) as `property` / `method` nodes under the type-alias node. Only
@@ -1620,6 +1675,23 @@ export class TreeSitterExtractor {
             });
           }
         }
+        // Link each imported binding to its definition so imported-but-not-
+        // called/typed symbols still record a cross-file dependency (TS/JS only).
+        if (
+          this.language === 'typescript' || this.language === 'tsx' ||
+          this.language === 'javascript' || this.language === 'jsx'
+        ) {
+          const parentId = this.nodeStack[this.nodeStack.length - 1];
+          if (parentId) this.emitImportBindingRefs(node, parentId);
+        }
+        // Python `from module import X, Y` — link each imported name to its
+        // definition (covers `__init__.py` re-export barrels, which are just
+        // `from .sub import X`). Same recall gap as TS: a name imported and
+        // used in a non-call position created no dependency edge.
+        if (this.language === 'python' && node.type === 'import_from_statement') {
+          const parentId = this.nodeStack[this.nodeStack.length - 1];
+          if (parentId) this.emitPyFromImportRefs(node, parentId);
+        }
         return;
       }
       // Hook returned null — fall through to multi-import inline handlers only
@@ -1722,6 +1794,134 @@ export class TreeSitterExtractor {
     });
   }
 
+  /**
+   * Emit one `imports` reference per named/default import binding (TS/JS family),
+   * attributed to the file node — so the resolver links each imported symbol to
+   * the file that DEFINES it.
+   *
+   * Importing a symbol IS a dependency, but extraction only emits references for
+   * calls, instantiations, type annotations, and inheritance. A symbol that's
+   * imported and then only re-exported (`export { X } from './x'`), placed in a
+   * registry array (`[expressResolver, …]`), passed as an argument, or used in
+   * JSX produced NO cross-file edge at all — so the providing file showed a
+   * false "0 dependents" and was invisible to blast-radius / `affected`. The
+   * resolver maps the local name (alias-aware) to the provider's definition and
+   * creates a cross-file `imports` edge; `getFileDependents` picks it up, while
+   * `getImpactRadius` keeps it as a bounded leaf (the importing file node).
+   *
+   * Namespace imports (`import * as NS`) bind a whole module: `NS.member` calls
+   * resolve on their own, but a namespace used ONLY via a value-member read
+   * (`NS.SOME_CONST`) would leave no edge — so we also emit the namespace local
+   * name, which the resolver links to the module FILE as a dependency backstop.
+   */
+  private emitImportBindingRefs(node: SyntaxNode, fromNodeId: string): void {
+    const clause = node.namedChildren.find((c) => c.type === 'import_clause');
+    if (!clause) return; // side-effect import (`import './x'`) — no bindings
+
+    const pushRef = (nameNode: SyntaxNode | null | undefined): void => {
+      if (!nameNode) return;
+      const name = getNodeText(nameNode, this.source);
+      if (!name) return;
+      this.unresolvedReferences.push({
+        fromNodeId,
+        referenceName: name,
+        referenceKind: 'imports',
+        line: nameNode.startPosition.row + 1,
+        column: nameNode.startPosition.column,
+      });
+    };
+
+    for (const child of clause.namedChildren) {
+      if (child.type === 'identifier') {
+        // default import: `import Foo from './x'`
+        pushRef(child);
+      } else if (child.type === 'named_imports') {
+        // `import { A, B as C } from './x'` — link the LOCAL name (alias if any)
+        for (const spec of child.namedChildren) {
+          if (spec.type !== 'import_specifier') continue;
+          pushRef(getChildByField(spec, 'alias') ?? getChildByField(spec, 'name') ?? spec.namedChild(0));
+        }
+      } else if (child.type === 'namespace_import') {
+        // `import * as NS from './x'` — emit NS so the module-import backstop can
+        // record the file dependency even if NS is only used by value-member read.
+        pushRef(child.namedChildren.find((c) => c.type === 'identifier') ?? child.namedChild(0));
+      }
+    }
+  }
+
+  /**
+   * Emit one `imports` reference per re-exported binding of a
+   * `export { A, B as C } from './y'` statement, attributed to the file node —
+   * so a barrel that re-exports from another module records a dependency on it.
+   *
+   * Links the SOURCE-side name (`A`, the `name` field — not the local alias
+   * `C`), since that is what the source module defines. `export * from './y'`
+   * has no named bindings to attribute and `export { default as X }` can't be
+   * name-matched, so both are skipped.
+   */
+  private emitReExportRefs(node: SyntaxNode, fromNodeId: string): void {
+    const clause = node.namedChildren.find((c) => c.type === 'export_clause');
+    if (!clause) return; // `export * from './y'` — no named bindings
+    for (const spec of clause.namedChildren) {
+      if (spec.type !== 'export_specifier') continue;
+      const nameNode = getChildByField(spec, 'name') ?? spec.namedChild(0);
+      if (!nameNode) continue;
+      const name = getNodeText(nameNode, this.source);
+      if (!name || name === 'default') continue;
+      this.unresolvedReferences.push({
+        fromNodeId,
+        referenceName: name,
+        referenceKind: 'imports',
+        line: nameNode.startPosition.row + 1,
+        column: nameNode.startPosition.column,
+      });
+    }
+  }
+
+  /**
+   * Emit one `imports` reference per name imported in a Python
+   * `from module import A, B as C` statement, attributed to the file node — so
+   * the resolver links each imported name to the module that DEFINES it.
+   *
+   * Same recall gap as TS: extraction only emitted references for calls,
+   * instantiations, and inheritance, so a name imported and then used in a
+   * non-call position (a list/dict literal, a default argument, a decorator
+   * target, or simply re-exported through an `__init__.py` barrel) produced no
+   * cross-file edge — the providing module showed a false "0 dependents". Links
+   * the LOCAL name (alias when present, since that's what the resolver's import
+   * mapping keys on); `from module import *` has no names to attribute.
+   */
+  private emitPyFromImportRefs(node: SyntaxNode, fromNodeId: string): void {
+    const moduleNameNode = getChildByField(node, 'module_name');
+    for (const child of node.namedChildren) {
+      // Skip the `from <module>` part itself and `import *`.
+      if (moduleNameNode &&
+          child.startIndex === moduleNameNode.startIndex &&
+          child.endIndex === moduleNameNode.endIndex) continue;
+      if (child.type === 'wildcard_import') continue;
+
+      let nameNode: SyntaxNode | null | undefined = null;
+      if (child.type === 'aliased_import') {
+        nameNode = getChildByField(child, 'alias') ?? getChildByField(child, 'name') ?? child.namedChild(0);
+      } else if (child.type === 'dotted_name') {
+        nameNode = child;
+      }
+      if (!nameNode) continue;
+
+      const raw = getNodeText(nameNode, this.source);
+      // Imported names are simple identifiers; defensively take the last segment.
+      const local = raw.includes('.') ? raw.split('.').pop()! : raw;
+      if (!local) continue;
+      this.unresolvedReferences.push({
+        fromNodeId,
+        referenceName: local,
+        referenceKind: 'imports',
+        line: nameNode.startPosition.row + 1,
+        column: nameNode.startPosition.column,
+      });
+    }
+  }
+
   /**
    * Extract a function call
    */
@@ -1856,6 +2056,16 @@ export class TreeSitterExtractor {
       }
     }
 
+    // Parenthesized type conversions — Go `(*T)(x)` / `(T)(x)` (and a
+    // parenthesized callee generally) parse as a call whose "function" is a
+    // parenthesized type/expression, so the callee text is the un-resolvable
+    // literal `(*T)`. Normalize to the inner name so it resolves to `T` (a real
+    // dependency on the converted-to type) instead of dropping on the floor.
+    if (calleeName) {
+      const conv = calleeName.match(/^\(\s*\*?\s*([A-Za-z_][\w.]*)\s*\)$/);
+      if (conv && conv[1]) calleeName = conv[1];
+    }
+
     if (calleeName) {
       this.unresolvedReferences.push({
         fromNodeId: callerId,
@@ -1890,6 +2100,29 @@ export class TreeSitterExtractor {
       node.namedChild(0);
     if (!ctor) return;
 
+    // Go composite literals: `Widget{...}` (same package) and `pkga.Widget{...}`
+    // (cross-package). Only a directly-named struct type is a meaningful
+    // instantiation target — skip slice/map/array literals (`[]T{}`,
+    // `map[K]V{}`) whose `type` field is a composite type, not a named type.
+    // Unlike `new ns.Foo()`, KEEP the package qualifier (`pkga.Widget`) so the
+    // Go cross-package resolver can disambiguate it to the right package's type.
+    if (node.type === 'composite_literal') {
+      if (ctor.type !== 'type_identifier' && ctor.type !== 'qualified_type') return;
+      let goType = getNodeText(ctor, this.source).trim();
+      const brIdx = goType.indexOf('['); // strip Go generic args: `Box[T]{}` -> `Box`
+      if (brIdx > 0) goType = goType.slice(0, brIdx).trim();
+      if (goType) {
+        this.unresolvedReferences.push({
+          fromNodeId: fromId,
+          referenceName: goType,
+          referenceKind: 'instantiates',
+          line: node.startPosition.row + 1,
+          column: node.startPosition.column,
+        });
+      }
+      return;
+    }
+
     let className = getNodeText(ctor, this.source);
     // Strip type-argument suffix first: `new Map<K, V>()` would
     // otherwise produce className 'Map<K, V>' (the constructor
@@ -2143,6 +2376,24 @@ export class TreeSitterExtractor {
         }
       }
 
+      // Local variable type annotations inside a body — `const items: Foo[] = []`,
+      // `const x: SomeType = svc.load()`. We deliberately do NOT create nodes for
+      // locals (that would explode the graph — the data-flow frontier we leave
+      // uncovered), but the TYPE a local is annotated with is a real dependency of
+      // the enclosing function, so attribute a `references` edge to it. Without
+      // this, a function that uses a type ONLY in its body (very common — e.g. a
+      // resolver building `const nodes: Node[] = []`) produced no edge to that
+      // type, so impact / `affected` missed the dependency entirely. We fall
+      // through to the default recursion below so the initializer's calls (and any
+      // nested declarators) are still walked.
+      if (
+        nodeType === 'variable_declarator' &&
+        this.TYPE_ANNOTATION_LANGUAGES.has(this.language)
+      ) {
+        const ownerId = this.nodeStack[this.nodeStack.length - 1];
+        if (ownerId) this.extractVariableTypeAnnotation(node, ownerId);
+      }
+
       // Nested NAMED functions inside a body — function declarations and named
       // function expressions like `.on('mount', function onmount(){})` — become
       // their own nodes so the graph can link to them (callback handlers, local

+ 13 - 47
src/graph/queries.ts

@@ -116,24 +116,11 @@ export class GraphQueryManager {
    * @returns Array of file paths this file depends on
    */
   getFileDependencies(filePath: string): string[] {
-    const nodes = this.queries.getNodesByFile(filePath);
-    const fileNode = nodes.find((n) => n.kind === 'file');
-
-    if (!fileNode) {
-      return [];
-    }
-
-    const dependencies = new Set<string>();
-    const importEdges = this.queries.getOutgoingEdges(fileNode.id, ['imports']);
-
-    for (const edge of importEdges) {
-      const targetNode = this.queries.getNodeById(edge.target);
-      if (targetNode && targetNode.filePath !== filePath) {
-        dependencies.add(targetNode.filePath);
-      }
-    }
-
-    return Array.from(dependencies);
+    // Follow the symbol-level cross-file edge graph, not just `imports`:
+    // an `imports` edge here points from a file to its own local import
+    // declarations (same-file), so the actual cross-file dependencies live in
+    // the resolved calls/references/instantiates/extends/... edges.
+    return this.queries.getDependencyFilePaths(filePath);
   }
 
   /**
@@ -145,35 +132,14 @@ export class GraphQueryManager {
    * @returns Array of file paths that depend on this file
    */
   getFileDependents(filePath: string): string[] {
-    const nodes = this.queries.getNodesByFile(filePath);
-    const dependents = new Set<string>();
-
-    // Check file-level incoming import edges (file:X imports file:Y)
-    const fileNode = nodes.find((n) => n.kind === 'file');
-    if (fileNode) {
-      const incomingFileEdges = this.queries.getIncomingEdges(fileNode.id, ['imports']);
-      for (const edge of incomingFileEdges) {
-        const sourceNode = this.queries.getNodeById(edge.source);
-        if (sourceNode && sourceNode.filePath !== filePath) {
-          dependents.add(sourceNode.filePath);
-        }
-      }
-    }
-
-    // Also check node-level imports of exported symbols
-    for (const node of nodes) {
-      if (node.isExported) {
-        const incomingEdges = this.queries.getIncomingEdges(node.id, ['imports']);
-        for (const edge of incomingEdges) {
-          const sourceNode = this.queries.getNodeById(edge.source);
-          if (sourceNode && sourceNode.filePath !== filePath) {
-            dependents.add(sourceNode.filePath);
-          }
-        }
-      }
-    }
-
-    return Array.from(dependents);
+    // Previously this only followed `imports` edges into the file node or its
+    // exported symbols and returned 0 dependents for *every* file — because an
+    // `imports` edge here connects a file to its own local import declarations
+    // (always same-file), never to the providing file. The real cross-file
+    // dependency signal is the resolved symbol graph (calls/references/
+    // instantiates/extends/implements/...), which is what blast-radius /
+    // `affected` need. Delegate to the indexed projection of that graph.
+    return this.queries.getDependentFilePaths(filePath);
   }
 
   /**

+ 69 - 2
src/resolution/callback-synthesizer.ts

@@ -444,8 +444,68 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] {
 // and are added below; their concrete-side nodes can be a `struct` (Swift)
 // or an `object` (Scala) so the loop also iterates those kinds.
 const IFACE_OVERRIDE_LANGS = new Set([
-  'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala',
+  'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go',
 ]);
+/**
+ * Go implicit interface satisfaction (#584). Go has no `implements` keyword — a
+ * struct satisfies an interface structurally when its method set covers the
+ * interface's. Synthesize the missing `implements` edge (struct → interface) by
+ * matching method-NAME sets, so impl-navigation works and the interface-dispatch
+ * bridge ({@link interfaceOverrideEdges}, now 'go'-enabled) can link an interface
+ * method call to the concrete overrides.
+ *
+ * Name-only matching (signatures ignored) — over-approximation accepted, in line
+ * with the other dispatch synthesizers; capped per interface. Empty interfaces
+ * (`any`) are skipped so they don't match every struct.
+ */
+function goImplementsEdges(queries: QueryBuilder): Edge[] {
+  const edges: Edge[] = [];
+  const seen = new Set<string>();
+
+  const methodNameSet = (id: string): Set<string> =>
+    new Set(
+      queries
+        .getOutgoingEdges(id, ['contains'])
+        .map((e) => queries.getNodeById(e.target))
+        .filter((n): n is Node => !!n && n.kind === 'method')
+        .map((n) => n.name),
+    );
+
+  const goStructs = queries.getNodesByKind('struct').filter((s) => s.language === 'go');
+  const structMethods = new Map<string, Set<string>>();
+  for (const s of goStructs) structMethods.set(s.id, methodNameSet(s.id));
+
+  for (const iface of queries.getNodesByKind('interface')) {
+    if (iface.language !== 'go') continue;
+    const want = methodNameSet(iface.id);
+    if (want.size === 0) continue; // empty interface (`any`) — would match everything
+    let added = 0;
+    for (const s of goStructs) {
+      if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
+      const have = structMethods.get(s.id);
+      if (!have || have.size < want.size) continue;
+      let all = true;
+      for (const m of want) {
+        if (!have.has(m)) { all = false; break; }
+      }
+      if (!all) continue;
+      const key = `${s.id}>${iface.id}`;
+      if (seen.has(key)) continue;
+      seen.add(key);
+      edges.push({
+        source: s.id,
+        target: iface.id,
+        kind: 'implements',
+        line: s.startLine,
+        provenance: 'heuristic',
+        metadata: { synthesizedBy: 'go-implements', via: iface.name, registeredAt: `${s.filePath}:${s.startLine}` },
+      });
+      added++;
+    }
+  }
+  return edges;
+}
+
 function interfaceOverrideEdges(queries: QueryBuilder): Edge[] {
   const edges: Edge[] = [];
   const seen = new Set<string>();
@@ -1190,6 +1250,13 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext):
  * count added. Never throws into indexing — callers wrap in try/catch.
  */
 export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
+  // Go implicit `implements` edges must be synthesized AND persisted first: the
+  // interface-dispatch bridge below reads `implements` edges from the DB, and
+  // Go has none statically. (Other languages already have static implements
+  // edges from extraction, so they don't need this pre-pass.)
+  const goImpl = goImplementsEdges(queries);
+  if (goImpl.length > 0) queries.insertEdges(goImpl);
+
   const fieldEdges = fieldChannelEdges(queries, ctx);
   const closureCollEdges = closureCollectionEdges(queries, ctx);
   const emitterEdges = eventEmitterEdges(ctx);
@@ -1229,5 +1296,5 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     merged.push(e);
   }
   if (merged.length > 0) queries.insertEdges(merged);
-  return merged.length;
+  return merged.length + goImpl.length;
 }

+ 157 - 0
src/resolution/import-resolver.ts

@@ -213,6 +213,24 @@ function resolveRelativeImport(
   const projectRoot = context.getProjectRoot();
   const extensions = EXTENSION_RESOLUTION[language] || [];
 
+  // Python dotted-relative imports (`from .certs import x`, `from ..pkg.mod
+  // import y`): leading dots are PACKAGE levels (1 = current package), and the
+  // remainder is a dotted submodule path. `path.resolve(dir, '.certs')` would
+  // treat `.certs` as a literal hidden filename, so translate the Python form
+  // to a real filesystem-relative path before resolving.
+  if (language === 'python' && importPath.startsWith('.')) {
+    const dots = importPath.length - importPath.replace(/^\.+/, '').length;
+    const up = '../'.repeat(Math.max(0, dots - 1));    // 1 dot = current dir
+    const rest = importPath.slice(dots).replace(/\./g, '/'); // 'sub.mod' -> 'sub/mod'
+    const pyBase = path.resolve(fromDir, up + rest);
+    const pyRel = path.relative(projectRoot, pyBase).replace(/\\/g, '/');
+    for (const ext of extensions) {
+      if (context.fileExists(pyRel + ext)) return pyRel + ext;
+    }
+    if (pyRel && context.fileExists(pyRel)) return pyRel;
+    return null;
+  }
+
   // Try the path as-is first
   const basePath = path.resolve(fromDir, importPath);
   const relativePath = path.relative(projectRoot, basePath).replace(/\\/g, '/');
@@ -1074,6 +1092,31 @@ export function resolveViaImport(
     if (javaResult) return javaResult;
   }
 
+  // Python qualified access through an imported MODULE: `certs.where()` after
+  // `from . import certs`, `mod.func()` after `import mod`. The receiver names a
+  // submodule (a file), not a symbol, so the generic symbol lookup below would
+  // search the *package* for `certs` instead of looking inside the module.
+  if (ref.language === 'python') {
+    const pyResult = resolvePythonModuleMember(ref, imports, context);
+    if (pyResult) return pyResult;
+  }
+
+  // Whole-module / namespace imports → link the importing file to the module
+  // file. Python `from . import certs` / `import mod`, and TS/JS `import * as ns
+  // from './x'` (so a namespace touched only via a value-member read still
+  // records the dependency). A named TS/JS import returns null here and falls
+  // through to symbol resolution below.
+  if (
+    ref.language === 'python' ||
+    ref.language === 'typescript' ||
+    ref.language === 'tsx' ||
+    ref.language === 'javascript' ||
+    ref.language === 'jsx'
+  ) {
+    const moduleFile = resolveModuleImportToFile(ref, imports, context);
+    if (moduleFile) return moduleFile;
+  }
+
   // Check if the reference name matches any import
   for (const imp of imports) {
     if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
@@ -1114,6 +1157,120 @@ export function resolveViaImport(
   return null;
 }
 
+/**
+ * Resolve a Python qualified reference whose receiver is an imported MODULE:
+ * `certs.where()` after `from . import certs`, `mod.func()` after `import mod`
+ * or `from pkg import mod`. The receiver names a submodule (a file), not a
+ * symbol, so the generic symbol lookup in `resolveViaImport` can't follow it —
+ * it would search the *package* for `certs`/`mod` instead of looking inside the
+ * module. This is the Python half of the cross-package qualified-call problem
+ * (cf. `resolveGoCrossPackageReference` for Go's `pkg.Func`, issue #388).
+ *
+ * Builds the module's dotted import path from the binding — `from . import
+ * certs` → `.certs`; `from pkg import mod` → `pkg.mod`; `import mod` → `mod` —
+ * resolves it to the module file, and finds the member defined there. Returns
+ * null when no module file exists at that path, so attribute access on an
+ * imported *value* (`helper.attr` where `helper` is a function) falls through
+ * to the other strategies untouched.
+ */
+function resolvePythonModuleMember(
+  ref: UnresolvedRef,
+  imports: ImportMapping[],
+  context: ResolutionContext
+): ResolvedRef | null {
+  const dotIdx = ref.referenceName.indexOf('.');
+  if (dotIdx <= 0) return null;
+  const receiver = ref.referenceName.substring(0, dotIdx);
+  // The immediate member of the module (first segment after the receiver).
+  const member = ref.referenceName.substring(dotIdx + 1).split('.')[0];
+  if (!member) return null;
+
+  for (const imp of imports) {
+    if (imp.localName !== receiver) continue;
+
+    // `import mod` / `import numpy as np` bind the module at `source` itself;
+    // `from . import certs` / `from pkg import mod` bind a SUBMODULE whose
+    // dotted path is the source joined with the imported name.
+    const modulePath = imp.isNamespace
+      ? imp.source
+      : imp.source.endsWith('.')
+        ? imp.source + imp.localName
+        : imp.source + '.' + imp.localName;
+
+    const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
+    if (!resolvedPath || resolvedPath === ref.filePath) continue;
+
+    // Find the member as a top-level definition in the module file. Exclude
+    // `method` so `mod.foo` never lands on a same-named class method.
+    const target = context.getNodesInFile(resolvedPath).find(
+      (n) =>
+        n.name === member &&
+        (n.kind === 'function' ||
+          n.kind === 'class' ||
+          n.kind === 'variable' ||
+          n.kind === 'constant')
+    );
+    if (target) {
+      return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
+    }
+  }
+  return null;
+}
+
+/**
+ * Resolve a whole-MODULE import to that module's file (a file→file dependency).
+ * The imported name is a module, not a symbol, so there's nothing to resolve to
+ * — but importing a module IS a dependency on it. Covers:
+ *   - Python submodule imports — `from . import certs`, `from pkg import sub`;
+ *   - namespace imports — Python `import mod` / `import numpy as np`, and
+ *     TS/JS `import * as ns from './x'`.
+ *
+ * It is also the robust backstop for {@link resolvePythonModuleMember} and for
+ * TS namespace usage: it records the dependency even when the used member is
+ * re-exported elsewhere (requests' `certs.where`, re-exported from `certifi`),
+ * the usage is module-level code that isn't extracted as a call, or a TS
+ * namespace is touched only via a value-member read (`ns.SOME_CONST`).
+ *
+ * Only fires for dot-free `imports`-kind refs whose module path resolves to a
+ * real file. A NAMED TS/JS import (`import { widget }`) is not a module, so it
+ * returns null and normal symbol resolution handles it.
+ */
+function resolveModuleImportToFile(
+  ref: UnresolvedRef,
+  imports: ImportMapping[],
+  context: ResolutionContext
+): ResolvedRef | null {
+  if (ref.referenceKind !== 'imports') return null;
+  if (ref.referenceName.includes('.')) return null;
+
+  for (const imp of imports) {
+    if (imp.localName !== ref.referenceName) continue;
+
+    let modulePath: string;
+    if (imp.isNamespace) {
+      // `import * as ns from './x'` / `import mod` — the source IS the module.
+      modulePath = imp.source;
+    } else if (ref.language === 'python') {
+      // `from . import certs` — the imported NAME is a submodule of the source.
+      modulePath = imp.source.endsWith('.')
+        ? imp.source + imp.localName
+        : imp.source + '.' + imp.localName;
+    } else {
+      // A named TS/JS import binds a symbol, not a module — leave it alone.
+      continue;
+    }
+
+    const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
+    if (!resolvedPath || resolvedPath === ref.filePath) continue;
+
+    const fileNode = context.getNodesInFile(resolvedPath).find((n) => n.kind === 'file');
+    if (fileNode) {
+      return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
+    }
+  }
+  return null;
+}
+
 /**
  * Resolve a Java/Kotlin reference whose receiver is the simple name of
  * an imported FQN: `Foo.bar(...)` where `import com.example.Foo;`. The