Repository navigation
Description
Summary
On Python, a top-level project function whose name collides with a common collection-method name (append, update, get, close, run, …) is wrong in both directions at once, and the two errors compound into a silently plausible answer:
- False negative — a real
module.append(x)call through a plain (non-aliased) module import produces nocallsedge. Its neighbourmodule.path()on the very next line resolves fine, so the loss is name-dependent, not import-dependent. - False positive — an unrelated
.append(...)on a local collection, reached through a call-expression receiver (d.setdefault(k, []).append(v)), degrades to a bareappendref and exact-matches the project's top-levelappendas the only same-named symbol.
Net effect: codegraph callers append returns a non-empty, confident-looking list in which every entry is fabricated and every real caller is missing. An empty result would be safer — it is visibly wrong. This one is not.
This is not a regression. It is the shape left uncovered by two shipped fixes:
- Python: codegraph_callers misses module-attribute call sites (module.func(...) after
from pkg import module) — zero recall on a common test/namespacing pattern #578 / Python: module-attribute callers still missed in large package tree after #715 / 1.0.1 #899 → fix(python): resolve call edges through imported modules (#578) #715 fixed plain module-attribute calls, but only for names that do not collide with a builtin collection method.mod.path()works;mod.append()does not. - [python] matching built-ins #1230 → fix(resolution): literal-receiver builtins and nested locals stop fabricating call edges #1317 fixed bare-name fabrication for literal receivers (
", ".join(...)) and nested-local scope. A call-expression receiver still degrades to a bare ref and still fabricates. - Distinct from Python: aliased module imports (
import x as y,from p import x as y) still produce zero call edges — remaining gap after #578 / #715 #1626 (aliased module imports). Nothing here is aliased —from . import ledgerthenledger.append(...).
Environment
@colbymchenry/codegraph1.5.0 and 1.6.0 (current npm latest) — reproduced identically on both- Linux (WSL2), Node via the package's own
npm-shim.js - Fresh
codegraph initper run; no stale index
Minimal repro (12 lines)
# pkg/ledger.py
def path(): return "/tmp/x"
def append(row): return row # top-level function named like a collection method
def join(vals): return vals# pkg/uses.py
from . import ledger
def real_module_call(row):
ledger.append(row) # A -> NO EDGE (expected: calls ledger.append)
return ledger.path() # B -> calls, "import" (correct)
def literal_receiver(vals):
return ", ".join(vals) # C -> no edge (correct, #1317)
def plain_identifier(out, k):
out.append(k) # D -> no edge (correct)
def chained_receiver(d, k, v):
d.setdefault(k, []).append(v) # E -> calls ledger.append, "exact-match" (FABRICATED)Observed
$ codegraph callers append
Callers of "append" (1):
function chained_receiver
pkg/uses.py:17
One caller, and it is the wrong one. real_module_call — the only genuine caller — is absent.
Edge table for uses.py, both versions, identical:
| line | call site | edge | resolvedBy |
|---|---|---|---|
| A | ledger.append(row) |
none | — |
| B | ledger.path() |
calls -> ledger.path |
import |
| C | ", ".join(vals) |
none | — |
| D | out.append(k) |
none | — |
| E | d.setdefault(k,[]).append(v) |
calls -> ledger.append |
exact-match |
B vs A is the whole false-negative story: same file, same receiver, same import, adjacent lines — only the method name differs.
D vs E is the whole false-positive story: same method, same target; only the receiver shape differs.
Suspected mechanism
Reading src/resolution/index.ts and src/extraction/tree-sitter.ts on main:
isBuiltInOrExternal()classifiesx.method()as a builtin collection call whenevermethodis a common collection-method name, unless the capitalized receiver matches a known class. It never checks whether the receiver is a known module.ledgeris a module, soledger.append(row)is judgedlist.append,resolveOne()returnsnullearly, and the import resolver that correctly handlesledger.path()is never reached.- In the extractor, the qualified
receiver.methodshape is preserved only when the receiver is a plain identifier. A call-chain receiver degrades to a bareappendref, which then passes the codebase-wide name check (a real top-levelappendexists) and exact-matches it.
Both stem from one assumption: "common method name ⇒ builtin, unless the receiver is a known class" — with no case for a project module that exports a function of that name.
Impact
Blast radius and "who calls this" are the queries this tool exists to answer, and for a name in this class it answers with fabricated confidence rather than an absence. Any Python project with a top-level append/update/get/run/close is affected; the false-positive count scales with how often that name appears as an ordinary collection method elsewhere in the repo.
Found while measuring a test-selection tool against its own index: a spot-check asserting "ledger.append has >= 5 callers" passed on 7 edges, all fabricated, while the two real callers were missing — so a commit breaking the function's only write path was selected by nothing.
Verification performed
- Reproduced on 1.5.0 and 1.6.0, fresh index each time.
- Cross-checked two independent read paths — the
codegraphCLI and direct SQL against.codegraph/codegraph.db— which agree. - Control shapes C and D confirm fix(resolution): literal-receiver builtins and nested locals stop fabricating call edges #1317's literal-receiver fix is present and working in the same build, so this is an uncovered shape rather than a regression of it.
Activity
inth3shadows commentedon Sep 4, 2026
Checked against unreleased
mainas well, so this is not something a pending release already closes.dist-tags→latest: 1.6.0, so 1.6.0 is genuinely the newest published build.origin/main@b9ca4b7(2026-08-31, ahead of the 1.6.0 release) still has the same guard insrc/resolution/index.ts— the only escape from the collection-method classification is "allow if the capitalized receiver matches a known codebase class" (knownNames.has(capitalized)). There is still no case for a receiver that is a known module, which is whatledger.append(row)needs.mainsince 1.6.0 are unrelated to this path (the nearest, TypeScript: calls to methods of exported object-literal constants resolve no edges (same-file or cross-file) — constant is not a STATIC_MEMBER_CONTAINERS kind #1573/#278a8ed, is object-literal namespace members).So shape A (false negative) is unfixed on
main, not just in the published 1.6.0.inth3shadows commentedon Sep 4, 2026
Adding the fix for the false-negative half (shape A,
ledger.append(row)producing no edge). Written and tested locally; the full upstream suite (2902 tests) passed with it applied, and after re-indexing,ledger.append's callers came back as exactly the two real ones with none of the fabricated entries.isBuiltInOrExternalonly lets a common collection-method name through when the capitalized receiver matches a known class. It never asks whether the receiver is an imported module, soledger.append(row)is classified aslist.appendandresolveOne()returnsnullbeforeresolveViaImport/resolvePythonModuleMember— which already handle it correctly, asledger.path()on the next line proves.The false-positive half of this issue (shape E, the chained receiver) has its own fix, which I have posted on #1683 — it turned out to be language-agnostic rather than Python-specific, so it is cleaner to track there. This issue is closed by the two together.
Not opening a PR: my checkout of this repo is kept read-only for bug-hunting. The patch is yours to take as-is.
colbymchenry commentedon Sep 8, 2026
Both halves are addressed:
d.setdefault(k, []).append(v)no longer fabricates an exact-match edge to a top-levelappend. Fixed onmainby fix(extraction): never fabricate an edge from a call-result receiver #1748 (fix(extraction): never fabricate an edge from a call-result receiver).ledger.append(row)after a plain module import (from . import ledger) now reachesresolveViaImportinstead of being dropped aslist.append. Fixed in fix(resolution): resolve module-qualified calls colliding with builtin methods (#1681) #1749 (focused extract of the FN escape from fix(python): resolve attribute-receiver calls by evidence, never by bare name #1704: allow the builtin-method filter through only when the receiver is an imported project module verified viaresolveImportPath).Verified on Linux against current
main+ #1749: before the FN change,real_module_callonly had apathedge andappendcallers were empty whilechained_receiveralready produced none; after, the module-qualifiedappendedge is present and the chained receiver still does not fabricate.Closing — thank you @inth3shadows for the diagnosis and the #1704 patch this lands a slice of.
fix(resolution): resolve module-qualified calls colliding with builti…