Răsfoiți Sursa

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.
Colby Mchenry 3 săptămâni în urmă
părinte
comite
186632fa88
3 a modificat fișierele cu 139 adăugiri și 0 ștergeri
  1. 1 0
      CHANGELOG.md
  2. 60 0
      __tests__/resolution.test.ts
  3. 78 0
      src/extraction/tree-sitter.ts

+ 1 - 0
CHANGELOG.md

@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
   - **Field-injected concrete-bean trace.** A Spring controller's `@Resource(name="userBO") private UserBO userbo;` followed by `this.userbo.toLogin2(...)` now resolves through to `UserBO.toLogin2` even when the field type is a concrete class whose name doesn't match the field by Java naming convention (`userbo` → `UserBO`). The fix is two layered changes in the language layer (Java only): (a) the call extractor unwraps `this.<field>` receivers (previously surfaced as `this.userbo.toLogin2` and dropped through every name-matcher strategy); (b) the resolver looks up the receiver name in the enclosing class's field declarations and uses the declared type to resolve the method. This generalizes beyond Spring — any Java code using `this.field.method()` now resolves correctly.
 
 ### Fixed
+- **TypeScript `type` aliases with object shapes no longer cause cross-module false-positive call edges (#359).** Receiver-typed `handle.stop()` where `handle: RecorderHandle` and `RecorderHandle = { stop: () => Promise<void> }` used to attach the call edge to an unrelated `class Foo { stop() {} }` in a sibling directory via path-proximity matching, because the type alias had no `stop` node — only the look-alike class did. The fix surfaces type-alias object-shape members (and intersection-type members) as first-class `property`/`method` nodes under the alias: `type X = { foo: T; bar(): T }` now produces `X::foo` and `X::bar` in the graph. Function-typed properties (`stop: () => Promise<void>`) are emitted as `method` kind so `obj.stop()` resolves to them; non-function properties remain `property` kind. With the alias's members in the graph, the existing camelCase receiver-name word overlap (`recorder` ↔ `RecorderHandle`) routes the call to the correct alias member instead of the wrong class. Anonymous nested object types inside generic arguments (`Promise<{ ok: true }>`) intentionally don't produce phantom members — only immediate `object_type` / `intersection_type` operands of the alias value are walked. Measured on excalidraw/excalidraw (314 .ts files): **+776 new property nodes** + **+1,008 method nodes from type-alias members** + **+226 newly accurate `calls` edges** pointing at alias members (some shifted from incorrect class targets, some previously unresolved).
 - **C# now produces `references` edges for parameter, return, property, and field types (#381).** Indexing any C# project used to yield **zero** `references` edges, so `codegraph_callers SomeDto` returned no results even when the DTO was used as a parameter or return type across the codebase, and `codegraph_callees` on a service class only saw its `using` imports. Two root causes: `csharp.ts` was missing `returnField`, and the type-leaf walker only matched `type_identifier` nodes — but C# tree-sitter emits `identifier`/`predefined_type`/`qualified_name`/`generic_name` instead. The fix adds the missing extractor field, routes C# through a dedicated type walker that only descends into known type-position fields (so parameter NAMES like `request` in `Build(UserDto request)` never mis-emit as type refs), and hooks `extractField`/`extractProperty` to invoke the walker. Measured on dotnet/eShop (527 `.cs` files): C# `references` edges go from **35 → 925** (+26x), with no regression in `calls`/`imports`/`instantiates`/`extends`/`implements`.
 - **Go cross-package qualified calls (`pkga.FuncX(...)`) now resolve to the right package (#388).** On a Go monorepo with a layered package layout (handler/service/domain/dao), `codegraph_callers`, `_callees`, `_impact`, and `_trace` used to return ~0-1 results where grep finds hundreds to thousands of real call sites — the central value proposition of CodeGraph silently degraded on entire Go codebases. Root cause: the import resolver flagged every Go import path without `/internal/` as third-party (because it had no idea what the project's own module path was), so cross-package calls fell through to name-matching with path-proximity scoring, which on real codebases picks ~one accidental candidate per call site. The Go branch now reads the project's `go.mod`, treats `<module-path>/...` imports as in-module, and looks up the qualified symbol in the imported package's directory; same-name functions in *different* packages no longer collide. As a side fix, Go nodes now correctly carry `is_exported=1` for capitalized identifiers (the resolver needs this to filter candidates). Measured on gRPC-Go (1,031 `.go` files, layered packages): cross-package `calls` edges go from 10,880 → 19,929 (**+83%**), total `calls` from 23,803 → 34,105 (**+43%**), with no false-positive resolution of stdlib calls (`fmt.Println` etc. stay external).
 - **`codegraph_files` now returns the whole project when an agent passes `path="/"`, `"."`, `"./"`, `""`, or a Windows-style `"\\"` — instead of "No files found matching the criteria."** Indexed file paths are stored as project-relative POSIX (e.g. `src/foo.ts`), but the path filter used a plain `startsWith`, so a leading slash or any of the other root-ish shapes an agent might guess matched nothing and pushed the agent back to Read/Glob — the exact opencode + Gemini Flash regression reported on Windows 11. Subdirectory filters are now equally forgiving: `"/src"`, `"./src"`, `"src/"`, `"src\\components"`, etc. all resolve correctly. Sibling-prefix bleed (`"src"` was previously matching `src-utils/...`) is also fixed — the filter now requires either an exact match or a `<filter>/` boundary. Closes #426.

+ 60 - 0
__tests__/resolution.test.ts

@@ -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

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

@@ -1318,8 +1318,86 @@ 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;
   }