fix(resolution): literal-receiver builtins and nested locals stop fabricating call edges (#1317)
", ".join(sorted(x)) resolved by bare name to a project function named join — one nested inside a DIFFERENT function, so scope alone rules the edge out. Both defects from #1230, fixed independently: 1. Extraction: a member call on a LITERAL receiver (string, number, collection, regex — across grammars) emits no call ref at all. A literal's methods are the language's builtins, never project symbols; the bare-name fallback let them exact-match any same-named project function. Silent miss, never a wrong edge. 2. Resolution: matchByExactName filters out candidates nested inside a same-file FUNCTION container unless the ref originates within that container's line range. Class members (parent is a class-like node), top-level symbols, and C++ namespace prefixes (no parent node) are untouched. requests re-index: byte-identical (813 calls edges). excalidraw: -27 edges, all literal-receiver refs by construction. The issue's repro is pinned: join has exactly one caller (format_fields), report_missing has zero project callees. Fixes #1230 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
41c2029798
commit
c472cfb52e
@@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Fixes
|
### 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)
|
- 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)
|
- 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 <symbol> -f <file>` 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)
|
- `codegraph node <symbol> -f <file>` 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)
|
||||||
|
|||||||
@@ -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)', () => {
|
describe('Go field-chain receiver calls (#1276)', () => {
|
||||||
// `target.conn.Exec(...)` where `conn *sql.DB` used to emit a BARE `Exec`
|
// `target.conn.Exec(...)` where `conn *sql.DB` used to emit a BARE `Exec`
|
||||||
// ref, which exact-matched the only local `Exec` — an unrelated
|
// ref, which exact-matched the only local `Exec` — an unrelated
|
||||||
|
|||||||
@@ -362,6 +362,30 @@ const INSTANTIATION_KINDS: ReadonlySet<string> = new Set([
|
|||||||
/**
|
/**
|
||||||
* TreeSitterExtractor - Main extraction class
|
* 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 {
|
export class TreeSitterExtractor {
|
||||||
private filePath: string;
|
private filePath: string;
|
||||||
private language: Language;
|
private language: Language;
|
||||||
@@ -4351,6 +4375,16 @@ export class TreeSitterExtractor {
|
|||||||
getChildByField(func, 'operand') ||
|
getChildByField(func, 'operand') ||
|
||||||
getChildByField(func, 'argument') ||
|
getChildByField(func, 'argument') ||
|
||||||
func.namedChild(0);
|
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']);
|
const SKIP_RECEIVERS = new Set(['self', 'this', 'cls', 'super']);
|
||||||
if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) {
|
if (receiver && (receiver.type === 'identifier' || receiver.type === 'simple_identifier' || receiver.type === 'field_identifier')) {
|
||||||
const receiverName = getNodeText(receiver, this.source);
|
const receiverName = getNodeText(receiver, this.source);
|
||||||
|
|||||||
@@ -340,6 +340,42 @@ export function matchFunctionRef(
|
|||||||
return null;
|
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
|
* 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
|
// findBestMatch — O(K²) per package, the dominant cost of "Resolving refs" on
|
||||||
// large import-heavy (front-end + back-end) repos (#915).
|
// large import-heavy (front-end + back-end) repos (#915).
|
||||||
const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref)
|
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) {
|
if (candidates.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user