diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 197ebd481..beaac5be3 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -1188,6 +1188,156 @@ 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 + + @staticmethod + def make(ids): + return ids + + @classmethod + 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" +` + ); + 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 + + @staticmethod + def make(ids): + return list(ids) + + @classmethod + def load(cls, ids): + return cls() + + def build(self): + return self + + @property + def label(self): + return "leaf" + +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'])); + // Constructors, static/class methods and properties do not dispatch to the override. + 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); + } + // 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('does not link a Python override through a supertype guessed by bare name (python-override)', async () => { + // `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'); + + 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('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 () => { // `impl Source for BufSource`: the implementing type parses as a // generic_type, so the old positional receiver scan picked the TRAIT. diff --git a/__tests__/synthesized-hop.test.ts b/__tests__/synthesized-hop.test.ts index 88524784a..ff73eb560 100644 --- a/__tests__/synthesized-hop.test.ts +++ b/__tests__/synthesized-hop.test.ts @@ -99,6 +99,7 @@ describe('describeSynthesizedHop', () => { [{ synthesizedBy: 'interface-impl', via: 'Report', promotedInto: 'scrapeLoop' }, 'interface → method promoted into scrapeLoop', ''], [{ synthesizedBy: 'closure-collection', field: 'validators' }, 'runs `validators` handlers', ''], [{ synthesizedBy: 'cpp-override', via: 'Run' }, 'cpp override', ''], + [{ synthesizedBy: 'python-override' }, 'base → override', ''], [{ synthesizedBy: 'redux-thunk', via: 'fetchUser' }, 'redux thunk', ''], [{ synthesizedBy: 'gin-middleware-chain', via: 'Auth' }, 'gin middleware chain', ''], [{ synthesizedBy: 'event-bus', channel: 'event', event: 'user.created' }, 'bus event `user.created`', ''], diff --git a/src/graph/synthesized-hop.ts b/src/graph/synthesized-hop.ts index aa68ee979..d866e6111 100644 --- a/src/graph/synthesized-hop.ts +++ b/src/graph/synthesized-hop.ts @@ -69,6 +69,9 @@ export function describeSynthesizedHop(edge: Edge | null | undefined): Synthesiz // Go: the implementing struct gets the method from a type it embeds. if (typeof m.promotedInto === 'string') return hop(`interface → method promoted into ${m.promotedInto}`); return hop('interface → impl'); + case 'python-override': + // Python: a call bound to the base declaration runs the subclass override. + return hop('base → override'); case 'closure-collection': return hop(`runs ${quoted(m.field, 'a collection')} handlers`); case 'fn-pointer-dispatch': diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index db842d158..d249a950b 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1789,6 +1789,105 @@ async function interfaceOverrideEdges(queries: QueryBuilder, ctx: ResolutionCont 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). + * 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 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 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, 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; 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; + }; + 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 = (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); + if (bases.length === 0) 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; + 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)); 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) { + 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 @@ -5167,6 +5266,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, c, 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) },