fix(python): attribute calls follow the type the class body, a factory or a base gives the attribute (#1926)

Python calls through an attribute reach the method the code gives that
attribute: `self.sink.stop()` follows `self.sink = Sink()`, a `sink: Sink`
annotation, a typed `__init__` parameter, a factory's return type or what
every `return` in it builds, and a base class's `__init__` in another file.
Calls straight on a factory's result (`Client.from_env().send()`) resolve
the same way, and a sync re-opens these answers when the factory or base
they were read from changes. The native Python extractor now keeps a call's
receiver text as the wasm one does, so `self.data.append(1)` is never a bare
`append` that matches an unrelated project function. This includes all of
PR 1704.

Reworked from evidence-or-silence, which dropped about 3,000 correct Django
links (`self.client.get()`, `self.engine.render_to_string()`). The reader
now has three answers. A project class that has the method: that method.
Evidence that rules project methods out: no edge, for a builtin, a class
from outside the project built in a method or named by an annotation, an
attribute only an outside base can give (Django's `TestCase` in an app), or
a class without the method whose bases are all known. Nothing typed: the
call is guessed by the name of its last link, read as written, as any
receiver is, but only for an owner whose name holds the link's head noun
(`self.session` for `SessionBase`, not `database.group_shopping_lists` for
`GroupRepositoryAIProvider`) and never as the one method of a name; the
guess records the files the reader looked at, so a sync re-opens it too. A
class-body field an outside class builds (`author = models.ForeignKey(Author)`)
may be a descriptor, so it is no evidence. A chain through an imported
module (`os.path.exists()`) is the module's. In the shared scoring, a
receiver that spells an owner's whole name (`client` for `Client`)
outscores one that only shares its word (`RedisCacheClient`). A call
through an imported value's attribute chain (`app.jinja_env.filters.keys()`)
no longer links to the value's `jinja_env`.

A/B against main with fresh indexes, every changed edge triaged, sites
matched by position since the extractor now names these calls by their
whole chain. Django: 3,317 calls linked that were not
(`self.client.force_login()` to `ClientMixin`, `User.objects.create_user()`
to `UserManager`, `self.stdout.write()` to `OutputWrapper`), 127 moved,
nearly all to a more precise target (`self.site.register()` to
`AdminSite.register` rather than the `register` decorator), and 134
removed: main's bare-name links on builtin values (`value.lower()` to the
template filter, `.encode()` to a test password hasher) and calls on
expressions such as `(q1 | q2).order_by()`, which now go unresolved as they
do in the wasm engine. flask: 32 linked, 4 moved to `JSONProvider`.
fastapi: 58 linked. mealie: 740 linked. Guesses still wrong, as main makes
them for a plain receiver of the same name: fastapi's
`request.headers.get()` to a telemetry `_HeadersGetter`, and about 20 of
mealie's repository attributes to a controller. Go, C#, Ruby, Swift,
Kotlin, Java and TS repositories are byte-identical, except excalidraw's
5 `props.onChange()` calls, which move between two unrelated `Props`
interfaces. Indexing CPU: Django +7%, mealie +5%, fastapi flat; wall time
unchanged within noise. Full suite on the merged tree: 7,999 passed, 0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eric Minish
2026-10-20 21:00:13 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4286083a84
commit 642c7bb3a5
13 changed files with 2824 additions and 25 deletions
+128
View File
@@ -213,6 +213,11 @@ function rowToNode(row: NodeRow): Node {
/**
* Convert database row to Edge object
*/
/** A failed python ref through one attribute (`self.c.send`) or a call receiver (`make().send`). */
const FAILED_PY_ATTR_REF = `status = 'failed' AND language = 'python'
AND reference_name NOT GLOB '*.*.*.*'
AND (reference_name GLOB '*.*.*' OR reference_name GLOB '*().*')`;
function rowToEdge(row: EdgeRow): Edge {
return {
source: row.source,
@@ -4713,6 +4718,129 @@ export class QueryBuilder {
* Replace resolution edges with their original unresolved references as one
* transaction. If ref insertion fails, the edge deletion is rolled back.
*/
/**
* Resolution edges whose answer was read from other files
* (`metadata.typeFrom` — python attribute types, see
* `resurrectTypeDependentEdges`), with the source file context a
* resurrection needs. One scan; only python attribute calls carry the key.
*/
getEdgesWithTypeFrom(): Array<Edge & { edgeId: number; sourceFilePath: string; sourceLanguage: Language }> {
const rows = this.db
.prepare(
`SELECT e.*, src.file_path AS source_file_path, src.language AS source_language
FROM edges e
JOIN nodes src ON src.id = e.source
WHERE e.metadata LIKE '%"typeFrom"%'`
)
.all() as Array<EdgeRow & { source_file_path: string; source_language: Language }>;
return rows.map((row) => ({
...rowToEdge(row),
edgeId: row.id,
sourceFilePath: row.source_file_path,
sourceLanguage: row.source_language,
}));
}
/**
* The python files whose failed attribute-chain refs (`self.client.send`,
* `make().send`) an edit to `filePaths` can affect. Such an answer is read
* only from files reached through imports — the owner class, its bases, a
* factory and the class it returns are each looked up through the imports
* of the file before — so these are the files that import a touched file,
* directly or within `depth` hops, and the touched files themselves.
*/
getPythonImporterClosure(filePaths: string[], depth: number = 6): string[] {
const closure = new Set<string>(filePaths);
let frontier = [...filePaths];
for (let hop = 0; hop < depth && frontier.length > 0; hop++) {
const next: string[] = [];
for (let i = 0; i < frontier.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = frontier.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT DISTINCT src.file_path AS file_path
FROM edges e
JOIN nodes tgt ON tgt.id = e.target
JOIN nodes src ON src.id = e.source
WHERE e.kind = 'imports' AND tgt.file_path IN (${placeholders}) AND src.language = 'python'`
)
.all(...chunk) as Array<{ file_path: string }>;
for (const row of rows) {
if (!closure.has(row.file_path)) {
closure.add(row.file_path);
next.push(row.file_path);
}
}
}
frontier = next;
}
return [...closure];
}
/**
* The failed python attribute-chain refs — one attribute hop
* (`self.client.send`) or a call receiver (`make().send`) — in `filePaths`,
* as just the columns a sync needs to choose among them.
*/
getFailedPythonAttrRefKeys(filePaths: string[]): Array<{ id: number; fromNodeId: string; referenceName: string; filePath: string }> {
// One scan, filtered here: a closure is often most of the project, and a
// `file_path IN (...)` per chunk re-runs the GLOB filters over every row.
const wanted = new Set(filePaths);
const rows = this.db
.prepare(`SELECT id, from_node_id, reference_name, file_path FROM unresolved_refs WHERE ${FAILED_PY_ATTR_REF}`)
.all() as Array<{ id: number; from_node_id: string; reference_name: string; file_path: string }>;
const out: Array<{ id: number; fromNodeId: string; referenceName: string; filePath: string }> = [];
for (const r of rows) {
if (wanted.has(r.file_path)) out.push({ id: r.id, fromNodeId: r.from_node_id, referenceName: r.reference_name, filePath: r.file_path });
}
return out;
}
/** Unresolved refs by row id. */
getUnresolvedReferencesByIds(ids: number[]): UnresolvedReference[] {
const rows: UnresolvedRefRow[] = [];
for (let i = 0; i < ids.length; i += SQLITE_PARAM_CHUNK_SIZE) {
const chunk = ids.slice(i, i + SQLITE_PARAM_CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const chunkRows = this.db
.prepare(`SELECT * FROM unresolved_refs WHERE id IN (${placeholders})`)
.all(...chunk) as UnresolvedRefRow[];
for (const row of chunkRows) rows.push(row);
}
return rows.map((row) => ({
fromNodeId: row.from_node_id,
referenceName: row.reference_name,
referenceKind: row.reference_kind as EdgeKind,
line: row.line,
column: row.col,
candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined,
filePath: row.file_path,
language: row.language as Language,
rowId: row.id,
}));
}
/**
* The python files with an edge of one of `kinds` into a node of `filePath`
* whose kind is one of `targetKinds` — who subclasses, calls or constructs
* what a file defines.
*/
getPythonFilesWithEdgesInto(filePath: string, kinds: string[], targetKinds: string[]): string[] {
const kp = kinds.map(() => '?').join(',');
const tp = targetKinds.map(() => '?').join(',');
return (this.db
.prepare(
`SELECT DISTINCT src.file_path AS file_path
FROM edges e
JOIN nodes tgt ON tgt.id = e.target
JOIN nodes src ON src.id = e.source
WHERE tgt.file_path = ? AND e.kind IN (${kp}) AND tgt.kind IN (${tp})
AND src.language = 'python' AND src.file_path != ?`
)
.all(filePath, ...kinds, ...targetKinds, filePath) as Array<{ file_path: string }>).map((r) => r.file_path);
}
replaceResolutionEdgesWithUnresolvedRefs(
edgeIds: number[],
refs: UnresolvedReference[]
+38
View File
@@ -164,6 +164,8 @@ export interface SyncResult {
* nothing downstream.
*/
definitionDelta?: string[];
/** Tracked files this sync found gone and deleted from the index. */
removedFilePaths?: string[];
}
/**
@@ -3337,6 +3339,39 @@ export class ExtractionOrchestrator {
return refs.length;
}
/**
* Re-open resolution edges whose answer was READ from a file this sync
* touched, although neither end of the edge changed: a python attribute typed
* through a factory or a base class in another file records those files as
* `metadata.typeFrom`. The definition delta cannot see these — editing what a
* factory returns, or what a base `__init__` assigns, adds or removes no
* definition — so without this a synced index keeps the old type forever.
* The edges go back to pending refs; returns the files they come from, whose
* pending rows the caller resolves.
*/
resurrectTypeDependentEdges(touchedPaths: string[], changedFilePaths: string[]): string[] {
const touched = new Set(touchedPaths.filter((p) => /\.pyi?$/.test(p)));
if (touched.size === 0) return [];
const alreadyFresh = new Set(changedFilePaths);
const edgeIds: number[] = [];
const refs: UnresolvedReference[] = [];
const files = new Set<string>();
for (const e of this.queries.getEdgesWithTypeFrom()) {
if (alreadyFresh.has(e.sourceFilePath)) continue;
const from = e.metadata?.typeFrom;
if (!Array.isArray(from) || !from.some((f) => typeof f === 'string' && touched.has(f))) continue;
const ref = resurrectRefFromDroppedEdge(e);
if (!ref) continue;
edgeIds.push(e.edgeId);
refs.push(ref);
files.add(e.sourceFilePath);
}
if (refs.length === 0) return [];
this.queries.replaceResolutionEdgesWithUnresolvedRefs(edgeIds, refs);
return [...files];
}
/**
* Re-open every resolution a language made, for the sync's sweep to redo
* against the current graph — the answer when an input to that language's
@@ -3466,6 +3501,7 @@ export class ExtractionOrchestrator {
let nodesUpdated = 0;
const changedFilePaths: string[] = [];
const addedFilePaths: string[] = [];
const removedFilePaths: string[] = [];
// `file\0name` definition pairs for the files this sync touches, sampled
// BEFORE their nodes are replaced/deleted. Compared against the post-store
// pairs below to derive `definitionDelta` (CG-33).
@@ -3575,6 +3611,7 @@ export class ExtractionOrchestrator {
}
onFileChange?.(tracked.path);
this.queries.deleteFile(tracked.path);
removedFilePaths.push(tracked.path);
filesRemoved++;
};
const removed: FileRecord[] = [];
@@ -3735,6 +3772,7 @@ export class ExtractionOrchestrator {
...(addedFilePaths.length > 0 ? { addedFilePaths } : {}),
...(failedFilePaths.length > 0 ? { failedFilePaths } : {}),
definitionDelta: definitionDelta.length > 0 ? definitionDelta : undefined,
...(removedFilePaths.length > 0 ? { removedFilePaths } : {}),
};
}
+15
View File
@@ -5779,6 +5779,21 @@ export class TreeSitterExtractor {
// Emit nothing: a silent miss, never a wrong edge. Mirrored in
// the kernel's extract_call (tsjs/extractors.rs).
return;
} else if (this.language === 'python' && receiver) {
// Any receiver shape the branches above do not claim — attribute
// chain (`self.data.append`) and subscript (`d[k].append`). The
// call-chain shape (`rows.setdefault(k, []).append(x)`) is taken
// by the `<inner>().<method>` branch above (#1748), which runs
// first. These previously fell through to a bare methodName. A
// bare name matching a common
// list/dict/str method (`append`, `get`, `update`, ...) can exact-match
// an unrelated top-level project function sharing that name, fabricating
// a call edge (#66, same class as #1230/#1276). Keep the receiver's
// source text as a qualifier so the ref can only resolve through
// import/module-member resolution, never the bare-name fallback; an
// unresolvable qualifier is a silent miss, never a wrong edge.
const receiverText = getNodeText(receiver, this.source).replace(/\s+/g, ' ').trim();
calleeName = receiverText ? `${receiverText}.${methodName}` : methodName;
} else {
calleeName = methodName;
}
+26
View File
@@ -1347,6 +1347,32 @@ export class CodeGraph {
result.pendingRefsUnresolved = recovery.stats.unresolved;
}
// Python attribute types read from OTHER files — a factory's return, a
// base class's `__init__` — change with a body-only edit the definition
// delta above cannot see. Re-open the edges whose answer was read from a
// touched file, and retry the failed attribute calls in files that
// import one; both resolve right here. After the orphan sweep, so the
// import edges this sync created (a caller whose `from f import make`
// only now resolves) are there to find the importers by.
if (process.env.CODEGRAPH_NO_REBIND !== '1') {
const touched = [...(result.changedFilePaths ?? []), ...(result.removedFilePaths ?? [])];
const touchedPy = touched.filter((p) => /\.pyi?$/.test(p));
if (touchedPy.length > 0) {
const tTypes = Date.now();
const reopenedFiles = this.orchestrator.resurrectTypeDependentEdges(touchedPy, result.changedFilePaths ?? []);
// CODEGRAPH_PY_ATTR_RETRY_CEILING overrides the candidate ceiling (tests).
const ceiling = Number(process.env.CODEGRAPH_PY_ATTR_RETRY_CEILING ?? '') || 2000;
const failed = this.resolver.pythonAttrRetryCandidates(touchedPy, ceiling);
const retry = [...this.queries.getUnresolvedReferencesByFiles(reopenedFiles), ...(failed ?? [])];
if (retry.length > 0) await this.resolver.resolveAndPersistListYielding(retry);
if (process.env.CODEGRAPH_SYNTH_TIMINGS) {
console.error(
`[phase-timing] sync-type-rebind: ${Date.now() - tTypes}ms (${retry.length} refs${failed ? '' : ', failed-ref fan-in over ceiling'})`
);
}
}
}
if (filesChanged || orphanCount > 0) {
// Second pass: chained calls whose method lives on a supertype the
// receiver conforms to (protocol-extension / inherited). Needs the
+54 -12
View File
@@ -3002,7 +3002,12 @@ function resolvePythonModuleMember(
}
for (const imp of imports) {
if (imp.localName !== receiver) continue;
// `import pkg.mod` binds `pkg`, so a call spells the whole path out —
// `pkg.mod.func()`. The mapping names the module by its last segment, which
// never matches that receiver; match the full dotted source instead.
const spelledOut = imp.isNamespace && imp.localName === imp.source.split('.').pop()
&& imp.source.includes('.') && ref.referenceName.startsWith(`${imp.source}.`);
if (imp.localName !== receiver && !spelledOut) continue;
// `import mod` / `import numpy as np` bind the module at `source` itself;
// `from . import certs` / `from pkg import mod` bind a SUBMODULE whose
@@ -3015,27 +3020,62 @@ function resolvePythonModuleMember(
// form (where the two names coincide) worked (#1626). For an unaliased
// import the two are identical, so this changes nothing there.
const moduleName = imp.exportedName === '*' ? imp.localName : imp.exportedName;
const modulePath = imp.isNamespace
let modulePath = imp.isNamespace
? imp.source
: imp.source.endsWith('.')
? imp.source + moduleName
: imp.source + '.' + moduleName;
const rest = ref.referenceName
.substring(spelledOut ? imp.source.length + 1 : dotIdx + 1)
.split('.');
if (!imp.isNamespace && pythonPackageBinds(imp, ref.filePath, context)) continue;
const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
let resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
if (!resolvedPath || resolvedPath === ref.filePath) continue;
// The member as the module binds it: a module-level def (never a method —
// `mod.foo` must not land on a same-named class method, and never a nested
// def), or a name the module re-exports from elsewhere (`pkg/__init__.py`
// doing `from .impl import foo`).
const target = findExportedSymbol(
resolvedPath,
{ isDefault: false, isNamespace: false, exportedName: member, memberName: null },
// The symbol `name` as the module at `filePath` binds it: a module-level
// def (never a method — `mod.foo` must not land on a same-named class
// method, and never a nested def), or a name the module re-exports from
// elsewhere (`pkg/__init__.py` doing `from .impl import foo`).
const bound = (filePath: string, name: string): Node | undefined => findExportedSymbol(
filePath,
{ isDefault: false, isNamespace: false, exportedName: name, memberName: null },
'python',
context,
new Set()
);
// A CALL through a longer path — `utils.helpers.do_x()`, `mod.Klass.run()`
// — names submodules and then one symbol. Descend while the next segment
// is a module file; what is left must be a top-level callable, or a class
// and one of its methods. Anything else is not something this path can
// prove, so it is no edge — never the first segment's symbol, which for
// `mod.Klass.run()` would record a call to the class. Other reference
// kinds keep the single-member lookup below.
if (ref.referenceKind === 'calls' && rest.length > 1) {
while (rest.length > 1) {
// `modulePath` is spelled from the importer (`.utils.helpers`), so it
// resolves from there, not from the module file just reached.
const sub = resolveImportPath(`${modulePath}.${rest[0]}`, ref.filePath, ref.language, context);
if (!sub) break;
modulePath = `${modulePath}.${rest.shift()}`;
resolvedPath = sub;
}
const head = bound(resolvedPath, rest[0]!);
let target: Node | undefined;
if (rest.length === 1) {
target = head && (head.kind === 'function' || head.kind === 'class') ? head : undefined;
} else if (rest.length === 2 && head?.kind === 'class') {
target = context.getNodesInFile(head.filePath).find((n) =>
n.kind === 'method' && n.name === rest[1] && n.qualifiedName === `${head.qualifiedName}::${rest[1]}`);
}
if (target) {
return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
}
continue;
}
const target = bound(resolvedPath, rest[0]!);
if (target) {
return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
}
@@ -3705,8 +3745,10 @@ function resolveImportedInstanceMember(
): ResolvedRef | null {
if (ref.referenceKind !== 'calls') return null;
if (value.kind !== 'constant' && value.kind !== 'variable') return null;
const member = ref.referenceName.slice(localName.length + 1).split('.')[0];
if (!member) return null;
// The method called on the value itself: `app.jinja_env.filters.keys()`
// calls `keys`, no method of the app's type — never `jinja_env`.
const member = ref.referenceName.slice(localName.length + 1);
if (!member || member.includes('.')) return null;
const source = context.readFile(value.filePath);
if (!source) return null;
+93 -1
View File
@@ -21,7 +21,7 @@ import {
isImportableKind,
CPP_DEFINE_SIGNATURE,
} from './types';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule } from './name-matcher';
import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves } from './name-matcher';
import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility';
import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor';
import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes';
@@ -45,6 +45,7 @@ import { loadProjectAliases, type AliasMap } from './path-aliases';
import { findGoModuleForImport, goModulePackageDir, loadGoModule, type GoModule } from './go-module';
import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packages';
import { logDebug } from '../errors';
import { stripCommentsForRegex } from './strip-comments';
import { lexicalPathWithinRoot } from '../utils';
import type { CrossFileNodes, ReExport } from './types';
import { resurrectRefFromDroppedEdge } from './resurrect-ref';
@@ -1230,6 +1231,97 @@ export class ReferenceResolver {
* Calls that land on an alias binding then forward once to the callable
* the alias names (see ./alias-binding), regardless of the strategy.
*/
/**
* The failed python attribute-chain refs that an edit to `touched` makes
* resolvable now — what a sync retries after a factory or a base class
* changed in another file (`pythonAttrRefResolves` answers each one).
*
* The candidates are the refs in the files that import a touched file
* within a few hops, counted once per (calling symbol, name). When there
* are more than `ceiling` of them — an edit to a module most of the project
* imports — only those the edit can plausibly reach are kept: an attribute
* a touched file assigns, a call receiver it defines, a class that
* subclasses one of its classes, or an attribute assigned from a call to
* something it defines. Null when even that is over the ceiling; those refs
* wait for a full index.
*
* Past the ceiling, then, these become resolvable only at the next full
* index: a factory two hops from the edit, a subclass two hops away, and an
* attribute typed by an annotation — a parameter `c: Client`, or a
* class-level `c: Client` in a class that only gains the base or import that
* resolves `Client` in the touched file.
*/
pythonAttrRetryCandidates(touched: string[], ceiling: number = 2000): UnresolvedReference[] | null {
const touchedSet = new Set(touched);
const closure = this.queries.getPythonImporterClosure(touched).filter((f) => !touchedSet.has(f));
// One answer per (calling symbol, name): the same call written twice in
// one function resolves the same way, so it is asked — and counted — once.
const keyOf = (ref: { fromNodeId: string; referenceName: string }) => `${ref.fromNodeId}\0${ref.referenceName}`;
const resolvable = (refs: UnresolvedReference[]) => {
const answers = new Map<string, boolean>();
return refs.filter((ref) => {
if (!ref.filePath || !ref.language) return false;
const key = keyOf(ref);
let ok = answers.get(key);
if (ok === undefined) answers.set(key, (ok = pythonAttrRefResolves(ref as UnresolvedRef, this.context)));
return ok;
});
};
const candidates = this.queries.getFailedPythonAttrRefKeys(closure);
const load = (keys: typeof candidates) => this.queries.getUnresolvedReferencesByIds(keys.map((k) => k.id));
if (new Set(candidates.map(keyOf)).size <= ceiling) return resolvable(load(candidates));
const assigned = new Set<string>();
const defined = new Set<string>();
const subclassing = new Set<string>();
const calling = new Set<string>();
for (const file of touched) {
const source = this.context.readFile(file);
if (source) {
const text = stripCommentsForRegex(source, 'python');
for (const m of text.matchAll(/\b(?:self|cls)\.(\w+)\s*(?::[^=\n]*)?=(?!=)|^[ \t]+(\w+)\s*(?::[^=\n]*)?=(?!=)/gm)) {
assigned.add((m[1] ?? m[2])!);
}
}
for (const n of this.context.getNodesInFile(file)) {
if (n.kind === 'class' || n.kind === 'function' || n.kind === 'method') defined.add(n.name);
}
for (const f of this.queries.getPythonFilesWithEdgesInto(file, ['extends'], ['class'])) subclassing.add(f);
for (const f of this.queries.getPythonFilesWithEdgesInto(file, ['calls', 'instantiates'], ['function', 'method', 'class'])) {
calling.add(f);
}
}
const callees = new Map<string, Map<string, Set<string>>>(); // file → attr → callee names
const assignedCallees = (file: string, attr: string): Set<string> => {
let byAttr = callees.get(file);
if (!byAttr) {
byAttr = new Map();
const source = this.context.readFile(file);
const text = source ? stripCommentsForRegex(source, 'python') : '';
for (const m of text.matchAll(/\b(\w+)\s*(?::[^=\n]*)?=(?!=)\s*(?:await\s+)?(?:[\w.]*\.)?(\w+)\s*\(/g)) {
let set = byAttr.get(m[1]!);
if (!set) byAttr.set(m[1]!, (set = new Set()));
set.add(m[2]!);
}
callees.set(file, byAttr);
}
return byAttr.get(attr) ?? new Set();
};
const kept = candidates.filter((ref) => {
const name = ref.referenceName;
if (name.includes('().')) return defined.has(name.split('().')[0]!.split('.').pop()!);
const segs = name.split('.');
if (segs.length !== 3 || !segs.every((seg) => /^[A-Za-z_]\w*$/.test(seg))) return false;
const attr = segs[1]!;
if (assigned.has(attr)) return true;
if (subclassing.has(ref.filePath) && (segs[0] === 'self' || segs[0] === 'cls')) return true;
return calling.has(ref.filePath) && [...assignedCallees(ref.filePath, attr)].some((c) => defined.has(c));
});
return new Set(kept.map(keyOf)).size <= ceiling ? resolvable(load(kept)) : null;
}
resolveOne(ref: UnresolvedRef): ResolvedRef | null {
// A C/C++ "call" whose name is a function-like macro visible in this
// translation unit is a macro expansion, not a call — it must never bind
File diff suppressed because it is too large Load Diff