Skip to content

fix(resolution): constrain what an inheritance or import reference may resolve to (#1536, #1537) - #1538

Open
ctype-lab wants to merge 3 commits into
colbymchenry:mainfrom
ctype-lab:fix/reference-target-kind
Open

fix(resolution): constrain what an inheritance or import reference may resolve to (#1536, #1537)#1538
ctype-lab wants to merge 3 commits into
colbymchenry:mainfrom
ctype-lab:fix/reference-target-kind

Conversation

@ctype-lab

Copy link
Copy Markdown
Contributor

Fixes #1536. Fixes #1537.

Summary

Two reference kinds resolve to targets they could never legally name:

  • impl Error for MapperError {}, where Error came from use std::error::Error,
    binds to MapperError's own enum variant — an implementation relationship
    absent from the source.
  • import * as path from 'node:path' binds to some class's path property.

Both come from one place: matchByExactName applies no kind constraint to its
candidate pool, and scoreCandidate treats node kind as a bonus rather than a
requirement — awarding none at all for extends/implements/imports. With one
same-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.

implements target before kind filter only this PR
trait (correct) 52 52 52
enum_member 7 0 0
type_alias 4 11 0

The same 11 references simply landed on an unrelated local pub type Error = …
instead — and that is worse for a consumer, because type_alias is a legal
supertype in TypeScript (class X implements SomeAliasedObjectType) and so cannot be
rejected downstream, whereas enum_member could.

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

matchByExactName restricts its candidate pool by reference kind, so a legitimate
supertype wins instead of merely having its false rival's edge deleted:

.filter((n) => !isInheritanceRef(ref) || SUPERTYPE_TARGET_KINDS.has(n.kind))
.filter((n) => ref.referenceKind !== 'imports' || isImportableKind(n.kind));

SUPERTYPE_TARGET_KINDS is deliberately wide — type_alias (TS
implements SomeAliasedObjectType), component, and module/namespace, because
whole languages inherit from one: Ruby include Trackable targets a module,
Erlang -behaviour(gen_server) targets the behaviour module, which Erlang extraction
indexes as a namespace. Both were caught by existing tests during development, not
by inspection.

2. One gate at the resolveOne seam

Filtering inside the name-matcher covers matchByExactName only; the framework,
import, chain and CFML-component-path strategies all bypass it. resolveOne is
wrapped once so every strategy passes the same check:

resolveOne(ref) {
  return this.gateTargetKind(this.resolveOneInner(ref), ref);
}

It only ever removes an edge, never adds one. A dropped reference stays in
unresolved_refs as failed — the honest record for a supertype the repo does not
contain, and what the existing lifecycle already expects.

3. Locality, only where the oracle cannot be wrong

  • Rust — a use path rooted at a standard-library crate (std/core/alloc/
    proc_macro), which by definition ships outside any repository. Guarded against
    2015-edition shadowing by requiring the path not resolve to a real module file.
  • ES modulesisExternalImport, which already understands tsconfig path
    aliases 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 dedicated
FQN/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};) leaves crate::ports::CacheStore with no
src/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)

isExternalImport listed typescript/tsx/javascript/jsx/arkts, so for the three SFC
languages it fell through every branch and answered "not external" for
import { Foo } from 'some-npm-pkg' — even though extractImportMappings already
routes all three through the same extractJSImports. The classifier disagreed with
the 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):

before after
nodes 2,669 2,669 (unchanged)
edges 9,114 9,075 (−39)
implements/extendstrait 50 50 (none lost)
implementsenum_member 7 0
implementstype_alias 4 0
importsmethod 28 0
synthesized (provenance='heuristic') 159 159 (unchanged)

−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:

  • 1 extends recording a Scala class Boot as extending a function named App
    (the real App is the out-of-repo Scala library trait)
  • 19 importsmethod and 4 importsproperty, every one a coincidence:
    Walker::join ×15, Telemetry::events ×2, FetchCall::url ×2,
    Tables::default, Widget::fmt, Documented::target_cb,
    TortureService::helper
  • 3 references fall through to a constant of that name instead of a member

The SFC commit changes no edge here (this repository has no .svelte/.vue/
.astro sources); it is covered by its own tests.

Tests

__tests__/reference-target-kind.test.ts, 12 cases, pinning both directions:

  • the Rust enum-variant repro produces no edge and leaves a failed ref
  • the false edge does not relocate onto a same-named type_alias
  • an in-repo trait still resolves even when a same-named enum_member exists
  • a trait reached through a re-exported sibling-crate module still resolves
    (the reverted-generalization guard)
  • class extends class, class implements interface, TS object-type alias
  • import → member produces nothing
  • Svelte, Vue and Astro npm supertypes dropped; a relative-path SFC import kept

Full 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 parity
    risk
    ; NODE_KINDS/EDGE_KINDS and the kernel wire layout are untouched.
  • The change is one-directional: it can only remove edges, never add them, which
    matches the "silent beats wrong" policy already stated for out-of-repo supertypes
    in the CFML component-path matcher.
  • Node counts are unchanged in every measurement, so explore budgets and
    candidate-set sizes are unaffected.
  • Re-index required to clear existing bad edges; noted in the CHANGELOG entries.

Not in this PR

An agent A/B run (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.

ctype-lab and others added 3 commits August 10, 2026 20:55
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant