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
+78
View File
@@ -1318,11 +1318,89 @@ export class TreeSitterExtractor {
const value = getChildByField(node, 'value');
if (value) {
this.extractTypeRefsFromSubtree(value, typeAliasNode.id);
// `type X = { foo: T; bar(): T }` — make the members first-class
// property/method nodes under the type alias so `recorder.stop()`
// can attach the call edge to `RecorderHandle.stop` instead of
// an unrelated class method picked by path-proximity (#359).
if (this.language === 'typescript' || this.language === 'tsx') {
this.extractTsTypeAliasMembers(value, typeAliasNode);
}
}
}
return false;
}
/**
* Surface the members of a TypeScript `type X = { ... }` (or intersection
* thereof) as `property` / `method` nodes under the type-alias node. Only
* walks the immediate object_type / intersection operands so anonymous
* nested object types inside generic arguments (`Promise<{ ok: true }>`)
* don't produce phantom members.
*/
private extractTsTypeAliasMembers(value: SyntaxNode, typeAliasNode: Node): void {
const objectTypes: SyntaxNode[] = [];
if (value.type === 'object_type') {
objectTypes.push(value);
} else if (value.type === 'intersection_type') {
for (let i = 0; i < value.namedChildCount; i++) {
const op = value.namedChild(i);
if (op && op.type === 'object_type') objectTypes.push(op);
}
} else {
return;
}
this.nodeStack.push(typeAliasNode.id);
for (const objType of objectTypes) {
for (let i = 0; i < objType.namedChildCount; i++) {
const child = objType.namedChild(i);
if (!child) continue;
if (child.type !== 'property_signature' && child.type !== 'method_signature') continue;
const nameNode = getChildByField(child, 'name');
const memberName = nameNode ? getNodeText(nameNode, this.source) : '';
if (!memberName) continue;
// `foo: () => T` and `foo(): T` are functionally a method on the
// type contract. Treat the property_signature with a function-typed
// annotation as a method too so call sites can resolve to it.
const memberKind: NodeKind = child.type === 'method_signature'
? 'method'
: this.isTsFunctionTypedProperty(child) ? 'method' : 'property';
const docstring = getPrecedingDocstring(child, this.source);
const signature = getNodeText(child, this.source);
this.createNode(memberKind, memberName, child, {
docstring,
signature,
qualifiedName: `${typeAliasNode.name}::${memberName}`,
});
// Emit `references` edges from the type alias to types named in the
// member's signature, matching the interface-member behavior added in
// #432. We attach refs to the type-alias parent (consistent with
// interface property_signature treatment).
this.extractTypeAnnotations(child, typeAliasNode.id);
}
}
this.nodeStack.pop();
}
/**
* `foo: () => T` → property_signature whose type_annotation contains a
* `function_type`. Treat that as a method-shaped contract member, since
* the call site `obj.foo()` has identical semantics to `bar(): T`.
*/
private isTsFunctionTypedProperty(propertySignature: SyntaxNode): boolean {
const typeAnno = getChildByField(propertySignature, 'type');
if (!typeAnno) return false;
for (let i = 0; i < typeAnno.namedChildCount; i++) {
const inner = typeAnno.namedChild(i);
if (inner && inner.type === 'function_type') return true;
}
return false;
}
// extractExportedVariables removed — the walker now descends into
// export_statement children and the inner declaration's dedicated
// extractor (extractVariable, extractFunction, extractClass, etc.)