mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
Merge remote-tracking branch 'origin/main' into fix/kotlin-property-initializer-walk
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -230,6 +230,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- Kotlin `init { }` blocks and destructuring declarations no longer swallow their code: `init { val cfg = load() }` and `val (a, b) = makePair()` contributed no call edge at all, and now attribute to the enclosing class or file.
|
||||
- A Kotlin property's accessor body now belongs to the property whichever line it is written on, instead of being dropped (same line) or handed to the enclosing class (own line).
|
||||
- Kotlin properties that hold a lambda, a SAM callback or an anonymous object — `private val frameListener = CameraFrameListener { … }`, the way Android and MSDK callbacks are almost always declared — now contribute call edges. Previously everything inside such an initializer was dropped, so a function reached only through one of these callbacks looked like it had no callers at all and its blast radius came back far too small. Delegated properties (`by lazy { … }`) and plain initializers (`val x = compute()`) were affected the same way and are fixed too. Re-index with `codegraph index -f` after upgrading to pick up the new edges.
|
||||
- Imports from Node built-ins or npm packages no longer connect to unrelated type members with matching names; re-index after upgrading to clear existing false dependencies. Thanks @ctype-lab. (#1537)
|
||||
|
||||
- Inheritance relationships no longer attach external Rust or npm supertypes to unrelated local symbols with the same name, including in Svelte, Vue and Astro components; re-index after upgrading to clear existing false relationships. Thanks @ctype-lab. (#1536)
|
||||
|
||||
- PHP static calls through imported class aliases now reach the correct class when services and repositories share method names, so callers and impact analysis show the right dependencies after re-indexing. (#1545)
|
||||
- TypeScript/JavaScript: a call through a field of the enclosing class — `this.mailer.send()` — now resolves on the field's declared type, so a delegating wrapper that shares the method's name no longer records itself as its own callee and `callers`, `impact` and trace stop lying on that shape. A field whose type is external or a builtin stays unresolved rather than guessed. Re-index after upgrading. (#1496)
|
||||
- TypeScript and JavaScript collection calls through local variables and their nested properties no longer link to unrelated project methods; re-index after upgrading. (#1566)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Reference target-kind gate — `extends`/`implements` and `imports`.
|
||||
*
|
||||
* The name-matcher treats node kind as a scoring BONUS, never a filter, and
|
||||
* awards no bonus at all for inheritance refs. When exactly one same-named
|
||||
* node exists, the single-candidate shortcut adopts it unconditionally at
|
||||
* confidence 0.9. So a supertype that lives OUTSIDE the repo — imported by a
|
||||
* bare name — bound to whatever local symbol happened to share that name,
|
||||
* asserting an inheritance relationship absent from the source:
|
||||
*
|
||||
* use std::error::Error; // the supertype is out-of-repo
|
||||
* impl Error for MapperError {} // ...but `MapperError::Error` is a variant
|
||||
* → implements: enum MapperError -> enum_member Error
|
||||
*
|
||||
* The gate drops any inheritance resolution whose target cannot be a
|
||||
* supertype. It only ever removes edges, so the tests below pin BOTH
|
||||
* directions: the false edge is gone, and every legitimate supertype kind
|
||||
* (in-repo trait, interface, class, and TS object-type alias) still resolves.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
describe('reference target-kind gate', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'inh-kind-')); });
|
||||
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
const write = (rel: string, body: string) => {
|
||||
const p = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, body);
|
||||
};
|
||||
|
||||
type InhEdge = { src: string; srcKind: string; tgt: string; tgtKind: string; kind: string };
|
||||
|
||||
const load = async (): Promise<{ edges: InhEdge[]; failed: { name: string; kind: string }[] }> => {
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
const db = (cg as any).db.db;
|
||||
const edges: InhEdge[] = db
|
||||
.prepare(
|
||||
`SELECT s.name src, s.kind srcKind, t.name tgt, t.kind tgtKind, e.kind kind
|
||||
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')`
|
||||
)
|
||||
.all();
|
||||
const failed: { name: string; kind: string }[] = db
|
||||
.prepare(
|
||||
`SELECT reference_name name, reference_kind kind
|
||||
FROM unresolved_refs
|
||||
WHERE reference_kind IN ('extends', 'implements')`
|
||||
)
|
||||
.all();
|
||||
cg.close?.();
|
||||
return { edges, failed };
|
||||
};
|
||||
|
||||
const has = (edges: InhEdge[], src: string, tgt: string, tgtKind: string) =>
|
||||
edges.some((e) => e.src === src && e.tgt === tgt && e.tgtKind === tgtKind);
|
||||
|
||||
it('drops an out-of-repo Rust supertype that name-matched a local enum member', async () => {
|
||||
write(
|
||||
'src/lib.rs',
|
||||
`use std::error::Error;\n\n` +
|
||||
`pub enum MapperError {\n Error,\n Missing,\n}\n\n` +
|
||||
`impl Error for MapperError {}\n`
|
||||
);
|
||||
const { edges, failed } = await load();
|
||||
expect(has(edges, 'MapperError', 'Error', 'enum_member')).toBe(false);
|
||||
// The reference is not silently forgotten — it stays on record as failed,
|
||||
// which is the honest outcome for a supertype the repo does not contain.
|
||||
expect(failed.some((r) => r.name === 'Error')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not relocate the false edge onto a same-named local type alias', async () => {
|
||||
// The kind filter alone would have moved this edge from the enum member to
|
||||
// `type Error`, which IS a legal supertype kind — still false data, and
|
||||
// harder for a consumer to reject. Locality is what removes it.
|
||||
write('src/alias.rs', `pub type Error = String;\n`);
|
||||
write(
|
||||
'src/lib.rs',
|
||||
`mod alias;\n\nuse std::error::Error;\n\n` +
|
||||
`pub enum MapperError {\n Missing,\n}\n\n` +
|
||||
`impl Error for MapperError {}\n`
|
||||
);
|
||||
const { edges, failed } = await load();
|
||||
expect(edges.filter((e) => e.tgt === 'Error')).toEqual([]);
|
||||
expect(failed.some((r) => r.name === 'Error')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a supertype imported by an in-repo `use` path', async () => {
|
||||
write('src/ports.rs', `pub trait Sha256Port {\n fn hash(&self) -> String;\n}\n`);
|
||||
write(
|
||||
'src/lib.rs',
|
||||
`mod ports;\n\nuse crate::ports::Sha256Port;\n\n` +
|
||||
`pub struct Hasher {\n salt: String,\n}\n\n` +
|
||||
`impl Sha256Port for Hasher {\n fn hash(&self) -> String { String::new() }\n}\n`
|
||||
);
|
||||
const { edges } = await load();
|
||||
expect(has(edges, 'Hasher', 'Sha256Port', 'trait')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a trait reached through a re-exported sibling-crate module', async () => {
|
||||
// `crate::ports` here is a re-export of ANOTHER crate's module, so no
|
||||
// `src/ports.rs` exists to walk to. Treating "module path does not resolve
|
||||
// to a file" as proof of out-of-repo deleted 13 real trait implementations
|
||||
// on the reference fixture — hence the rule keys on stdlib roots only.
|
||||
write('Cargo.toml', `[workspace]\nmembers = ["core", "app"]\n`);
|
||||
write('core/Cargo.toml', `[package]\nname = "pupil_core"\nversion = "0.1.0"\n`);
|
||||
write('core/src/lib.rs', `pub mod ports;\n`);
|
||||
write('core/src/ports.rs', `pub trait CacheStore {\n fn get(&self);\n}\n`);
|
||||
write('app/Cargo.toml', `[package]\nname = "app"\nversion = "0.1.0"\n`);
|
||||
write('app/src/lib.rs', `pub use pupil_core::ports;\n\npub mod platform;\n`);
|
||||
write(
|
||||
'app/src/platform.rs',
|
||||
`use crate::ports::CacheStore;\n\npub struct SafStorage {\n root: String,\n}\n\n` +
|
||||
`impl CacheStore for SafStorage {\n fn get(&self) {}\n}\n`
|
||||
);
|
||||
const { edges } = await load();
|
||||
expect(has(edges, 'SafStorage', 'CacheStore', 'trait')).toBe(true);
|
||||
});
|
||||
|
||||
it('still resolves an in-repo Rust trait (the gate is not a blanket block)', async () => {
|
||||
write(
|
||||
'src/lib.rs',
|
||||
`pub trait Mapper {\n fn map(&self) -> u32;\n}\n\n` +
|
||||
`pub enum MapperError {\n Mapper,\n}\n\n` +
|
||||
`pub struct Real {\n n: u32,\n}\n\n` +
|
||||
`impl Mapper for Real {\n fn map(&self) -> u32 { 1 }\n}\n`
|
||||
);
|
||||
const { edges } = await load();
|
||||
expect(has(edges, 'Real', 'Mapper', 'trait')).toBe(true);
|
||||
expect(has(edges, 'Real', 'Mapper', 'enum_member')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a TypeScript class implementing an object-type alias', async () => {
|
||||
write(
|
||||
'src/api.ts',
|
||||
`export type SearchApi = { query(q: string): string };\n\n` +
|
||||
`export class LocalSearch implements SearchApi {\n` +
|
||||
` query(q: string): string { return q; }\n}\n`
|
||||
);
|
||||
const { edges } = await load();
|
||||
expect(has(edges, 'LocalSearch', 'SearchApi', 'type_alias')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['svelte', 'src/Box.svelte', '<script lang="ts">\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<div>hi</div>\n'],
|
||||
['vue', 'src/Box.vue', '<script lang="ts">\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<template><div/></template>\n'],
|
||||
['astro', 'src/Box.astro', '---\n$IMPORT$\nexport class SfcBox implements Serializable {\n n = 1;\n}\n---\n<div/>\n'],
|
||||
])('drops an npm supertype in a %s single-file component', async (_lang, file, body) => {
|
||||
// An SFC imports inside its <script> block (Astro: the `---` frontmatter)
|
||||
// with ordinary ES module syntax, so a bare specifier there is external for
|
||||
// exactly the same reason it is in a .ts file. Missing that, the npm
|
||||
// supertype name-matched the local class below.
|
||||
write('package.json', `{"name":"sfc","version":"1.0.0"}\n`);
|
||||
write('src/models.ts', `export class Serializable {\n a = 1;\n}\n`);
|
||||
write(file, body.replace('$IMPORT$', `import { Serializable } from 'some-npm-pkg';\n`));
|
||||
const { edges, failed } = await load();
|
||||
expect(edges.filter((e) => e.tgt === 'Serializable')).toEqual([]);
|
||||
expect(failed.some((r) => r.name === 'Serializable')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an SFC supertype imported from a relative path', async () => {
|
||||
write('package.json', `{"name":"sfc","version":"1.0.0"}\n`);
|
||||
write('src/models.ts', `export class Serializable {\n a = 1;\n}\n`);
|
||||
write(
|
||||
'src/Box.svelte',
|
||||
`<script lang="ts">\nimport { Serializable } from './models';\n\n` +
|
||||
`export class SfcBox implements Serializable {\n n = 1;\n}\n</script>\n<div>hi</div>\n`
|
||||
);
|
||||
const { edges } = await load();
|
||||
expect(has(edges, 'SfcBox', 'Serializable', 'class')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not resolve an import to a type member that shares its name', async () => {
|
||||
// `import * as path from 'node:path'` is unresolvable — the module is
|
||||
// external — so the name-matcher looked for any node called `path` and
|
||||
// found a class property. No language lets you import a type's member.
|
||||
write('src/types.ts', `export class Request {\n path = '';\n url = '';\n}\n`);
|
||||
write(
|
||||
'src/run.ts',
|
||||
`import * as path from 'node:path';\n\nexport function run() {\n return path.join('a', 'b');\n}\n`
|
||||
);
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
const db = (cg as any).db.db;
|
||||
const rows: { tgt: string; tgtKind: string }[] = db
|
||||
.prepare(
|
||||
`SELECT t.name tgt, t.kind tgtKind
|
||||
FROM edges e JOIN nodes t ON t.id = e.target
|
||||
WHERE e.kind = 'imports'`
|
||||
)
|
||||
.all();
|
||||
cg.close?.();
|
||||
expect(rows.filter((r) => r.tgtKind === 'property' || r.tgtKind === 'field')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps class extends class and class implements interface', async () => {
|
||||
write(
|
||||
'src/base.ts',
|
||||
`export interface Runner { run(): void }\n` +
|
||||
`export class Base { run(): void {} }\n` +
|
||||
`export class Child extends Base implements Runner { run(): void {} }\n`
|
||||
);
|
||||
const { edges } = await load();
|
||||
expect(has(edges, 'Child', 'Base', 'class')).toBe(true);
|
||||
expect(has(edges, 'Child', 'Runner', 'interface')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -316,6 +316,21 @@ const C_CPP_STDLIB_HEADERS = new Set([
|
||||
'version',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Languages whose imports are ES-module specifiers, extracted by
|
||||
* `extractJSImports` and therefore classified by the same bare-specifier /
|
||||
* alias / workspace rules. Svelte, Vue and Astro belong here: an SFC imports
|
||||
* inside its `<script>` block (Astro: the `---` frontmatter) with exactly the
|
||||
* same syntax, and leaving them out made `isExternalImport` answer "not
|
||||
* external" for every npm specifier in an SFC.
|
||||
*/
|
||||
const ESM_IMPORT_LANGUAGES = new Set<Language>([
|
||||
'typescript', 'tsx', 'javascript', 'jsx', 'arkts', 'svelte', 'vue', 'astro',
|
||||
]);
|
||||
|
||||
/** Rust path roots that always name a standard-library crate. */
|
||||
const RUST_STDLIB_ROOTS = new Set(['std', 'core', 'alloc', 'proc_macro']);
|
||||
|
||||
/**
|
||||
* Check if an import is external (npm package, etc.)
|
||||
*
|
||||
@@ -344,7 +359,7 @@ function isExternalImport(
|
||||
}
|
||||
|
||||
// Common external patterns
|
||||
if (language === 'typescript' || language === 'javascript' || language === 'tsx' || language === 'jsx' || language === 'arkts') {
|
||||
if (ESM_IMPORT_LANGUAGES.has(language)) {
|
||||
// Node built-ins
|
||||
if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
|
||||
return true;
|
||||
@@ -2487,3 +2502,123 @@ function resolveStaticMember(
|
||||
}
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rust `use` declarations, flattened to `localName → full path`.
|
||||
*
|
||||
* Rust is the one supported language with NO `ImportMapping` extraction (see
|
||||
* `extractImportMappings`), so this is the only channel that can tell whether
|
||||
* a bare type name in a Rust file was brought in by a `use`. Handles nested
|
||||
* groups (`use a::{b::C, d as E}`), globs (skipped — they bind no single
|
||||
* name), and `as` aliases.
|
||||
*/
|
||||
function collectRustUseBindings(content: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
|
||||
// Expand one level of `{...}` at a time so `a::{b::{C, D}, E}` flattens.
|
||||
const expand = (spec: string): string[] => {
|
||||
const open = spec.indexOf('{');
|
||||
if (open === -1) return [spec.trim()];
|
||||
const prefix = spec.slice(0, open);
|
||||
let depth = 0;
|
||||
let close = -1;
|
||||
for (let i = open; i < spec.length; i++) {
|
||||
if (spec[i] === '{') depth++;
|
||||
else if (spec[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) { close = i; break; }
|
||||
}
|
||||
}
|
||||
if (close === -1) return [];
|
||||
const suffix = spec.slice(close + 1);
|
||||
const inner = spec.slice(open + 1, close);
|
||||
const parts: string[] = [];
|
||||
let depth2 = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i <= inner.length; i++) {
|
||||
const ch = inner[i];
|
||||
if (ch === '{') depth2++;
|
||||
else if (ch === '}') depth2--;
|
||||
if (i === inner.length || (ch === ',' && depth2 === 0)) {
|
||||
const seg = inner.slice(start, i).trim();
|
||||
if (seg) parts.push(seg);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
return parts.flatMap((p) => expand(prefix + p + suffix));
|
||||
};
|
||||
|
||||
// `use` items end at the first `;`. Attributes/visibility (`pub use`) are
|
||||
// irrelevant to the binding itself.
|
||||
const useRe = /(^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = useRe.exec(content)) !== null) {
|
||||
for (const spec of expand(m[2]!.replace(/\s+/g, ' '))) {
|
||||
const aliasMatch = /^(.*?)\s+as\s+([A-Za-z_]\w*)$/.exec(spec);
|
||||
const rawPath = (aliasMatch ? aliasMatch[1]! : spec).trim();
|
||||
if (!rawPath || rawPath.endsWith('*')) continue;
|
||||
const segments = rawPath.split('::').map((s) => s.trim()).filter(Boolean);
|
||||
const leaf = segments[segments.length - 1];
|
||||
if (!leaf) continue;
|
||||
const local = aliasMatch ? aliasMatch[2]! : leaf;
|
||||
out.set(local, segments.join('::'));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `name`, as used in `ref`'s file, bound by an import whose module lives
|
||||
* OUTSIDE the repository?
|
||||
*
|
||||
* When it is, no in-repo node can be the referent: the symbol is defined in a
|
||||
* third-party crate/package, and any same-named local symbol the name-matcher
|
||||
* finds is a coincidence. Rust `use std::error::Error;` + `impl Error for
|
||||
* MapperError {}` bound to a local `MapperError::Error` variant, and once
|
||||
* non-type kinds were filtered out it simply moved to an unrelated local
|
||||
* `type Error` alias — restricting kinds alone RELOCATES the false edge
|
||||
* instead of removing it, so locality has to be checked too.
|
||||
*
|
||||
* Answers only when it can be CERTAIN, because a false "yes" deletes a real
|
||||
* edge. Two languages qualify, each with an oracle that cannot be wrong:
|
||||
*
|
||||
* - **Rust** — the `use` path is rooted at a standard-library crate
|
||||
* (`std`/`core`/`alloc`/`proc_macro`), which by definition ships outside
|
||||
* any repository. Deliberately NOT generalized to "the module path doesn't
|
||||
* resolve to a file": a crate can re-export another workspace crate's
|
||||
* modules (`pub use pupil_core::{ports, domain};`), so `crate::ports::X`
|
||||
* has no `src/ports/` directory to walk yet is entirely in-repo — that
|
||||
* generalization measured 13 real trait implementations deleted.
|
||||
* - **ES modules** — `isExternalImport`, which already accounts for tsconfig
|
||||
* path aliases and monorepo workspace packages.
|
||||
*
|
||||
* Everything else returns false 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 here.
|
||||
*/
|
||||
export function isBoundToOutOfRepoImport(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): boolean {
|
||||
const name = ref.referenceName;
|
||||
if (name.includes('::') || name.includes('.')) return false; // qualified refs resolve by path
|
||||
|
||||
if (ref.language === 'rust') {
|
||||
const content = context.readFile(ref.filePath);
|
||||
if (!content) return false;
|
||||
const usePath = collectRustUseBindings(content).get(name);
|
||||
if (!usePath) return false;
|
||||
const segments = usePath.split('::');
|
||||
if (segments.length < 2 || !RUST_STDLIB_ROOTS.has(segments[0]!)) return false;
|
||||
// 2015-edition crate-relative paths can shadow a stdlib root with a local
|
||||
// module of the same name — if the path walks to a real file, it's local.
|
||||
return resolveRustModuleFile(segments.slice(0, -1), ref.filePath, context) === null;
|
||||
}
|
||||
|
||||
if (!ESM_IMPORT_LANGUAGES.has(ref.language)) return false;
|
||||
for (const imp of context.getImportMappings(ref.filePath, ref.language)) {
|
||||
if (imp.localName !== name) continue;
|
||||
return isExternalImport(imp.source, ref.language, context);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+58
-2
@@ -15,9 +15,12 @@ import {
|
||||
ResolutionContext,
|
||||
FrameworkResolver,
|
||||
ImportMapping,
|
||||
SUPERTYPE_TARGET_KINDS,
|
||||
isInheritanceRef,
|
||||
isImportableKind,
|
||||
} from './types';
|
||||
import { isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesKnownFamily, dumpNameMatcherProfile, clearNameMatcherMemos } from './name-matcher';
|
||||
import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, clearImportResolverMemos, resolveImportPath } from './import-resolver';
|
||||
import { resolveViaImport, resolvePhpImportedStaticCall, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isBoundToOutOfRepoImport, clearImportResolverMemos, resolveImportPath } from './import-resolver';
|
||||
import { ResolverPool, minRefsForPool } from './resolver-pool';
|
||||
import { detectFrameworks } from './frameworks';
|
||||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||||
@@ -36,6 +39,11 @@ const SUPERTYPE_BEARING_KINDS = new Set<Node['kind']>([
|
||||
'class', 'struct', 'interface', 'trait', 'protocol', 'enum',
|
||||
]);
|
||||
|
||||
// SUPERTYPE_TARGET_KINDS (the kinds an extends/implements edge may TARGET)
|
||||
// lives in ./types — the name-matcher needs the same set to restrict its
|
||||
// candidate pool before ranking. It is deliberately wider than
|
||||
// SUPERTYPE_BEARING_KINDS above, which is about the DECLARING side.
|
||||
|
||||
/**
|
||||
* Languages whose chained static-factory/fluent calls defer to the conformance
|
||||
* second pass. Dotted-receiver languages resolve via matchDottedCallChain; the
|
||||
@@ -857,9 +865,18 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single reference
|
||||
* Resolve a single reference.
|
||||
*
|
||||
* Thin decorator over `resolveOneInner` so every strategy — framework,
|
||||
* import, name-match, chain, CFML component path — passes through the
|
||||
* inheritance target-kind gate at ONE seam. Filtering inside the
|
||||
* name-matcher would have covered `matchByExactName` only.
|
||||
*/
|
||||
resolveOne(ref: UnresolvedRef): ResolvedRef | null {
|
||||
return this.gateTargetKind(this.resolveOneInner(ref), ref);
|
||||
}
|
||||
|
||||
private resolveOneInner(ref: UnresolvedRef): ResolvedRef | null {
|
||||
// Skip built-in/external references
|
||||
if (this.isBuiltInOrExternal(ref)) {
|
||||
return null;
|
||||
@@ -2582,6 +2599,45 @@ export class ReferenceResolver {
|
||||
return this.persistDeferredReferences(deferred, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a resolution whose target cannot be what the reference names.
|
||||
* Applied at the `resolveOne` seam so it covers every strategy uniformly —
|
||||
* framework, import, name-match, chain, CFML component path.
|
||||
*
|
||||
* For `imports`: the target must be importable. A member that only exists
|
||||
* inside a type never is.
|
||||
*
|
||||
* For `extends`/`implements`, it cannot be describing a real supertype when:
|
||||
*
|
||||
* 1. The target's kind can never be a supertype (an enum member, a method,
|
||||
* a variable). `matchByExactName` additionally narrows its candidate
|
||||
* pool by the same set, so a legitimate supertype outranks a same-named
|
||||
* non-type rather than merely losing its edge.
|
||||
* 2. The name is imported from outside the repo, so NO local node is the
|
||||
* referent. Without this, filtering by kind alone just relocates the
|
||||
* false edge onto the next same-named local type.
|
||||
*
|
||||
* Direction is one-way: this only ever REMOVES an edge, never adds one. A
|
||||
* dropped ref stays in `unresolved_refs` as `failed`, which is the honest
|
||||
* record for a supertype that lives outside the repo — silent beats wrong.
|
||||
*/
|
||||
private gateTargetKind(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (!result) return result;
|
||||
|
||||
// An `imports` reference names something importable — never a member that
|
||||
// only exists inside a type.
|
||||
if (ref.referenceKind === 'imports') {
|
||||
const target = this.queries.getNodeById(result.targetNodeId);
|
||||
return target && !isImportableKind(target.kind) ? null : result;
|
||||
}
|
||||
|
||||
if (!isInheritanceRef(ref)) return result;
|
||||
const target = this.queries.getNodeById(result.targetNodeId);
|
||||
if (target && !SUPERTYPE_TARGET_KINDS.has(target.kind)) return null;
|
||||
if (isBoundToOutOfRepoImport(ref, this.context)) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (!result) return result;
|
||||
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as path from 'path';
|
||||
import { Language, Node } from '../types';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types';
|
||||
import { UnresolvedRef, ResolvedRef, ResolutionContext, SUPERTYPE_TARGET_KINDS, isInheritanceRef, isImportableKind } from './types';
|
||||
import { blankStringContents, stripCommentsForRegex } from './strip-comments';
|
||||
import { JS_BUILT_INS } from './js-builtins';
|
||||
|
||||
@@ -762,7 +762,19 @@ export function matchByExactName(
|
||||
.filter((n) => !(bareJs && n.kind === 'method'))
|
||||
// A name the file binds itself (a parameter, a const) shadows every other
|
||||
// file's symbol of that name, so a bare call has no cross-file candidate.
|
||||
.filter((n) => !(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)));
|
||||
.filter((n) => !(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))
|
||||
// An `extends`/`implements` ref names a supertype, so anything that can't
|
||||
// BE one is not a candidate at all. This is eligibility, not
|
||||
// ranking: kind is only a scoring bonus below (and none is awarded for
|
||||
// inheritance refs), so without this a same-named `enum_member` outranked
|
||||
// the real `trait`, and as the sole candidate was adopted outright by the
|
||||
// single-match shortcut. Restricting the pool BEFORE ranking lets the
|
||||
// legitimate supertype win instead of merely dropping the false edge.
|
||||
.filter((n) => !isInheritanceRef(ref) || SUPERTYPE_TARGET_KINDS.has(n.kind))
|
||||
// Likewise for `imports`: a member that only exists inside a type is not
|
||||
// importable, so it is not a candidate. Without this a `path`/`id`/`url`
|
||||
// import resolved to some interface's same-named property.
|
||||
.filter((n) => ref.referenceKind !== 'imports' || isImportableKind(n.kind));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -295,3 +295,54 @@ export type ReExport =
|
||||
/** 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user