Skip to content

resolution: an out-of-repo supertype binds to whatever local symbol shares its name — and filtering by node kind alone only moves the false edge #1536

Description

@ctype-lab

When impl Error for MapperError {} names a supertype that lives outside the
repository, the reference does not stay unresolved. It resolves to whichever local
symbol happens to be called Error — in the repro below, MapperError's own
enum variant — and the graph then asserts an implementation relationship that
does not exist in the source.

Everything downstream believes it: codegraph_explore, impact radius, "who
implements this trait", and any consumer rendering a class diagram all draw the
fabricated realization.

Environment

  • codegraph: 1.5.0 (npm), reproduced on main @ c6aaa20
  • Backend: node:sqlite (WAL)
  • Platform: Linux x64
  • Extraction path is irrelevant — this lives in src/resolution/, which has no
    kernel counterpart, so the native kernel and the wasm/TS walker behave identically

Repro

The trigger is a bare name brought in by use. This matters, because the
fully-qualified spelling does not reproduce.

src/lib.rs:

use std::error::Error;

pub enum MapperError {
    Error,
    Missing,
}

impl Error for MapperError {}
codegraph init .
sqlite3 .codegraph/codegraph.db "
  SELECT e.kind, s.kind, s.name, t.kind, t.name,
         json_extract(e.metadata,'$.confidence'),
         json_extract(e.metadata,'$.resolvedBy')
  FROM edges e JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
  WHERE e.kind IN ('extends','implements');"

Expected

No edge. std::error::Error is not in the repository, so there is nothing to point
at; the reference belongs in unresolved_refs as failed.

Actual

implements | enum MapperError | enum_member Error | 0.9 | exact-match

Does NOT reproduce

impl std::error::Error for MapperError {}   // no `use` — stays unresolved, correct
0 inheritance edges
std::error::Error | implements | failed

resolveOne's fast pre-filter looks the whole dotted string up as a symbol name,
misses, and drops the ref before the name-matcher runs. Only the bare-name form gets
that far. Worth stating explicitly, because it makes the bug look absent when probed
the obvious way.

Also reproduces in Svelte / Vue / Astro

An SFC imports inside its <script> block (Astro: the --- frontmatter) with
ordinary ES module syntax, so the same shape applies:

src/models.ts

export class Serializable { a = 1; }

src/Box.svelte (identical in .vue and .astro)

<script lang="ts">
import { Serializable } from 'some-npm-pkg';

export class SfcBox implements Serializable { n = 1; }
</script>
<div>hi</div>

Actual, all three languages:

implements | class AstroBox @src/Box.astro  | class Serializable @src/models.ts
implements | class SfcBox   @src/Box.svelte | class Serializable @src/models.ts
implements | class VueBox   @src/Box.vue    | class Serializable @src/models.ts

Note when reproducing this one: the decoy has to be a single same-named local
symbol. With two competing decoys (an enum_member and a type_alias)
findBestMatch declines and no edge forms, which makes the bug look absent.

Impact (measured)

A 2,669-node Rust/TypeScript application (Tauri desktop app, 149 files):

target kind edges correct?
trait 43 implements + 7 extends yes
enum_member 7 no
type_alias 4 no

11 of 61 inheritance edges (18%) are fabricated. All 11 originate from
use std::error::Error; + impl Error for … across 11 different error types, and
every one lands on a different file's Error symbol.

Cause

Two places in src/resolution/name-matcher.ts, neither of which constrains kind:

  1. matchByExactName builds its candidate pool from getNodesByName and filters by
    language gate, kind !== 'import', and lexical reachability. No kind filter.
  2. scoreCandidate treats node kind as a bonus, never a constraint — and awards no
    bonus at all for extends/implements; only calls, instantiates and
    decorates have one.

With exactly one same-named node in the repo, the single-candidate shortcut adopts it
unconditionally at confidence 0.9 without ever calling findBestMatch. With several,
findBestMatch ranks by path proximity, where an enum variant competes on equal
footing with a trait.

The part that makes the obvious fix useless

Filtering candidates by kind does not remove the false edge — it moves it.

Restricting extends/implements candidates to kinds that can be a supertype (so
enum_member is no longer eligible), measured on the same fixture:

target kind before after kind filter alone
enum_member 7 0
type_alias 4 11
trait (correct) 50 50

The same 11 false edges, now pointing at an unrelated local pub type Error = ….
And it is worse for a consumer: type_alias is a legal supertype in TypeScript
(class X implements SomeAliasedObjectType), so it cannot be rejected by kind
downstream, whereas enum_member could.

The second missing constraint 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.

I have a fix ready and will open a PR referencing this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions