Skip to content

fix(pycg): canonicalize the builtins module spelling on PyCG edges - #134

Merged
rahlk merged 1 commit into
mainfrom
fix/issue-132-builtin-module-spelling
Aug 20, 2026
Merged

fix(pycg): canonicalize the builtins module spelling on PyCG edges#134
rahlk merged 1 commit into
mainfrom
fix/issue-132-builtin-module-spelling

Conversation

@rahlk

@rahlkrahlk commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Closes#132.

Problem

PyCG spells the builtins module <builtin>. Jedi spells it builtins. Same builtin, two can:// ids.

requests fixture at -a 2, before the fix:

  • 29 ids under @external/<builtin>/ — all prov=['pycg']
  • 14 ids under @external/builtins/ — all prov=['jedi']
  • 12 names in both: len, isinstance, getattr, sorted, all, any, chr, hasattr, iter, max, print, setattr

Two consequences.

First. Asking "who calls len" returns two disjoint answers. Neither is complete.

Second.merge_edges coalesces on (src, dst). Different dst means the two backends never meet, so a builtin can never reach prov: ["jedi", "pycg"]. In the same run, 198 non-builtin edges do carry both — the merge works. Builtins are structurally locked out.

Root cause: core.py:_home_external_symbols splits the signature on its last dot, mints @external/<module>/<name>, and never canonicalizes module.

Fix

Rewrite <builtin>.x to builtins.x at one place — the PyCG.build_call_graph_edges exit. Every shard strategy returns through it.

Placement matters:

  • Must land beforemerge_edges (core.py:598), not at id-minting (core.py:633). A mint-time fix leaves two already-merged edges with identical endpoints and split provenance — the symptom moves rather than disappears.
  • Endpoints that collide after the rewrite are coalesced: weights sum, provenance unions. Same semantics as merge_edges.
  • Coalescing is done locally, not through _coalesce_edges. That one raises on its duplicate branch (_coalesce_edges raises on any duplicate edge: constructs PyCallEdge with source=/target= instead of src=/dst= #133), and this fix is what makes the branch reachable.

PyCG only ever emits the bare <builtin> module (69 occurrences across requests and flask). The dotted forms (builtins.str, builtins.dict) come from Jedi and are already canonical, so an exact-match alias suffices — no prefix rewriting.

Verification

Both fixtures re-analyzed at -a 2 on this branch:

requestsflask
<builtin> ids left00
<builtin> edges left00
distinct builtins/ names3141
builtin edges with prov: ["jedi","pycg"]74 (was 0)99 (was 0)

31 = 29 + 14 − 12. The merge is exact: no id lost, none invented.

Tests

test/test_pycg_builtin_canonicalization.py, five tests:

  • <builtin>.x is rewritten
  • already-canonical and unrelated names are untouched (builtins.str.format, first-party signatures, bare names, bare <builtin>)
  • colliding spellings coalesce with summed weight
  • end-to-end provenance merge — the point of the issue
  • input edges are not mutated

Caveats

Suite is green on a quiet machine: 198 passed, 2 skipped, 14 deselected in 6:30 — the 193 from main plus the 5 added here. An earlier run hung while a 3.5-hour xarray analysis held the machine; that was contention, not this diff. The suite is load-sensitive (PyCG shard timeouts under load produce spurious L2 failures), so it needs a quiet machine to mean anything.

Edge weights change where two spellings collapse into one. That is the correct new value, not a regression.

)
PyCG spells the builtins module `<builtin>`; Jedi spells it `builtins`.
Nothing normalized the two, so `_home_external_symbols` minted a separate
`can://.../@external/<module>/<name>` home per spelling and one builtin ended
up with two identities. Measured on the `requests` fixture at -a 2: 29 ids
under `<builtin>/` against 14 under `builtins/`, with 12 names present under
both (len, isinstance, getattr, sorted, ...).
Two things follow from that. A consumer asking "who calls len" gets two
disjoint answers, neither complete. And provenance can never merge for a
builtin: `merge_edges` coalesces on (src, dst), so differing dst ids keep the
two backends' edges apart -- in the same run 198 non-builtin edges do carry
`prov: ["jedi", "pycg"]`, while builtins are structurally excluded from it.
Canonicalization happens at `build_call_graph_edges`' single exit, so every
shard strategy is covered, and before `merge_edges` runs in core.py -- doing it
at id-minting time would leave two already-merged edges with identical
endpoints and split provenance, moving the symptom rather than removing it.
Endpoints that collide once rewritten are coalesced with summed weight and
unioned provenance, matching merge_edges' semantics. That coalescing is done
locally rather than through `_coalesce_edges`, which raises on its duplicate
branch (#133).
PyCG only ever emits the bare `<builtin>` module, so an exact-match alias
suffices; the dotted forms (`builtins.str`, `builtins.dict`) are Jedi's and are
already canonical.
@rahlkrahlk added the bug Something isn't working label Aug 20, 2026
@rahlk
rahlk merged commit 6587099 into mainAug 20, 2026
@rahlk
rahlk deleted the fix/issue-132-builtin-module-spelling branch August 20, 2026 01:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugSomething isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Builtins get two can:// identities: PyCG spells the module <builtin>, Jedi spells it builtins

1 participant

@rahlk