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:
@@ -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.)
|
||||
|
||||
Reference in New Issue
Block a user