From 09277e8bf3fd28d4d96846ff11c180f39c539e79 Mon Sep 17 00:00:00 2001 From: datrixlab <325650023+datrixlab@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:10:57 +0700 Subject: [PATCH] fix(graph): resolve a Python receiver that names a class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SomeClass.method()` never got a recvType, so every such call site dropped as an untyped member call. pyReceiver already yields the class name; the loss is in resolveRecvType, where a class name is not `self`/`cls`, does not start with `self.`, and misses the bindings lookup because it is not a variable that was ever assigned a type. The PHP and Swift branches that handle exactly this shape are language-gated, so Python fell to undefined. The internal `cls.method()` call still resolved through enclosingClass, which makes the failure worse than an empty result: a classmethod with four call sites reported one, reading as nearly unused. Add the Python branch beside Swift's, on the same naming argument — PEP 8 puts classes in CapWords and variables in lower_case, and a shadowing local binding is still tried first, so a capitalised variable keeps its bound type. Three tests: the extraction half (where the bug was), a lower_case receiver that must stay untyped so the fix cannot over-reach, and the resolution half with a same-named free function present so a bare-name fallback would pick the wrong target. --- src/graph/bindings.ts | 8 +++++ test/graph-resolve-typed.test.ts | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/src/graph/bindings.ts b/src/graph/bindings.ts index 96d01089b..89c610e92 100644 --- a/src/graph/bindings.ts +++ b/src/graph/bindings.ts @@ -221,6 +221,14 @@ export function resolveRecvType( // local binding is the type itself (Swift naming: types are UpperCamelCase, // values lowerCamelCase — and a shadowing binding was already tried above). (ctx.lang === "swift" && /^[A-Z]/.test(receiver) ? receiver : undefined) ?? + // Python type-member call `SomeClass.method()`: classmethods and staticmethods + // are invoked on the class itself, so an uppercase receiver with no local + // binding is the type. Same shape as Swift above, and PEP 8 gives the same + // naming signal (classes CapWords, variables lower_case). Without this the + // whole service-class convention — business logic on classmethods, called as + // `MyService.do_thing()` — resolved to nothing, so `graft callers` reported + // only the internal `cls.` call sites (#305). + (ctx.lang === "python" && /^[A-Z]/.test(receiver) ? receiver : undefined) ?? undefined ); } diff --git a/test/graph-resolve-typed.test.ts b/test/graph-resolve-typed.test.ts index 4382dfac6..6f595d5c2 100644 --- a/test/graph-resolve-typed.test.ts +++ b/test/graph-resolve-typed.test.ts @@ -226,3 +226,65 @@ test("A2: owner-keyed ownerMethod/classParents resolve identically to the old id "Widget's own render must win over Base's/Other's (owner-scoped), and Sub inherits Other's via classParents", ); }); + +// #305 — Python's service-class convention (business logic on classmethods, +// invoked as `MyService.do_thing()`) resolved to nothing: the receiver is a class +// name, not a variable, so the bindings lookup missed and no recvType was stamped. +// `graft callers` then reported only the internal `cls.` call sites, which is worse +// than empty — a method with four callers reads as having one. + +test("#305: a Python call bound by class name stamps the class as recvType", () => { + const src = [ + "class SearchIndexService:", + " @classmethod", + " def variables_manquantes(cls):", + " return cls.helper()", + "", + "def outside():", + " return SearchIndexService.variables_manquantes()", + "", + ].join("\n"); + + const edges = extractFile("svc.py", src, "python").rawEdges; + const external = edges.find((e) => e.relation === "calls" && e.name === "variables_manquantes" && e.source === "svc.py#outside"); + + assert.ok(external, "the SomeClass.method() call site is extracted at all"); + assert.equal(external?.recvType, "SearchIndexService"); +}); + +test("#305: a lower_case Python receiver is still left untyped", () => { + // PEP 8 is the whole signal, so a variable receiver must keep falling through + // to the bindings table rather than being read as a type. + const src = ["def outside(service):", " return service.variables_manquantes()", ""].join("\n"); + + const edges = extractFile("svc.py", src, "python").rawEdges; + const call = edges.find((e) => e.relation === "calls" && e.name === "variables_manquantes"); + + assert.ok(call, "the call is extracted"); + assert.equal(call?.recvType, undefined); +}); + +test("#305: the class-bound call resolves to the classmethod, not a same-named function", () => { + const nodes = [ + n("svc.py", "file"), + n("svc.py#SearchIndexService", "class"), + n("svc.py#SearchIndexService.variables_manquantes", "method"), + n("other.py", "file"), + n("other.py#variables_manquantes", "function"), + n("caller.py", "file"), + n("caller.py#outside", "function"), + ]; + + const edges = resolveEdges(nodes, [ + { + source: "caller.py#outside", + relation: "calls", + name: "variables_manquantes", + viaMember: true, + recvType: "SearchIndexService", + file: "caller.py", + }, + ]); + + assert.equal(edges.find((e) => e.relation === "calls")?.target, "svc.py#SearchIndexService.variables_manquantes"); +});