Repository navigation
Conversation
A layered Python codebase imports the module, not each function, so most of its cross-module traffic reads `billing.charge(...)`. Such a call reached the resolver as a member call with no inferable receiver type and was dropped — correctly, under the drop-rather-than-guess rule, since a bare `charge` says nothing about which file it lives in. The missing half was the import binding: collectImportedSymbols covers TypeScript and PHP and returned an empty map for Python, so the module an alias names never reached the call site. With a module table, the call states both halves — the file (the import) and the symbol (the attribute) — which is the same evidence a named import already gives a `references` edge, and resolves exactly. No bare-name fallback is added. Measured on a 375-file FastAPI service: 319 module-alias calls, of which the graph knew 1; now all 319. Cross-file Python call edges go 2009 -> 3038, and every one of them is confirmed by pyright's call hierarchy — zero edges the compiler does not also report. Build time is unchanged (5.2s), where `--lsp` on the same repo costs 22s. Two supporting fixes fall out of the same gap: dotted and relative imports now resolve to in-repo files instead of staying external strings (suffix matching, as Java and PHP already do here), and `import a.b as x` produces an import edge at all — importSpecifier never looked inside an `aliased_import`.
🌱 graft blast radius1 area changed → 3 areas can be affected. 9 dependent symbols, depth 2. flowchart TB
A0(("Workspace Graph<br/>5 symbols"))
A1(("Engine Core<br/>2 symbols"))
A2(("Pull Request Review<br/>2 symbols"))
classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
class A0,A1,A2 reached;
Who knows this code — 2 people across 4 areas
Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no All 9 dependent symbols, grouped by areaWorkspace Graph — 5 symbols in 5 files
Engine Core — 2 symbols in 1 file
Pull Request Review — 2 symbols in 2 files
Test signal per changed area — 1 ⚠Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.
37 test suites also reference this code46 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.
Open the interactive graph → — click an area to see its dependent symbols at file:line. |
|
I reproduced all three cases in #465 on main The patch below extends #437 using its module-path resolver. It resolves named imports and class methods within the imported file, and drops external, missing, ambiguous and shadowed bindings. Support is limited to unconditional module-level named imports. Conditional imports that replace a binding are also excluded, so they cannot create a false call to the original import. A class-method call creates an edge to the method without inventing a constructor call. The build and all 18 focused Python tests pass on Windows. A fresh graph build resolves all four positive probes. The full suite has 1,242 passes, 16 failures and 5 skips, compared with 1,231 passes and the same failures and skips on the unchanged PR. All 11 added tests pass. The remaining failures concern Git fixture commits and The complete patch applies cleanly to Named-import extension and regression testsdiff --git a/src/graph/extract.ts b/src/graph/extract.ts
index bd12834..280e926 100644
--- a/src/graph/extract.ts
+++ b/src/graph/extract.ts
@@ -342,6 +342,7 @@ export interface WalkCtx {
* attribute names the symbol, so resolve.ts can resolve it exactly instead of
* dropping it for want of a receiver type. Empty for every other language. */
importedModules: ReadonlyMap<string, string>;
+ pythonImportedNames: ReadonlySet<string>;
// R6 (Phase 2): which list we're inside while walking an `R6Class(...)` call's
// arguments — set only for the direct span of a `public =`/`private =`/
// `active =` `list(...)`'s own entries (see walk()'s special-cased `argument`
@@ -395,8 +396,11 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac
parser.setLanguage(GRAMMARS[lang] as never);
const root = parseSource(source);
const bindings = collectBindings(root, lang);
- const importedSymbols = collectImportedSymbols(root, lang);
- const importedModules = lang === "python" ? collectPythonImportedModules(root) : EMPTY_MODULE_MAP;
+ const pythonSymbols = new Map<string, { name: string; specifier: string }>();
+ const importedModules = lang === "python" ? collectPythonImportedModules(root, pythonSymbols) : EMPTY_MODULE_MAP;
+ const importedSymbols = lang === "python"
+ ? withoutPythonShadowedModules(pythonSymbols, root)
+ : collectImportedSymbols(root, lang);
const rGenerics = lang === "r" ? collectRGenerics(root) : EMPTY_SET;
const nodes: NodeV1[] = [
@@ -431,6 +435,7 @@ export function extractFile(rel: string, source: string, lang: Language): Extrac
goReceiverVar: null,
importedSymbols,
importedModules,
+ pythonImportedNames: new Set(pythonSymbols.keys()),
rR6Access: null,
rGenerics,
rSuperClass: null,
@@ -649,7 +654,9 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd
goReceiverVar: isGoMethod ? goReceiverVarOf(node) : ctx.goReceiverVar,
importedSymbols:
desc.kind === "function" || desc.kind === "method"
- ? withoutShadowedImports(ctx.importedSymbols, node)
+ ? ctx.lang === "python"
+ ? withoutPythonShadowedModules(ctx.importedSymbols, node)
+ : withoutShadowedImports(ctx.importedSymbols, node)
: ctx.importedSymbols,
importedModules:
ctx.lang === "python" && (desc.kind === "function" || desc.kind === "method")
@@ -783,13 +790,29 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd
// two-halves evidence a named import gives a `references` edge. Carry the
// module as the specifier so resolve.ts can look the name up in that file
// alone — no bare-name guessing, and no receiver type to infer.
+ const importedName = callee.viaMember ? callee.receiver : callee.name;
+ const imported = ctx.lang === "python" && importedName
+ ? ctx.importedSymbols.get(importedName)
+ : undefined;
+ const shadowed = ctx.lang === "python" && importedName && ctx.pythonImportedNames.has(importedName)
+ && (!imported || pythonCallHasLocalBinding(node, importedName));
const moduleSpec =
ctx.lang === "python" && callee.viaMember && callee.receiver
? ctx.importedModules.get(callee.receiver)
: undefined;
const recvType = moduleSpec ? undefined : resolveRecvType(callee.receiver, ctx);
- if (moduleSpec) edges.push({ ...callEdge, specifier: moduleSpec });
- else edges.push(recvType ? { ...callEdge, recvType } : callEdge);
+ if (imported && !shadowed) {
+ edges.push({
+ ...callEdge,
+ name: callee.viaMember ? callee.name : imported.name,
+ specifier: imported.specifier,
+ ...(callee.viaMember ? { recvType: imported.name } : {}),
+ });
+ } else if (!shadowed) {
+ // A shadowed bare import must not reconnect through global name lookup.
+ if (moduleSpec) edges.push({ ...callEdge, specifier: moduleSpec });
+ else edges.push(recvType ? { ...callEdge, recvType } : callEdge);
+ }
}
}
} else if (ctx.lang === "php" && node.type === "use_declaration") {
@@ -885,7 +908,10 @@ function walk(node: Parser.SyntaxNode, ctx: WalkCtx, out: NodeV1[], edges: RawEd
* TOP package name, so the call site reads `a.b.f()` — a two-hop receiver Python's
* callee reader does not report — and is therefore left out rather than guessed at.
*/
-function collectPythonImportedModules(root: Parser.SyntaxNode): Map<string, string> {
+function collectPythonImportedModules(
+ root: Parser.SyntaxNode,
+ symbols: Map<string, { name: string; specifier: string }>,
+): Map<string, string> {
const out = new Map<string, string>();
const bind = (aliasNode: Parser.SyntaxNode, path: string): void => {
const names = aliasNode.namedChildren;
@@ -913,8 +939,17 @@ function collectPythonImportedModules(root: Parser.SyntaxNode): Map<string, stri
if (!moduleNode) return;
const modulePath = moduleNode.text;
for (const child of node.namedChildren) {
- if (child === moduleNode) continue;
- if (child.type === "aliased_import" || child.type === "dotted_name") bind(child, modulePath);
+ if (sameSyntaxNode(child, moduleNode)) continue;
+ if (child.type === "aliased_import" || child.type === "dotted_name") {
+ bind(child, modulePath);
+ // ponytail: index unconditional module imports here. Local and
+ // conditional imports need per-scope binding tables before inclusion.
+ if (node.parent?.type === "module") {
+ const name = child.childForFieldName("name")?.text ?? child.text;
+ const local = child.childForFieldName("alias")?.text ?? name;
+ symbols.set(local, { name, specifier: modulePath });
+ }
+ }
}
return;
}
@@ -930,28 +965,74 @@ function joinModulePath(prefix: string, tail: string): string {
return prefix.endsWith(".") ? `${prefix}${tail}` : `${prefix}.${tail}`;
}
-/**
- * Python module aliases minus the names this definition rebinds — a parameter or
- * an assignment named like an imported module shadows it for the whole body, and
- * a call on the local value is not a call into that module.
- */
-function withoutPythonShadowedModules(
- modules: ReadonlyMap<string, string>,
+/** Binding patterns exclude type annotations, default values and attribute names. */
+function collectPythonBindingNames(node: Parser.SyntaxNode, names: Set<string>): void {
+ if (node.type === "identifier") names.add(node.text);
+ else if (node.type === "default_parameter" || node.type === "typed_default_parameter") {
+ const name = node.childForFieldName("name");
+ if (name) collectPythonBindingNames(name, names);
+ } else if (node.type !== "attribute" && node.type !== "subscript") {
+ const type = node.childForFieldName("type");
+ for (const child of node.namedChildren) {
+ if (!sameSyntaxNode(child, type)) collectPythonBindingNames(child, names);
+ }
+ }
+}
+
+function pythonCallHasLocalBinding(call: Parser.SyntaxNode, name: string): boolean {
+ for (let scope = call.parent; scope && scope.type !== "function_definition"; scope = scope.parent) {
+ const names = new Set<string>();
+ if (scope.type === "lambda") {
+ const params = scope.childForFieldName("parameters");
+ if (params) collectPythonBindingNames(params, names);
+ } else if (scope.type.endsWith("comprehension") || scope.type === "generator_expression") {
+ for (const child of scope.namedChildren) {
+ if (child.type !== "for_in_clause") continue;
+ const left = child.childForFieldName("left");
+ if (left) collectPythonBindingNames(left, names);
+ }
+ }
+ if (names.has(name)) return true;
+ }
+ return false;
+}
+
+/** Remove imports rebound in this scope, retaining bindings used by nested scopes. */
+function withoutPythonShadowedModules<T>(
+ modules: ReadonlyMap<string, T>,
definition: Parser.SyntaxNode,
-): ReadonlyMap<string, string> {
+): ReadonlyMap<string, T> {
if (modules.size === 0) return modules;
const shadowed = new Set<string>();
const params = definition.childForFieldName("parameters");
- const collectParam = (node: Parser.SyntaxNode): void => {
- if (node.type === "identifier") shadowed.add(node.text);
- else for (const child of node.namedChildren) collectParam(child);
- };
- if (params) for (const p of params.namedChildren) collectParam(p);
- const body = definition.childForFieldName("body");
+ if (params) collectPythonBindingNames(params, shadowed);
+ const body = definition.type === "module" ? definition : definition.childForFieldName("body");
const visitBody = (node: Parser.SyntaxNode): void => {
- if (node.type === "assignment") {
+ if (node.type === "function_definition" || node.type === "class_definition" || node.type === "lambda") {
+ const name = node.childForFieldName("name");
+ if (name) shadowed.add(name.text);
+ return;
+ }
+ if (node.type === "assignment" || node.type === "augmented_assignment" || node.type === "for_statement") {
const left = node.childForFieldName("left");
- if (left?.type === "identifier") shadowed.add(left.text);
+ if (left) collectPythonBindingNames(left, shadowed);
+ }
+ if (node.type === "as_pattern" || node.type === "named_expression") {
+ const target = node.childForFieldName(node.type === "as_pattern" ? "alias" : "name");
+ if (target) collectPythonBindingNames(target, shadowed);
+ }
+ if (node.type === "delete_statement") {
+ collectPythonBindingNames(node, shadowed);
+ }
+ if (node.parent?.type !== "module" && (node.type === "import_statement" || node.type === "import_from_statement")) {
+ for (const child of node.namedChildren) {
+ if (child.type === "aliased_import") {
+ const alias = child.childForFieldName("alias");
+ if (alias) shadowed.add(alias.text);
+ } else if (child.type === "dotted_name" && !sameSyntaxNode(child, node.childForFieldName("module_name"))) {
+ shadowed.add(child.text.split(".")[0]);
+ }
+ }
}
for (const child of node.namedChildren) visitBody(child);
};
diff --git a/src/graph/resolve.ts b/src/graph/resolve.ts
index 86f3416..2575078 100644
--- a/src/graph/resolve.ts
+++ b/src/graph/resolve.ts
@@ -282,20 +282,36 @@ export function resolveEdges(
if (hit && hit.id !== e.source) add(e.source, hit.id, "references", hit.confidence);
}
} else if (e.relation === "calls") {
- // A Python call through an imported module alias (`credits.reserve()`)
- // carries the module as its specifier. That is the same two-halves evidence
- // a named import gives — the file to look in, and the name to look for — so
- // it resolves inside that file alone. `from a.b import c` is ambiguous in
- // Python's grammar (submodule or symbol?); an unresolved module means it
- // was the symbol reading, and the call falls through to the paths below
- // rather than guessing.
+ // Python imports identify the target module. A named receiver can be a
+ // class or a submodule; both resolve within their explicit import path.
if (e.specifier && e.name && e.file.endsWith(".py")) {
const moduleFile = resolvePythonImport(e.specifier, e.file, byId, pythonFilesByModule);
- const candidates = byId.has(moduleFile) ? (perFileName.get(moduleFile)?.get(e.name) ?? []) : [];
+ let targetFile = moduleFile;
+ let candidates: NodeV1[];
+ if (e.viaMember && e.recvType) {
+ const owners = (perFileName.get(moduleFile)?.get(e.recvType) ?? [])
+ .filter((n) => n.id.replace(/~\d+$/, "") === `${moduleFile}#${e.recvType}`);
+ if (owners.length === 1 && owners[0].kind === "class") {
+ candidates = (ownerMethod.get(`${e.recvType}.${e.name}`) ?? [])
+ .filter((n) => n.path === moduleFile && n.id.replace(/~\d+$/, "") === `${owners[0].id}.${e.name}`);
+ } else if (owners.length === 0) {
+ // `from pkg import submodule` shares the syntax of a named import.
+ const spec = e.specifier.endsWith(".") ? `${e.specifier}${e.recvType}` : `${e.specifier}.${e.recvType}`;
+ targetFile = resolvePythonImport(spec, e.file, byId, pythonFilesByModule);
+ candidates = (perFileName.get(targetFile)?.get(e.name) ?? [])
+ .filter((n) => n.id.replace(/~\d+$/, "") === `${targetFile}#${e.name}` && (n.kind === "function" || n.kind === "class"));
+ } else {
+ candidates = [];
+ }
+ } else {
+ candidates = (perFileName.get(targetFile)?.get(e.name) ?? [])
+ .filter((n) => n.id.replace(/~\d+$/, "") === `${targetFile}#${e.name}` && (n.kind === "function" || n.kind === "class"));
+ }
if (candidates.length === 1) {
add(e.source, candidates[0].id, "calls", "extracted");
- continue;
}
+ // Explicit imports must not fall through to an unrelated global name.
+ continue;
}
if (e.viaMember) {
if (!e.recvType) continue;
diff --git a/test/graph-python-named-imports.test.ts b/test/graph-python-named-imports.test.ts
new file mode 100644
index 0000000..4033667
--- /dev/null
+++ b/test/graph-python-named-imports.test.ts
@@ -0,0 +1,163 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { extractFile } from "../src/graph/extract.js";
+import { resolveEdges } from "../src/graph/resolve.js";
+
+const definitions = `def validate_result(value):
+ return value
+
+class Workspace:
+ @classmethod
+ def open(cls):
+ return cls()
+`;
+
+function calls(files: Record<string, string>, source = "consumer.py#run"): string[] {
+ const extracted = Object.entries(files).map(([path, text]) => extractFile(path, text, "python"));
+ return resolveEdges(extracted.flatMap((x) => x.nodes), extracted.flatMap((x) => x.rawEdges))
+ .filter((e) => e.source === source && e.relation === "calls")
+ .map((e) => e.target);
+}
+
+test("Python named imports resolve aliases, class methods and duplicate function names", () => {
+ const files = {
+ "validators.py": definitions,
+ "other.py": definitions,
+ "first/helpers.py": "def parse_value(value):\n return value\n",
+ "second/helpers.py": "def parse_value(value):\n return value\n",
+ "consumer.py": `from validators import validate_result as validate_equity_result
+from validators import Workspace as Project
+from first.helpers import parse_value
+
+def run():
+ validate_equity_result(1)
+ Project.open()
+ return parse_value(1)
+`,
+ };
+ assert.deepEqual(calls(files), [
+ "validators.py#validate_result", "validators.py#Workspace.open", "first/helpers.py#parse_value",
+ ]);
+});
+
+test("Python named imports resolve relative modules and package initialisers", () => {
+ assert.deepEqual(calls({
+ "pkg/validators.py": definitions,
+ "pkg/tools/__init__.py": "def parse_value(value):\n return value\n",
+ "pkg/consumer.py": `from .validators import Workspace
+from .tools import parse_value
+def run():
+ Workspace.open()
+ return parse_value(1)
+`,
+ }, "pkg/consumer.py#run"), ["pkg/validators.py#Workspace.open", "pkg/tools/__init__.py#parse_value"]);
+});
+
+test("Python named imports do not guess past an external, absent or ambiguous target", () => {
+ for (const module of ["external", "empty", "shared"]) {
+ assert.deepEqual(calls({
+ "validators.py": definitions,
+ "empty.py": "def unrelated():\n pass\n",
+ "a/shared.py": definitions,
+ "b/shared.py": definitions,
+ "consumer.py": `from ${module} import validate_result, Workspace\ndef run():\n validate_result(1)\n Workspace.open()\n`,
+ }), [], module);
+ }
+});
+
+test("Python named imports stay shadowed by parameters and local bindings", () => {
+ const bodies = [
+ "def run(validate_result):\n return validate_result(1)\n",
+ "def run():\n validate_result = callback\n return validate_result(1)\n",
+ "def run():\n validate_result, other = callbacks\n return validate_result(1)\n",
+ "def run():\n for validate_result in callbacks:\n validate_result(1)\n",
+ "def run():\n from external import other as validate_result\n return validate_result(1)\n",
+ "def run(Workspace):\n return Workspace.open()\n",
+ "def run():\n with manager() as validate_result:\n return validate_result(1)\n",
+ "def run():\n try:\n pass\n except Error as validate_result:\n validate_result(1)\n",
+ "def run():\n (validate_result := callback)\n return validate_result(1)\n",
+ "def run():\n del validate_result\n return validate_result(1)\n",
+ ];
+ for (const body of bodies) {
+ assert.deepEqual(calls({
+ "validators.py": definitions,
+ "consumer.py": "from validators import validate_result, Workspace\n" + body,
+ }), [], body);
+ }
+});
+
+test("Python lambda and comprehension bindings shadow imported names locally", () => {
+ for (const expression of [
+ "lambda validate_result: validate_result(1)",
+ "[validate_result(1) for validate_result in callbacks]",
+ "(validate_result(1) for validate_result in callbacks)",
+ ]) {
+ assert.deepEqual(calls({
+ "validators.py": definitions,
+ "consumer.py": `from validators import validate_result\ndef run():\n return ${expression}\n`,
+ }), [], expression);
+ }
+});
+
+test("Python nested scope assignments do not shadow an outer imported caller", () => {
+ assert.deepEqual(calls({
+ "validators.py": definitions,
+ "consumer.py": `from validators import validate_result
+def run():
+ def inner():
+ validate_result = callback
+ return validate_result(1)
+`,
+ }), ["validators.py#validate_result"]);
+});
+
+test("Python module assignments suppress named import resolution", () => {
+ assert.deepEqual(calls({
+ "validators.py": definitions,
+ "consumer.py": "from validators import validate_result\nvalidate_result = callback\ndef run():\n return validate_result(1)\n",
+ }), []);
+});
+
+test("Python conditional imports suppress overwritten module bindings", () => {
+ for (const statement of [
+ "from external import validate_result",
+ "from external import other as validate_result",
+ "import external as validate_result",
+ ]) {
+ assert.deepEqual(calls({
+ "validators.py": definitions,
+ "external.py": "def validate_result(value):\n return value\ndef other(value):\n return value\n",
+ "consumer.py": `from validators import validate_result
+if True:
+ ${statement}
+def run():
+ return validate_result(1)
+`,
+ }), [], statement);
+ }
+});
+
+test("Python annotations, default values and attribute assignments do not rebind imports", () => {
+ assert.deepEqual(calls({
+ "validators.py": definitions,
+ "consumer.py": `from validators import validate_result, Workspace
+def run(value: Workspace = Workspace):
+ value.validate_result = callback
+ return validate_result(1)
+`,
+ }), ["validators.py#validate_result"]);
+});
+
+test("Python function-local imports do not become named imports in another function", () => {
+ assert.deepEqual(calls({
+ "validators.py": definitions,
+ "consumer.py": "def load():\n from validators import validate_result as validate\ndef run():\n return validate(1)\n",
+ }), []);
+});
+
+test("Python named imports do not select the first duplicate definition", () => {
+ assert.deepEqual(calls({
+ "validators.py": definitions + definitions,
+ "consumer.py": "from validators import validate_result, Workspace\ndef run():\n validate_result(1)\n Workspace.open()\n",
+ }), []);
+}); |
|
Thanks! Your module-alias commit is included unchanged (same patch-id, your authorship) as the first commit of #490. On our test fixture, |
Problem
A layered Python codebase imports the module, not each function, so most cross-module
traffic reads
credits.reserve(...). Such a call reached the resolver as a member callwith no inferable receiver type and was dropped — correctly under the drop-rather-than-guess
rule of #35, since a bare
reservesays nothing about which file it lives in.The missing half was the import binding:
collectImportedSymbolscovers TypeScript and PHPand returns an empty map for Python, so the module an alias names never reached the call site.
Measured on a 375-file FastAPI service: 319 calls through a module alias, of which the graph
knew 1. Cross-file Python call edges: 2009.
Relation to #375 and #334
#375 reports this exact gap. #334 proposes the same mechanism, written independently — I found
it only after finishing this work, and the two even picked the same field name (
importedModules).I am opening this anyway because it closes three things #334 leaves open; maintainers should of
course take whichever they prefer, or the pieces below on top of #334.
Shadowing. python: resolve calls through an imported module name (M.f() / from a import b as M) #334 reads the module table without checking whether the name still denotes
the module. Applying its diff to
mainand running this PR's negative test, a parameter namedafter the module produces a false edge:
Same for a local rebind (
billing = _Stub()). This PR drops the binding for any name thedefinition rebinds, so both resolve to nothing — the precision rule of
mapclusters one entry per file instead of per directory #35 holds.importsedges. Python import edges still carry the raw dotted string (app.db.session)rather than a file, because
resolveImportonly understands relative TS-style specifiers.This PR resolves them the way Java and PHP imports already resolve here, by importable path
suffix, so the import graph stops pointing at external-looking strings.
import a.b as xproduced no import edge at all —importSpecifiernever looked insidean
aliased_import, so the statement was recorded as nothing.A smaller difference in approach: module lookup here is a suffix index requiring a unique
match, mirroring
javaFilesBySuffix/phpFilesBySuffix, instead of walking the caller's ancestordirectories nearest-first. Two same-named modules under different roots therefore resolve to
nothing rather than to the nearer one.
Fix
import a.b as x,from a.b import c [as x],relative
from . import c. A member call whose receiver is one of those names carries themodule as
specifier. The table is filtered per definition by the names it rebinds.for relative ones) and look the name up inside that file only; ambiguous or missing name
drops, no bare-name fallback. The same resolver serves the
importsbranch.Tests
test/graph-python-module-calls.test.ts: five import spellings resolve; four negative cases(parameter shadowing, local rebind, a name absent from the module, an out-of-repo module) stay
unresolved; import edges name in-repo files while an external package stays a truthful string.
Full suite: 1252 tests, 0 failures, 1 pre-existing skip.
Numbers
On the same 375-file service, against
pyright's call hierarchy as a compiler-grade oracle:--lsp(pyright)All 3038 edges are confirmed by pyright — zero edges the compiler does not also report. The
196 it finds beyond them are typed member calls (
obj.method()), which stay--lspterritory.