Skip to content

fix(resolution): Python module-qualified calls colliding with builtin methods - #1704

Open
inth3shadows wants to merge 23 commits into
colbymchenry:mainfrom
inth3shadows:fix/python-module-member-builtin-collision
Open

fix(resolution): Python module-qualified calls colliding with builtin methods#1704
inth3shadows wants to merge 23 commits into
colbymchenry:mainfrom
inth3shadows:fix/python-module-member-builtin-collision

Conversation

@inth3shadows

Copy link
Copy Markdown

Problem

Two Python call-resolution bugs with one root cause: the method-call heuristics assume a common collection-method name always means a builtin, and never account for a project module exporting a function of that name.

1. Real calls silently dropped. ledger.append(row) — where ledger is a project module exporting a top-level append — was classified as list.append by isBuiltInOrExternal and discarded. The only escape hatch was a capitalized receiver matching a known class, so a module receiver never qualified. The ref never reached resolveViaImport / resolvePythonModuleMember, which already resolve it correctly. ledger.append's actual callers went uncounted.

2. Wrong edges fabricated. A method call through a non-identifier receiver — an attribute chain (self.data.append(x)), a subscript (d[k].append(x)), a call chain (rows.setdefault(k, []).append(x)) — degraded at extraction time to a bare append ref. That bare ref then exact-matched an unrelated top-level append as the sole same-named symbol project-wide, inventing a call edge between functions with no relationship.

Fix

  • src/resolution/index.ts — before declaring a qualified call a builtin, check whether the receiver is an imported module in that file (getImportMappings). If it is, let it through to import resolution.
  • src/extraction/tree-sitter.ts — for Python, keep the receiver's source text as a qualifier instead of collapsing an unresolvable receiver shape to a bare method name. An unresolved qualifier is then a silent miss, never a wrong edge.

Same philosophy as #1230 / #1276: prefer a missing edge over a fabricated one.

Tests

__tests__/resolution.test.ts — one test covering both directions: the module-qualified call resolves to the module's function, and the chained-receiver call does not attach to it.

resolution.test.ts   190 passed
extraction.test.ts   622 passed

Provenance

Found by running testgraph's trace-derived ground-truth comparison against its own codebase — the Python graph showed ledger.append with zero callers while the runtime trace showed several, and showed callers it did not have (inth3shadows/testgraph#66).

CHANGELOG entry added under [Unreleased] → Fixes.

… methods

A call like `ledger.append(row)` was silently dropped: isBuiltInOrExternal
treated any `x.method()` as `list.append`/`dict.update`/etc whenever `method`
matched a common collection method name, unless the capitalized receiver
matched a known CLASS — never checking whether the receiver was a known
imported MODULE exporting a same-named top-level function. The real call
never reached resolveViaImport, so ledger.py's actual callers went uncounted.

Separately, a method call through a non-identifier receiver (an attribute
chain like `self.data`, or a call chain like `rows.setdefault(k, []).append`)
degraded at extraction time to a bare `append` ref. That bare ref then
exact-matched the same unrelated top-level `append` as the sole same-named
symbol project-wide, fabricating a call edge from unrelated functions.

Same root cause as both bugs: the Python method-call heuristics assumed a
common method name always means a builtin, and never accounted for a project
module exporting a function of that name. Fix: check import bindings before
declaring a qualified call a builtin, and stop collapsing non-identifier
receivers to a bare name that can collide (mirrors the colbymchenry#1230/colbymchenry#1276 fix
philosophy — an unresolved qualifier is a silent miss, never a wrong edge).

Found and reproduced via testgraph's trace-derived ground truth run against
itself (inth3shadows/testgraph#66).
@danusha2345

Copy link
Copy Markdown
Contributor

Two notes from merging this into a local integration build of current main:

  1. The extractor half is dead once the native kernel is present. Python extraction runs in codegraph-kernel/src/python.rs whenever the .node is staged (every published bundle ships it), so a change made only in src/extraction/tree-sitter.ts never reaches runtime and the two arms diverge — the same trap fix(resolution): resolve direct calls through aliased Python function imports #1518 / fix(go): resolve cross-module calls in multi-module layouts #1521 fell into. Your own rows.setdefault(k, []).append(x) test passes in that build only because fix(extraction): never fabricate an edge from a call-result receiver #1692 already keeps call receivers in both arms (<inner>().<method>, TS/JS/Python, tsjs/extractors.rs + python.rs, parity fixtures). The broader receiver shapes you cover (self.data.append, d[k].append) would need the same mirror in python.rs plus a line in __tests__/fixtures/kernel-parity/torture.py.

  2. The resolver half is the part fix(extraction): never fabricate an edge from a call-result receiver #1692 does not have, and it is good. isBuiltInOrExternal letting a receiver through when it is an imported module of the caller's file (ledger.appendresolveViaImport / resolvePythonModuleMember) closes the false-negative side of Python: a top-level function named like a collection method (append/update/get) gets ZERO real callers and fabricated ones — uncovered shapes left by #715 and #1317, live in 1.6.0 #1681. I took exactly that hunk plus your tests into the integration build (resolution 208 → 262 passed, no regressions); the extractor hunk was dropped as above.

Suggestion so the maintainer does not get two competing PRs for one bug: keep this PR to the resolver half (the ledger.append recall fix, which stands on its own and merges clean), and let #1692 carry the receiver encoding for both arms. Happy to cross-reference either way.

…on.rs

The TS extractor keeps a python call's receiver text as a qualifier when the
receiver is not a plain identifier — an attribute chain (`self.data.append`),
a subscript (`d[k].append`) or a call chain (`d.setdefault(k, []).append`) —
so a bare `append` can never exact-match an unrelated project function of that
name (colbymchenry#66). `codegraph-kernel/src/python.rs` still collapsed all three to the
bare method name.

Python is in the kernel's DEFAULT_ROUTED set and every published bundle ships
the .node, so the TS-only fix never ran where it mattered: on the installed
1.5.0 build, `self.data.append(...)` and `rows["k"].append(2)` both fabricated
a `calls` edge onto an unrelated module-level `append`, while the real
`ledger.append(row)` was missing. A from-source checkout has no .node, so the
existing coverage silently exercised the wasm arm and stayed green.

Mirrored the branch, with a `collapse_js_whitespace` helper rather than
`char::is_whitespace`: the sets differ (U+0085 in one, U+FEFF in the other),
and the parity sweep compares the two arms byte for byte.

torture.py gains the subscript and call-chain shapes; the attribute-chain
shape (`self.registry.lookup`) was already there and is what makes
kernel-tsjs-parity fail without this commit. The new test asserts the
end-to-end property on the kernel arm specifically, and skips when no .node is
staged, like the parity suites.

Verified: kernel-tsjs-parity 17/17 with the mirror, 2 failures without it
(rebuilt both ways); the new suite passes against a freshly built kernel.
@inth3shadows

Copy link
Copy Markdown
Author

Thanks — point 1 is correct, and I verified it rather than taking it on trust. It turned out to be a stronger argument for mirroring the extractor hunk than for dropping it, so I've pushed the mirror (2c5319c) instead of narrowing the PR.

The bug reproduces on a published bundle

Installed @colbymchenry/codegraph 1.5.0 (which ships codegraph-linux-x64/lib/kernel/codegraph-kernel.node), three files, codegraph index, then reading the edges table directly:

build_map (unrelated.py) --calls--> append (ledger.py)   <- fabricated (self.data.append)
build_map (unrelated.py) --calls--> append (ledger.py)   <- fabricated (rows["k"].append)
add_outcome --calls--> ledger.append                     <- MISSING

So both halves of #66 are live in the shipped build, which is exactly your point: a fix in src/extraction/tree-sitter.ts alone never runs there.

#1692 covers one of the three shapes

python.rs's extract_call filters the receiver to identifier | simple_identifier | field_identifier and sends everything else to a bare method_name. #1692 adds one branch for receiver.kind() == "call". That fixes d.setdefault(k, []).append(x) — but an attribute chain (self.data.append) and a subscript (d[k].append) still fall through to the bare name, and the bare name is what exact-matches an unrelated project function.

The parity suite already fails without the mirror

__tests__/fixtures/kernel-parity/torture.py line 26 is self.registry.lookup("x") — the attribute-chain shape. With this PR's TS hunk applied and no kernel mirror, kernel-tsjs-parity fails on it:

wasm:   referenceName "self.registry.lookup"
kernel: referenceName "lookup"

That failure is invisible in a from-source checkout: with no .node staged, every kernel-*-parity suite describe.skipIfs itself and the extraction tests silently exercise the wasm arm. I only saw it after installing Rust and running scripts/build-kernel.sh.

What 2c5319c does

  • Mirrors the branch into codegraph-kernel/src/python.rs for all non-identifier receivers, with a collapse_js_whitespace helper rather than char::is_whitespace — the two sets differ (U+0085 in one, U+FEFF in the other) and the parity sweep compares arms byte for byte.
  • Adds the subscript and call-chain shapes to torture.py.
  • Adds __tests__/kernel-python-call-fabrication.test.ts, which forces CODEGRAPH_KERNEL_LANGS=python and asserts the end-to-end property on the arm that ships, skipping when no .node is staged.

Verified by rebuilding the kernel both ways: kernel-tsjs-parity 17/17 with the mirror, 2 failures without it; the new suite passes against a freshly built kernel.

Happy to defer the call-chain shape to #1692 and keep this PR to the attribute-chain/subscript shapes plus the resolver half, if that avoids overlap — the two changes are compatible either way, since #1692's branch is checked before the general one.

…odule

The escape added for colbymchenry#66 asked only whether SOME import bound the receiver's
local name. Every import produces a mapping — stdlib and PyPI included — so it
was also true for `os`, `requests`, `np`. That opened the built-in-method
filter for them; `resolveViaImport` then found no project file, resolution fell
through to the bare-name strategy, and the call bound to whatever project
method happened to share the name. The escape hatch reintroduced the exact
fabrication class the filter exists to prevent.

Verified before the fix, on a project with `Store.remove` / `Store.get`:

    import os; import requests
    cleanup -> Store.remove   refName "os.remove"
    cleanup -> Store.get      refName "requests.get"

Two wrong edges where 1.6.0 produced none.

The escape now resolves the import specifier and opens only when it names a
file in this project — the same question `resolveViaImport` asks next, so a
receiver that passes is one the qualified path can actually serve. `from . import
mod` and `import pkg.mod as m` are both handled; anything else stays a silent
miss rather than a wrong edge.

`__tests__/python-import-gate.test.ts` pins both directions, and fails on the
first without this change (`['remove@store.py','get@store.py']` vs `[]`).
resolution + extraction + frameworks + kernel parity: 855 tests, all pass.
@inth3shadows

Copy link
Copy Markdown
Author

Heads-up, and an apology: the resolver hunk you took into your integration build has a regression. Fixed in b34d85f, but please re-check that build.

A code-review pass over the em-tagged build of this branch caught it, and I reproduced it before believing it.

The regression

extractPythonImports emits an ImportMapping for every import — stdlib and PyPI included — so imp.localName === receiver was true for os, requests, np. That opened the built-in-method filter for them; resolveViaImport then found no project file, resolution fell through to the bare-name strategy, and the call bound to whatever project method shares the name.

A project with class Store: remove(), get() and a file containing only import os / import requests:

this branch @2c5319c   cleanup -> Store.remove   refName "os.remove"
                       cleanup -> Store.get      refName "requests.get"
installed 1.6.0        (0 edges)

Two fabricated edges where the released build produced none — the exact class this PR set out to remove, arriving through its own escape hatch.

The fix (b34d85f)

The escape now resolves the import specifier and opens only when it names a file in this project — the same question resolveViaImport asks next, so a receiver that passes is one the qualified path can actually serve. from . import mod and import pkg.mod as m are both handled; anything else stays a silent miss.

__tests__/python-import-gate.test.ts pins both directions and fails on the first without the change (['remove@store.py','get@store.py'] vs []). resolution + extraction + kernel parity: 832 tests, all pass.

You were also right that my tests were vacuous for the attribute-chain shape

I checked this properly. ledger.append is a top-level function, and Strategy 3 only considers method kinds — so the negative assertion in my test could never have failed. With a method decoy (class Sink: def append(self, x)) the picture is:

site released 1.6.0 this branch
self.data.append(1) wrong edge · exact-match 0.9 wrong edge · instance-method 0.7
rows[k].append(2) wrong edge · exact-match 0.9 gone
self.inner.get(k) wrong edge · exact-match 0.9 wrong edge · instance-method 0.7

So the extraction hunk is a strict improvement here — three wrong edges become two, and the survivors drop from the top confidence tier to 0.7 with an honest refName — but it is not the fix its own comment claims. self.data.append still reaches the bare-name fallback, because Python has no exclusive chained-receiver branch equivalent to matchGoFieldChainCall / matchRustSelfFieldCall. Only receivers containing non-word characters (subscript, call chain) get the silent-miss behaviour.

That gap is real and I have not fixed it here — it needs a Python chained-receiver resolution branch, which is a bigger change than this PR should carry.

Where that leaves the split you proposed

Still happy to go either way. If you take the resolver half, please take b34d85f with ite799cd0 alone is the regression above.

Since colbymchenry#66 kept the receiver's text, `self.data.append(1)` reaches the resolver as
`self.data.append` — which `matchMethodCall`'s dotMatch splits into receiver
`self.data` + method `append`, and the bare-name strategies then bound it to any
project method of that name. The comment on colbymchenry#66 claimed the qualifier prevented
exactly this. It did not; only receivers with non-word characters (subscript,
call chain) got the promised silent miss.

This is the discipline Go (colbymchenry#1276), Rust (colbymchenry#1585) and PHP's `this->prop.method`
already have in this same function: a dotted python receiver resolves through
validated inference or not at all.

**It costs recall, and the cost is measured rather than waved at.** Indexing the
tracked .py of four real projects: three unchanged (278 / 643 / 301 call edges),
and a 249-file one 2605 -> 2564. Of the 41 dropped, 38 were fabrications — 21 x a
dict `.update` bound to a service's `update`, 16 x application code bound to a
`get` defined in a TEST file, and `self._model.transcribe` on an external Whisper
model bound to the file's own `transcribe` — and 3 were genuine
`self._capture.stop()` hops onto the class the constructor assigns.

Those 3 are recoverable: python names an attribute's type in the class body
(`self.x: T`, `self.x = T()`, a typed `__init__` parameter, a class-level
annotation, a base class). A first attempt read those with regexes over the
class's source lines and review killed it — with only `#` stripped it took a type
out of a DOCSTRING and turned a correct edge into a wrong one, read a nested
class's `__init__` as the outer class's, and stripped the package off
`requests.Session()` to bind an external object to a project class. Doing it
right needs the AST, and it is its own change. Until then this shape is a silent
miss, which is the trade this file makes everywhere else.

The three negative tests each use a DISTRACTOR — a second project symbol with the
same method name. Without one the old fallback found the right target by
single-candidate luck and the test passed on both arms, proving nothing; that is
how the first version of this suite was vacuous. Verified: all three fail against
the pre-change resolver, and the two boundary guards (a single-segment inferable
receiver, a module-qualified call) pass on both arms.

resolution + extraction + frameworks + kernel parity: 860 tests, all pass.
Plan: ~/.claude/plans/codegraph-python-attribute-chain-receiver.md
@inth3shadows

Copy link
Copy Markdown
Author

b964128 closes the attribute-chain gap I flagged in my last comment. It is the discipline Go (#1276), Rust (#1585) and PHP's this->prop.method already have in matchMethodCall: a dotted python receiver resolves through validated inference or not at all, never through the bare-name strategies.

It costs recall, and I measured rather than assumed

Indexing the tracked .py of four real projects and diffing the calls edges. Three unchanged (278 / 643 / 301). A 249-file one: 2605 → 2564.

dropped
21 a dict .update bound to a service's update
16 application code bound to a get defined in a test file
1 self._model.transcribe on an external Whisper model, bound to the file's own transcribe
3 genuineself._capture.{start,stop,chunks}() onto the class the constructor assigns

So 38 fabrications for 3 real edges. I think that trade is right for a graph agents read, but it is a trade, not a free win.

The 3 are recoverable, and my first attempt at recovering them was wrong

Python names an attribute's type in the class body — self.x: T, self.x = T(), a typed __init__ parameter, a class-level annotation, a base class. I wrote a helper that read those with regexes over the class's source lines. Review killed it, and rightly:

  • only # was stripped, so a type in a docstring beat the real assignment below it — turning a correct edge into a wrong one
  • a nested class's __init__ was read as the outer class's
  • self.session = requests.Session() had the package stripped and bound an external object to a project Session — the fabrication class this whole thread is about
  • the typed-__init__-parameter idiom (def __init__(self, conn: Client): self.conn = conn) wasn't read at all, so it lost edges that worked before

Doing it properly needs the AST rather than a line scan, so it is a separate change. Until it exists this shape is a silent miss.

On the tests

Worth flagging as a general trap for this area: my first version of these tests passed against the pre-change resolver. With a single project-wide symbol of that name, the old bare-name fallback found the right target by luck, so the test proved nothing. Every negative test here now carries a distractor — a second project symbol with the same method name — and I verified all three fail against the pre-change resolver while the two boundary guards (a single-segment inferable receiver, a module-qualified call) pass on both arms.

860 tests across resolution, extraction, frameworks and kernel parity.

The gate in c8106bf made `self.data.append(1)` a silent miss instead of a
fabricated edge — 38 fabrications dropped on a 249-file python repo, at the cost
of 3 genuine `self._capture.{start,stop,chunks}()` edges. This recovers those 3
without bringing any fabrication back.

Python declares no field types, so the evidence is spread around the class body,
and there are four shapes: a class-level annotation (`conn: Client`, the
dataclass / pydantic / attrs style), a typed `__init__` parameter, `self.x: T`,
and `self.x = T()`. `memberTypesInTree` in `graph/branch-guards.ts` already reads
"the declared types of a class's members" from the tree for TS, Java, Kotlin and
C#; this adds the python cases, so the UI's Steps tab gets them too — which is
what that function exists for. `memberTypesForSourceSync` is the sync entry the
resolver needs, in `guardsForFileSync`'s shape: it serves only a language whose
grammar is already loaded and yields nothing otherwise, never a wrong type.

**Reading the TREE rather than the class's source lines is the whole point, and
it was learned the hard way.** An earlier regex version of this reader was
rejected in review for taking a type out of a DOCSTRING and turning a correct
edge into a wrong one, reading a nested class's `__init__` as the outer class's,
and stripping the package off `requests.Session()` to bind an external object to
a project class. The tree answers the first two by construction: a docstring is a
`string` node and never an assignment, and a nested `class_definition` is simply
not descended into. The third is answered by refusing a dotted type outright,
the stricter form of the rule `matchGoFieldChainCall` already applies.

A typed parameter binds to the attribute assigned FROM it, not to one that
merely shares its name, so `self._conn = conn` is right. Precedence is decided
rather than accidental: annotation, then parameter, then constructor call, first
declaration winning within a tier — source order would let `self.conn = Stub()`
beat a later `self.conn: Client`.

Measured on the same four corpora. Three unchanged (278 / 643 / 301 call edges).
coriolis-local, against the pre-gate baseline of 2605:

  gate only            2564   38 fabrications gone, 3 genuine edges lost
  gate + this          2567   the same 38 gone, all 3 recovered, 0 invented

Diffed edge-by-edge: nothing lost against the gate-only arm, and nothing present
that was not in the pre-gate baseline. Indexing that corpus: 2983ms -> 3091ms
median of 3, inside run-to-run spread.

11 new tests, each negative one carrying a DISTRACTOR — a second project symbol
with the same method name — because without one the bare-name fallback finds the
right target by luck and the test passes on both arms. 7 of the 11 fail against
the pre-change tree; the other 4 assert a silent miss the gate alone also gives,
and are guards rather than pins. Full suite 4143 passed, every failure a
dist-dependent CLI/MCP suite.

Plan: ~/.claude/plans/codegraph-python-attribute-type-inference.md
Four defects in the reader added by 35bd4e9, all found by review.

**A decorated method was invisible.** tree-sitter wraps `@inject def __init__`
and `@property def x` in `decorated_definition`, and both scans tested
`stmt.type === 'function_definition'`. Dependency injection and `@property` are
exactly where python puts its types, so the common case read as "this class
declares nothing".

**An `__init__` parameter's type leaked onto any same-named binding.** The
parameter types were collected once and applied to `self.x = <identifier>` in
EVERY method, so an unrelated local inherited the constructor's type:

    def __init__(self, dep: Real): self._dep = dep
    def load(self):
        dep = make_decoy()      # a Decoy
        self._cache = dep       # was typed Real

That is a wrong edge, the fabrication class this path exists to prevent. A
parameter's type now applies only inside the `__init__` that declared it.

**The class was lost six blocks down.** The climb from the call site stopped
after 16 frames; `with` + `for` + `if` + `try` inside a method already exceeds
it, and losing the class reads as an empty declaration set. It now climbs to the
root.

**The read was quadratic in refs per class.** The resolver asks once per REF and
python's reader walks every method body — unlike the other languages, which scan
only the class body's direct children. Measured on a full index: a 2000-method
class went 1.2s -> 41s, a 4000-method one to 196s. The parse tree cache now
carries the answer per enclosing class, so each class is read once.

Five unit tests, one per defect plus the docstring/nested-class guard, at the
reader level: several of these are invisible end-to-end, because they only
change what happens where the resolver would have produced nothing anyway.
…ports

Two wrong-edge defects from review, both in what the inferred type name was
allowed to match.

**A bare-imported external class bound to a project class of that name.**
`pythonAnnotationType` refused a DOTTED type, so `self.h = requests.Session()`
was safe — but `from requests import Session` then `self.h = Session()` is the
far more common spelling and nothing refused it. It bound to a project
`models.Session`. Same shape for `from pathlib import Path` against a project
`class Path`.

**With two project classes of a name, the method lookup fell back to index
order** and landed on a `Client` declared in a TEST file — the exact thing the
CHANGELOG entry this branch adds says no longer happens.

`pythonTypeClass` answers both: a name the file IMPORTS must come from the
module the import names, and a name it does not import must be declared in the
file itself. An external import matches no project file and yields null. The
method is then looked up on THAT class in THAT file; the shared
`resolveMethodOnType` is used only for the inherited case, and only when the
class name is unambiguous, because with two candidates there is nothing here to
choose between them.

That also settles a third, smaller finding: `self.h = make_client()` recorded
the "type" `make_client`, which now matches no class and yields nothing.

Three tests, each with a distractor.
…that resolves

The python attribute-type reader added by 35bd4e9 did nothing in a real build.

It reads member types off the parse tree and, by design, returns nothing when
the grammar is not loaded — no type beats a wrong one. Nothing loaded it where
the resolver runs. A full index routes parsing to `parse-worker.js`, so
`loadGrammarsForLanguages` is called only inside those workers and never on the
main thread, where `ReferenceResolver` runs. `sync` loads them on the main
thread, so it worked there.

The result was worse than the feature being absent: `codegraph index` produced
no attribute edges and `codegraph sync` produced them, so a project's graph
depended on how each file happened to be indexed last, and `sync` runs from a
git hook.

`resolver-worker.ts` had the same hole independently, and its own contract makes
it sharper — `resolveAndPersistBatched` promises the parallel switch "changes
wall-clock, never the graph", but a worker with no grammar resolves the same
refs to fewer edges. It now warms before posting `ready`, so no batch can
arrive first.

`warmResolverGrammars` loads only the languages the resolver reads trees for
(python today) and only those the project actually contains.

**Every test for this feature ran from source, where `parse-worker.js` does not
exist, so they all took the in-process fallback and could not fail.** That is
the same trap the kernel/wasm split set two commits earlier on this branch. The
new test runs the built binary: one case asserts the edge after a full index,
the other asserts index and sync agree. Both fail against the unfixed build —
`[]` after index, and index/sync disagreeing — which is exactly the bug.
…eir names

Review found three statements this branch had made false and four tests that
passed for reasons other than what they claim.

**Two false statements.** The gate's comment still said the AST reader did not
exist — it is on the next line. And the CHANGELOG said "Nothing to re-index",
but these are `calls` rows written while indexing; without a re-index or a
`sync` nothing changes for an existing project.

**`matchRustSelfFieldCall`'s doc block was orphaned** — the python helpers had
been inserted between it and its declaration, so it documented the python
builtin set and the Rust function had none.

**Four tests, verified by mutation rather than by reading.** Deleting the
branch each is named for used to leave the whole suite green:

- `unwraps Optional and a | None union` declared both attributes and called only
  one, leaving the `| None` branch untested. Split in two, so each calls what it
  declares.
- `a plain container stays a silent miss` was carried entirely by the generics
  check and by `self.h = []` not being a call; the builtin list was unexercised.
  A new case annotates `self.h: dict` in a project that shadows `dict` — a bare
  name, so only the builtin refusal stops it.
- `a same-named class in another file donates nothing` left the attribute
  untyped, so the reader produced nothing and the cross-file question was never
  reached. It now types the attribute, and is renamed for what it tests.
- the external-object test covered only the DOTTED spelling; renamed to say so,
  now that the bare-import case has its own.

Both previously-dead branches now fail their test when removed.
…y uses it

Correcting 32c49f8. That commit added a per-class memo to stop the python
reader re-walking a whole class once per ref, and claimed the measured fix. The
memo landed on `memberTypesForFile` — the async twin the UI uses — because the
line it replaced appears in both functions and the first match won.
`memberTypesForSourceSync`, which is the one the resolver calls, kept re-walking
every time, so the cost 32c49f8 said it had fixed was still there in full.

I only caught it by re-running the benchmark after the fact rather than trusting
the change. Measured, indexing a single class of N methods each calling
`self.h.run(x)`:

                    before      32c49f8      now      (gate-only baseline)
    500 methods     2,944ms     2,593ms      371ms     303ms
    2000 methods   39,453ms    39,005ms    1,252ms     939ms

Quadratic in both earlier arms; linear now, at ~1.3x the baseline.

Pinned by IDENTITY rather than wall-clock: two calls for two different lines in
one class must hand back the same Map object, which is only true if the memo is
on the path under test. A timing assertion would be flaky and would not have
caught this failure mode — the code was present and correct, just wired to the
wrong function.
…k inheritance

Five wrong-edge mechanisms and one dead code path, all found by review of the
previous round. Every one is `pythonTypeClass` trusting something it shouldn't.

**A docstring decided the module.** `getImportMappings` is a REGEX over raw
text, so `from decoy import Real` written inside a docstring produces a mapping
indistinguishable from the real import, and `.find()` took the first. The AST
rewrite closed exactly this hole on the TYPE side and I re-opened it one layer
down on the MODULE side. Now every binding of a name must agree, or there is no
answer.

**Hand-rolled path math.** `replace(/^\.+/, '')` discarded the dot COUNT, so
`from ..core import X` was looked up inside the importing file's own package —
wrong module when one existed there, silent miss otherwise. And matching
candidate class paths with `endsWith` accepted `examples/services/client.py` for
`from services.client import …`. `pythonModuleFile` resolves the specifier
properly — the resolver's own `resolveModulePath` first, then a dot-count-correct
path checked AGAINST THE INDEX rather than pattern-matched.

**`hit[0]` on ambiguity.** With two survivors the alphabetically-first path won,
which is the "picks by index order" failure this function was written to prevent
— I applied that rule to the class name and not to the file. Two candidates now
means no answer.

**An alias never resolved.** `from kinds import Real as R` looks up `R`, which
names no class. The lookup uses the EXPORTED name, and the method lookup uses
the chosen class's own name.

**The inheritance hand-off could never fire.** The comment said an inherited
method falls through to `resolveMethodOnType`'s supertype walk. That walk needs
`extends` edges, which do not exist in the pass that resolves these refs — which
is why PHP's `this->prop.method` is parked for the conformance pass. Python's
shape was parked by nothing, so `class Real(Base)` — a service subclass, the
most ordinary shape there is — resolved to nothing. It is parked now
(`PY_SELF_ATTR_SHAPE`), and the walk is done here against the pinned class
rather than by name, since searching every class of a name is the ambiguity this
function exists to refuse.

Six tests, each with a distractor, each verified to fail against the previous
commit.
Within the `constructed` tier `put` is first-wins and the methods were walked in
source order, so a `reset()` written ABOVE `__init__` decided the attribute's
type: `self.h = Decoy()` there beat `self.h = Real()` in the constructor, and
the call resolved to Decoy.

That is the same accident the annotation/parameter/constructor tiering exists to
avoid — I tiered the categories and then let source order decide inside one of
them. `__init__` is the declaration; another method's assignment is a
reassignment.
…ames

**A third resolution path resolved without grammars.** `sync`'s retry sweep
re-resolves refs in files it did NOT change, while the orchestrator loads
grammars only for the CHANGED files' languages — so a TypeScript edit that makes
parked Python refs retryable re-resolved them with no Python parser and dropped
their attribute edges. Warmed there too. The public synchronous
`resolveReferences()` cannot warm (loading is async) and now says so rather than
degrading silently; only tests call it.

**The deep-nesting test passed under the old cap.** Its fixture was 15 frames
and the cap it claimed to pin was 16, so reverting the change left it green —
the "unmutated-but-dead" pattern the file's own header warns about. Each python
block costs two frames; the fixture is now 21, measured rather than assumed, and
fails under the old cap.

**The worker's grammar warming is left uncovered, deliberately.** A resolver
worker takes no batch until one reaches `MIN_PARALLEL_BATCH`, and fixtures up to
150 files x 30 calls still resolve entirely on the main thread — so a test at
any size this suite can afford passes with the fix removed. Two attempts did
exactly that before I checked. The path was verified by measurement instead
(9,000 edges vs 3,332 on a 1,500-file project with the pool forced on), and the
gap is written down where the test would have gone.
… and

restore the recall the module rewrite overshot

Five findings from review of the previous round.

**The supertype walk crossed same-named classes — a wrong edge.**
`getSupertypes` matches by NAME, so it unions the `extends` targets of every
python class called `Real`: a `b/real.py::Real` that inherits nothing was given
`a/real.py::Real`'s base and resolved `self.h.zorp(x)` onto it. That is the same
"two classes of a name is no evidence" rule this function enforces for the
receiver, dropped one level down — the third time in this feature that I applied
a rule in one place and not the adjacent one. No unique receiver class, no walk.

**The module rewrite overshot and cost real recall.** I removed the `endsWith`
match because it TIEBROKE wrongly, and took the suffix search with it:

- a package root that is not the repo root — `src/pkg/core.py` imported as
  `pkg.core`, which is the packaged-project default, plus `backend/` and
  monorepo subdirectories — stopped resolving entirely
- `from pkg import Client` re-exported by `pkg/__init__.py`, the dominant python
  package idiom, stopped resolving

Both are back, and neither reintroduces the tiebreak: the suffix search returns
an answer only when EXACTLY ONE file matches, so `services/client.py` and
`examples/services/client.py` still cancel each other out. The re-export follows
one hop through the package's own import of that name, so the answer is still a
module the source names.

**A commented-out import cancelled the real one.** The mappings come from a
regex with no comment stripping, so `# from legacy import Real` above a real
import produced a permanent disagreement and the new agreement rule refused
both. Bindings are now filtered against the file's uncommented import lines.

**Two adjacent JSDoc blocks** left the original strategy list detached from
`resolveReferences`.

Four tests, each reproducing the reviewer's case, all four verified to fail
against the previous commit. Full suite 4271 passed; the one failure is
`sync-rebuild-convergence`'s 5s timeout, which passes in isolation and has been
flaky on this box all day. Corpus delta unchanged: coriolis-local 2605 -> 2567,
the same 3 genuine edges recovered, 0 invented, 0 lost.
The suffix fallback accepted any indexed path ending in `/<module>.py` as long
as exactly one did. Uniqueness is not evidence — the same mistake this file's
header already records for `examples/services/client.py`, reintroduced one
layer out.

The imports that reach the fallback are overwhelmingly NOT project modules: on
one real corpus 81 of 172 absolute import sources were stdlib. Each claimed any
project file that merely shared its name, and the edge carried `provenance:
null`, so nothing downstream could discount it. Reproduced through the built
binary: `from logging import Logger` bound to a project's own
`app/utils/logging.py`; `from redis.client import Redis` bound to a test double
under `tests/fixtures/`; `from config.settings import Real` bound to a
deployment tree's `deploy/config/settings.py`.

Two gates, both python's actual import rule rather than a heuristic:

- `PYTHON_STDLIB_TOP` — a module python itself provides is never the project
  file of that name.
- `isPythonPackageRoot` — the anchor must not itself carry an `__init__.py`
  (python puts the top-level package directly on `sys.path`, so a directory
  inside a package is never a root), and every intermediate segment of the
  module path must be a real package. PEP 420 namespace packages are refused
  deliberately: a silent miss is the correct outcome under this feature's gate.

The `src layout` fixture had no `__init__.py`, so it was pinning a layout python
could not import; it also had no distractor, so it would have passed under a
bare-name fallback. Both fixed.

Each of the three new tests fails against the parent commit.
The comment filter added last round was a hand-written, line-anchored regex
run over comment-stripped lines. Three problems, all of them the filter
disagreeing with the thing it filters:

- **Stricter than the extractor.** `extractPythonImports` matches `from X
  import Y` anywhere on a line; the filter required column 0. So `import os;
  from kinds import Real` had its binding dropped and stopped resolving — a
  straight recall regression against the previous commit.
- **Comments only, not strings.** A docstring's usage example still landed in
  the live set. At the top level the agreement rule absorbed that (by refusing
  both bindings), which is why it looked closed.
- **Unreadable and empty were the same value.** The `size === 0` escape was
  meant for a file that could not be read, but it also fired for a file whose
  only import-looking line WAS the comment — leaving that comment as the sole
  binding, unopposed, deciding an edge.

Replaced with `livePythonImportSources`: strip comments and single- and
triple-quoted strings, then run the extractor's own two patterns over what is
left, and return null (not an empty set) when the file cannot be read.

Behavior change worth naming: `an import written in a DOCSTRING does not decide
the module` now resolves to the real import instead of to nothing. The
guarantee is unchanged — the decoy never decides — but a docstring is now
distinguishable from code rather than merely disagreeing with it, so the real
binding survives.

Three of the four new tests fail against the parent commit; the fourth is
labelled in-place as a guard on the un-anchoring, not as a fix.
The `__init__.py` re-export hop read `getImportMappings` raw, forty lines below
the text filter the previous commit added for the importing file. This is the
defect class this feature keeps reproducing: a rule enforced at one site and not
the sibling one level down.

Here it was the worse of the two sites. The top level has an agreement rule, so
a bogus binding at worst causes a refusal; the hop has none, so a commented-out
or docstring import was the SOLE binding and DECIDED an edge. Reproduced through
the built binary: a `pkg/__init__.py` carrying `# from decoy import Client` above
a real `from pkg.core import *` produced `box.py::Box::go -> decoy.py::Client::run`
with `provenance: null`.

Three rules brought across, all of them already settled at the top level:

- the live-source filter, so comments and docstrings are not bindings;
- the ambiguity key is source AND exported name, not source alone, which had
  called two different re-exports of one name unambiguous;
- the hopped-to class is looked up under its EXPORTED name, so
  `from .core import Legacy as Client` finds `Legacy`.

Both new tests fail against the parent commit. The existing re-export test had
no distractor and would have passed under a bare-name fallback; it has one now.
The supertype walk keyed on `cls.name`, so `getSupertypes` unioned the bases of
every python class sharing that name and a class inheriting nothing appeared to
inherit its namesake's. The fix for that refused the walk whenever the name was
not unique project-wide — the right question answered with the wrong evidence.

`cls` is already ONE node in ONE file; `pythonTypeClass` pinned it. A `Real` in
a test tree says nothing about it, and vetoing on that lost every inherited edge
for the commonest class names in the codebases this feature exists for — a
`Client`, `Config`, `Service` or `Manager` defined twice took its whole subtree
with it. Reproduced: `class Real(Base)` with an unrelated `unrelated/real.py`
present resolved to nothing, and resolved again as soon as the namesake was
renamed.

Adds `ResolutionContext.getSupertypesOfNode(nodeId, language)` — the `extends` /
`implements` edges out of that exact node — and walks nodes rather than names.
The one ambiguity that genuinely remains, resolving a supertype NAME back to a
class, is still refused exactly as before, which is what keeps the namesake test
passing.

The new test fails against the parent commit.
…scanning

`getAllFiles()` is an uncached `SELECT path FROM files`, and the suffix fallback
scanned the whole result — three `endsWith` per path — for every python import
that reached it. `import-resolver.ts` already documents this exact mistake being
removed once, for lua requires, and supplies the fix: a `WeakMap` per-context
basename index.

This case was worse than the lua one. The imports reaching the fallback mostly
match NOTHING (third-party packages), so the `found.length > 1` early exit never
fires and each pays the full scan — twice, because the resolver retries in a
second pass.

Measured on a synthetic 4,641-file python project, 600 consumers importing a
third-party module, 3 runs each, through the built binary:

    parent (a3c412f)   32.0 / 34.8 / 32.4 s
    with the index      6.0 /  5.7 /  6.3 s

Buckets preserve `getAllFiles()` order, so the candidate list filters to exactly
the array the full scan produced — same matches, same winner, and the existing
suite is the proof of that.

Also memoizes `livePythonImportSources` per (context, file). `getFileLines` is
LRU-cached but the strip-and-scan was not, so it re-ran for every
`self.attr.method()` ref in a file — the O(refs x file length) shape
`resolution/types.ts` records as ~20% of index CPU on a java-heavy repo once
before.

No behavior change, so no new test: the 35 existing cases pin the results and
the numbers above are the claim.
@inth3shadows

Copy link
Copy Markdown
Author

This is the AST change I said was needed but missing. My last comment ended with the 3 genuine edges lost to the gate, and: "doing it properly needs the AST rather than a line scan, so it is a separate change. Until it exists this shape is a silent miss." Pushed 15 commits; it exists now, and on the 249-file project the count goes back the other way — the same 3 edges (self._capture.{start,stop,chunks}()) recovered, with none of the 38 fabrications returning.

It reads the four shapes python actually uses to name an attribute's type — constructor assignment, typed __init__ parameter, class-level annotation, Optional / | None unwrapping — out of the tree-sitter AST, and it fixes each defect review killed the regex version for: a docstring type no longer beats the real assignment, a nested class's __init__ no longer donates to the outer class, and self.session = requests.Session() stays external.

Five review rounds hit this. The last one reproduced two defects through the built binary, both of which invented edges:

  • A unique path suffix is not an importable module. The module-file fallback accepted any indexed path ending in /<module>.py so long as exactly one did. But the imports reaching it are overwhelmingly not project modules — 81 of 172 absolute import sources on my corpus were stdlib — so from logging import Logger bound to a project's own app/utils/logging.py, and from redis.client import Redis bound to a test double under tests/fixtures/. Now gated on a stdlib denylist plus a real package-root check (the anchor must not itself carry an __init__.py; every intermediate segment must be a package). PEP 420 namespace packages are refused deliberately — under this gate a silent miss is the right answer.
  • A commented-out import decided an edge. The __init__.py re-export hop read import mappings raw, forty lines below the text filter added for the importing file. The top level has an agreement rule that absorbs a bogus binding by refusing both; the hop has none, so the comment was the sole binding and picked the file.

That is the same failure twice — a rule applied at one site and not the sibling one level down — which is the trap worth naming for anyone working in this file.

Also here: import text is classified properly now (comments and single/triple-quoted strings stripped, then the extractor's own patterns re-run over what's left, so the filter can never be stricter than the thing it filters); the supertype walk asks the pinned receiver node for its bases instead of its name, which was costing every inherited edge in any repo with a Client or Config defined twice; and the per-import file scan is indexed per resolution context, following the existing luaFileBasenameIndexes pattern.

Verification:

  • npm test — 242 files, 4281 passed / 11 skipped.
  • Corpus re-index byte-identical to the pre-fix build: same 2683 python call edges, none added, none lost. That corpus contains none of the layouts these defects need, which is why each fix ships with a purpose-built fixture instead of leaning on it.
  • Every new test verified failing against its own parent commit — and per the distractor trap I flagged earlier, each carries a second project symbol of the same method name. One test is labelled in-place as a guard rather than a fix, because it passes on both arms.
  • Indexing cost on a synthetic 4,641-file python project: 32.0 / 34.8 / 32.4 s before, 6.0 / 5.7 / 6.3 s after.

__tests__/python-attr-type-cli.test.ts drives the built binary on purpose: grammars load only inside parse workers, so an earlier version of this feature was inert in a real build while the whole suite stayed green over dead code.

The split offer stands unchanged — if you'd rather take only the resolver half, take b34d85f with e799cd0.

@colbymchenry

Copy link
Copy Markdown
Owner

Heads-up: the false-negative half of #1681 (isBuiltInOrExternal escape for project-module receivers) is landing as a focused extract in #1749 — FP half already on main via #1748. This PR still carries the broader attr-type / branch-guard / kernel work; please rebase or drop the overlapping isPythonProjectModule / resolution.test.ts bits once #1749 merges to avoid conflict.

The maintainer asked on colbymchenry#1704 to drop the bits that were landing separately
once colbymchenry#1749 merged. Both named PRs are on main now (colbymchenry#1748 bb1d309, colbymchenry#1749
edcd36e), along with three more python-resolution changes the same morning,
so this reconciles against all of them rather than only the two.

Six conflicts, resolved by what each side is now the authority on:

- src/resolution/index.ts — take main in all three hunks. Its
  isPythonProjectModule is our own, byte-identical apart from one comment,
  so ours is deleted rather than merged; git had auto-merged both copies
  into a duplicate definition. Its isKnownClass is the stricter colbymchenry#1776
  version, and the collection-binding filter now short-circuits before the
  class escape.

- __tests__/resolution.test.ts — take main. Our +54 was subsumed by its
  +347; the delta of this branch against main for that file is now zero,
  which is exactly what was asked for.

- src/extraction/tree-sitter.ts and codegraph-kernel/src/python.rs — keep
  both arms, main's first. colbymchenry#1748 claims the call-chain receiver
  (`d.setdefault(k, []).append(v)`) and encodes it as `<inner>().<method>`;
  ours stays the catch-all beneath it for the shapes that arm does not
  match, the attribute chain (`self.data.append`) and the subscript
  (`d[k].append`). The comments on both arms said they covered the
  call-chain shape and no longer do, so they are corrected in place.

- __tests__/fixtures/kernel-parity/torture.py — union. bucket_chains keeps
  the call receivers; fabrication_shapes keeps a real attribute chain and a
  subscript, since its former call-chain line is now bucket_chains' job.

What this branch still carries that main does not: the python attribute-type
inference read from the AST, the chained-receiver gate, the kernel mirror for
non-call receivers, the branch-guard changes and the module-suffix index.

Verified: tsc --noEmit clean; npm test 4226 passed / 238 files. The three
remaining failures (object-literal-methods, two in ui-steps-api) reproduce on
a clean upstream/main worktree at ee83636 and are not from this merge; a
fourth, ui-server-api's "under 100 ms", is a wall-clock assertion that passed
and then failed at 113ms in the same command.

NOT verified: the Rust arm. There is no cargo on this machine, so every
kernel-*-parity suite describe.skipIf's itself and the python.rs change is
unexercised.
…ipper

livePythonImportSources hand-rolled its own comment and string scanner. main
now carries strip-comments.ts (colbymchenry#1746) with a python arm that does the same
job, and this file already composes it one function above for the TS
receiver-type read, so the bespoke scanner is the odd one out — and a bespoke
one drifts from the extractor it exists to mirror.

blankStringContents(stripCommentsForRegex(source, 'python')) blanks comments,
triple-quoted docstrings and single-line string contents while preserving
offsets, so the line anchoring the `^import` pattern relies on survives. The
extractor's own two patterns are unchanged, which is the property that
matters: a filter stricter than the thing it filters silently drops real
bindings, and an earlier line-anchored version did exactly that.

Checked against the cases this path exists for before swapping — a
commented-out import, a docstring usage example, a single-line string holding
import text, an inline comment after a real import, a triple quote opened and
closed on one line, and two shapes that could trip the helper's JS regex-
literal heuristic (`(a)/b`, an f-string with a slash). All eight give the same
answer as the scanner they replace, including the two the hand-written version
got wrong.

The null-versus-empty distinction is kept: null means the file could not be
read, empty means it was read and every import-looking line was comment or
string — the case where every binding must be refused.
@inth3shadows

Copy link
Copy Markdown
Author

Done — merged main in at ee83636 and dropped the overlapping bits. The PR is MERGEABLE again.

Dropped, because they landed as your extracts

Reconciled rather than dropped

  • fix(extraction): never fabricate an edge from a call-result receiver #1748 / the extractor half. Both arms kept, yours first. <inner>().<method> claims the call-chain receiver; this branch's arm stays the catch-all beneath it for the two shapes that arm does not match — the attribute chain (self.data.append) and the subscript (d[k].append). Same ordering mirrored in codegraph-kernel/src/python.rs. Both comments here claimed to cover the call-chain shape and no longer do, so they are corrected in place. torture.py keeps both fixtures.
  • fix(resolution): read a quoted Python annotation as a receiver type #1770 / quoted annotations. Nothing to do and nothing duplicated — that fix is in buildLocalReceiverTypePatterns (local variables), while the attribute-type path here goes through pythonAnnotationType, which already strips a forward reference's quotes. Different receivers, both covered.
  • fix(resolution): a binding in a module that exports nothing is not a cross-file candidate (#1719) #1746 / strip-comments.ts. livePythonImportSources had hand-rolled its own comment and string scanner; it now composes blankStringContents(stripCommentsForRegex(source, 'python')), the same way the TS receiver-type read one function above already does. Checked against the eight cases that path exists for before swapping — a commented-out import, a docstring usage example, a single-line string holding import text, an inline comment after a real import, a triple quote opened and closed on one line, and two shapes that could trip the helper's JS regex-literal heuristic ((a)/b, an f-string with a slash). All eight agree with the scanner they replace, including the two the hand-written one got wrong.

Verification

Linux, Node 22.23.2, with the native kernel built and staged — so the kernel-*-parity suites actually run instead of skipping themselves:

tsc --noEmit                       clean
kernel-tsjs-parity                 17/17     (this is what puts torture.py through the .node)
kernel-python-call-fabrication      1/1
npm test                           254 files, 4400 passed, 8 failed, 10 skipped

All 8 failures are pre-existing on main, checked rather than assumed:

  • object-literal-methods and two in ui-steps-api — reproduce on a clean detached upstream/main worktree.
  • ui-server-api's "answers in under 100 ms" — a wall-clock assertion; passed and then failed at 113.06ms in the same command on a loaded box.
  • all four kernel-dart-parity cases — reproduce on a clean upstream/main worktree with its own freshly built kernel. The arms disagree on km / report line numbers in TortureCtors.dart, so the kernel and wasm paths extract that file differently on main today. Unrelated to this PR (the kernel diff here is python.rs only), but worth someone's attention since published bundles run the kernel arm — happy to open it as an issue if it is not already tracked.

Measured what this branch's python work is worth, on repos neither of us chose — and ablated it so the number is attributable to one hunk rather than to "the branch".

Method. Three python codebases (318 .py files) indexed twice, both arms built from source on Linux/Node 22.23.2 with the native kernel staged (python is default-routed, so python.rs is live — a wasm-only run would measure the wrong thing). Upstream baseline 2f8cce5. Diffed the calls edges where source and target are both python.

The trade

corpus       upstream-only   ours-only
kb                      79           6
lodestone                8           6
testgraph                3           2

82 of those 90 are fabricated. I read every call site rather than inferring from the name:

config.py::load          -> reads.py::get         site: raw.get("machine", {})      a dict
propose.py::_merge_…     -> ledger.py::append     site: merged[key].append(r)       a list
                            …and propose.py never imports ledger
worker.py::_llm_verdict  -> test_llm_shapes::create  site: client.messages.create(  the SDK
test_tunnel.py::start    -> test_tunnel.py::start    site: self._thread.start()     a Thread
test_tunnel.py::stop     -> db.py::close             site: a connection

Production code bound to a symbol in a test file, and a function that appears to call itself. merged[key].append(r) is the subscript receiver; (ts if cond else other).append(rel) in the same repo is a parenthesised one.

The 8 that are real, stated plainly. All one shape — idx.lexical.query(...) where idx = Index(...) is an untyped local, so the receiver is two hops from nothing and the gate refuses. Against that, the branch gains 5 real edges upstream never had: self.vector.query resolves because self.vector = VectorIndex(...) and the attribute-type reader reads that assignment. Same file, same method name — self.lexical.query is kept, idx.lexical.query is dropped. That is the intended line, not an accident of tuning.

Which hunk earns it

Rebuilt the branch twice, removing one piece at a time.

corpus       upstream   V1: no extractor arm   V2: arm, no gate   this branch
kb               3595                   3595               3530          3522
lodestone         839                    839                849           837
testgraph         466                    466                465           465
  • V1 — remove the extractor arm (the python catch-all in extractCall, plus its python.rs mirror). The edge set becomes byte-identical to upstream on all three corpora — zero diff lines, not merely the same count. So the arm is the whole mechanism: every one of the 82 fabrications, and all 14 recovered edges, trace to it. Keeping the receiver's source text as a qualifier is what stops append from exact-matching a project function named append.
  • V2 — keep the arm, remove the gate. Worse than either side: 8 fabrications return and 20 new ones appear, putting lodestone at 849 — above upstream's own 839. The inventions show why: idx.lexical.query(...) binds to vector.py::query, a different module that merely also has a query. Renaming the ref without also refusing to resolve it by bare name is the half-bridged state, and it measures worse than not doing it at all.

So the two halves are one mechanism, not two independent changes — and the attribute-type inference is what makes the strict half affordable, since it is the only thing that turns a refused self.x.method() back into a real edge.

Where it is not better

Cross-copy binding. My kb and lodestone corpora each contain two sibling copies of one project, and both arms mis-bind across them — upstream 664 and 113, this branch 668 and 115. It adds 4 and 2. Neither side handles the duplicate-copy ambiguity; I am not claiming this branch fixes it, and the corpus is unusually hostile there.

Reproducible end to end if you want the fixtures — the whole thing is codegraph init twice and a sqlite3 diff over the calls edges.

Three commits landed since the last merge at ee83636, one of them colbymchenry#1785 —
the aliased-import fix, which patches BOTH join sites in import-resolver.ts
(resolvePythonModuleMember and resolveModuleImportToFile), so the file->file
imports edge for `from pkg import mod as alias` is covered too.

Only CHANGELOG conflicted; both sides append at the end of [Unreleased], so
main's colbymchenry#1626 bullet keeps its place and this branch's "Language and framework
accuracy" sub-heading follows it.

tsc --noEmit clean. Full suite 255 files / 4416 passed; the same 8 failures as
before the merge, all reproduced on a clean upstream/main worktree (4 dart
kernel-parity, object-literal-methods, 2 ui-steps-api) or a wall-clock flake
(ui-server-api's "under 100 ms"). Python and kernel suites specifically:
284/284 across resolution, the six python suites, kernel-tsjs-parity and
kernel-python-call-fabrication.
# Conflicts:
#	src/resolution/index.ts
#	src/resolution/name-matcher.ts
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.

3 participants