fix(resolution): constrain what an inheritance or import reference may resolve to (#1536, #1537) - #1538
Open
ctype-lab wants to merge 3 commits into
Open
fix(resolution): constrain what an inheritance or import reference may resolve to (#1536, #1537)#1538ctype-lab wants to merge 3 commits into
ctype-lab wants to merge 3 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.