diff --git a/__tests__/ts-di-method-resolution.test.ts b/__tests__/ts-di-method-resolution.test.ts new file mode 100644 index 000000000..9e6ec28bd --- /dev/null +++ b/__tests__/ts-di-method-resolution.test.ts @@ -0,0 +1,133 @@ +/** + * TS/JS `this..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.` is now + * re-encoded as `.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..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); + }); +}); diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 19648e10d..22f103713 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -3182,6 +3182,31 @@ export class TreeSitterExtractor { } else { calleeName = methodName; } + } else if ( + (this.language === 'typescript' || this.language === 'javascript') && + receiver && + receiver.type === 'member_expression' + ) { + // `this..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.`, re-encode as `.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' || diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 9990d690d..b12bfd131 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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) => { @@ -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, @@ -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([ @@ -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..method()` call, + * re-encoded by the extractor as `.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 + * " " 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 */ @@ -995,6 +1124,34 @@ export function matchMethodCall( } } + // TS/JS: `this..method()` re-encoded as `.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!);