Repository navigation
fix(python): resolve named imports and imported class methods (#465) - #490
ryanduguid wants to merge 4 commits into
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`.
Builds on the module-alias resolver from trailhq#437. Unconditional module-level named imports now resolve inside the imported file, including aliases, class methods and a function whose name is defined in more than one file. A name an import binds resolves through that import or not at all. A call through a shadowed binding, or through a local or conditional import in scope that does not resolve yet, is dropped instead of matched to a global name. Shadowing covers parameters, assignments, loop, with and except targets, walrus (including one in another definition's header), del, match captures, type aliases, local imports, and lambda, comprehension and class-body bindings. Defaults, annotations and the leftmost comprehension iterable see the enclosing scope, and a def's own imports apply only inside it. Module aliases follow the same rules, and `from pkg import submodule` resolves inside its own package, package directory first, never through an ambiguous one. Python named imports feed call resolution only, not identifier references. Refs trailhq#465
🌱 graft blast radius1 area changed → 4 areas can be affected. 10 dependent symbols, depth 2. flowchart TB
A0(("Dependency Graph Construction<br/>5 symbols"))
A1(("Graph Engine<br/>2 symbols"))
A2(("Symbol Overlap Detection<br/>2 symbols"))
A3(("Pull Request Review<br/>1 symbol"))
classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
class A0,A1,A2,A3 reached;
Who knows this code — 2 people across 5 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 10 dependent symbols, grouped by areaDependency Graph Construction — 5 symbols in 5 files
Graph Engine — 2 symbols in 1 file
Symbol Overlap Detection — 2 symbols in 1 file
Pull Request Review — 1 symbol in 1 file
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.
38 test suites also reference this code47 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. |
## Summary Adds the two forks created on 28 September 2026 to FORKS.md: qmd ([tobi/qmd#1006](tobi/qmd#1006)) and Graft ([trailhq/Trail-Code#490](trailhq/Trail-Code#490)). It also records what merged after the 27 September review: [frappe/erpnext#59499](frappe/erpnext#59499), which a maintainer opened from the erpnext fork's commit, and eight openaccountants pull requests (#173 to #182). - openaccountants has no open pull request now, so it moves to the no-open-contribution list. - The hledger fork shows as archived, matching #52. - The Furtim row picks up six newer camofox-browser pull requests. - The recent-contributions list now covers everything since 13 September 2026, so the earlier entries stay. ## Evidence Regenerated from `users/ryanduguid/repos` and `search/issues` (`author:ryanduguid is:pr`): 74 open pull requests, each matched to a fork, and 8 merged since 27 September. 43 of 49 forks hold an open upstream pull request. ## Merge danger **Door:** two-way. **Blast radius:** one document ## Unverified None.
## What this changes Rebuilds the current-state sections of FORKS.md from the GitHub API on 17 October 2026. The fork count goes from 49 to 115, and 101 forks now hold an open upstream PR. - **Active forks:** regenerated from the fork list and open PRs by author. - **Recent contributions:** every earlier entry is kept, and PRs that merged or closed since the 28 September review are added. - **Forks with no open contribution:** the list is refreshed, with a dated note that no fork is archived any more and that 40 merged fork branches were deleted. - **Preserved for retirement, Pruned forks and Retention rule:** unchanged. ## Why FORKS.md was last reviewed on 28 September and listed 49 of what are now 115 forks. Every fork's description points here. ## Verification ``` # every PR link in the previous FORKS.md still appears (one moved to the upstream's new name, trailhq/Trail-Code#490) git show HEAD~1:FORKS.md | grep -oE '\(https://github.com/[^)]+/pull/[0-9]+\)' | sort -u | while read l; do grep -qF "$l" FORKS.md || echo "LOST $l"; done # LOST (trailhq/Trail-Code#490) ``` PRs that the old file listed as open were checked one by one with `GET /repos/{owner}/{repo}/pulls/{n}`, because the search API missed four closed jo-inc/camofox-browser PRs. ## Final review - Reviewed head commit: see the PR head - Review type (self-review or independent): self-review - Review record and scope: FORKS.md only - Findings and disposition: the search index missed some closures; fixed by checking each PR directly - Provider results and coverage gaps: open-PR rows come from the search API, which can lag; a PR opened or closed in the last few minutes may be missing - Remaining risks and unrun checks: no link checker was run. Door: two-way. Blast radius: documentation. ## Checklist - [x] No client, taxpayer, employee or payroll data, and no credentials, tokens, tenant IDs or `.env` contents, appear in the diff, the commit messages or this description. - [x] The change is single-purpose. - [ ] New or changed behaviour has a test that fails without the change. (Documentation only.) - [x] Documentation, README and any docstring that describes the changed rule were updated together with it. - [x] Any new dependency is justified in the description, or there is none. --------- Co-authored-by: Ryan Duguid <ryanduguid@users.noreply.github.com>
Summary
Fixes #465.
graft callersmissed Python calls made through a named import: an aliased function (from validators import validate_result as validate_equity_result), a class method on an imported class (Workspace.open()) and a function whose name is defined in more than one file (from first.helpers import parse_value).This builds on #437 by @JeglovDev and includes its commit, rebased on current main, because it reuses #437's module-path resolver. If #437 merges first, this reduces to the second commit.
Unconditional module-level
from x import y [as z]bindings now resolve inside the imported file only:A name an import binds resolves through that import or not at all. A call is dropped when a parameter, assignment, loop target,
withorexceptalias, walrus (including one in another definition's header),del, match capture,typealias, local import, or a lambda, comprehension or class-body binding rebinds the name, and when the only import of the name in scope is local or conditional. Defaults, annotations and the leftmost comprehension iterable see the enclosing scope. A class-method call links to the method without inventing a constructor call.#437's module aliases follow the same rules: they now come from unconditional module-level imports only, so a function-local
importno longer leaks into other functions, and a module-level reassignment drops the alias. Python named imports feed call resolution only; they do not emit identifierreferencesedges.Evidence
The #465 reproduction on Windows 11 with Node 22:
graft callers validate_result .run(consumer.py)graft callers Workspace.open .run(consumer.py:7)graft callers parse_value .run_duplicatefor first/helpers.py; none for second/helpers.pygraft callers Workspace .still reports no callers, since the call goes to the method.test/graph-python-named-imports.test.tshas 19 tests. Against #437's code alone, the resolution and shadowing cases fail. The eight added last cover module-alias scope, match captures and class bodies, walrus targets in headers andtypealiases, local imports in another def, defaults and comprehension iterables, unresolved imports, submodule anchoring, and the absence of references; each case produced a wrong edge or dropped a right one in an earlier version of this commit. The three Python test files pass 26 of 26 on Node 22 and Node 20.Full suite on Windows with Node 22: main passes 1,245 and fails 16; this branch, rebased on main, passes 1,267 with the same 16 failures (the
blast,diffandensureGitignoredtests, which depend on Git fixture commits and.gitignorehandling on this machine). The 22 extra passes are #437's 3 tests and the 19 intest/graph-python-named-imports.test.ts.Risk
Python graphs gain call edges for named imports. They lose edges that a global name match produced for shadowed, local or conditional imports, and the edges #437 drew from a function-local import into other functions. Other languages are unchanged.
Unverified
__init__.pynext to a same-named submodule file can still resolve to the submodule; telling them apart needs an index of what each__init__.pybinds.CI workflow maintenance
Use Ubuntu 24.04 for the affected Linux jobs. Keep the existing event, permission and test-matrix contracts.
Evidence
The previous head reported 1 matching action-runtime or runner annotations. Actionlint and the Git whitespace check pass.
Risk
Action or runner upgrades can affect CI behaviour. The workflow changes can be reverted.
Unverified
Hosted CI observed at 2026-10-17T11:24:41Z for dd01e6d: 1 skipped, 1 success. 1 action-runtime or Ubuntu-label annotations remain in publish. 3 workflow runs await approval or dispatch. Application tests were not run locally for these workflow changes.