diff --git a/CHANGELOG.md b/CHANGELOG.md index 955349b..536110e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Method calls on literals (`", ".join(...)` in Python, `"x".split(...)` in JavaScript, and the like) no longer produce call edges to unrelated project functions that happen to share the builtin's name — a codebase with a function called `join`, `get`, or `update` could show phantom callers from every string-builtin use. Additionally, a function nested inside another function is now only matched as a call target from inside its container, since it isn't reachable from anywhere else. Blast-radius and affected-test results get cleaner on Python and JavaScript codebases especially. (#1230) - Go method calls through a struct field (`target.conn.Exec(...)`) no longer bind to unrelated same-named local methods when the field's type is external — `conn *sql.DB` calls were being attributed to a local interface that happened to declare `Exec`, fabricating internal dependencies. Chained field calls now resolve by inferring the field's declared type from the struct definition: in-project types (including unexported ones like chi's `tree *node`) gain correct, validated call edges that never existed before, and external types (standard library, third-party modules) are left unlinked instead of guessed. (#1276) - TypeScript/JavaScript method calls through an imported singleton (`import { store } from './store'; store.notify()`) now resolve to the class method instead of the exported constant, so `codegraph callers` sees cross-file callers of the method — previously only same-file calls were attributed and a method used everywhere could look unused. The same declaration-based type inference applies across the languages that share it (Python, Java, Kotlin, Go, and more), and a failed inference keeps the old edge rather than guessing. (#1292) - `codegraph node -f ` now prints the symbol's source body. Pinning an ambiguous name to a specific file (the whole point of `-f` when many files define the same function) returned only the location and caller trail with no code. (#1284) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 3965ea9..eca1778 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -2541,6 +2541,57 @@ func main() { }); }); + describe('Literal receivers and nested-local scope (#1230)', () => { + // Two stacked fabrications: `", ".join(...)` (a builtin on a string + // literal) exact-matched a project function named `join` — one that was + // moreover nested inside a DIFFERENT function and thus lexically + // unreachable. Literal receivers now emit no call ref at all, and + // exact-match refuses candidates nested in a function the ref isn't in. + it("str-literal builtin calls don't bind to project symbols; nested locals only resolve from inside their container", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1230-')); + try { + fs.writeFileSync( + path.join(tmpDir, 'repro.py'), + `def format_fields(values): + def join(vals): + return "-".join(sorted(vals)) + + return join(values) + + +def report_missing(unresolved): + missing_list = ", ".join(sorted(unresolved)) + return f"Could not resolve: {missing_list}" +` + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const join = (await cg.searchNodes('join', { limit: 5 })).find( + (r) => r.node.kind === 'function' && r.node.name === 'join' + ); + expect(join).toBeDefined(); + + // Exactly one caller: the enclosing format_fields. Neither + // report_missing (literal receiver) nor join itself (its own literal + // "-".join) may appear. + const callers = await cg.getCallers(join!.node.id); + expect(callers.map((c) => c.node.name)).toEqual(['format_fields']); + + // report_missing has zero project callees. + const reportMissing = (await cg.searchNodes('report_missing', { limit: 5 })).find( + (r) => r.node.kind === 'function' + ); + const callees = await cg.getCallees(reportMissing!.node.id); + expect(callees.filter((c) => c.node.name === 'join')).toHaveLength(0); + cg.close(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 30000); + }); + describe('Go field-chain receiver calls (#1276)', () => { // `target.conn.Exec(...)` where `conn *sql.DB` used to emit a BARE `Exec` // ref, which exact-matched the only local `Exec` — an unrelated diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 9ee791c..f806738 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -362,6 +362,30 @@ const INSTANTIATION_KINDS: ReadonlySet = new Set([ /** * TreeSitterExtractor - Main extraction class */ +/** + * tree-sitter node types (across grammars) for literal expressions in method- + * call RECEIVER position. A literal's methods are the language's builtins — + * `", ".join`, `"x".toUpperCase()`, `5.times`, `[].concat` — never project + * symbols, so a member call on one must not emit a `calls` ref that bare-name + * matching could bind to an unrelated same-named project function (#1230). + */ +const LITERAL_RECEIVER_TYPES = new Set([ + // strings + 'string', 'string_literal', 'interpreted_string_literal', 'raw_string_literal', + 'template_string', 'concatenated_string', 'formatted_string', 'f_string', + 'line_string_literal', 'string_content', 'heredoc_body', + // numbers + 'number', 'number_literal', 'integer', 'integer_literal', 'float', + 'float_literal', 'int_literal', 'decimal_integer_literal', 'real_literal', + // chars / runes / regex / booleans / null-likes + 'char_literal', 'character_literal', 'rune_literal', 'regex', 'regex_literal', + 'true', 'false', 'boolean_literal', 'bool_literal', 'none', 'null', 'nil', + 'null_literal', 'undefined', + // collection literals + 'list', 'list_literal', 'array', 'array_literal', 'array_creation_expression', + 'dictionary', 'dict_literal', 'object', 'tuple', 'set', +]); + export class TreeSitterExtractor { private filePath: string; private language: Language; @@ -4351,6 +4375,16 @@ export class TreeSitterExtractor { getChildByField(func, 'operand') || getChildByField(func, 'argument') || func.namedChild(0); + // A LITERAL receiver — `", ".join(...)`, `"x".toUpperCase()`, + // `5.times`, `[].concat(...)` — calls a builtin of the literal's + // type, never a project symbol. The bare-name fallback below let + // these exact-match an unrelated same-named project function + // (`", ".join` bound to a local `join` defined inside a DIFFERENT + // function, #1230). Emit nothing: a silent miss, never a wrong + // edge. Nested calls in the arguments are visited independently. + if (receiver && LITERAL_RECEIVER_TYPES.has(receiver.type)) { + return; + } const SKIP_RECEIVERS = new Set(['self', 'this', 'cls', 'super']); if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) { const receiverName = getNodeText(receiver, this.source); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index fca29e2..329dc5d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -340,6 +340,42 @@ export function matchFunctionRef( return null; } +/** + * A function nested inside another FUNCTION is only callable from within its + * container — Python, JS/TS, and every closure language scope it lexically. + * Resolving a bare name from elsewhere to a nested local fabricates an edge + * scope already rules out: `join(...)` in one function must never bind to a + * `join` defined inside a DIFFERENT function (#1230). A candidate whose + * qualifiedName parent is a same-file function/method is kept only when the + * ref originates inside that parent's line range. Class members are + * unaffected (their parent resolves to a class-like node), as are top-level + * symbols and C++ namespace-prefixed names (the prefix has no node). + */ +function isLexicallyReachable( + candidate: Node, + ref: UnresolvedRef, + context: ResolutionContext +): boolean { + if (candidate.kind !== 'function') return true; + const qn = candidate.qualifiedName; + if (!qn || !qn.includes('::')) return true; + const parentQn = qn.slice(0, qn.lastIndexOf('::')); + const containers = context + .getNodesByQualifiedName(parentQn) + .filter( + (p) => + p.filePath === candidate.filePath && + (p.kind === 'function' || p.kind === 'method') && + p.startLine <= candidate.startLine && + p.endLine >= candidate.endLine + ); + if (containers.length === 0) return true; + return ( + ref.filePath === candidate.filePath && + containers.some((p) => ref.line >= p.startLine && ref.line <= p.endLine) + ); +} + /** * Try to resolve a reference by exact name match */ @@ -357,7 +393,9 @@ export function matchByExactName( // findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on // large import-heavy (front-end + back-end) repos (#915). const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref) - .filter((n) => n.kind !== 'import'); + .filter((n) => n.kind !== 'import') + // Nested locals are only reachable from inside their container (#1230). + .filter((n) => isLexicallyReachable(n, ref, context)); if (candidates.length === 0) { return null;