mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-09 09:33:37 +08:00
* fix(resolution): gate extends/implements to real supertypes
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>
* fix(resolution): an import never resolves to a member of a type
`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>
* fix(resolution): classify SFC script imports as ES module specifiers
`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>
---------
Co-authored-by: ctype_lab <cksgud1226@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
/**
|
||
* Reference Resolution Types
|
||
*
|
||
* Types for the reference resolution system.
|
||
*/
|
||
|
||
import { EdgeKind, Language, Node, ReferenceKind } from '../types';
|
||
|
||
/**
|
||
* An unresolved reference from extraction
|
||
*/
|
||
export interface UnresolvedRef {
|
||
/** ID of the source node containing the reference */
|
||
fromNodeId: string;
|
||
/** The name being referenced */
|
||
referenceName: string;
|
||
/** Type of reference */
|
||
referenceKind: ReferenceKind;
|
||
/** Line where reference occurs */
|
||
line: number;
|
||
/** Column where reference occurs */
|
||
column: number;
|
||
/** File path where reference occurs */
|
||
filePath: string;
|
||
/** Language of the source file */
|
||
language: Language;
|
||
/** Possible qualified names it might resolve to */
|
||
candidates?: string[];
|
||
/** `unresolved_refs.id` when loaded from the database — post-pass cleanup
|
||
* targets exactly this row instead of every same-key sibling (#1269). */
|
||
rowId?: number;
|
||
}
|
||
|
||
/**
|
||
* A resolved reference
|
||
*/
|
||
export interface ResolvedRef {
|
||
/** Original unresolved reference */
|
||
original: UnresolvedRef;
|
||
/** ID of the target node */
|
||
targetNodeId: string;
|
||
/** Confidence score (0-1) */
|
||
confidence: number;
|
||
/** How it was resolved */
|
||
resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref';
|
||
/**
|
||
* Edge kind the edge should carry when it is NOT the ref's own kind — a
|
||
* framework that turns a `calls` ref into a `navigates` edge, for example.
|
||
* The original kind is still recorded on the edge as `metadata.refKind`, so
|
||
* re-resolution after a target is removed reconstructs the ref faithfully.
|
||
*/
|
||
edgeKind?: EdgeKind;
|
||
/** Extra metadata the strategy wants persisted on the edge (`href`, …). */
|
||
metadata?: Record<string, unknown>;
|
||
/**
|
||
* The OTHER targets, when one reference names several.
|
||
*
|
||
* A navigation whose destination is a conditional reaches every arm —
|
||
* `!isAdmin ? keyword ? '/search/…' : '/page/…' : '/admin/…'` is one call
|
||
* and three screens — and drawing only the first would hide two places the
|
||
* code goes. `createEdges` fans these out into an edge apiece, sharing this
|
||
* resolution's kind and confidence; each carries its own metadata.
|
||
*
|
||
* The reference itself still resolves ONCE, so the resolution pipeline's
|
||
* bookkeeping — cleanup by row id, counts, re-resolution — is unchanged.
|
||
*/
|
||
alsoTargets?: { targetNodeId: string; metadata?: Record<string, unknown> }[];
|
||
}
|
||
|
||
/**
|
||
* Result of resolution attempt
|
||
*/
|
||
export interface ResolutionResult {
|
||
/** Successfully resolved references */
|
||
resolved: ResolvedRef[];
|
||
/** References that couldn't be resolved */
|
||
unresolved: UnresolvedRef[];
|
||
/** Statistics */
|
||
stats: {
|
||
total: number;
|
||
resolved: number;
|
||
unresolved: number;
|
||
byMethod: Record<string, number>;
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Context for resolution - provides access to the graph
|
||
*/
|
||
export interface ResolutionContext {
|
||
/** Get all nodes in a file */
|
||
getNodesInFile(filePath: string): Node[];
|
||
/** Get all nodes by name */
|
||
getNodesByName(name: string): Node[];
|
||
/** Get all nodes by qualified name */
|
||
getNodesByQualifiedName(qualifiedName: string): Node[];
|
||
/** Get all nodes of a kind */
|
||
getNodesByKind(kind: Node['kind']): Node[];
|
||
/**
|
||
* Stream nodes of a kind one at a time instead of materializing (and, unlike
|
||
* `getNodesByKind`, without populating the resolver's per-kind array cache).
|
||
* For unbounded kinds (`function`, `method`, `struct`) on a symbol-dense
|
||
* project the full array is gigabytes — the dynamic-edge synthesizers must
|
||
* use this so their memory stays O(1) in node count (#610, #1212). Optional
|
||
* so minimal test contexts compile; callers fall back to getNodesByKind.
|
||
*/
|
||
iterateNodesByKind?(kind: Node['kind']): IterableIterator<Node>;
|
||
/** Check if a file exists */
|
||
fileExists(filePath: string): boolean;
|
||
/** Read file content */
|
||
readFile(filePath: string): string | null;
|
||
/**
|
||
* `readFile(filePath)` split into lines, LRU-cached per file. Receiver-type
|
||
* inference scans source lines for EVERY `receiver.method()` ref; splitting
|
||
* the whole file per ref made that O(refs-in-file × file-size) — ~20% of
|
||
* total index CPU on a Java-heavy repo and a driver of the #1122 watchdog
|
||
* kill on large ones. Optional so external/test contexts compile without it;
|
||
* callers fall back to splitting `readFile` themselves.
|
||
*/
|
||
getFileLines?(filePath: string): string[] | null;
|
||
/**
|
||
* The method-definition nodes matching `typeName::methodName` in `language` —
|
||
* exactly `resolveMethodOnType`'s kind/language/qualifiedName-suffix filter,
|
||
* LRU-cached per (language, type, method). The uncached path re-fetches every
|
||
* node sharing the METHOD name (unbounded — tens of thousands on a collision-
|
||
* heavy Java repo) and re-scans it per ref, the dominant term in the #1122
|
||
* watchdog kill. Cached entries hold only the small filtered result; per-ref
|
||
* disambiguation (import FQN, call-site file) stays in the caller so a cached
|
||
* entry is valid from any call site. Optional for external/test contexts.
|
||
*/
|
||
getMethodMatches?(typeName: string, methodName: string, language: Language): Node[];
|
||
/** Get project root */
|
||
getProjectRoot(): string;
|
||
/** Get all files */
|
||
getAllFiles(): string[];
|
||
/** Get nodes by lowercase name (O(1) lookup for fuzzy matching) */
|
||
getNodesByLowerName(lowerName: string): Node[];
|
||
/**
|
||
* Direct supertypes of the type named `typeName` (same language): the classes
|
||
* it extends and the interfaces / protocols / traits it implements/conforms to,
|
||
* by simple name. Backed by the resolved `implements`/`extends` edges, so it is
|
||
* EMPTY during the first resolution pass (edges aren't built yet) and populated
|
||
* afterward — the conformance pass uses it to resolve a chained method defined
|
||
* on a supertype the receiver type conforms to (e.g. a protocol-extension
|
||
* method). Optional so external/test contexts compile without it.
|
||
*/
|
||
getSupertypes?(typeName: string, language: Language): string[];
|
||
/**
|
||
* Look up a node by its id. Lets matchers derive the FROM-symbol's
|
||
* enclosing-class scope (Swift implicit-self method scoping, `this.X`
|
||
* member resolution). Optional so external/test contexts compile
|
||
* without it.
|
||
*/
|
||
getNodeById?(id: string): Node | null;
|
||
/** Get cached import mappings for a file */
|
||
getImportMappings(filePath: string, language: Language): ImportMapping[];
|
||
/**
|
||
* Project import-path aliases (tsconfig/jsconfig `paths`). Returns
|
||
* `null` when the project doesn't define any. Cached per resolver
|
||
* instance — safe to call from any resolver code path. Optional so
|
||
* existing test fixtures and external context implementations
|
||
* compile without modification; production resolver implements it.
|
||
*/
|
||
getProjectAliases?(): import('./path-aliases').AliasMap | null;
|
||
/**
|
||
* Go module info from `go.mod` at the project root. Returns `null`
|
||
* when the project has no `go.mod` (non-Go projects, pre-modules
|
||
* Go code, or projects whose modules live in subdirectories). Used
|
||
* by the Go branch of import resolution to distinguish in-module
|
||
* cross-package imports from third-party packages.
|
||
*/
|
||
getGoModule?(): import('./go-module').GoModule | null;
|
||
/**
|
||
* Monorepo workspace member packages, keyed by declared package name.
|
||
* Returns `null` for single-package repos (no `workspaces` field).
|
||
* Lets the resolver treat `@scope/ui/sub` as a local import into the
|
||
* member's directory instead of an external npm package (#629).
|
||
*/
|
||
getWorkspacePackages?(): import('./workspace-packages').WorkspacePackages | null;
|
||
/**
|
||
* Re-exports declared by a file (`export { x } from './other'`,
|
||
* `export * from './other'`). Empty array when the file has none.
|
||
* Optional so older callers compile; the import resolver follows
|
||
* re-export chains when this is provided.
|
||
*/
|
||
getReExports?(filePath: string, language: Language): ReExport[];
|
||
/**
|
||
* List immediate subdirectories of `relativePath` (relative to the
|
||
* project root). Returns an empty array when the path doesn't exist
|
||
* or isn't a directory. Used by framework resolvers that need to
|
||
* walk build-system metadata (e.g. Cargo workspace globs). Optional
|
||
* so external context implementations and test fixtures compile
|
||
* without modification.
|
||
*/
|
||
listDirectories?(relativePath: string): string[];
|
||
/**
|
||
* C/C++ include search directories (relative to project root),
|
||
* extracted from compile_commands.json or discovered by heuristic.
|
||
* Used by resolveCppIncludePath to search -I directories when
|
||
* relative resolution fails. Optional so existing callers compile.
|
||
*/
|
||
getCppIncludeDirs?(): string[];
|
||
}
|
||
|
||
/**
|
||
* Result of framework-specific file extraction.
|
||
*/
|
||
export interface FrameworkExtractionResult {
|
||
/** Framework-specific nodes (e.g. routes) */
|
||
nodes: Node[];
|
||
/** Framework-specific unresolved references (e.g. route -> handler) */
|
||
references: UnresolvedRef[];
|
||
}
|
||
|
||
/**
|
||
* Framework-specific resolver
|
||
*/
|
||
export interface FrameworkResolver {
|
||
/** Framework name */
|
||
name: string;
|
||
/** Languages this framework applies to. If omitted, applies to all languages. */
|
||
languages?: Language[];
|
||
/** Detect if project uses this framework (project-level, called once at startup) */
|
||
detect(context: ResolutionContext): boolean;
|
||
/** Resolve a reference using framework-specific patterns */
|
||
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null;
|
||
/**
|
||
* Opt a reference NAME through the resolver's name-exists pre-filter, even when
|
||
* no node is named that. Needed for dynamic dispatch where the call target is
|
||
* an attribute/descriptor, not a declared symbol (e.g. Django's
|
||
* `self._iterable_class(...)`, React effect callbacks). Returning true lets the
|
||
* ref reach `resolve()` instead of being dropped for having no name match.
|
||
*/
|
||
claimsReference?(name: string): boolean;
|
||
/**
|
||
* Extract framework-specific nodes and references from a file.
|
||
*
|
||
* Returns route nodes, middleware nodes, etc., plus unresolved references
|
||
* that link those nodes to handlers (view classes, controller methods,
|
||
* included modules). Unresolved references flow into the normal resolution
|
||
* pipeline; the framework's own `resolve()` is one of the strategies tried.
|
||
*/
|
||
extract?(filePath: string, content: string): FrameworkExtractionResult;
|
||
/**
|
||
* Cross-file finalization pass, called once after all per-file extraction
|
||
* completes (and again on every incremental sync). Used by frameworks where
|
||
* a symbol's final representation depends on a sibling file the per-file
|
||
* `extract()` never saw — e.g. NestJS's `RouterModule.register([...])`
|
||
* sets route prefixes for controllers declared elsewhere.
|
||
*
|
||
* Implementations return route/etc. nodes with mutated fields (typically
|
||
* `name`); the orchestrator persists each via `updateNode`. The node `id`
|
||
* MUST be preserved so existing edges (route → handler, etc.) stay intact;
|
||
* `qualifiedName` SHOULD be preserved so the pass stays idempotent — a
|
||
* second run can recover the original in-file form from `qualifiedName`.
|
||
*/
|
||
postExtract?(context: ResolutionContext): Node[];
|
||
}
|
||
|
||
/**
|
||
* Import mapping from a file
|
||
*/
|
||
export interface ImportMapping {
|
||
/** Local name used in the file */
|
||
localName: string;
|
||
/** Original exported name (may differ due to aliasing) */
|
||
exportedName: string;
|
||
/** Source module/path */
|
||
source: string;
|
||
/** Whether it's a default import */
|
||
isDefault: boolean;
|
||
/** Whether it's a namespace import (import * as X) */
|
||
isNamespace: boolean;
|
||
/** Resolved file path (if local) */
|
||
resolvedPath?: string;
|
||
}
|
||
|
||
/**
|
||
* Re-export from a file: `export { x } from './other'` or
|
||
* `export * from './other'`. Used by the resolver to chase
|
||
* symbols through barrel files.
|
||
*/
|
||
export type ReExport =
|
||
| {
|
||
kind: 'named';
|
||
/** Name as exported by THIS file. */
|
||
exportedName: string;
|
||
/** Name in the upstream module (differs when renamed: `as`). */
|
||
originalName: string;
|
||
/** Module specifier of the upstream module. */
|
||
source: string;
|
||
}
|
||
| {
|
||
kind: 'wildcard';
|
||
/** Module specifier of the upstream module. */
|
||
source: string;
|
||
};
|
||
|
||
/**
|
||
* Node kinds an `extends`/`implements` edge may legally TARGET — the things a
|
||
* type can actually inherit from or conform to.
|
||
*
|
||
* Kept deliberately wide: `type_alias` because TS `class X implements
|
||
* SomeAliasedObjectType` is valid, `component` because a framework component
|
||
* node stands in for a class, 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` (the conformance pass in
|
||
* `resolution/index.ts` makes the same `module` allowance).
|
||
*
|
||
* Everything omitted (`enum_member`, `method`, `field`, `property`,
|
||
* `variable`, `constant`, `function`, `parameter`, `import`, `export`,
|
||
* `file`, `route`) can never be a supertype in any supported language, so an
|
||
* inheritance edge pointing at one is false data.
|
||
*
|
||
* Why this is needed: the name-matcher scores node kind as a BONUS,
|
||
* never a filter, and awards no bonus at all for inheritance refs — so a
|
||
* same-named non-type outranked (or, as the sole candidate, was adopted
|
||
* outright as) the real supertype. Rust `use std::error::Error;` + `impl Error
|
||
* for MapperError {}` bound to the local `MapperError::Error` VARIANT. The
|
||
* supertype is out-of-repo and simply unresolvable; a failed ref is correct.
|
||
*/
|
||
export const SUPERTYPE_TARGET_KINDS = new Set<Node['kind']>([
|
||
'class', 'struct', 'interface', 'trait', 'protocol', 'enum', 'union',
|
||
'type_alias', 'component', 'module', 'namespace',
|
||
]);
|
||
|
||
/** True for the reference kinds that assert an inheritance/conformance relation. */
|
||
export function isInheritanceRef(ref: UnresolvedRef): boolean {
|
||
return ref.referenceKind === 'extends' || ref.referenceKind === 'implements';
|
||
}
|
||
|
||
/**
|
||
* Node kinds an `imports` edge may never TARGET: members that only exist
|
||
* INSIDE a type. No language lets you import a class's property, an
|
||
* interface's method or an enum's variant — you import the type that
|
||
* contains it. The name-matcher has no kind filter, so a bare
|
||
* `import path from 'node:path'` (unresolvable, since the module is external)
|
||
* name-matched an interface property called `path` in an unrelated file.
|
||
*/
|
||
const NON_IMPORTABLE_KINDS = new Set<Node['kind']>([
|
||
'property', 'field', 'method', 'enum_member', 'parameter',
|
||
]);
|
||
|
||
/** Can an `imports` reference legally resolve to this node kind? */
|
||
export function isImportableKind(kind: Node['kind']): boolean {
|
||
return !NON_IMPORTABLE_KINDS.has(kind);
|
||
}
|