fix(extraction): TS type-alias object members are first-class nodes (#359) (#471)

A call site `recorder.stop()` where `recorder: RecorderHandle` and
`type RecorderHandle = { stop: () => Promise<void> }` used to attach
its edge to an unrelated `class Foo { stop() {} }` in a sibling
directory — there was no `RecorderHandle::stop` node, so the existing
camelCase/path-proximity scoring picked the only `stop` method in the
graph (which happened to be wrong). False-positive `calls` edges
silently widened `codegraph_impact` blast radius.

`extractTypeAlias` now surfaces object-shape (and intersection-type)
members as first-class graph nodes:

  type X = { foo: T; bar(): T };
  ->  X        (type_alias)
      X::foo   (property)
      X::bar   (method)

Function-typed properties (`stop: () => Promise<void>`) emit as `method`
kind so `obj.stop()` resolves to them at the call site — same node
kind the existing receiver-name/word-overlap heuristic in
`matchMethodCall` already prefers. No new resolver logic needed.

Walk only immediate `object_type` / `intersection_type` operands of the
alias value. Anonymous nested object types inside generic arguments
(`Promise<{ ok: true }>`) intentionally don't produce phantom members.

Validation on excalidraw/excalidraw (314 .ts files):
  +776 new property nodes (alias non-function members)
  +1,008 new method nodes (alias function-typed properties + method_signatures)
  +226 calls edges newly accurate against alias members

User's exact 3-file repro:
  before: finaliseRecording -> StdioMcpClient::stop (wrong, sibling dir)
  after:  finaliseRecording -> RecorderHandle::stop (correct)
  StdioMcpClient::stop callers: voice/ false-positives gone

Closes #359.
This commit is contained in:
Colby Mchenry
2026-05-26 17:35:26 -05:00
committed by GitHub
parent 046e03a05f
commit 186632fa88
3 changed files with 139 additions and 0 deletions
+60
View File
@@ -742,6 +742,66 @@ func UseAliased() {
expect(target?.filePath.replace(/\\/g, '/')).toBe('pkgb/lib.go');
});
it('TS type_alias object-shape members resolve method calls (#359)', async () => {
// Pre-#359, `recorder.stop()` (recorder: RecorderHandle) attached
// to `StdioMcpClient.stop` in a sibling directory via path-proximity
// because the type_alias had no `stop` node — only the unrelated
// class did. Now type_alias produces member nodes (property/method),
// so the camelCase receiver↔type word overlap pulls the call to
// `RecorderHandle::stop` instead of the look-alike class.
fs.mkdirSync(path.join(tempDir, 'voice'));
fs.mkdirSync(path.join(tempDir, 'codegraph'));
fs.writeFileSync(
path.join(tempDir, 'voice', 'recorder.ts'),
`export type RecorderHandle = {
wavPath: string;
stop: () => Promise<{ ok: true }>;
};
`
);
fs.writeFileSync(
path.join(tempDir, 'voice', 'controller.ts'),
`import type { RecorderHandle } from "./recorder";
export async function finaliseRecording(recorder: RecorderHandle) {
return await recorder.stop();
}
`
);
fs.writeFileSync(
path.join(tempDir, 'codegraph', 'stdio-client.ts'),
`export class StdioMcpClient {
private stopped = false;
async stop(): Promise<void> { this.stopped = true; }
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
const handleStop = cg
.getNodesByKind('method')
.find((n) => n.qualifiedName === 'RecorderHandle::stop');
expect(handleStop).toBeDefined();
const clientStop = cg
.getNodesByKind('method')
.find((n) => n.qualifiedName === 'StdioMcpClient::stop');
expect(clientStop).toBeDefined();
const handleCallers = cg.getIncomingEdges(handleStop!.id).filter((e) => e.kind === 'calls');
const clientCallers = cg.getIncomingEdges(clientStop!.id).filter((e) => e.kind === 'calls');
expect(handleCallers.length).toBeGreaterThanOrEqual(1);
// The class method must have NO callers — voice/'s call must NOT
// mis-attribute. A non-empty list would mean the false-positive
// path is still firing.
expect(clientCallers).toHaveLength(0);
// Function-typed property surfaces as a `method` node, not `property`,
// because `stop()` semantics at the call site are method semantics.
expect(handleStop!.kind).toBe('method');
});
it('C# extracts references from method/property/field types (#381)', async () => {
// Pre-#381, every C# project produced ZERO `references` edges:
// csharp.ts was missing returnField, and the type-leaf walker