Skip to content

python: resolve calls through an imported module name (M.f() / from a import b as M) - #334

Open
DavinchiPes wants to merge 1 commit into
trailhq:mainfrom
DavinchiPes:python-module-alias-calls
Open

python: resolve calls through an imported module name (M.f() / from a import b as M)#334
DavinchiPes wants to merge 1 commit into
trailhq:mainfrom
DavinchiPes:python-module-alias-calls

Conversation

@DavinchiPes

Copy link
Copy Markdown

Problem

from analysis import pump_signature as P followed by P.find_pumps() is the dominant call style in numeric Python codebases (numpy's import x as np, generalised to a project's own modules). The extractor saw receiver P, found no type binding for it and dropped the edge — the right rule for an object of unknown class (#35), the wrong one for a module. On our repo graft callers find_pumps reported "no indexed callers" for a function with thirteen call sites across 32 files that import this way; graft grep found them all.

Fix

A module receiver is not an unknown object: the import statement names the exact file.

  • extract.ts — per Python file, collect every import that binds a module to a local name (import a.b as M, from a import b as M, from a import b, import a.b, relative from . import b). A member call whose receiver did not resolve to a type but is one of those names is emitted as a plain call carrying the module's dotted path as specifier. A receiver that resolved to a type keeps the typed-member path (a class alias is not a module).
  • resolve.ts — resolve that specifier to a file (a/b.py, then a/b/__init__.py, under each ancestor directory of the caller nearest-first, so a src/ layout needs no build-file parsing; relative imports climb from the caller's directory) and look the name up inside that file only. A same-named function elsewhere in the repo can never become a false edge; an ambiguous or missing name drops, as before — precision rule from map clusters one entry per file instead of per directory #35 preserved.
  • pyReceiver also spells a pure dotted chain (pkg.mod.f()) so the import pkg.mod form matches.

Tests

test/graph-python-module-alias.test.ts (TDD: written first, failed on main, passes now):

  • the four import spellings plus the package-__init__.py case all produce an extracted call edge into the module's file;
  • a same-named decoy in another file gains no callers;
  • an untyped receiver (obj.find_pumps()) still drops.

Full suite: 1218/1222 — the four claude-shim-resolve failures are pre-existing on main (machine-dependent global-install lookup) and untouched.

Result on a real repo

Rebuilt the graph on a ~1 900-file Python repo: graft callers find_pumps 0 → 6 calling functions (13 sites), graft callers compute_all 1 → 2; call counts in modules that use direct imports unchanged.

🤖 Generated with Claude Code

`from analysis import pump_signature as P` followed by `P.find_pumps()` is the
dominant call style in numeric Python (numpy's `import x as np`, generalised to
a project's own modules). The extractor saw receiver `P`, found no type binding
for it and dropped the edge — the right rule for an object of unknown class
(trailhq#35), the wrong one for a module: `graft callers find_pumps` reported "no
indexed callers" for a function with thirteen call sites.

A module receiver is not an unknown object: the import statement names the
exact file. So:

- extract.ts collects, per Python file, every import that binds a MODULE to a
  local name (`import a.b as M`, `from a import b as M`, `from a import b`,
  `import a.b`, relative `from . import b`). A member call whose receiver did
  not resolve to a type but IS one of those names is emitted as a plain call
  carrying the module's dotted path as `specifier`. A receiver that resolved
  to a type keeps the typed-member path (a class alias is not a module).
- resolve.ts resolves that specifier to a file (`a/b.py`, then
  `a/b/__init__.py`, under each ancestor directory of the caller nearest-first,
  so a `src/` layout needs no build-file parsing; relative imports climb from
  the caller's directory) and looks the name up inside that file only — a
  same-named function elsewhere in the repo can never become a false edge, and
  an ambiguous or missing name drops, as before.
- pyReceiver also spells a pure dotted chain (`pkg.mod.f()`) so the
  `import pkg.mod` form matches.

test/graph-python-module-alias.test.ts pins the four spellings plus the
package-`__init__` case, and that neither a same-named decoy nor an untyped
receiver gains an edge. Full suite: unchanged apart from the two new tests
(claude-shim-resolve's four machine-dependent failures are pre-existing on
main and untouched).
@trailhq-graft

trailhq-graft Bot commented Sep 9, 2026

Copy link
Copy Markdown

🌱 graft blast radius

1 area changed → 16 areas can be affected. 226 dependent symbols, depth 2.
Tests: 1 area updated its tests.
Tag: @anirudhkumar-nanonets — 16 of 17 areas · @shhdwi — 11 of 17 areas · @Frankie-Xu — Context Caching

flowchart TB
  A0(("Graph Building<br/>65 symbols"))
  A1(("Claude Integration<br/>24 symbols"))
  A2(("Host Configuration<br/>24 symbols"))
  A3(("CLI Orchestration<br/>17 symbols"))
  A4(("Context Caching<br/>16 symbols"))
  AX(("11 smaller areas<br/>80 symbols"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3,A4 reached;
  classDef tail fill:#EEF2F3,stroke:#9AA4A9,stroke-width:1px,color:#3A4247;
  class AX tail;
Loading
Can be affected Symbols Nearest hop Reached from
Graph Building 65 src/graph/build.ts:L151-L410 buildGraph — calls, depth 1 Graph Extraction
Claude Integration 24 src/claude/hooks.ts:L173-L182 editedFilePath — calls, depth 1 Graph Extraction
Host Configuration 24 src/hosts/antigravity.ts:L20-L22 skillPath — calls, depth 1 Graph Extraction
CLI Orchestration 17 src/cli-meta.ts:L98-L109 readGlobalInstalledVersion — calls, depth 1 Graph Extraction
Context Caching 16 src/context/build.ts:L418-L420 cachePath — calls, depth 1 Graph Extraction
Telemetry Pipeline 14 src/telemetry/identity.ts:L92-L99 repoId — calls, depth 1 Graph Extraction
10 smaller areas 66 State Management, Page Checkout, Blast Analysis, … see below
Who knows this code — 5 people across 17 areas
Area Who knows it
Graph Extraction · changed @shhdwi — 12 commits, last 27d ago · @anirudhkumar-nanonets — 9 commits, last 15d ago
Graph Building · affected @anirudhkumar-nanonets — 30 commits, last 13d ago · @shhdwi — 27 commits, last 27d ago
Claude Integration · affected @anirudhkumar-nanonets — 20 commits, last 7d ago · @shhdwi — 19 commits, last 28d ago
Host Configuration · affected @shhdwi — 12 commits, last 27d ago · @anirudhkumar-nanonets — 5 commits, last 8d ago
CLI Orchestration · affected @anirudhkumar-nanonets — 39 commits, last 7d ago · @shhdwi — 24 commits, last 27d ago
Context Caching · affected @anirudhkumar-nanonets — 9 commits, last 20d ago · @Frankie-Xu — 5 commits, last 9d ago
Telemetry Pipeline · affected @anirudhkumar-nanonets — 3 commits, last 9d ago · @afeddersen — 1 commit, last 9d ago
State Management · affected @anirudhkumar-nanonets — 5 commits, last 1mo ago
…9 further 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 @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 226 dependent symbols, grouped by area

Graph Building — 65 symbols in 17 files

  • src/graph/build.ts:L151-L410 — buildGraph (calls, depth 1)
    264: ? extractFile(rel, source, lang)
  • src/graph/cards.ts:L58-L64 — cardPathFor (calls, depth 1)
    60: const primary = join(outDir, md);
  • src/graph/cards.ts:L122-L142 — listExistingCards (calls, depth 1)
    124: const walk = (dir: string): void => {
  • src/graph/cards.ts:L124-L133 — walk (calls, depth 1)
    124: const walk = (dir: string): void => {
  • src/graph/cards.ts:L146-L163 — visit (calls, depth 1)
    154: const child = join(dir, e.name);
  • src/graph/cards.ts:L275-L314 — writeCovers (calls, depth 1)
    292: const full = join(outDir, entry);
  • src/graph/cards.ts:L210-L251 — writeIndex (calls, depth 1)
    232: const srcs = c.sources.map((s) => s.path).join(", ");
  • src/graph/check.ts:L58-L164 — checkGraph (calls, depth 1)
    119: ? extractFile(rel, source, lang)
  • src/graph/container.ts:L151-L208 — extractContainer (calls, depth 1)
    171: inner = extractFile(rel, script, lang.inner);
  • src/graph/extract-cache.ts:L75-L78 — extractCachePath (calls, depth 1)
    77: return stamp === null ? null : join(outDir, CACHE_DIR, `${EXTRACT_CACHE_PREFIX}.${stamp}.json`);
  • src/graph/extract-cache.ts:L181-L188 — packageVersion (calls, depth 1)
    183: const pkg = JSON.parse(readFileSync(join(graphDir, "..", "..", "package.json"), "utf8")) as { version?: string };
  • src/graph/extract-cache.ts:L83-L96 — pruneSidecars (calls, depth 1)
    88: const full = join(cacheDir, f);
  • src/graph/extract-cache.ts:L168-L178 — stampDir (calls, depth 1)
    175: h.update(readFileSync(join(dir, f)));
  • src/graph/extract-cache.ts:L213-L223 — writeExtractCache (calls, depth 1)
    218: pruneSidecars(join(outDir, CACHE_DIR), EXTRACT_CACHE_PREFIX);
  • src/graph/fingerprint.ts:L73-L75 — fingerprintPath (calls, depth 1)
    74: return join(outDir, CACHE_DIR, `${FINGERPRINT_PREFIX}.${stamp()}.json`);
  • src/graph/fingerprint.ts:L87-L103 — writeFingerprint (calls, depth 1)
    98: pruneSidecars(join(outDir, CACHE_DIR), FINGERPRINT_PREFIX);
  • src/graph/generic.ts:L1-L543 — generic.ts (calls, depth 1)
    22: import { dirname, join } from "node:path";
  • src/graph/generic.ts:L104-L118 — loadQuery (calls, depth 1)
    107: const raw = readFileSync(join(dir, `${name}.scm`), "utf8");
  • src/graph/refresh.ts:L150-L227 — ensureFreshGraph (calls, depth 1)
    185: const lockCache = join(outDir, CACHE_DIR);
  • src/graph/refresh.ts:L124-L141 — seedUnderLock (calls, depth 1)
    129: const lockCache = join(outDir, CACHE_DIR);
  • src/graph/scopes.ts:L133-L272 — discoverScopes (calls, depth 1)
    145: const found = MARKERS.filter((m) => existsSync(join(absRoot, dir, m)));
  • src/graph/scopes.ts:L329-L341 — discoverWorkspaceChildren (calls, depth 1)
    339: .filter((e) => e.isDirectory() && !shouldSkipDir(e.name, includes) && existsSync(join(absRoot, e.name, ".git")))
  • src/graph/scopes.ts:L69-L91 — readWorkspaceGlobs (calls, depth 1)
    70: const pnpmPath = join(root, "pnpm-workspace.yaml");
  • src/graph/seed.ts:L169-L178 — walk (calls, depth 1)
    169: const walk = (rel: string): void => {
  • src/graph/seed.ts:L80-L108 — mainWorktreeRoot (calls, depth 1)
    82: const dot = join(root, ".git");
  • src/graph/workspace.ts:L240-L558 — federateAsk (calls, depth 1)
    259: throw new Error(`no workspace repo "${name}" - repos: ${allChildren.join(", ")}`);
  • src/graph/workspace.ts:L630-L655 — federateCheck (calls, depth 1)
    638: const g = await checkGraph(join(root, child));
  • src/graph/workspace.ts:L563-L603 — federateGrep (calls, depth 1)
    578: const r = grepGraph(graph, join(root, child), pattern, {
  • src/graph/workspace.ts:L97-L101 — isWorkspaceBuildRoot (calls, depth 1)
    99: if (existsSync(join(root, ".git"))) return false;
  • src/graph/workspace.ts:L145-L156 — loadWorkspaceGraphs (calls, depth 1)
    151: const graph = loadGraphCached(contextDirFor(join(root, child)));
  • src/graph/workspace.ts:L709-L722 — splitWorkspace (calls, depth 1)
    718: for (const child of children) await buildChild(join(root, child), child);
  • src/graph/workspace.ts:L66-L68 — workspacePath (calls, depth 1)
    67: return join(contextDirFor(root, override), WORKSPACE_FILE);
  • src/graph/workspace.ts:L84-L91 — writeWorkspace (calls, depth 1)
    87: const path = join(dir, WORKSPACE_FILE);
  • src/graph/write.ts:L20-L22 — wiringPath (calls, depth 1)
    21: return join(outDir, GRAPH_DIR, GRAPH_FILE);
  • src/graph/build.ts:L1-L411 — build.ts (imports, depth 2)
    20: import { extractFile, languageLabelOf, languageOf, type RawEdge } from "./extract.js";
  • src/graph/cards.ts:L80-L90 — conceptsByPath (calls, depth 2)
  • src/graph/cards.ts:L171-L204 — writeCards (calls, depth 2)
  • src/graph/cards.ts:L145-L165 — pruneEmptyDirs (calls, depth 2)
    154: const child = join(dir, e.name);
  • src/graph/check.ts:L1-L221 — check.ts (imports, depth 2)
    23: import { extractFile, languageOf } from "./extract.js";
  • src/graph/container.ts:L1-L209 — container.ts (imports, depth 2)
    16: * **The span shift is the whole risk here.** `extractFile` numbers its spans from
  • src/graph/extract-cache.ts:L198-L207 — readExtractCache (calls, depth 2)
  • src/graph/extract-cache.ts:L144-L160 — computeStamp (calls, depth 2)
  • src/graph/fingerprint.ts:L77-L82 — readFingerprint (calls, depth 2)
  • src/graph/generic.ts:L123-L149 — warmGenericGrammars (calls, depth 2)
  • src/graph/load.ts:L112-L115 — invalidateGraphCaches (calls, depth 2)
  • src/graph/load.ts:L95-L100 — loadAskIndexCached (calls, depth 2)
  • src/graph/load.ts:L86-L91 — loadGraphCached (calls, depth 2)
  • src/graph/refresh.ts:L235-L261 — ensureFreshChildren (calls, depth 2)
    259: note: `refreshed ${refreshedIn.join(", ")} (${files || "?"} file${files === 1 ? "" : "s"} changed) before answering`,
  • src/graph/refresh.ts:L107-L114 — waitForLock (calls, depth 2)
  • src/graph/refresh.ts:L95-L99 — onSignal (calls, depth 2)
  • src/graph/root.ts:L23-L25 — hasGraftIndex (calls, depth 2)
  • src/graph/seed.ts:L211-L228 — seedGraph (calls, depth 2)
  • src/graph/seed.ts:L168-L180 — copyTree (calls, depth 2)
    169: const walk = (rel: string): void => {
  • src/graph/source-files.ts:L1-L121 — source-files.ts (imports, depth 2)
    42: * output dir. When no pre-enumerated `repoFiles` is passed, the walk reads
  • src/graph/traverse-cli.ts:L166-L242 — runCallersCommand (calls, depth 2)
    194: // `all` (aka full/max) = the whole transitive closure: walk until no new
  • src/graph/workspace-cli.ts:L48-L86 — runWorkspaceBuild (calls, depth 2)
    68: console.log(`✓ ${childName}/: ${g.nodes} nodes, ${g.edges} edges, ${g.cards} cards [${g.languages.join(", ")}]`);
  • src/graph/workspace-cli.ts:L88-L97 — runWorkspaceAsk (calls, depth 2)
  • src/graph/workspace-cli.ts:L126-L130 — runWorkspaceCheck (calls, depth 2)
  • src/graph/workspace-cli.ts:L99-L116 — runWorkspaceGrep (calls, depth 2)
  • src/graph/workspace.ts:L124-L126 — clearParentGraft (calls, depth 2)

…165 further symbols not listed.

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.

  • Graph Extraction — 3 of 8 reached · 1 test file changed here: test/graph-python-module-alias.test.ts
    • not reached: walk, pyReceiver, pyDottedName, asPath, hit
99 test suites also reference this code

249 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/app-checkout.test.ts
  • test/app-pages.test.ts
  • test/app-review-process.test.ts
  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/blast-name.test.ts
  • test/blast-owners.test.ts
  • test/blast-render.test.ts
  • test/blast-viz.test.ts
  • test/blast.test.ts
  • test/claude-hooks.test.ts
  • test/claude-init-workspace.test.ts
  • test/claude-init.test.ts
  • test/claude-paths.test.ts
  • test/claude-shim-resolve.test.ts
  • test/claude-state.test.ts
  • test/claude-stats.test.ts
  • test/claude-statusline.test.ts
  • test/claude-tally.test.ts
  • test/cli-deep-failure.test.ts
  • …79 more

graft blast · origin/main...HEAD · depth 2 · 3 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant