From d2aaadeba05da17db85d3b31e5f32836e3f794d8 Mon Sep 17 00:00:00 2001 From: Josh66 Date: Mon, 28 Sep 2026 09:18:26 +0000 Subject: [PATCH 1/3] feat(resolution): link Python base methods to their overrides (python-override) A call bound to a base method (a base-typed receiver `store: Base; store.fetch()`, or a module global holding one of several backends) runs the subclass override at runtime, but no static edge reaches the override, so `callers`/`impact` of `Leaf.fetch` missed every such call. The interface-impl synthesis that bridges this for Java, Kotlin, C#, TS/JS, Swift, Scala, Go and Rust did not cover Python. The new pass links each Python override to the NEAREST declaration of the same name up its `extends` chain, since Python classes often inherit through a base that does not redeclare the method (`Leaf(Mid(Base))`). Class-creation hooks (`__init__`, `__new__`, `__init_subclass__`, `__class_getitem__`) are not virtual and are skipped. Edges are `calls`, provenance `heuristic`, `synthesizedBy: 'python-override'`, labelled in explore output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66 --- __tests__/resolution.test.ts | 88 ++++++++++++++++++++++++++ src/mcp/tools.ts | 7 ++ src/resolution/callback-synthesizer.ts | 77 ++++++++++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 0fb2a1bfd5..3625042b7a 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1163,6 +1163,94 @@ impl Describe for Ctl { fn describe(&self) -> String { "ctl".into() } } ).toBe('interface-impl'); }); + it('bridges a Python base method to every override, through bases that do not redeclare it (python-override)', async () => { + // A call bound to `Base.fetch` (a base-typed receiver, or a module global + // holding one of several backends) dispatches at runtime to the subclass + // override. `Mid` does not redeclare `fetch`, so `Leaf.fetch` overrides + // `Base.fetch` directly. Constructors do not dispatch: `Base()` never runs + // `Leaf.__init__`. An unrelated class with the same method name is not a + // subclass and gets nothing. + fs.writeFileSync( + path.join(tempDir, 'base.py'), + `class Base: + def __init__(self): + self.ready = True + + def fetch(self, ids): + raise NotImplementedError("backend") + + def close(self): + return None +` + ); + fs.writeFileSync( + path.join(tempDir, 'backends.py'), + `from base import Base + +class Mid(Base): + def helper(self): + return 1 + +class Leaf(Mid): + def __init__(self): + super().__init__() + + def fetch(self, ids): + return ids + +class Direct(Base): + def fetch(self, ids): + return list(ids) + +class Refined(Direct): + def fetch(self, ids): + return sorted(ids) + +class Unrelated: + def fetch(self, ids): + return [] +` + ); + fs.writeFileSync( + path.join(tempDir, 'consumer.py'), + `from base import Base + +def gc(store: Base, ids): + return store.fetch(ids) +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const methods = cg.getNodesByKind('method'); + const find = (q: string) => methods.find((n) => n.qualifiedName === q); + const baseFetch = find('Base::fetch')!; + const leafFetch = find('Leaf::fetch')!; + const directFetch = find('Direct::fetch')!; + const unrelatedFetch = find('Unrelated::fetch')!; + const baseInit = find('Base::__init__')!; + for (const [name, node] of Object.entries({ baseFetch, leafFetch, directFetch, unrelatedFetch, baseInit })) { + expect(node, `${name} should be in the graph`).toBeDefined(); + } + + const synth = cg.getOutgoingEdges(baseFetch.id) + .filter((e) => e.kind === 'calls' && e.provenance === 'heuristic'); + expect(new Set(synth.map((e) => e.target))).toEqual(new Set([leafFetch.id, directFetch.id])); + expect(new Set(synth.map((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy))) + .toEqual(new Set(['python-override'])); + expect(cg.getOutgoingEdges(baseInit.id).filter((e) => e.provenance === 'heuristic')).toHaveLength(0); + // Refined overrides Direct's override: only the nearest declaration links to it. + const refinedFetch = find('Refined::fetch')!; + expect(cg.getOutgoingEdges(directFetch.id).filter((e) => e.provenance === 'heuristic').map((e) => e.target)) + .toEqual([refinedFetch.id]); + expect(cg.getCallers(refinedFetch.id, 3).map((c) => c.node.name)).toContain('gc'); + + // The reported question: who reaches the override? The base-typed call site does. + const reach = cg.getCallers(leafFetch.id, 2).map((c) => c.node.name); + expect(reach).toContain('gc'); + expect(cg.getCallers(unrelatedFetch.id, 2).map((c) => c.node.name)).not.toContain('gc'); + }); + it('qualifies a generic impl by its type, so trait dispatch reaches it and no edge is invented from its body (#1588)', async () => { // `impl Source for BufSource`: the implementing type parses as a // generic_type, so the old positional receiver scan picked the TRAIT. diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index f2d3980256..a91c6da3ea 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3050,6 +3050,13 @@ export class ToolHandler { registeredAt, }; } + if (m?.synthesizedBy === 'python-override') { + return { + label: `base-method dispatch — runs the subclass override (dynamic dispatch)`, + compact: `dynamic: base → override${at}`, + registeredAt, + }; + } if (m?.synthesizedBy === 'closure-collection') { const field = m.field ? `\`${String(m.field)}\`` : 'a collection'; return { diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 23ede99210..4ca9a863fd 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1088,6 +1088,82 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield return edges; } +/** + * Python override dispatch. A call bound to a base method (a base-typed + * receiver `store: Base; store.fetch()`, or a module global holding one of + * several backends) runs the subclass override at runtime — no static edge + * reaches it, so `callers`/`impact` of the override miss every such call. + * Link each override to the NEAREST declaration of the same name up its + * `extends` chain: Python classes often inherit through a base that does not + * redeclare the method (`Leaf(Mid(Base))`, only Base and Leaf define it). + * Constructors are not virtual — `Base()` never runs `Leaf.__init__` — so + * class-creation hooks are skipped. Over-approximation accepted + * (reachability-correct), like interface-impl; no cap, since each override + * yields one edge per nearest declaration. + */ +const PYTHON_NON_VIRTUAL = new Set(['__init__', '__new__', '__init_subclass__', '__class_getitem__']); +async function pythonOverrideEdges(queries: QueryBuilder, onYield: MaybeYield): Promise { + let scanned = 0; + const edges: Edge[] = []; + const seen = new Set(); + const methodsMemo = new Map>(); + const methodsOf = (classId: string): Map => { + let byName = methodsMemo.get(classId); + if (byName) return byName; + byName = new Map(); + for (const e of queries.getOutgoingEdges(classId, ['contains'])) { + const n = queries.getNodeById(e.target); + if (!n || n.kind !== 'method' || n.language !== 'python') continue; + const arr = byName.get(n.name); + if (arr) arr.push(n); else byName.set(n.name, [n]); + } + methodsMemo.set(classId, byName); + return byName; + }; + const basesOf = (classId: string): Node[] => + queries.getOutgoingEdges(classId, ['extends']) + .map((e) => queries.getNodeById(e.target)) + .filter((n): n is Node => !!n && n.kind === 'class' && n.language === 'python' && n.id !== classId); + for (const cls of queries.iterateNodesByKind('class')) { + if ((++scanned & 63) === 0) await onYield(); + if (cls.language !== 'python') continue; + const bases = basesOf(cls.id); + if (bases.length === 0) continue; + for (const [name, overrides] of methodsOf(cls.id)) { + if (PYTHON_NON_VIRTUAL.has(name)) continue; + // Breadth-first up the bases; a branch stops at its first declaration. + const visited = new Set([cls.id]); + let frontier = bases; + for (let depth = 0; frontier.length && depth < 16; depth++) { + const next: Node[] = []; + for (const base of frontier) { + if (visited.has(base.id)) continue; + visited.add(base.id); + const declared = methodsOf(base.id).get(name); + if (!declared) { next.push(...basesOf(base.id)); continue; } + for (const bm of declared) { + for (const m of overrides) { + const key = `${bm.id}>${m.id}`; + if (bm.id === m.id || seen.has(key)) continue; + seen.add(key); + edges.push({ + source: bm.id, + target: m.id, + kind: 'calls', + line: bm.startLine, + provenance: 'heuristic', + metadata: { synthesizedBy: 'python-override', via: m.name, registeredAt: `${m.filePath}:${m.startLine}` }, + }); + } + } + } + frontier = next; + } + } + } + return edges; +} + /** * Go gRPC stub → impl bridge. The protoc-gen-go-grpc codegen emits an * `UnimplementedXxxServer` struct in `*_grpc.pb.go` carrying one method @@ -3671,6 +3747,7 @@ export const SYNTH_PASSES: SynthPassDef[] = [ gate: (has) => has('java', 'kotlin', 'csharp', 'swift', 'scala', 'go', 'rust', 'arkts', ...JS_FAMILY), run: (q, _c, y) => interfaceOverrideEdges(q, y), }, + { name: 'pythonOverrideEdges', gate: (has) => has('python'), run: (q, _c, y) => pythonOverrideEdges(q, y) }, { name: 'kotlinExpectActual', gate: (has) => has('kotlin'), run: (q, _c, y) => kotlinExpectActualEdges(q, y) }, { name: 'goGrpcEdges', gate: (has) => has('go'), run: (q, _c, y) => goGrpcStubImplEdges(q, y) }, { name: 'rnEventEdgesList', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => rnEventEdges(c, y) }, From ee1fbd3b60b9a89518ba805a3113acff844683cb Mon Sep 17 00:00:00 2001 From: Josh66 Date: Mon, 28 Sep 2026 12:57:14 +0000 Subject: [PATCH 2/3] fix(resolution): python-override skips non-dispatch members and guessed supertypes Review follow-up. `Base.make()` on a @staticmethod or @classmethod names its class and never runs the override, and a @property is read, not called: skip them (and cached_property / .setter / .getter / .deleter) on either side; Python decorators are read from the source lines, since only `isStatic` is on the node. An `extends` edge resolved by bare-name guess (confidence < 0.7, e.g. two `Base` classes and no import) no longer carries dispatch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66 --- __tests__/resolution.test.ts | 48 +++++++++++++++++++++++++- src/resolution/callback-synthesizer.ts | 37 +++++++++++++++----- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 3625042b7a..3a294b8041 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1181,6 +1181,18 @@ impl Describe for Ctl { fn describe(&self) -> String { "ctl".into() } } def close(self): return None + + @staticmethod + def make(ids): + return ids + + @classmethod + def load(cls, ids): + return cls() + + @property + def label(self): + return "base" ` ); fs.writeFileSync( @@ -1198,6 +1210,18 @@ class Leaf(Mid): def fetch(self, ids): return ids + @staticmethod + def make(ids): + return list(ids) + + @classmethod + def load(cls, ids): + return cls() + + @property + def label(self): + return "leaf" + class Direct(Base): def fetch(self, ids): return list(ids) @@ -1238,7 +1262,11 @@ def gc(store: Base, ids): expect(new Set(synth.map((e) => e.target))).toEqual(new Set([leafFetch.id, directFetch.id])); expect(new Set(synth.map((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy))) .toEqual(new Set(['python-override'])); - expect(cg.getOutgoingEdges(baseInit.id).filter((e) => e.provenance === 'heuristic')).toHaveLength(0); + // Constructors, static/class methods and properties do not dispatch to the override. + for (const q of ['Base::__init__', 'Base::make', 'Base::load', 'Base::label']) { + expect(find(q), `${q} should be in the graph`).toBeDefined(); + expect(cg.getOutgoingEdges(find(q)!.id).filter((e) => e.provenance === 'heuristic'), q).toHaveLength(0); + } // Refined overrides Direct's override: only the nearest declaration links to it. const refinedFetch = find('Refined::fetch')!; expect(cg.getOutgoingEdges(directFetch.id).filter((e) => e.provenance === 'heuristic').map((e) => e.target)) @@ -1251,6 +1279,24 @@ def gc(store: Base, ids): expect(cg.getCallers(unrelatedFetch.id, 2).map((c) => c.node.name)).not.toContain('gc'); }); + it('does not link a Python override through a supertype guessed by bare name (python-override)', async () => { + // `Base` exists in two files and `child.py` imports neither: the extends + // edge is a low-confidence name guess, so it must not carry dispatch. + fs.writeFileSync(path.join(tempDir, 'a.py'), 'class Base:\n def fetch(self):\n return 1\n'); + fs.writeFileSync(path.join(tempDir, 'b.py'), 'class Base:\n def fetch(self):\n return 2\n'); + fs.writeFileSync(path.join(tempDir, 'child.py'), 'class Child(Base):\n def fetch(self):\n return 3\n'); + fs.writeFileSync(path.join(tempDir, 'imported.py'), + 'from a import Base\n\nclass Known(Base):\n def fetch(self):\n return 4\n'); + + cg = await CodeGraph.init(tempDir, { index: true }); + + const methods = cg.getNodesByKind('method').filter((n) => n.name === 'fetch'); + const into = (q: string) => cg.getIncomingEdges(methods.find((n) => n.qualifiedName === q)!.id) + .filter((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === 'python-override'); + expect(into('Child::fetch')).toHaveLength(0); + expect(into('Known::fetch')).toHaveLength(1); + }); + it('qualifies a generic impl by its type, so trait dispatch reaches it and no edge is invented from its body (#1588)', async () => { // `impl Source for BufSource`: the implementing type parses as a // generic_type, so the old positional receiver scan picked the TRAIT. diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 4ca9a863fd..13cb254849 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1096,13 +1096,26 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield * Link each override to the NEAREST declaration of the same name up its * `extends` chain: Python classes often inherit through a base that does not * redeclare the method (`Leaf(Mid(Base))`, only Base and Leaf define it). - * Constructors are not virtual — `Base()` never runs `Leaf.__init__` — so - * class-creation hooks are skipped. Over-approximation accepted - * (reachability-correct), like interface-impl; no cap, since each override - * yields one edge per nearest declaration. + * Not dispatch, so skipped on either side: class-creation hooks (`Base()` + * never runs `Leaf.__init__`), static and class methods (`Base.fetch()` names + * its class), and properties (read, not called). A guessed supertype (an + * `extends` edge resolved by bare name below 0.7 confidence) is not followed. + * Over-approximation accepted (reachability-correct), like interface-impl; no + * cap, since each override yields one edge per nearest declaration. */ const PYTHON_NON_VIRTUAL = new Set(['__init__', '__new__', '__init_subclass__', '__class_getitem__']); -async function pythonOverrideEdges(queries: QueryBuilder, onYield: MaybeYield): Promise { +const PYTHON_NON_DISPATCH_DECORATOR = /^@?(?:staticmethod|classmethod|(?:functools\.)?cached_property|property|\w+\.(?:setter|getter|deleter))\b/; +const MIN_SUPERTYPE_CONFIDENCE = 0.7; +async function pythonOverrideEdges(queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield): Promise { + // Python decorators are not on the node (only `isStatic`); read the `@` lines above the def. + const isPythonDispatchMethod = (m: Node): boolean => { + if (PYTHON_NON_VIRTUAL.has(m.name) || m.isStatic) return false; + const lines = ctx.getFileLines?.(m.filePath) ?? ctx.readFile(m.filePath)?.split('\n') ?? []; + for (let i = m.startLine - 2; i >= 0 && lines[i]!.trim().startsWith('@'); i--) { + if (PYTHON_NON_DISPATCH_DECORATOR.test(lines[i]!.trim())) return false; + } + return true; + }; let scanned = 0; const edges: Edge[] = []; const seen = new Set(); @@ -1122,6 +1135,10 @@ async function pythonOverrideEdges(queries: QueryBuilder, onYield: MaybeYield): }; const basesOf = (classId: string): Node[] => queries.getOutgoingEdges(classId, ['extends']) + .filter((e) => { + const confidence = (e.metadata as { confidence?: number } | undefined)?.confidence; + return confidence === undefined || confidence >= MIN_SUPERTYPE_CONFIDENCE; + }) .map((e) => queries.getNodeById(e.target)) .filter((n): n is Node => !!n && n.kind === 'class' && n.language === 'python' && n.id !== classId); for (const cls of queries.iterateNodesByKind('class')) { @@ -1129,8 +1146,9 @@ async function pythonOverrideEdges(queries: QueryBuilder, onYield: MaybeYield): if (cls.language !== 'python') continue; const bases = basesOf(cls.id); if (bases.length === 0) continue; - for (const [name, overrides] of methodsOf(cls.id)) { - if (PYTHON_NON_VIRTUAL.has(name)) continue; + for (const [name, all] of methodsOf(cls.id)) { + const overrides = all.filter(isPythonDispatchMethod); + if (overrides.length === 0) continue; // Breadth-first up the bases; a branch stops at its first declaration. const visited = new Set([cls.id]); let frontier = bases; @@ -1141,7 +1159,8 @@ async function pythonOverrideEdges(queries: QueryBuilder, onYield: MaybeYield): visited.add(base.id); const declared = methodsOf(base.id).get(name); if (!declared) { next.push(...basesOf(base.id)); continue; } - for (const bm of declared) { + // The nearest declaration ends this branch even when it is static or a property. + for (const bm of declared.filter(isPythonDispatchMethod)) { for (const m of overrides) { const key = `${bm.id}>${m.id}`; if (bm.id === m.id || seen.has(key)) continue; @@ -3747,7 +3766,7 @@ export const SYNTH_PASSES: SynthPassDef[] = [ gate: (has) => has('java', 'kotlin', 'csharp', 'swift', 'scala', 'go', 'rust', 'arkts', ...JS_FAMILY), run: (q, _c, y) => interfaceOverrideEdges(q, y), }, - { name: 'pythonOverrideEdges', gate: (has) => has('python'), run: (q, _c, y) => pythonOverrideEdges(q, y) }, + { name: 'pythonOverrideEdges', gate: (has) => has('python'), run: (q, c, y) => pythonOverrideEdges(q, c, y) }, { name: 'kotlinExpectActual', gate: (has) => has('kotlin'), run: (q, _c, y) => kotlinExpectActualEdges(q, y) }, { name: 'goGrpcEdges', gate: (has) => has('go'), run: (q, _c, y) => goGrpcStubImplEdges(q, y) }, { name: 'rnEventEdgesList', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => rnEventEdges(c, y) }, From 617330c449598c961a4513dd1db6df1ef83c032f Mon Sep 17 00:00:00 2001 From: Josh66 Date: Mon, 28 Sep 2026 13:15:51 +0000 Subject: [PATCH 3/3] fix(resolution): python-override follows imported or same-file supertypes only Review follow-up. Confidence reflects proximity, not whether a supertype was guessed: a same-file base can score 0.4 while a cross-file bare-name guess under a package path scores 0.7. Follow an `extends` edge when it was resolved through an import or its base is in the same file; skip a cross-file `exact-match` / `fuzzy` guess. Decorators are read past blank and comment lines between them and the def. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018CQubRS1bXvaoApgPyLf66 --- __tests__/resolution.test.ts | 24 ++++++++++++++--- src/resolution/callback-synthesizer.ts | 36 ++++++++++++++------------ 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 3a294b8041..733a702bc8 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1190,6 +1190,11 @@ impl Describe for Ctl { fn describe(&self) -> String { "ctl".into() } } def load(cls, ids): return cls() + @classmethod + # a comment between the decorator and the def + def build(cls): + return cls() + @property def label(self): return "base" @@ -1218,6 +1223,9 @@ class Leaf(Mid): def load(cls, ids): return cls() + def build(self): + return self + @property def label(self): return "leaf" @@ -1263,7 +1271,7 @@ def gc(store: Base, ids): expect(new Set(synth.map((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy))) .toEqual(new Set(['python-override'])); // Constructors, static/class methods and properties do not dispatch to the override. - for (const q of ['Base::__init__', 'Base::make', 'Base::load', 'Base::label']) { + for (const q of ['Base::__init__', 'Base::make', 'Base::load', 'Base::build', 'Base::label']) { expect(find(q), `${q} should be in the graph`).toBeDefined(); expect(cg.getOutgoingEdges(find(q)!.id).filter((e) => e.provenance === 'heuristic'), q).toHaveLength(0); } @@ -1280,11 +1288,17 @@ def gc(store: Base, ids): }); it('does not link a Python override through a supertype guessed by bare name (python-override)', async () => { - // `Base` exists in two files and `child.py` imports neither: the extends - // edge is a low-confidence name guess, so it must not carry dispatch. - fs.writeFileSync(path.join(tempDir, 'a.py'), 'class Base:\n def fetch(self):\n return 1\n'); + // `Base` exists in two files. `child.py` / `src/app/deep.py` import + // neither, so their extends edges are name guesses — whatever confidence + // proximity gives them — and must not carry dispatch. An import, or a + // base in the same file, is not a guess. + fs.writeFileSync(path.join(tempDir, 'a.py'), + 'class Base:\n def fetch(self):\n return 1\n\nclass Local(Base):\n def fetch(self):\n return 5\n'); fs.writeFileSync(path.join(tempDir, 'b.py'), 'class Base:\n def fetch(self):\n return 2\n'); fs.writeFileSync(path.join(tempDir, 'child.py'), 'class Child(Base):\n def fetch(self):\n return 3\n'); + fs.mkdirSync(path.join(tempDir, 'src', 'app'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'src', 'app', 'a.py'), 'class Base:\n def fetch(self):\n return 6\n'); + fs.writeFileSync(path.join(tempDir, 'src', 'app', 'deep.py'), 'class Deep(Base):\n def fetch(self):\n return 7\n'); fs.writeFileSync(path.join(tempDir, 'imported.py'), 'from a import Base\n\nclass Known(Base):\n def fetch(self):\n return 4\n'); @@ -1294,7 +1308,9 @@ def gc(store: Base, ids): const into = (q: string) => cg.getIncomingEdges(methods.find((n) => n.qualifiedName === q)!.id) .filter((e) => (e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy === 'python-override'); expect(into('Child::fetch')).toHaveLength(0); + expect(into('Deep::fetch')).toHaveLength(0); expect(into('Known::fetch')).toHaveLength(1); + expect(into('Local::fetch')).toHaveLength(1); }); it('qualifies a generic impl by its type, so trait dispatch reaches it and no edge is invented from its body (#1588)', async () => { diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 13cb254849..0d5dffbbb7 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1098,21 +1098,26 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield * redeclare the method (`Leaf(Mid(Base))`, only Base and Leaf define it). * Not dispatch, so skipped on either side: class-creation hooks (`Base()` * never runs `Leaf.__init__`), static and class methods (`Base.fetch()` names - * its class), and properties (read, not called). A guessed supertype (an - * `extends` edge resolved by bare name below 0.7 confidence) is not followed. + * its class), and properties (read, not called). A supertype is followed when + * its `extends` edge was resolved through an import or lies in the same file; + * a cross-file bare-name guess (`exact-match` / `fuzzy`) is not. * Over-approximation accepted (reachability-correct), like interface-impl; no * cap, since each override yields one edge per nearest declaration. */ const PYTHON_NON_VIRTUAL = new Set(['__init__', '__new__', '__init_subclass__', '__class_getitem__']); const PYTHON_NON_DISPATCH_DECORATOR = /^@?(?:staticmethod|classmethod|(?:functools\.)?cached_property|property|\w+\.(?:setter|getter|deleter))\b/; -const MIN_SUPERTYPE_CONFIDENCE = 0.7; +const GUESSED_SUPERTYPE = new Set(['exact-match', 'fuzzy']); async function pythonOverrideEdges(queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield): Promise { - // Python decorators are not on the node (only `isStatic`); read the `@` lines above the def. + // Python decorators are not on the node (only `isStatic`); read the `@` lines + // above the def, past blank and comment lines between them. const isPythonDispatchMethod = (m: Node): boolean => { if (PYTHON_NON_VIRTUAL.has(m.name) || m.isStatic) return false; const lines = ctx.getFileLines?.(m.filePath) ?? ctx.readFile(m.filePath)?.split('\n') ?? []; - for (let i = m.startLine - 2; i >= 0 && lines[i]!.trim().startsWith('@'); i--) { - if (PYTHON_NON_DISPATCH_DECORATOR.test(lines[i]!.trim())) return false; + for (let i = m.startLine - 2; i >= 0; i--) { + const line = lines[i]!.trim(); + if (line === '' || line.startsWith('#')) continue; + if (!line.startsWith('@')) break; + if (PYTHON_NON_DISPATCH_DECORATOR.test(line)) return false; } return true; }; @@ -1133,18 +1138,17 @@ async function pythonOverrideEdges(queries: QueryBuilder, ctx: ResolutionContext methodsMemo.set(classId, byName); return byName; }; - const basesOf = (classId: string): Node[] => - queries.getOutgoingEdges(classId, ['extends']) - .filter((e) => { - const confidence = (e.metadata as { confidence?: number } | undefined)?.confidence; - return confidence === undefined || confidence >= MIN_SUPERTYPE_CONFIDENCE; - }) - .map((e) => queries.getNodeById(e.target)) - .filter((n): n is Node => !!n && n.kind === 'class' && n.language === 'python' && n.id !== classId); + const basesOf = (cls: Node): Node[] => + queries.getOutgoingEdges(cls.id, ['extends']) + .map((e) => ({ e, base: queries.getNodeById(e.target) })) + .filter(({ e, base }) => !!base && base.kind === 'class' && base.language === 'python' && base.id !== cls.id && + (base.filePath === cls.filePath || + !GUESSED_SUPERTYPE.has(String((e.metadata as { resolvedBy?: string } | undefined)?.resolvedBy)))) + .map(({ base }) => base!); for (const cls of queries.iterateNodesByKind('class')) { if ((++scanned & 63) === 0) await onYield(); if (cls.language !== 'python') continue; - const bases = basesOf(cls.id); + const bases = basesOf(cls); if (bases.length === 0) continue; for (const [name, all] of methodsOf(cls.id)) { const overrides = all.filter(isPythonDispatchMethod); @@ -1158,7 +1162,7 @@ async function pythonOverrideEdges(queries: QueryBuilder, ctx: ResolutionContext if (visited.has(base.id)) continue; visited.add(base.id); const declared = methodsOf(base.id).get(name); - if (!declared) { next.push(...basesOf(base.id)); continue; } + if (!declared) { next.push(...basesOf(base)); continue; } // The nearest declaration ends this branch even when it is static or a property. for (const bm of declared.filter(isPythonDispatchMethod)) { for (const m of overrides) {