Skip to content

fix: resolve import-alias calls in Python and TypeScript (#258) - #274

Open
JunaidAhamed-7777 wants to merge 16 commits into
Anandb71:mainfrom
JunaidAhamed-7777:fix/python-import-alias-edges
Open

JunaidAhamed-7777 wants to merge 16 commits into
Anandb71:mainfrom
JunaidAhamed-7777:fix/python-import-alias-edges

Conversation

@JunaidAhamed-7777

Copy link
Copy Markdown
Contributor

Description

Calls through an aliased import now reach the imported function. This covers Python from m import f as g and TypeScript/JavaScript import { f as g } from "./m".

Before this PR, g() recorded no edge. Worse, when another file defined a function named g, the call was bound there at confidence 1.0. Both are fixed, and relative modules (.m, ..m, ./m, ../m) are read from the importing file's directory, so a same-named module elsewhere is never the target.

Fixes #258. Addresses case 2 of #178 (from x import y as z). #178's re-export and module-alias cases are not changed here; see Known limitations.

Follow-up issues opened from this work: #271, #272, #273. #178 covers (b) and (c).

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Code refactoring

Changes Made

  • crates/arbor-core/src/node.rs, lib.rs: new reference encoding alias:<local>:<original> with import_alias_ref / import_alias (prefix constant IMPORT_ALIAS_REF_PREFIX).
  • crates/arbor-core/src/languages/python.rs: from m import f as g (bare and parenthesised lists) records alias:g:f.
  • crates/arbor-core/src/languages/typescript.rs: import { f as g } records alias:g:f. This covers .ts, .tsx and the JS extensions, which share the extractor.
  • crates/arbor-graph/src/builder.rs: a per-file alias map. Calls to an alias resolve through SymbolTable::resolve_imported.
  • crates/arbor-graph/src/symbol_table.rs: resolve_imported, plus the relative-module helpers relative_module_path and is_module_file.
  • crates/arbor-graph/src/store.rs: CACHE_VERSION bumped extract-4 → extract-5.
  • eval/: retires py-alias's known_missing; adds the py-alias-shadow, py-alias-dotted and ts-alias fixtures.
  • CHANGELOG.md: entry under [Unreleased].

29 files changed, +1087 / −43.

Root cause (line numbers at e10cb78)

  1. The parsers dropped the original name.
    • crates/arbor-core/src/languages/python.rs:244-256 (bare list) and :265-275 (parenthesised list) pushed only the alias for an aliased_import.
    • crates/arbor-core/src/languages/typescript.rs:462-471 did the same for import_specifier.
    • For from money import money_to_minor_units as to_minor, the import node's references held only to_minor.
  2. The builder only knew local name → module. crates/arbor-graph/src/builder.rs:116-119 inserted import_map[file][to_minor] = "money". resolve_edges then looked up a symbol named to_minor.
  3. The result was either nothing or the wrong function.
    • Nothing (the Aliased import calls produce no call edge (from x import y as z) #258 report): eval/corpus/py-alias had a recorded known_missing.
    • The wrong function, at full confidence, whenever any file defined a function named like the alias. The name lookup matched it, and apply_import_validation (builder.rs:357-360) treated the match as corroborated because to_minor is imported. Measured at e10cb78:
      • py-alias-shadow: orders.py::total_cents -> convert.py::to_minor at confidence 1.0, while the real target money.py::money_to_minor_units had no edge.
      • ts-alias: app.ts::label -> legacy/fmt.ts::fmt at 1.0, while format.ts::formatName had no edge.
      • Arbor Cloud's web app (private, Next.js): import { POST as subscribe } from "../route" bound a route test to an unrelated useMotionPreference.ts::subscribe at 1.0.

Design

Encoding: alias:<local>:<original>

The import node now records both names, in the same prefix style as field: and returns:. This is safe for three reasons:

  • Import nodes never become graph vertices. add_nodes reads them into the import map and continues (builder.rs:103-126). Outside the parsers, GraphBuilder is the only reader of an import node's references; the sled store persists nodes opaquely and replays them through the builder.
  • The local name is still the import_map key. So everything keyed on it keeps working: import validation, resolve_ref_with_imports's tie-break, and the local_definition_still_shadows_an_import rule. An alias call gets the same final confidence as an unaliased call to a uniquely named import: ViaImport 0.93, raised to 0.95 by validation.
  • The pair is emitted only when local ≠ original. Y as Y stays plain. TS { default as Widget } keeps recording Widget, as a default import does, because no export is named default. Python and TS identifiers can't contain :.

A later plain import of the same local name rebinds it and removes the alias entry (a_plain_import_rebinds_an_earlier_alias).

Resolution: SymbolTable::resolve_imported(name, module, context_file)

GraphBuilder::resolve_alias runs only for a reference with a known receiver whose file aliased exactly that name. If the caller's own file defines the alias name, it returns None: a local definition still wins, as for unaliased imports. Every other reference goes through the unchanged paths: resolve_path for ::, resolve_ref_with_imports otherwise.

resolve_imported rules:

  • Candidates: definitions whose FQN is exactly the imported name. An import binds a module-level name, so methods (Class.f) never qualify. Candidates must be in the caller's language family.
  • Never the importing file. In a wrapper that keeps the imported name (from money import f as _f, then def f(): return _f()), _f reaches money's f, not the wrapper itself.
  • Relative modules (./x, ../x, .x, ..x, ., ..) are resolved to one path from the importing file's directory:
    • the module's own file, or its package index / __init__, first;
    • then anything inside that package;
    • ./format.js names format.ts;
    • an import that climbs out of the tree resolves nothing.
  • Non-relative modules (pkg.mod, money): the module's own file first (module_is), then anything inside a package of that name (entry_belongs_to_module).
  • A module the project doesn't define (numpy, react, @scope/pkg) resolves nothing. Before this PR the alias name also resolved to nothing in that case, so no edge is lost or added there.

Cache bump to extract-5

store.rs:9-11 says to "bump extract-N whenever extracted nodes or references change shape". The CLI also stamps saved graphs with CACHE_VERSION, so a graph from another extractor is rebuilt rather than reused. Without the bump, upgraded users would keep cached import nodes without alias: entries, and keep the wrong or missing edges, until each file happened to change. Indexes rebuild once.

Course correction: relative modules

The first version of resolve_imported matched every module by its last segment, the same way the existing ViaImport tie-break does. Dogfooding on a Next.js app with 12 route.ts files that each export POST showed the risk. The fan-out cap happened to drop that call, but with 2–4 same-named route files import { POST as handler } from "../route" would have produced an edge to every one at 0.95, where main produced none.

  • 8b5a02f first added three failing tests: a Next.js-shaped route test, a from ..money import … parent-package import, and a relative import of a file that only re-exports the name. They produced edges to every same-named module.
  • 36f6ced then anchored relative modules to the importing file.

The symbol-table test from the first version (a_relative_package_alias_prefers_the_callers_directory) was renamed and its expectations changed to the anchored results in that commit. It was added earlier in this PR, not taken from main.

Commit map

Commit Change
2ec9e3a test(graph): 7 end-to-end Python alias tests (real parser + GraphBuilder), all failing on main
2af5a40 test(graph): 2 guards that pass on main: a local definition shadows the alias; an alias from an external module links nothing
3904f28 feat(core): alias:<local>:<original> encoding and round-trip test
d5b16b2 fix(graph): alias map in the builder, resolve_alias, SymbolTable::resolve_imported, hand-built-node unit tests
66e3d71 fix(python): parser records the imported name (both import-list forms); the 7 tests pass
ac6561b fix(graph): CACHE_VERSION → extract-5
b6e6609 test(eval): retire the py-alias known_missing (plus notes and manifest text)
3574db0 test(eval): py-alias-shadow fixture
b3a60d3 test(eval): py-alias-dotted fixture
6d00f6b test(graph): failing TS alias test
03efaf6 fix(ts): parser records the imported name of an aliased named import
d241468 test(eval): ts-alias fixture
99587e6 docs(changelog): [Unreleased] entry
8b5a02f test(graph): 3 failing relative-import tests (course correction)
36f6ced fix(graph): relative aliased imports are read from the importing file
f8ddc15 docs(changelog): mention relative anchoring

Commits are deliberately small: each failing test lands before its fix. Squash-merge is fine.

Testing

  • Ran cargo test --workspace (the workspace equivalent of cargo test --all)
  • Ran cargo clippy --workspace --all-targets --all-features -- -D warnings
  • Ran flutter test: not applicable
  • Tested manually with real codebases (see Dogfood)

Results on f8ddc15

Run on Windows 11, rustc 1.98.1, with a dedicated CARGO_TARGET_DIR and TMP/TEMP outside the home folder (see #273 for why):

cargo fmt --all -- --check                                          # exit 0
cargo clippy --workspace --all-targets --all-features -- -D warnings  # exit 0
cargo test --workspace                                              # 21 suites: 523 passed, 0 failed, 1 ignored (doctest graph::ArborGraph::search_ranked, ignored on main too)
cargo test -p arbor-graph-cli --test eval_corpus -- --nocapture     # 1 passed

25 new tests (none removed or weakened)

Location Count What
builder.rs import_alias_tests 13 End-to-end: real .py / .ts source → real parser → GraphBuilder, asserting the exact set of call edges
builder.rs tests 3 Hand-built import nodes: alias map contents, alias edge confidence ≥ 0.95, plain import rebinding an alias
symbol_table.rs import_resolution_tests 6 resolve_imported: named module only, package submodule, external module, relative Python, relative TS (./x, ../x.js, ./utils → index.ts, missing, climbing out), methods and other languages excluded
node.rs 1 Encoding round-trip and rejection of malformed / *as: references
python.rs 1 Import node references for single, comma, parenthesised, relative and f as f imports
typescript.rs 1 Specifiers: aliased, plain, a as a, default as X, default plus aliased, namespace

Which tests fail on main. I appended the 13 end-to-end tests unchanged (a test-only module) to builder.rs at e10cb78. Of those 13, 10 fail and 3 pass:

  • The 3 that pass (by design): the 2 guards (a_local_definition_of_the_alias_name_still_shadows_the_import, an_alias_of_an_external_name_links_nothing), and a_relative_alias_to_a_re_export_links_nothing_else, which expects no edge, the same as main.
  • The 3 relative tests from 8b5a02f also failed on this branch before 36f6ced. Two failed because edges went to every same-named module; the re-export test failed because the call got an edge to each other route.ts.
  • The TS end-to-end test failed on the branch until 03efaf6.
  • The other 12 tests use the new APIs and fields, so they can't compile on main; they test the new code directly.

Eval corpus

main (e10cb78) this PR (f8ddc15)
Fixtures / states 10 / 13 13 / 16
Recall (expected edges found) 40 / 41 47 / 47
Incorrect edges 1 1, unchanged (the pre-existing recorded known_extra in py-export-removed:after, #263)
known_missing 1 (py-alias) 0
New known_extra none

New fixtures, all hand-audited:

Fixture Symbols Expected edges Forbidden edges On main
py-alias-shadow 5 2 3 Wrong edge orders.total_cents -> convert.to_minor at 1.0; misses the money.py edge
py-alias-dotted 4 2 2 No call edges at all
ts-alias 4 2 2 Wrong edge app.label -> legacy/fmt.fmt at 1.0; misses format.ts
  • py-alias-shadow: money.py, legacy_money.py and convert.py, with an alias named like convert.to_minor and a second money_to_minor_units.
  • py-alias-dotted: billing/money.py and legacy/money.py, with from billing.money import … as … and from .money import … as ….
  • ts-alias: import { formatName as fmt }, plus a real legacy/fmt.ts::fmt with its own caller.

Every other fixture is unchanged, including py-real-slice.

Benchmarks

The criterion benches don't exercise this change. crates/arbor-graph/benches/graph_bench.rs builds ArborGraphs directly (L7-50) and times search, impact, centrality and a query bundle. It never calls GraphBuilder or SymbolTable. I ran them anyway, then timed the code path that did change.

Method.

  • CI's command: cargo bench -p arbor-graph --bench graph_bench -- --noplot, after clearing target/criterion.
  • Base: a git archive e10cb78 tree, run with --save-baseline base.
  • PR: this branch, run with --baseline base in its own CARGO_TARGET_DIR, with base's criterion data copied in.

A mistake I made and corrected. My first "PR" run shared base's target directory, and cargo silently reused base's bench binary (Finished … in 1.99s, no Compiling lines). That run was really base against base. It is useful as a noise floor: on identical code it reported graph_query_bundle +14.4% and compute_centrality_100 −22.8%. The corrected run compiled the PR's crates.

Bench PR vs base base vs base (same session)
search_symbols_500 +4.7% +0.6%
analyze_impact_depth5_200 −4.3% −2.9%
compute_centrality_100 −21.6% −23.2%
compute_centrality_10k +4.0% −1.7%
compute_centrality_10k_warm −0.9% (no change) −0.3%
graph_query_bundle +1.3% (within noise) −0.5%

The two benches criterion flagged against the PR were re-run in 3 interleaved rounds, each against the same baseline:

Bench PR rounds base rounds
search_symbols_500 −0.3%, +14.7% (p = 0.32), −1.6% +0.7%, +5.3%, −1.1%
compute_centrality_10k +4.1%, +3.0%, −3.4% +1.4%, +8.4%, −0.2%

No consistent direction, and the PR stays inside the band that identical code shows. The code these benches run (graph.rs, search_index.rs, ranking.rs, impact.rs) is unchanged.

Index time, on the path that changed. Release builds, arbor index . --no-cache, 8 interleaved runs each, read from the CLI's own Indexed … in N ms. A byte-identical copy of the base binary is included as an A/A control:

Project base base copy (A/A) this PR
A private Python project (112 files) 103.0 ms 95.5 ms 96.5 ms
Arbor Cloud web + API (596 files) 616.0 ms 608.0 ms 606.5 ms

Medians. Process wall-clock was unusable here: on this Windows machine a freshly written executable paid a ~55–60 ms launch penalty per run, and the A/A copy showed the same penalty.

Dogfood (before → after)

  • eval/corpus/py-alias. arbor callers money_to_minor_units . on main gives No callers found for 'money_to_minor_units'. On this PR: Callers of 'money_to_minor_units' (1): function total_cents (orders.py:4).
  • eval/corpus/py-real-slice (real itsdangerous code): all 27 call edges identical, confidences included.
  • A private Python project (112 files, 9 aliased names in 8 from … import … as … lines): 777 → 786 call edges, +9 added, 0 removed.
    • I checked every added edge against the source: each call site is inside the attributed function, and each target is in the module the import names.
    • Two edges come from a loader whose nested functions share the imported function's name. The alias call reaches the imported module, not those local functions; one edge is from a nested function and one from the enclosing loader, which Arbor already credits with its nested functions' calls.
    • One function's arbor callers went from 4 (all tests) to 5, adding its only non-test caller.
    • The one aliased constant gets no edge.
  • Arbor Cloud's web + API (private, 596 files, at 909a2d8): 11080 call edges before and after, with one swap.
    • Removed: the wrong subscribe edge described above (route test → useMotionPreference.ts::subscribe, 1.0).
    • Added: ReceiptsHome.tsx::ReceiptsHome -> receipts.ts::flags at 0.95, via a multi-line flags as findFlags that is called at ReceiptsHome.tsx:75.
    • The subscribe alias now resolves to nothing, because ../route only re-exports POST (limitation c).
  • getArbor-dev/arbor-torture (5490a11, 372 files): 1336 → 1337 edges, adding hard/aliased.py::via_alias -> hard/reexport_base.py::original_function. via_module_alias (import hard.reexport_base as base_mod; base_mod.original_function(n)) stays unresolved (limitation b).

Known limitations / follow-ups

Not changed by this PR.

(a) The live re-index path bypasses all of this: #271.

  • Under arbor bridge and arbor viz, a saved file is re-parsed by parser_v2 and its calls are bound with find_by_name(..).first() (crates/arbor-server/src/sync_server.rs:718-753 at e10cb78). That skips import filtering, SymbolTable and alias resolution.
  • remove_file (L723) also drops every edge into the saved file from other files.
  • Measured over MCP on this branch:
    • Saving orders.py unchanged made the alias edge disappear and re-bound total_cents to convert.to_minor.
    • Saving convert.py unchanged removed report.summary's caller edge.
    • main loses the incoming edge the same way.
  • Both persist until a full re-index. arbor serve has no watcher (it indexes once), so it is not affected.

(b) Python dotted module calls: covered by #178 (case 3). import money as m; m.f() and import money; money.f() produce no edge on main or here:

  • extract_import (python.rs:192-204 at e10cb78) stores import money as m as an import node named "money as m" with no names.
  • Calls keep their full text m.money_to_minor_units (python.rs:427-432), which never matches a symbol.

(c) Re-exports aren't followed: #178 (case 1). Arbor Cloud's subscribe example above is one.

(d) Not covered:

  • Aliased base classes (class Foo(BaseAlias)): inheritance resolution doesn't consult the alias map. Untested.
  • TS @/… path aliases: not mapped to directories, so an aliased import through one resolves nothing, as before.
  • CommonJS destructured require (const { f: g } = require("./m")): not an import statement, so not recorded.

(e) Plain non-aliased relative imports still use loose module matching: #272. from ..money import f and import { POST } from "../route" link every same-named module at 0.95. Measured on main and here. The helpers this PR adds for aliases are the suggested fix.

(f) find_workspace_root treats ~/.arbor as a project root: #273. On a Windows machine installed with install.ps1 (default $HOME\.arbor\bin), unmarked folders under home resolve to the home directory. That includes the eval harness's %TEMP% copies, which made a local cargo test -p arbor-graph-cli --test eval_corpus start indexing the whole profile.

Risk, rollout and migration

  • Risk. Only references that a file imported under an alias take the new path. Every other reference resolves exactly as before. The new path can only:
    • add an edge to the imported module, or
    • replace the old same-named guess with that edge or with no edge.
  • Migration. Indexes and saved graphs rebuild once (extract-5). There are no CLI, MCP or config changes.
  • API. New public items: arbor_core::{import_alias, import_alias_ref}, arbor_core::node::IMPORT_ALIAS_REF_PREFIX and SymbolTable::resolve_imported. Import nodes' references can now contain alias: entries.
  • Release. No version bump in this PR. Arbor Cloud only picks this up after an engine release and a separate pin bump in Arbor Cloud.

Checklist

  • My code follows the project's style guidelines (cargo fmt, clippy -D warnings)
  • I have added tests for my changes (25 unit/integration tests, 3 corpus fixtures)
  • I have updated the documentation where necessary (CHANGELOG.md [Unreleased]; doc comments on the new functions)
  • All new and existing tests pass
  • I have added appropriate comments where the code isn't self-explanatory

…db71#258)

`from money import money_to_minor_units as to_minor` then `to_minor()`
records no edge: nothing is named `to_minor`. When another module does
define a function named like the alias, the call binds to that one
instead, so the edge exists and points at the wrong function.

These parse real Python with the real parser and build the graph. They
cover a plain module, a dotted module with a same-named decoy, a
relative module, several aliases in one import (comma and parenthesised),
an alias shadowing an unrelated function, an alias that is never called,
and a wrapper named like the function it imports. All seven fail on main.
Two behaviours the alias fix must keep, both passing on main: a function
the caller's own file defines under the alias name still wins over the
import, and an alias of a name from a module outside the project
(`from numpy import array as arr`) links to nothing, even when the
project defines a function with the imported name.
An import node's references hold the names the importing file can call.
For `from money import money_to_minor_units as to_minor` that was only
`to_minor`, so the name the module defines was lost before the graph
builder ever saw it. `alias:to_minor:money_to_minor_units` keeps both,
in the same prefix style as `field:` and `returns:`.
The builder keyed its import map on the local name only, so `to_minor()`
was looked up as `to_minor`: nothing, or a same-named function in an
unrelated module, which then passed import validation because
`to_minor` was imported.

An `alias:` reference now records the imported name next to the local
one. A call to the alias resolves the imported name in the module the
import named (`SymbolTable::resolve_imported`): that module's file
first, then a package of that name. A module the project doesn't define
resolves nothing, methods and the importing file are never candidates,
and a definition in the caller's own file still shadows the import.
A later plain import of the same name rebinds it.
)

`from money import money_to_minor_units as to_minor` recorded only
`to_minor` on the import node, so the graph never learned which function
the alias names and `to_minor()` produced no edge. Both the bare and the
parenthesised import lists now record `alias:to_minor:money_to_minor_units`.
`Y as Y` stays a plain import.

The end-to-end alias tests in arbor-graph now pass.
Import nodes now carry `alias:` references. A cache or saved graph from
the previous extractor holds the old shape and would keep answering
without the alias edges until each file happened to change.
`to_minor()` after `from money import money_to_minor_units as to_minor`
now reaches money.py, so the corpus reported the recorded miss as
resolved. The edge stays expected; only the known_missing entry and the
notes change.
…b71#258)

orders.py imports money.money_to_minor_units as to_minor while
convert.py defines a real to_minor that report.py imports and calls,
and legacy_money.py defines a second money_to_minor_units. The alias
call must reach money.py only; the other two targets are forbidden.

Hand-audited. Arbor 3.0.5 indexes this fixture as
orders.total_cents -> convert.to_minor at confidence 1.0 and misses the
money.py edge.
…Anandb71#258)

billing/money.py and legacy/money.py both define money_to_minor_units.
shop/orders.py aliases it from billing.money; legacy/invoices.py aliases
it from .money. Each alias call must reach only the module its import
names, so the crossed edges are forbidden.

Hand-audited. Arbor 3.0.5 records no call edges for this fixture.
…tion

`import { formatName as fmt } from "./format"` then `fmt()` has the
same gap as Python's `from x import y as z`: the TypeScript parser
records only `fmt`, so the call binds to an unrelated `fmt` in another
file, or to nothing. Fails on main and on this branch until the parser
records the imported name.
`import { formatName as fmt } from "./format"` recorded only `fmt`, so
`fmt()` bound to an unrelated `fmt` elsewhere in the project, or to
nothing. Named specifiers with an alias now record
`alias:fmt:formatName`, and the shared graph resolution from Anandb71#258 sends
the call to format's `formatName`. This covers .ts, .tsx and the JS
extensions, which share the extractor.

`{ default as Widget }` keeps recording `Widget`, as a default import
does, because no export is named `default`. `{ a as a }` stays plain.
app.ts imports formatName as fmt from ./format while legacy/fmt.ts
defines a real fmt that legacy/report.ts imports and calls. The alias
call must reach format.ts; the crossed edges are forbidden.

Hand-audited. Arbor 3.0.5 indexes this fixture as
app.label -> legacy/fmt.fmt at confidence 1.0 and misses format.ts.
`import { POST as subscribe } from "../route"` names one file, but the
alias resolution added for Anandb71#258 matched the module by its last segment,
so every `route.ts` that exports POST was a candidate. With up to four
of them the call gets an edge to each, raised to 0.95 by import
validation; before Anandb71#258's fix these aliases produced no edge at all.
Python's `from ..money import f as g` has the same shape.

Covers a Next.js-style route test, a parent-package Python import, and a
relative import of a file that only re-exports the name. All three fail
on this branch.
`../route`, `./format`, `.money` and `..core.money` each name one
place. resolve_imported matched them by their last segment, so any
same-named module was a candidate and a Next.js-style app could get an
edge from one route's test to every route's POST. A relative module is
now resolved against the importing file's directory: the module's own
file or its package `index`/`__init__` first, then anything inside that
package. `./format.js` names `format.ts`, and an import that climbs out
of the tree resolves nothing.

`from . import f as g` was a free-for-all with a same-directory tie
break; it now reads the importing package, so the symbol-table test
added with the alias fix on this branch is renamed and its expectations
changed to the anchored results. Non-relative modules are unchanged,
and one that normalizes to nothing now resolves nothing instead of
matching any definition. The three relative tests in import_alias_tests
now pass.
@JunaidAhamed-7777

Copy link
Copy Markdown
Contributor Author

@Anandb71

@Anandb71 Anandb71 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. It's a careful PR: small commits, failing tests before fixes, an A/A control on the benchmarks, and dogfood numbers. The main fix is right. to_minor() now reaches money.money_to_minor_units, and the old wrong 1.0 edge to a same-named function is gone. I'd like two small changes before merging, both in the new non-relative-module path.

What I checked

  • CI is green on f8ddc15: 13/13, including eval_corpus, clippy -D warnings, fmt, criterion, the cross-platform builds, installer and npm smoke tests, cargo-deny and arbor-impact. It merges cleanly into main (e10cb78).
  • I read the full diff and the code around it: add_nodes, resolve_edges, apply_import_validation, resolve_imported, pick, module_is and entry_belongs_to_module.
  • Nothing else reads import-node references. changes.rs uses qualified_name only, and sync_server is #271. The extract-5 bump is needed and is enough.
  • No recursion is added, and every choice between candidates goes through the sorted pick.
  • I built the branch in a scratch worktree and ran extra builder tests with absolute paths, as arbor index produces them.
    • These came out right: TS from '.' → index.ts, and a nested local def c() shadowing an alias c.
    • The cases below came out wrong.

Requested changes

  1. crates/arbor-graph/src/symbol_table.rs:379-394: the non-relative branch doesn't treat pkg/__init__.py as the module's own file.

    • Setup: pkg/__init__.py defines f, pkg/sub.py also defines f, and app.py has from pkg import f as g and calls g(x).
    • Result: run -> pkg/__init__.py::f @0.95 and run -> pkg/sub.py::f @0.95.
    • Cause: normalize_module_path drops index/mod but not __init__, so module_is misses the package file. Then the entry_belongs_to_module fallback takes the whole package.
    • The relative branch already gets this right through is_module_file, and the doc comment says "the module's own file first".
    • Fix: in the non-relative in_module filter, also accept an entry whose stem is __init__ and whose parent normalizes to wanted. Please add a test with both definitions present.
  2. CHANGELOG.md:11 (and the PR description): "An alias imported from a module outside the project links nothing" isn't true when the external module's name matches a project path.

    • module_is matches the last segment, and entry_belongs_to_module matches any segment of the absolute file path. Both of these are verified on the branch:
      • import { request as httpRequest } from 'http', with the project's src/lib/http.ts exporting request, gives go -> src/lib/http.ts::request @0.95. main gives no edge for the alias.
      • from flask import render_template as rt, in a checkout under /home/u/flask/… that defines its own render_template, gives an edge at 0.95.
    • The unaliased forms already bind like this on main (at 1.0), so aliases now behave like plain imports rather than worse. But the changelog promises more than the code does.
    • Two options:
      • (a) Reword it, e.g. "…links nothing unless a project file or directory has the same name".
      • (b) Skip single-segment bare specifiers for JS-family callers. http, path and react are packages or Node builtins; baseUrl imports like components/Button have more than one segment.
    • (a) is enough for this PR, and (b) can be a follow-up.

Nits / follow-ups (not blocking)

  • builder.rs:422: when resolve_imported returns Ambiguous, apply_import_validation raises every candidate to 0.95, because the alias is in the import map.
    • Example: from billing import convert as conv, with billing/a.py and billing/b.py both defining convert, gives two 0.95 edges.
    • Plain from billing import convert does the same today, so this is #272 territory. It may still be worth not boosting an ambiguous alias resolution.
  • Pre-existing, not from this PR: arbor index --changed-only (commands.rs:1611-1672) rebuilds from base_graph.nodes(). Import nodes are never graph vertices, so unchanged files lose their import_map, and now their import_aliases. After an incremental index, alias calls in unchanged files go back to the old name lookup. This is worth an issue next to #271.
  • #221 will need a rebase after this lands. It already conflicts with main, and this PR adds conflicts at the GraphBuilder fields and new(). The two fit together: #221's visibility check keys on the local name, and this PR keeps the local name as the import_map key.

Happy to re-review once 1 and 2 are in.

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.

Aliased import calls produce no call edge (from x import y as z)

2 participants