Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> Source for BufSource<T>`: the implementing type parses as a
// generic_type, so the old positional receiver scan picked the TRAIT.
Expand Down
1 change: 1 addition & 0 deletions __tests__/synthesized-hop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`', ''],
Expand Down
3 changes: 3 additions & 0 deletions src/graph/synthesized-hop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
100 changes: 100 additions & 0 deletions src/resolution/callback-synthesizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Edge[]> {
// 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<string>();
const methodsMemo = new Map<string, Map<string, Node[]>>();
const methodsOf = (classId: string): Map<string, Node[]> => {
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<string>([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
Expand Down Expand Up @@ -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) },
Expand Down