fix(resolution): constrain what an inheritance or import reference may resolve to (#1536, #1537) - #1538
fix(resolution): constrain what an inheritance or import reference may resolve to (#1536, #1537)#1538ctype-lab wants to merge 4 commits into
Conversation
An inheritance reference bound to whatever local symbol shared its name.
The name-matcher scores node kind as a bonus, never a filter, and awards
no bonus at all for inheritance refs, so `use std::error::Error;` +
`impl Error for MapperError {}` resolved to the local `MapperError::Error`
VARIANT — an implementation relationship absent from the source.
Two changes, both needed. Filtering by kind alone was measured and it
only RELOCATES the false edge: with enum members excluded, the same 7
refs moved onto an unrelated local `type Error` alias, which is a legal
supertype kind and therefore harder for a consumer to reject.
1. Eligibility before ranking. `matchByExactName` restricts its candidate
pool to kinds that can BE a supertype, so a legitimate trait outranks
a same-named variant instead of merely losing its edge. `resolveOne`
is wrapped by a gate that applies the same set to every other strategy
at one seam — filtering inside the name-matcher would have missed the
framework, import, chain and CFML paths.
2. Locality. A name imported from outside the repository has no in-repo
referent at all, so no candidate is correct. Only oracles that cannot
be wrong are consulted: Rust `use` paths rooted at a stdlib crate, and
`isExternalImport` for ES modules. Generalizing the Rust side to "the
module path doesn't resolve to a file" was tried and reverted — a
crate re-exporting a sibling's modules (`pub use pupil_core::ports;`)
has no directory to walk, and that version deleted 13 real trait
implementations.
Measured on a Rust/Tauri project (2,682 nodes): the 11 false inheritance
edges are gone, all 59 real trait relationships are preserved, and node
count is unchanged. On this repository as a control, the only edge
removed is a class recorded as extending a function. Synthesized-edge
counts are identical in both.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`import * as path from 'node:path'` is unresolvable — the module is external — so the name-matcher fell back to finding any node called `path`, and a common word like path/url/join/get matches a class property or interface method somewhere in almost any repo. Nothing in any supported language lets an import bind to a member that only exists inside a type; you import the type. Same shape as the inheritance gate that precedes it: eligibility applied to the candidate pool before ranking, plus the resolveOne gate as the backstop for every other strategy. On this repository as a control: 19 imports pointing at methods and 4 at properties are gone (all of them coincidences — `Walker::join`, `Telemetry::events`), 3 refs now find the module constant they actually name, node count unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`isExternalImport` had a TS/JS branch listing typescript/tsx/javascript/jsx/
arkts, so for Svelte, Vue and Astro it fell through every branch and returned
false — "not external" — for `import { Foo } from 'some-npm-pkg'`.
An SFC imports inside its `<script>` block (Astro: the `---` frontmatter) with
ordinary ES module syntax; `extractImportMappings` already routes all three
through the same `extractJSImports`. So the classifier disagreed with the
extractor about what those imports are.
Effect on the preceding commit: its locality check asks `isExternalImport`, so
it silently did nothing for SFCs. A class in a `.svelte`/`.vue`/`.astro` file
implementing a type imported from an npm package still bound to whatever local
class shared that name — verified against this branch before the fix, all three
languages.
The language set is now one constant used by both the classifier and the
locality check, so they cannot drift apart again. Relative and aliased
specifiers are unaffected: the branch returns "not external" for `./…`,
workspace members, tsconfig alias prefixes, `@/`, `~/` and `src/` exactly as it
does for `.ts`.
No edge changes on this repository as a control.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Verified on a real TypeScript repo (Chrome MV3 extension, 582 files, TS/JS/Vue/markdown, Windows 11, tree-sitter wasm walker, kernel off). Branch: this PR merged onto current Merges onto main with a CHANGELOG-only conflict. On our tree: Also cancels the 19 → 61 growth of the same shape that #1686 causes on this tree once interface members exist, so the two are complementary. |
|
Data point on the overlap with #1686, since the two interact and it affects merge order. #1686 indexes TypeScript interface members, which makes every interface member a fresh candidate for the name-only import resolver — the #1537 shape this PR constrains. I measured on the codegraph repo itself, self-indexed three ways (fresh
Two things worth separating, because they cut against each other: This PR zeroes them outright — all 492, on a repo where the baseline is already 489. That is the result I care about and it is unambiguous. But on this repo #1686 is barely the cause. I reported on #1686 that these edges went 19 → 61 on a Chrome-extension tree, and read that as #1686 creating them. Self-indexing codegraph shows a baseline of 489 before #1686's members exist at all — most of them are not TS interface members (Rust traits and the like reach the same The practical consequence is unchanged, and if anything simpler: this PR is worth landing on its own account rather than as a mitigation for #1686. It merges cleanly onto |
|
Thank you for the test and explanation. |
|
Hi @colbymchenry. I just wanted to leave a gentle check-in on this PR whenever you have a moment. Conflicts are resolved and all tests pass. Thanks for maintaining this project. |
Fixes #1536. Fixes #1537.
Summary
Two reference kinds resolve to targets they could never legally name:
impl Error for MapperError {}, whereErrorcame fromuse std::error::Error,binds to
MapperError's own enum variant — an implementation relationshipabsent from the source.
import * as path from 'node:path'binds to some class'spathproperty.Both come from one place:
matchByExactNameapplies no kind constraint to itscandidate pool, and
scoreCandidatetreats node kind as a bonus rather than arequirement — awarding none at all for
extends/implements/imports. With onesame-named node in the repo, the single-candidate shortcut adopts it outright at
confidence 0.9, never reaching
findBestMatch.They are one PR because they are one mechanism applied twice. Three commits, each
independently reviewable and green.
Why a kind filter alone would have been pointless
The obvious fix — drop candidates that cannot be a supertype — was implemented and
measured first. It does not remove the false edge. It moves it.
implementstargettrait(correct)enum_membertype_aliasThe same 11 references simply landed on an unrelated local
pub type Error = …instead — and that is worse for a consumer, because
type_aliasis a legalsupertype in TypeScript (
class X implements SomeAliasedObjectType) and so cannot berejected downstream, whereas
enum_membercould.So kind is only half of it. The other half is locality: a name the file imports
from outside the repository has no in-repo referent at all, so no candidate of any
kind is correct.
The fix
1. Eligibility, applied before ranking
matchByExactNamerestricts its candidate pool by reference kind, so a legitimatesupertype wins instead of merely having its false rival's edge deleted:
SUPERTYPE_TARGET_KINDSis deliberately wide —type_alias(TSimplements SomeAliasedObjectType),component, andmodule/namespace, becausewhole languages inherit from one: Ruby
include Trackabletargets amodule,Erlang
-behaviour(gen_server)targets the behaviour module, which Erlang extractionindexes as a
namespace. Both were caught by existing tests during development, notby inspection.
2. One gate at the
resolveOneseamFiltering inside the name-matcher covers
matchByExactNameonly; the framework,import, chain and CFML-component-path strategies all bypass it.
resolveOneiswrapped once so every strategy passes the same check:
It only ever removes an edge, never adds one. A dropped reference stays in
unresolved_refsasfailed— the honest record for a supertype the repo does notcontain, and what the existing lifecycle already expects.
3. Locality, only where the oracle cannot be wrong
usepath rooted at a standard-library crate (std/core/alloc/proc_macro), which by definition ships outside any repository. Guarded against2015-edition shadowing by requiring the path not resolve to a real module file.
isExternalImport, which already understands tsconfig pathaliases and monorepo workspace packages.
Everything else returns "don't know" and resolves exactly as before. JVM and Python
imports notably do not go through
resolveImportPath(they have dedicatedFQN/module matchers), so there is no trustworthy oracle to consult for them, and
asking anyway reported every Java import as external — caught by
frameworks-integration.test.ts.A wider Rust oracle was tried and reverted. Treating "the module path does not
resolve to a file" as proof of out-of-repo deleted 13 real trait implementations
on the reference project: a crate re-exporting a sibling crate's modules
(
pub use pupil_core::{ports, domain};) leavescrate::ports::CacheStorewith nosrc/ports/directory to walk, yet it is entirely in-repo. That case is now a test,so the generalization cannot be reintroduced silently.
4. Svelte / Vue / Astro (third commit)
isExternalImportlisted typescript/tsx/javascript/jsx/arkts, so for the three SFClanguages it fell through every branch and answered "not external" for
import { Foo } from 'some-npm-pkg'— even thoughextractImportMappingsalreadyroutes all three through the same
extractJSImports. The classifier disagreed withthe extractor about what those imports are, and commit 1's locality check therefore
did nothing for SFCs.
The language set is now one constant used by both, so they cannot drift apart again.
Relative and aliased specifiers are unaffected — the branch still answers "not
external" for
./…, workspace members, tsconfig alias prefixes,@/,~/,src/.Measurements
Both against
main@c6aaa20, re-indexed from scratch.Rust/TypeScript application (Tauri desktop app, 149 files, 2,669 nodes):
implements/extends→traitimplements→enum_memberimplements→type_aliasimports→methodprovenance='heuristic')−39 = 11 false inheritance edges + 28 false import edges. No correct edge is lost.
This repository as a control (571 files, 12,749 nodes): node count unchanged,
edges 44,983 → 44,962. The complete diff is 24 removals and 3 rebinds:
extendsrecording a Scalaclass Bootas extending a function namedApp(the real
Appis the out-of-repo Scala library trait)imports→methodand 4imports→property, every one a coincidence:Walker::join×15,Telemetry::events×2,FetchCall::url×2,Tables::default,Widget::fmt,Documented::target_cb,TortureService::helperconstantof that name instead of a memberThe SFC commit changes no edge here (this repository has no
.svelte/.vue/.astrosources); it is covered by its own tests.Tests
__tests__/reference-target-kind.test.ts, 12 cases, pinning both directions:failedreftype_aliasenum_memberexists(the reverted-generalization guard)
class extends class,class implements interface, TS object-type aliasimport→ member produces nothingFull suite green: 3,082 passed / 9 skipped, kernel built and parity suites running.
Scope and risk
src/resolution/only. No extraction change, so no kernel mirror and no parityrisk;
NODE_KINDS/EDGE_KINDSand the kernel wire layout are untouched.matches the "silent beats wrong" policy already stated for out-of-repo supertypes
in the CFML component-path matcher.
candidate-set sizes are unaffected.
Not in this PR
An
agent A/Brun (scripts/agent-eval/ab-new-vs-baseline.sh) was not performed.The deterministic gates were: full suite, kernel parity, node-explosion check,
synthesized-edge counts, and a control-repo diff. Happy to run the A/B if you want it
before merge.