mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-28 22:52:00 +08:00
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:
co-authored by
Claude Opus 5.5
parent
4286083a84
commit
642c7bb3a5
@@ -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[]
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+1180
-12
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user