Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions __tests__/ts-di-method-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* TS/JS `this.<field>.method()` resolution via the field's declared type.
*
* A method call on an injected/declared field, like `this.userService.findAll()`,
* used to degrade to the bare name `findAll`, which (a) misbound to a
* same-named method on an unrelated class when several existed, and (b) left
* the real target with zero callers. The receiver `this.<field>` is now
* re-encoded as `<field>.method` and resolved on the field's declared type,
* recovered from the constructor parameter property or a class-body field.
*
* This is the static-analysis case that dominates NestJS (controllers calling
* injected services, services calling injected repositories) and any typed TS
* OOP code. resolveMethodOnType validates the method exists on the inferred
* type, so the typed path only ever REPLACES a wrong/ambiguous bare-name
* binding with the correct one. It never forces an unvalidated edge.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { initGrammars, loadAllGrammars } from '../src/extraction/grammars';

let tmpDir: string;
let cg: CodeGraph;

/** `Class::method` qualified names every `calls` edge out of the method `fromQn` points at. */
const callTargetsFrom = (fromQn: string): string[] => {
const from = cg.getNodesByName(fromQn.split('::').pop()!).find((n) => n.qualifiedName === fromQn);
if (!from) return [];
return cg
.getOutgoingEdges(from.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)?.qualifiedName)
.filter((qn): qn is string => Boolean(qn));
};

const callerCount = (methodQn: string): number => {
const node = cg
.getNodesByName(methodQn.split('::').pop()!)
.find((n) => n.qualifiedName === methodQn);
if (!node) return -1;
return cg.getIncomingEdges(node.id).filter((e) => e.kind === 'calls').length;
};

beforeAll(async () => {
await initGrammars();
await loadAllGrammars();

tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-tsdi-'));
const mk = (rel: string, content: string): void => {
const p = path.join(tmpDir, rel);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, content);
};

// Two services share findAll() and create(); the bare name alone can't tell
// them apart. wipe() exists only on ProductService.
mk(
'src/user.service.ts',
['export class UserService {', ' findAll() { return ["u"]; }', ' create() { return "u"; }', '}'].join('\n')
);
mk(
'src/product.service.ts',
[
'export class ProductService {',
' findAll() { return ["p"]; }',
' create() { return "p"; }',
' wipe() { return "gone"; }',
'}',
].join('\n')
);
// Constructor parameter property injection (the NestJS norm).
mk(
'src/user.controller.ts',
[
"import { UserService } from './user.service';",
'export class UserController {',
' constructor(private readonly userService: UserService) {}',
' list() { return this.userService.findAll(); }',
' add() { return this.userService.create(); }',
'}',
].join('\n')
);
// Class-body field injection with an explicit type annotation.
mk(
'src/product.controller.ts',
[
"import { ProductService } from './product.service';",
'export class ProductController {',
' private readonly productService: ProductService;',
' constructor(productService: ProductService) { this.productService = productService; }',
' list() { return this.productService.findAll(); }',
' purge() { return this.productService.wipe(); }',
'}',
].join('\n')
);

cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();
}, 120_000);

afterAll(() => {
cg?.destroy();
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
});

describe('TS this.<field>.method() resolves on the field type', () => {
it('constructor-injected service: shared method names bind to the injected type', () => {
expect(callTargetsFrom('UserController::list')).toContain('UserService::findAll');
expect(callTargetsFrom('UserController::add')).toContain('UserService::create');
// The wrong-class bindings the bare-name path produced are gone.
expect(callTargetsFrom('UserController::list')).not.toContain('ProductService::findAll');
expect(callTargetsFrom('UserController::add')).not.toContain('ProductService::create');
});

it('class-body typed field is resolved the same way', () => {
expect(callTargetsFrom('ProductController::list')).toContain('ProductService::findAll');
expect(callTargetsFrom('ProductController::list')).not.toContain('UserService::findAll');
});

it('the real targets gain their caller (no more zero-caller false positive)', () => {
expect(callerCount('UserService::findAll')).toBe(1);
expect(callerCount('UserService::create')).toBe(1);
expect(callerCount('ProductService::findAll')).toBe(1);
});

it('a method unique to the injected type still resolves', () => {
expect(callTargetsFrom('ProductController::purge')).toContain('ProductService::wipe');
expect(callerCount('ProductService::wipe')).toBe(1);
});
});
25 changes: 25 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3182,6 +3182,31 @@ export class TreeSitterExtractor {
} else {
calleeName = methodName;
}
} else if (
(this.language === 'typescript' || this.language === 'javascript') &&
receiver &&
receiver.type === 'member_expression'
) {
// `this.<field>.method()`: the receiver is itself a member access,
// so it degrades to the bare `method` name below. A bare method
// name is unresolvable when several classes share it, and worse,
// tends to bind to the wrong class (a same-named method elsewhere).
// When the receiver is `this.<field>`, re-encode as `<field>.method`
// so the resolver can recover the field's declared type from the
// enclosing class (constructor parameter property or class-body
// field) and resolve the method on THAT type, mirroring the Java
// `this.userbo.toLogin2()` unwrap above. Deeper chains
// (`this.a.b.method()`) keep the bare name.
const innerObj = getChildByField(receiver, 'object');
const innerProp = getChildByField(receiver, 'property');
const innerObjIsThis =
innerObj?.type === 'this' ||
(innerObj ? getNodeText(innerObj, this.source) === 'this' : false);
if (innerObjIsThis && innerProp && innerProp.type === 'property_identifier') {
calleeName = `${getNodeText(innerProp, this.source)}.${methodName}`;
} else {
calleeName = methodName;
}
} else if (
(this.language === 'cpp' ||
this.language === 'c' ||
Expand Down
159 changes: 158 additions & 1 deletion src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ function resolveMethodOnType(
return null;
}

if (matches.length > 1 && preferredFqn) {
if (matches.length > 1 && preferredFqn && (ref.language === 'java' || ref.language === 'kotlin')) {
const ext = ref.language === 'kotlin' ? '.kt' : '.java';
const fqnPath = preferredFqn.replace(/\./g, '/') + ext;
const chosen = matches.find((m) => {
Expand All @@ -465,6 +465,21 @@ function resolveMethodOnType(
}
}

// TS/JS: a per-app `UserService` (and its `findAll`) repeats across a
// monorepo, so several methods share `UserService::findAll`. The caller's own
// location disambiguates: prefer the candidate whose file shares the longest
// directory prefix with the call site (the importer's app). This is the
// file-path proximity the bare-name path used before re-encoding, kept here so
// the typed path doesn't bind `apps/billing`'s call to `apps/admin`'s method.
if (matches.length > 1 && (ref.language === 'typescript' || ref.language === 'javascript')) {
return {
original: ref,
targetNodeId: pickClosestByDir(matches, ref.filePath).id,
confidence,
resolvedBy,
};
}

return {
original: ref,
targetNodeId: matches[0]!.id,
Expand All @@ -473,6 +488,30 @@ function resolveMethodOnType(
};
}

/** Pick the candidate whose file shares the longest leading directory prefix with `fromPath`. */
function pickClosestByDir(candidates: Node[], fromPath: string): Node {
const fromDirs = fromPath.replace(/\\/g, '/').split('/').slice(0, -1);
const sharedPrefix = (p: string): number => {
const d = p.replace(/\\/g, '/').split('/').slice(0, -1);
let shared = 0;
for (let i = 0; i < Math.min(fromDirs.length, d.length); i++) {
if (fromDirs[i] === d[i]) shared++;
else break;
}
return shared;
};
let best = candidates[0]!;
let bestProx = sharedPrefix(best.filePath);
for (let i = 1; i < candidates.length; i++) {
const prox = sharedPrefix(candidates[i]!.filePath);
if (prox > bestProx) {
best = candidates[i]!;
bestProx = prox;
}
}
return best;
}

// C++ keywords/control-flow tokens that can appear right before a receiver
// (e.g. `return ptr->m()`) and must NOT be treated as a type.
const CPP_NON_TYPE_TOKENS = new Set([
Expand Down Expand Up @@ -924,6 +963,96 @@ function inferJavaFieldReceiverType(
return lastPart;
}

/** Normalize a TS type expression to its bare class name, or null if it isn't a class. */
function bareTsTypeName(typeExpr: string): string | null {
const noGenerics = typeExpr.replace(/<[^>]*>/g, '').trim();
const noArray = noGenerics.replace(/\[\s*\]/g, '').trim();
const lastPart = noArray.split('.').filter(Boolean).pop();
if (!lastPart) return null;
if (!/^[A-Z][\w$]*$/.test(lastPart)) return null; // primitives / lowercase / unions → skip
return lastPart;
}

/**
* TS/JS: infer a receiver's declared type for a `this.<field>.method()` call,
* re-encoded by the extractor as `<field>.method`. Two field-declaration sites
* are covered, both ubiquitous in NestJS / typed OOP TS:
*
* 1. Class-body property `private readonly userService: UserService;` is
* extracted as a `property` node whose signature is the Java-style
* "<Type> <name>" form, so the same parse as inferJavaFieldReceiverType
* recovers the type.
* 2. Constructor parameter property `constructor(private readonly
* userService: UserService) {}` is NOT a class member node; its type
* lives in the constructor method's signature, so we read it from there.
*
* Returns the bare class name (generics stripped) or null when the field isn't
* declared in the enclosing class with a class-typed annotation. A null result
* leaves the ref to the regular strategies, forcing nothing.
*/
function inferTsFieldReceiverType(
receiverName: string,
ref: UnresolvedRef,
context: ResolutionContext,
): string | null {
const inFile = context.getNodesInFile(ref.filePath);
if (inFile.length === 0) return null;

// Find the class enclosing the call line (tightest match by latest start).
let enclosing: Node | null = null;
for (const n of inFile) {
if (n.kind !== 'class' && n.kind !== 'interface') continue;
if (n.language !== ref.language) continue;
const end = n.endLine ?? n.startLine;
if (n.startLine <= ref.line && end >= ref.line) {
if (!enclosing || n.startLine >= enclosing.startLine) enclosing = n;
}
}
if (!enclosing) return null;
const enclosingEnd = enclosing.endLine ?? enclosing.startLine;
const inEnclosing = (n: Node): boolean =>
n.language === ref.language &&
n.startLine >= enclosing!.startLine &&
(n.endLine ?? n.startLine) <= enclosingEnd;

// 1. Class-body property declared with an explicit type.
const prop = inFile.find(
(n) =>
(n.kind === 'property' || n.kind === 'field') &&
n.name === receiverName &&
inEnclosing(n),
);
if (prop?.signature) {
const beforeName = prop.signature.slice(0, prop.signature.lastIndexOf(prop.name));
const fromProp = bareTsTypeName(beforeName.trim());
if (fromProp) return fromProp;
}

// 2. Constructor parameter property: the type is in the constructor's signature.
const ctor = inFile.find(
(n) => n.kind === 'method' && n.name === 'constructor' && inEnclosing(n),
);
if (ctor?.signature) {
const escaped = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match the parameter named `receiverName`, skipping any leading modifiers
// (access/readonly) or decorators (`@Inject(TOKEN)`), then capture its type
// up to the next top-level `,` or `)`. Stops the type at `<`/`|`/`&` so
// generics and unions are handled by bareTsTypeName.
const re = new RegExp(
'(?:^|[,(])\\s*(?:(?:public|private|protected|readonly|override)\\s+|@[\\w$.]+(?:\\([^)]*\\))?\\s+)*' +
escaped +
'\\s*[?!]?\\s*:\\s*([A-Za-z_$][\\w$.]*)',
);
const m = ctor.signature.match(re);
if (m && m[1]) {
const fromCtor = bareTsTypeName(m[1]);
if (fromCtor) return fromCtor;
}
}

return null;
}

/**
* Try to resolve by method name on a class/object
*/
Expand Down Expand Up @@ -995,6 +1124,34 @@ export function matchMethodCall(
}
}

// TS/JS: `this.<field>.method()` re-encoded as `<field>.method` by the
// extractor. Recover the field's declared type from the enclosing class
// (constructor parameter property or class-body field) and resolve the method
// on that type. resolveMethodOnType validates the method exists on the type
// (and its supertypes), so a wrong inference yields no edge rather than a
// wrong one, which is also what fixes the misbinding to a same-named method
// on an unrelated class (the bare-name path's failure mode). Mirrors the
// Java/Kotlin field-injection block above.
if ((ref.language === 'typescript' || ref.language === 'javascript') && dotMatch) {
const inferredType = inferTsFieldReceiverType(objectOrClass!, ref, context);
if (inferredType) {
const imports = context.getImportMappings(ref.filePath, ref.language);
const importedFqn = imports.find((i) => i.localName === inferredType)?.source;
const typedMatch = resolveMethodOnType(
inferredType,
methodName!,
ref,
context,
0.9,
'instance-method',
importedFqn,
);
if (typedMatch) {
return typedMatch;
}
}
}

// Strategy 1: Direct class name match (existing logic)
const classCandidates = context.getNodesByName(objectOrClass!);

Expand Down