mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-28 22:52:00 +08:00
fix(resolution): a Python call reaches a method through its receiver, never by name alone (#2125)
Found by the README-wide sweep — Django/Flask/FastAPI apps' most-depended-on lists were test and serializer methods: netbox `UserConfig.all` (3,610 from `X.objects.all()`), `PortSerializer.create` (2,093 from `.objects.create`), a test-local `FakeQuerySet.count` (1,910); healthchecks `AuthTestCase.get` (880 from `Channel.objects.get()`), `ProfileAdmin.login` (702 from `self.client.login()`); mealie `_FakeHTTPResponse.json`. Python keeps a call through a receiver it cannot name (`User.objects.get`, `self.client.login`, `request.POST.get`) as the bare `get` / `login`, and exact-name matching then took any class's method by proximity. The call's shape is now read at its column (the call's start): - `bare` (`get(1)`): not a method — Python has no implicit self — and not a project symbol when the file takes that name `from` a module the project doesn't contain (`from django.shortcuts import render`); - `chained`: only a member of what the chain names last — a method of a class of that name (case/underscores ignored: `self.store` → `Store`, `self.user_service` → `UserService`) or a function/class in a module of that name (`app.helpers.slugify()` → helpers.py); - `self.x()` / `cls.x()`, and chains split across lines: unchanged. Fuzzy matching is case-exact for Python (`dir(…)` ≠ class `Dir`). Lexical reachability now walks every enclosing scope, so a method of a class declared inside a function is only reachable in there (memoized per candidate). A/B vs main (edge diffs, every change classified): netbox −18,091/+73 (indexes 8s → 5s), healthchecks −3,354/+361, pytest −2,950/+105, mealie −1,802/+6, allauth −1,086/+57, DRF −943/+50, flask −167/+17, httpx −147/+24. Gains are precise member links (`self.channel.notify` → Channel.notify, `item.stash.get` → Stash.get, `config.cache.get` → Cache.get, `self.provider.verify_token`); the one deliberate loss class is calls through an instance whose type needs inference (`flask.g.setdefault` in flask's own repo). excalidraw, typeorm, gson, okio, AutoMapper, gin and hono byte-identical. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ddc03f7c79
commit
99209b6f3d
+151
-22
@@ -616,9 +616,9 @@ function isLexicallyReachable(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): boolean {
|
||||
// A function — or a type (`case class B()` in a test method) — declared
|
||||
// inside a function is only in scope in there.
|
||||
if (candidate.kind !== 'function' && !LOCAL_TYPE_KINDS.has(candidate.kind)) return true;
|
||||
// A function — or a type (`case class B()` in a test method), or a method
|
||||
// of such a type — declared inside a function is only in scope in there.
|
||||
if (candidate.kind !== 'function' && candidate.kind !== 'method' && !LOCAL_TYPE_KINDS.has(candidate.kind)) return true;
|
||||
// C and C++ have no nested named functions, so a function the graph shows
|
||||
// inside another is an extraction artifact, not a scope: tree-sitter-c
|
||||
// cannot parse a macro call whose arguments are designated initializers
|
||||
@@ -627,23 +627,50 @@ function isLexicallyReachable(
|
||||
// the file, nesting every function after it. Trusting that nesting rejected
|
||||
// 117 real calls into pid.c on that tree; the functions are reachable.
|
||||
if (NO_NESTED_FUNCTIONS.has(candidate.language)) 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)
|
||||
);
|
||||
const scope = lexicalScopeOf(candidate, context);
|
||||
return scope === null || (ref.filePath === candidate.filePath && ref.line >= scope.start && ref.line <= scope.end);
|
||||
}
|
||||
|
||||
/** Per context: a candidate's scoping function body, or null when nothing scopes it. */
|
||||
const LEXICAL_SCOPE_MEMO = new WeakMap<ResolutionContext, Map<string, { start: number; end: number } | null>>();
|
||||
|
||||
/**
|
||||
* The innermost function or method BODY that scopes a declaration, walking out
|
||||
* through its qualified name (`test_x::Request::User::has_perm` → `test_x`).
|
||||
* A method directly on a class is reachable through its instances, and an
|
||||
* object-literal method a function returns through the object — neither is
|
||||
* scoped by the function it sits in.
|
||||
*/
|
||||
function lexicalScopeOf(candidate: Node, context: ResolutionContext): { start: number; end: number } | null {
|
||||
let memo = LEXICAL_SCOPE_MEMO.get(context);
|
||||
if (!memo) {
|
||||
memo = new Map();
|
||||
LEXICAL_SCOPE_MEMO.set(context, memo);
|
||||
}
|
||||
const hit = memo.get(candidate.id);
|
||||
if (hit !== undefined) return hit;
|
||||
let scope: { start: number; end: number } | null = null;
|
||||
const own = candidate.qualifiedName ?? '';
|
||||
const parentQn = own.includes('::') ? own.slice(0, own.lastIndexOf('::')) : '';
|
||||
let qn = own;
|
||||
while (qn.includes('::')) {
|
||||
qn = qn.slice(0, qn.lastIndexOf('::'));
|
||||
const container = context
|
||||
.getNodesByQualifiedName(qn)
|
||||
.find(
|
||||
(p) =>
|
||||
p.filePath === candidate.filePath &&
|
||||
(p.kind === 'function' || p.kind === 'method') &&
|
||||
p.startLine <= candidate.startLine &&
|
||||
p.endLine >= candidate.endLine
|
||||
);
|
||||
if (!container) continue;
|
||||
if (candidate.kind === 'method' && qn === parentQn) break;
|
||||
scope = { start: container.startLine, end: container.endLine };
|
||||
break;
|
||||
}
|
||||
memo.set(candidate.id, scope);
|
||||
return scope;
|
||||
}
|
||||
|
||||
/** Languages whose module boundary is `import`/`export` (or CommonJS). */
|
||||
@@ -1039,6 +1066,99 @@ function canNameInTypePosition(n: Node): boolean {
|
||||
return !(n.kind === 'property' || n.kind === 'method' || n.kind === 'enum_member' || n.kind === 'field');
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape of a Python call that reaches the resolver as a bare name — the
|
||||
* column is the call's start. `bare`: `get(1)`, which cannot mean a method
|
||||
* (Python has no implicit self). `chained`: a receiver the extractor could not
|
||||
* keep — `User.objects.get(…)`, `self.client.login(…)`, `request.POST.get(…)`
|
||||
* arrive as `get` / `login`, and can only mean a member of what the chain
|
||||
* names last (`POST`, `client`, `objects`). netbox bound 3,610 `.all()` calls
|
||||
* to one `UserConfig.all`, healthchecks 880 `objects.get` to a test case's
|
||||
* `get`. `self.x()` / `cls.x()`, and a chain split across lines, are null:
|
||||
* today's behavior.
|
||||
*/
|
||||
type PythonCallShape = { kind: 'bare' } | { kind: 'chained'; owner: string };
|
||||
|
||||
function pythonCallShape(ref: UnresolvedRef, context: ResolutionContext): PythonCallShape | null {
|
||||
if (ref.language !== 'python' || ref.referenceKind !== 'calls') return null;
|
||||
const name = ref.referenceName;
|
||||
if (!/^[A-Za-z_]\w*$/.test(name)) return null;
|
||||
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
|
||||
if (line === undefined) return null;
|
||||
const text = line.slice(ref.column);
|
||||
if (text.startsWith(name) && /^\s*\(/.test(text.slice(name.length))) return { kind: 'bare' };
|
||||
if (new RegExp(String.raw`^(?:self|cls)\s*\.\s*${name}\s*\(`).test(text)) return null;
|
||||
// The call starts at its receiver, so everything up to `.name(` is the receiver chain.
|
||||
const chain = new RegExp(String.raw`^(.*?)\.\s*${name}\s*\(`).exec(text);
|
||||
if (!chain) return null;
|
||||
const owner = /(\w+)\s*(?:\([^()]*\)|\[[^\[\]]*\])?\s*$/.exec(chain[1]!)?.[1];
|
||||
return owner ? { kind: 'chained', owner } : null;
|
||||
}
|
||||
|
||||
/** Can a Python call of this shape mean the candidate? */
|
||||
function fitsPythonCallShape(n: Node, shape: PythonCallShape, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
if (shape.kind === 'bare') {
|
||||
if (n.kind === 'method') return false;
|
||||
// `from django.shortcuts import render`: the call is the package's.
|
||||
return !(n.filePath !== ref.filePath && isPythonNameImportedFromOutside(ref.referenceName, ref, context));
|
||||
}
|
||||
// A member of what the chain names: a method of a class of that name, or a
|
||||
// function / class in a module of that name (`helpers.slugify()`).
|
||||
if (n.kind === 'method') {
|
||||
// `self.store.fetch()` on a `Store`, `self.user_service.find()` on a `UserService`.
|
||||
const cut = n.qualifiedName.lastIndexOf('::');
|
||||
const owner = cut >= 0 ? n.qualifiedName.slice(0, cut).split('::').pop()! : '';
|
||||
const plain = (s: string): string => s.replace(/_/g, '').toLowerCase();
|
||||
return owner !== '' && plain(owner) === plain(shape.owner);
|
||||
}
|
||||
const parts = n.filePath.split('/');
|
||||
const stem = parts[parts.length - 1]!.replace(/\.pyi?$/, '');
|
||||
return stem === shape.owner || (stem === '__init__' && parts[parts.length - 2] === shape.owner);
|
||||
}
|
||||
|
||||
const PY_IMPORTS = new WeakMap<ResolutionContext, Map<string, Map<string, string>>>();
|
||||
const PY_MODULE_LOCAL = new WeakMap<ResolutionContext, Map<string, boolean>>();
|
||||
|
||||
/** `name` → the module a `from <module> import name` line takes it from, per file. */
|
||||
function pythonFromImports(filePath: string, context: ResolutionContext): Map<string, string> {
|
||||
let memo = PY_IMPORTS.get(context);
|
||||
if (!memo) {
|
||||
memo = new Map();
|
||||
PY_IMPORTS.set(context, memo);
|
||||
}
|
||||
const hit = memo.get(filePath);
|
||||
if (hit) return hit;
|
||||
const names = new Map<string, string>();
|
||||
const text = context.readFile(filePath) ?? '';
|
||||
for (const m of text.matchAll(/^\s*from\s+([\w.]+)\s+import\s+(\([^)]*\)|[^\n#]+)/gm)) {
|
||||
const module = m[1]!;
|
||||
for (const item of m[2]!.replace(/[()]/g, '').split(',')) {
|
||||
const bound = /(\w+)\s*$/.exec(item.trim())?.[1];
|
||||
if (bound && bound !== '*') names.set(bound, module);
|
||||
}
|
||||
}
|
||||
memo.set(filePath, names);
|
||||
return names;
|
||||
}
|
||||
|
||||
function isPythonNameImportedFromOutside(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const module = pythonFromImports(ref.filePath, context).get(name);
|
||||
if (!module || module.startsWith('.')) return false;
|
||||
let memo = PY_MODULE_LOCAL.get(context);
|
||||
if (!memo) {
|
||||
memo = new Map();
|
||||
PY_MODULE_LOCAL.set(context, memo);
|
||||
}
|
||||
let local = memo.get(module);
|
||||
if (local === undefined) {
|
||||
const rel = module.replace(/\./g, '/');
|
||||
local = context.getAllFiles().some((f) =>
|
||||
f === `${rel}.py` || f.endsWith(`/${rel}.py`) || f === `${rel}/__init__.py` || f.endsWith(`/${rel}/__init__.py`));
|
||||
memo.set(module, local);
|
||||
}
|
||||
return !local;
|
||||
}
|
||||
|
||||
/** Names the Rust prelude puts in every module; a project item of the same name needs a `use` to shadow one. */
|
||||
const RUST_PRELUDE = new Set([
|
||||
'Ok', 'Err', 'Some', 'None', 'Result', 'Option', 'Box', 'Vec', 'String', 'Default', 'Drop', 'Iterator',
|
||||
@@ -1328,7 +1448,9 @@ export function matchByExactName(
|
||||
sameName.some((n) => n.kind === 'constant' && CPP_DEFINE_SIGNATURE.test(n.signature ?? ''));
|
||||
const typeRef = isDotNetTypeRef(ref, context);
|
||||
const rustBare = ref.language === 'rust' && /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||||
const pythonShape = pythonCallShape(ref, context);
|
||||
const candidates = sameName.filter((n) =>
|
||||
!(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)) &&
|
||||
!(rustBare && !isRustNameInScope(n, ref, context)) &&
|
||||
!(cMacroCall && n.kind !== 'function' && n.kind !== 'method') &&
|
||||
!(typeRef && !canNameInTypePosition(n)) &&
|
||||
@@ -2430,6 +2552,9 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
C_STATIC_MEMO.delete(context);
|
||||
RUST_TRAIT_IMPL_MEMO.delete(context);
|
||||
RUST_USES.delete(context);
|
||||
LEXICAL_SCOPE_MEMO.delete(context);
|
||||
PY_IMPORTS.delete(context);
|
||||
PY_MODULE_LOCAL.delete(context);
|
||||
SEALED_MODULES.delete(context);
|
||||
LOCAL_BINDING_MEMO.delete(context);
|
||||
LOCAL_BINDING_SITES.delete(context);
|
||||
@@ -4335,9 +4460,13 @@ export function matchFuzzy(
|
||||
const callableKinds = new Set(['function', 'method', 'class']);
|
||||
const typeRef = isDotNetTypeRef(ref, context);
|
||||
const rustBare = ref.language === 'rust' && /^[A-Za-z_]\w*$/.test(ref.referenceName);
|
||||
// Rust names are case-sensitive: `Bytes` is not the method `bytes`, `Ok` not a function `ok`.
|
||||
const pythonShape = pythonCallShape(ref, context);
|
||||
// Rust and Python names are case-sensitive: `Bytes` is not the method `bytes`,
|
||||
// Python's builtin `dir(…)` not a class `Dir`.
|
||||
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)) &&
|
||||
!(rustBare && (n.name !== ref.referenceName || !isRustNameInScope(n, ref, context))))
|
||||
!(rustBare && (n.name !== ref.referenceName || !isRustNameInScope(n, ref, context))) &&
|
||||
!(ref.language === 'python' && n.name !== ref.referenceName) &&
|
||||
!(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)))
|
||||
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
|
||||
sameLanguageFamily(n.language, ref.language));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user