mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-28 14:08:56 +08:00
feat(impact): cross-language blast-radius coverage (22 languages + 14 frameworks) (#708)
Completes the cross-file dependency graph behind impact / affected / explore across all 22 supported languages and 14 web frameworks, validated on real-world repos (measured fair-coverage table added to the README). Per-language resolution + framework resolvers/synthesizers (Lua/Luau require, Shopify OS 2.0 Liquid sections, Delphi forms, Rust cross-module + Rocket macros, Swift Fluent, SvelteKit/Nuxt loader/component conventions, RN/Expo bridges). 0 cross-family false edges, full suite green (1187 passed). See #708. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bfa84d32b8
commit
07af3db6c7
@@ -42,6 +42,10 @@ const MAX_JSX_CHILDREN = 30;
|
||||
// event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
|
||||
// are already caught by JSX_TAG_RE via the SFC component node.
|
||||
const VUE_KEBAB_RE = /<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g;
|
||||
// PascalCase component tags — `<MediaCard ...>`, `<NavBar/>`. HTML elements are
|
||||
// lowercase, so an uppercase-initial tag is a component usage; built-ins
|
||||
// (`<NuxtLink>`, `<Transition>`) simply resolve to nothing and emit no edge.
|
||||
const VUE_PASCAL_RE = /<([A-Z][A-Za-z0-9]*)[\s/>]/g;
|
||||
const VUE_HANDLER_RE = /(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)"/g;
|
||||
// Composable/hook destructure: `const { close: closeSidebar } = useSidebarControl()`.
|
||||
// Captures the destructure body + the called composable; only `use*` calls qualify.
|
||||
@@ -64,6 +68,30 @@ function kebabToPascal(s: string): string {
|
||||
return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuxt auto-import name for a component, derived from its path UNDER `components/`:
|
||||
* `components/media/Card.vue` → `MediaCard`, `components/base/foo/Bar.vue` →
|
||||
* `BaseFooBar`. Each directory segment and the filename is PascalCased and
|
||||
* concatenated; a directory whose PascalCase name prefixes the next segment is
|
||||
* collapsed (Nuxt's de-dup: `base/BaseButton.vue` → `BaseButton`, not
|
||||
* `BaseBaseButton`). Returns null for a flat component (`components/NavBar.vue`)
|
||||
* — its node is already named by basename, so a direct tag match finds it.
|
||||
*/
|
||||
function nuxtComponentName(filePath: string): string | null {
|
||||
const marker = filePath.lastIndexOf('components/');
|
||||
if (marker === -1) return null;
|
||||
const rel = filePath.slice(marker + 'components/'.length).replace(/\.(vue|ts|tsx|js|jsx)$/i, '');
|
||||
const segs = rel.split('/').filter(Boolean).map(kebabToPascal);
|
||||
if (segs.length < 2) return null;
|
||||
const out: string[] = [];
|
||||
for (const s of segs) {
|
||||
const prev = out[out.length - 1];
|
||||
if (prev && s.startsWith(prev)) out[out.length - 1] = s;
|
||||
else out.push(s);
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
|
||||
if (!startLine || !endLine) return null;
|
||||
return content.split('\n').slice(startLine - 1, endLine).join('\n');
|
||||
@@ -444,8 +472,134 @@ function cppOverrideEdges(queries: QueryBuilder): Edge[] {
|
||||
// and are added below; their concrete-side nodes can be a `struct` (Swift)
|
||||
// or an `object` (Scala) so the loop also iterates those kinds.
|
||||
const IFACE_OVERRIDE_LANGS = new Set([
|
||||
'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala',
|
||||
'java', 'kotlin', 'csharp', 'typescript', 'javascript', 'swift', 'scala', 'go', 'rust',
|
||||
]);
|
||||
/**
|
||||
* Go implicit interface satisfaction (#584). Go has no `implements` keyword — a
|
||||
* struct satisfies an interface structurally when its method set covers the
|
||||
* interface's. Synthesize the missing `implements` edge (struct → interface) by
|
||||
* matching method-NAME sets, so impl-navigation works and the interface-dispatch
|
||||
* bridge ({@link interfaceOverrideEdges}, now 'go'-enabled) can link an interface
|
||||
* method call to the concrete overrides.
|
||||
*
|
||||
* Name-only matching (signatures ignored) — over-approximation accepted, in line
|
||||
* with the other dispatch synthesizers; capped per interface. Empty interfaces
|
||||
* (`any`) are skipped so they don't match every struct.
|
||||
*/
|
||||
function goImplementsEdges(queries: QueryBuilder): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const methodNameSet = (id: string): Set<string> =>
|
||||
new Set(
|
||||
queries
|
||||
.getOutgoingEdges(id, ['contains'])
|
||||
.map((e) => queries.getNodeById(e.target))
|
||||
.filter((n): n is Node => !!n && n.kind === 'method')
|
||||
.map((n) => n.name),
|
||||
);
|
||||
|
||||
const goStructs = queries.getNodesByKind('struct').filter((s) => s.language === 'go');
|
||||
const structMethods = new Map<string, Set<string>>();
|
||||
for (const s of goStructs) structMethods.set(s.id, methodNameSet(s.id));
|
||||
|
||||
for (const iface of queries.getNodesByKind('interface')) {
|
||||
if (iface.language !== 'go') continue;
|
||||
const want = methodNameSet(iface.id);
|
||||
if (want.size === 0) continue; // empty interface (`any`) — would match everything
|
||||
let added = 0;
|
||||
for (const s of goStructs) {
|
||||
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
|
||||
const have = structMethods.get(s.id);
|
||||
if (!have || have.size < want.size) continue;
|
||||
let all = true;
|
||||
for (const m of want) {
|
||||
if (!have.has(m)) { all = false; break; }
|
||||
}
|
||||
if (!all) continue;
|
||||
const key = `${s.id}>${iface.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: s.id,
|
||||
target: iface.id,
|
||||
kind: 'implements',
|
||||
line: s.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'go-implements', via: iface.name, registeredAt: `${s.filePath}:${s.startLine}` },
|
||||
});
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotlin Multiplatform `expect`/`actual` linking. A `common` source set declares
|
||||
* `expect fun foo()` / `expect class Bar`; each platform source set (jvm, native,
|
||||
* js, …) provides an `actual` implementation with the IDENTICAL fully-qualified
|
||||
* name in a different file. Callers in common code resolve to the `expect`
|
||||
* declaration, so every `actual` impl ends up with zero dependents — invisible to
|
||||
* impact/affected even though editing it can break every caller of the API.
|
||||
*
|
||||
* Synthesize a `calls` edge from the common declaration to each platform `actual`
|
||||
* (mirroring the interface-impl bridge: abstract → concrete), so editing a
|
||||
* platform impl surfaces the common `expect` and its callers, and the impl file
|
||||
* participates in the graph.
|
||||
*
|
||||
* `expect`/`actual` are captured onto the node's `decorators` list at extraction
|
||||
* (kotlin.ts `extractModifiers`). Members of an `expect class` are NOT themselves
|
||||
* keyword-marked, so the declaration side is matched as the same-FQN, same-kind
|
||||
* node that is NOT marked `actual`. Requiring an `actual`-marked counterpart also
|
||||
* gates out plain cross-file overloads (neither side is marked).
|
||||
*/
|
||||
// Kinds that an `expect`/`actual` pair may legitimately straddle. `expect class`
|
||||
// is routinely fulfilled by an `actual typealias` (e.g. `actual typealias
|
||||
// CancellationException = …`, `actual typealias SchedulerTask = Task`), so a
|
||||
// strict kind match would miss those one-line alias files. Same-FQN + the
|
||||
// `actual` marker already gates out unrelated symbols, so widening to the
|
||||
// type-like kinds is safe.
|
||||
const KMP_TYPE_KINDS = new Set(['class', 'interface', 'struct', 'enum', 'type_alias']);
|
||||
function kmpKindsCompatible(a: string, b: string): boolean {
|
||||
return a === b || (KMP_TYPE_KINDS.has(a) && KMP_TYPE_KINDS.has(b));
|
||||
}
|
||||
|
||||
function kotlinExpectActualEdges(queries: QueryBuilder): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
const actuals = queries
|
||||
.getAllNodes()
|
||||
.filter((n) => n.language === 'kotlin' && !!n.decorators?.includes('actual'));
|
||||
for (const act of actuals) {
|
||||
let added = 0;
|
||||
for (const cand of queries.getNodesByQualifiedNameExact(act.qualifiedName)) {
|
||||
if (added >= MAX_CALLBACKS_PER_CHANNEL) break;
|
||||
// The declaration side: same FQN + compatible kind, a different file, NOT
|
||||
// itself an `actual` (that would be a sibling platform impl, not the decl).
|
||||
if (cand.language !== 'kotlin' || cand.id === act.id) continue;
|
||||
if (!kmpKindsCompatible(cand.kind, act.kind) || cand.filePath === act.filePath) continue;
|
||||
if (cand.decorators?.includes('actual')) continue;
|
||||
const key = `${cand.id}>${act.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: cand.id,
|
||||
target: act.id,
|
||||
kind: 'calls',
|
||||
line: cand.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'kotlin-expect-actual',
|
||||
via: act.name,
|
||||
registeredAt: `${act.filePath}:${act.startLine}`,
|
||||
},
|
||||
});
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function interfaceOverrideEdges(queries: QueryBuilder): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -675,6 +829,16 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
|
||||
// A composable's returned member may be a fn (`function close(){}`) or an
|
||||
// arrow assigned to a const (`const close = () => {}`).
|
||||
const RETURN_KINDS = new Set(['method', 'function', 'variable', 'constant']);
|
||||
// Nuxt auto-imports nested components by a DIRECTORY-PREFIXED name —
|
||||
// `components/media/Card.vue` is used as `<MediaCard/>`, not `<Card/>` — but
|
||||
// the component node is named by basename (`Card`), so a direct tag match
|
||||
// misses it (flat components match by basename and don't need this). Map each
|
||||
// nested component's Nuxt name → node so those template usages resolve.
|
||||
const nuxtComponents = new Map<string, Node>();
|
||||
for (const c of ctx.getNodesByKind('component')) {
|
||||
const nn = nuxtComponentName(c.filePath);
|
||||
if (nn && !nuxtComponents.has(nn)) nuxtComponents.set(nn, c);
|
||||
}
|
||||
for (const file of ctx.getAllFiles()) {
|
||||
if (!file.endsWith('.vue')) continue;
|
||||
const content = ctx.readFile(file);
|
||||
@@ -716,7 +880,18 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
VUE_KEBAB_RE.lastIndex = 0;
|
||||
while ((m = VUE_KEBAB_RE.exec(tpl))) addEdge(resolve(kebabToPascal(m[1]!), COMPONENT_KINDS), { synthesizedBy: 'jsx-render', via: m[1] });
|
||||
while ((m = VUE_KEBAB_RE.exec(tpl))) {
|
||||
const tag = kebabToPascal(m[1]!);
|
||||
addEdge(resolve(tag, COMPONENT_KINDS) ?? nuxtComponents.get(tag), { synthesizedBy: 'jsx-render', via: m[1] });
|
||||
}
|
||||
// PascalCase component tags. Try a direct name match first (flat components
|
||||
// and explicit registrations), then the Nuxt dir-prefixed auto-import name
|
||||
// (`<MediaCard>` → components/media/Card.vue). Built-ins match neither → no edge.
|
||||
VUE_PASCAL_RE.lastIndex = 0;
|
||||
while ((m = VUE_PASCAL_RE.exec(tpl))) {
|
||||
const tag = m[1]!;
|
||||
addEdge(resolve(tag, COMPONENT_KINDS) ?? nuxtComponents.get(tag), { synthesizedBy: 'jsx-render', via: tag });
|
||||
}
|
||||
VUE_HANDLER_RE.lastIndex = 0;
|
||||
while ((m = VUE_HANDLER_RE.exec(tpl))) {
|
||||
const event = m[1]!;
|
||||
@@ -780,6 +955,14 @@ const RN_SWIFT_SEND_RE = /\bsendEvent\s*\(\s*withName\s*:\s*"([^"]+)"/g;
|
||||
// JVM source files in the consumer so we don't re-process JS emits
|
||||
// (which `eventEmitterEdges` already handles).
|
||||
const RN_JVM_EMIT_RE = /\.emit\s*\(\s*"([^"]+)"\s*,/g;
|
||||
// Custom `sendEvent(reactContext, "X", body)` wrapper — extremely common
|
||||
// (react-native-device-info and many libs wrap `DeviceEventManagerModule…emit`
|
||||
// behind a helper whose `.emit(eventName, …)` uses a VARIABLE, so RN_JVM_EMIT_RE
|
||||
// misses it; the literal lives in the wrapper CALL instead). Captures the first
|
||||
// string literal inside a `sendEvent(...)` call. `[^;{}]*?` keeps it on one
|
||||
// statement and stops at a block boundary, so the wrapper DEFINITION (whose `(`
|
||||
// is followed by `… ) {`) never matches. Multi-line tolerant. (java/kotlin/swift)
|
||||
const RN_NATIVE_SENDEVENT_RE = /\bsendEvent\s*\([^;{}]*?"([^"]+)"/g;
|
||||
|
||||
function rnEventEdges(ctx: ResolutionContext): Edge[] {
|
||||
// Native dispatchers (source = the native method whose body sends the
|
||||
@@ -819,17 +1002,26 @@ function rnEventEdges(ctx: ResolutionContext): Edge[] {
|
||||
while ((m = RN_SWIFT_SEND_RE.exec(content))) {
|
||||
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
||||
}
|
||||
RN_NATIVE_SENDEVENT_RE.lastIndex = 0;
|
||||
while ((m = RN_NATIVE_SENDEVENT_RE.exec(content))) {
|
||||
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
||||
}
|
||||
}
|
||||
|
||||
// JVM side: `.emit("X", …)` in Java/Kotlin. (We pattern-match
|
||||
// anywhere in the file; the JS in-language path uses a separate
|
||||
// emitter object pattern and is already handled by eventEmitterEdges.)
|
||||
// JVM side: `.emit("X", …)` in Java/Kotlin, plus the common
|
||||
// `sendEvent(ctx, "X", body)` wrapper. (We pattern-match anywhere in the
|
||||
// file; the JS in-language path uses a separate emitter object pattern and
|
||||
// is already handled by eventEmitterEdges.)
|
||||
if (file.endsWith('.java') || file.endsWith('.kt')) {
|
||||
RN_JVM_EMIT_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
RN_JVM_EMIT_RE.lastIndex = 0;
|
||||
while ((m = RN_JVM_EMIT_RE.exec(content))) {
|
||||
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
||||
}
|
||||
RN_NATIVE_SENDEVENT_RE.lastIndex = 0;
|
||||
while ((m = RN_NATIVE_SENDEVENT_RE.exec(content))) {
|
||||
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
||||
}
|
||||
}
|
||||
|
||||
// JS subscribers (.addListener("X", handler)). Restrict to JS-family
|
||||
@@ -951,6 +1143,130 @@ function rnEventEdges(ctx: ResolutionContext): Edge[] {
|
||||
*/
|
||||
const FABRIC_NATIVE_SUFFIXES = ['', 'View', 'ViewManager', 'ComponentView', 'Manager'];
|
||||
|
||||
/**
|
||||
* Expo Modules cross-platform pairing. An Expo Module exposes the SAME
|
||||
* JS-visible method (`AsyncFunction("getBatteryLevelAsync")`) from BOTH an iOS
|
||||
* (Swift) and an Android (Kotlin) implementation. A JS callsite name-resolves to
|
||||
* only ONE of them, so the other platform's impl looked like nothing called it
|
||||
* (and editing it showed no blast radius). Link the iOS and Android impls of the
|
||||
* same `<module>.<method>` to each other (both directions), so a JS call that
|
||||
* reaches one platform reaches the other, and editing either surfaces the JS
|
||||
* caller. The Expo method nodes are id-prefixed `expo-module:` and qualified
|
||||
* `<file>::<module>.<method>` by the framework extractor.
|
||||
*/
|
||||
function expoCrossPlatformEdges(queries: QueryBuilder): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
const byKey = new Map<string, Node[]>();
|
||||
for (const m of queries.getNodesByKind('method')) {
|
||||
if (!m.id.startsWith('expo-module:')) continue;
|
||||
const key = m.qualifiedName.split('::').pop(); // `<module>.<method>`
|
||||
if (!key) continue;
|
||||
const arr = byKey.get(key);
|
||||
if (arr) arr.push(m);
|
||||
else byKey.set(key, [m]);
|
||||
}
|
||||
for (const group of byKey.values()) {
|
||||
if (group.length < 2) continue;
|
||||
for (const a of group) {
|
||||
for (const b of group) {
|
||||
if (a.id === b.id || a.language === b.language) continue; // cross-platform only
|
||||
const key = `${a.id}>${b.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: a.id,
|
||||
target: b.id,
|
||||
kind: 'calls',
|
||||
line: a.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'expo-cross-platform', via: a.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classic React Native NativeModules cross-platform pairing. A native module
|
||||
* method (`@ReactMethod` on Android, `RCT_EXPORT_METHOD` on iOS) is implemented
|
||||
* on BOTH platforms, but a JS callsite name-resolves to only ONE — so the other
|
||||
* platform's impl looked like nothing called it. A native method that HAS a JS
|
||||
* caller is a confirmed bridge method; link it to the same-named native method
|
||||
* in another language (the other platform's impl) so a JS call reaching one
|
||||
* platform reaches the other, and editing either surfaces the JS caller.
|
||||
*
|
||||
* Names are normalized to the first selector keyword (`getFreeDiskStorage:` →
|
||||
* `getFreeDiskStorage`) — that's the JS-visible name, and how the iOS selector
|
||||
* lines up with the bare Android method name.
|
||||
*/
|
||||
function rnCrossPlatformEdges(queries: QueryBuilder): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
const NATIVE = new Set(['java', 'kotlin', 'objc', 'cpp']);
|
||||
const JS = new Set(['typescript', 'tsx', 'javascript', 'jsx']);
|
||||
// RN module INFRASTRUCTURE methods exist on every native module (called by the
|
||||
// RN runtime, not user JS), so pairing them by name would cross-link unrelated
|
||||
// modules in a multi-module repo. Skip them — they aren't user-facing methods.
|
||||
const RN_INFRA = new Set([
|
||||
'addListener', 'removeListeners', 'getConstants', 'constantsToExport', 'getName',
|
||||
'invalidate', 'initialize', 'getDefaultEventTypes', 'supportedEvents',
|
||||
'requiresMainQueueSetup', 'methodQueue',
|
||||
]);
|
||||
const norm = (name: string): string => {
|
||||
const i = name.indexOf(':');
|
||||
return i >= 0 ? name.slice(0, i) : name;
|
||||
};
|
||||
|
||||
// Index native methods by their JS-visible (normalized) name. Only names with
|
||||
// impls in ≥2 native languages can pair, so the per-method JS-caller check
|
||||
// below only runs for genuine cross-platform candidates.
|
||||
const byName = new Map<string, Node[]>();
|
||||
for (const m of queries.iterateNodesByKind('method')) {
|
||||
if (!NATIVE.has(m.language)) continue;
|
||||
const key = norm(m.name);
|
||||
const arr = byName.get(key);
|
||||
if (arr) arr.push(m);
|
||||
else byName.set(key, [m]);
|
||||
}
|
||||
|
||||
for (const [groupName, group] of byName) {
|
||||
if (RN_INFRA.has(groupName)) continue;
|
||||
const langs = new Set(group.map((m) => m.language));
|
||||
if (langs.size < 2) continue; // single-platform — nothing to pair
|
||||
for (const m of group) {
|
||||
// Is m a bridge method? (a JS-language `calls` edge points at it)
|
||||
const incoming = queries.getIncomingEdges(m.id, ['calls']);
|
||||
if (incoming.length === 0) continue;
|
||||
const sources = queries.getNodesByIds(incoming.map((e) => e.source));
|
||||
const isBridge = incoming.some((e) => {
|
||||
const s = sources.get(e.source);
|
||||
return !!s && JS.has(s.language);
|
||||
});
|
||||
if (!isBridge) continue;
|
||||
// Link to the other-platform impls (both directions).
|
||||
for (const sib of group) {
|
||||
if (sib.id === m.id || sib.language === m.language) continue;
|
||||
for (const [a, b] of [[m, sib], [sib, m]] as const) {
|
||||
const key = `${a.id}>${b.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
edges.push({
|
||||
source: a.id,
|
||||
target: b.id,
|
||||
kind: 'calls',
|
||||
line: a.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'rn-cross-platform', via: norm(m.name) },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const seen = new Set<string>();
|
||||
@@ -1183,25 +1499,116 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext):
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delphi form code-behind: a form unit `UFRMAbout.pas` owns its visual form
|
||||
* definition `UFRMAbout.dfm` (VCL) / `.fmx` (FireMonkey) — paired by basename in
|
||||
* the same directory, wired by the `{$R *.dfm}` directive rather than a `uses`
|
||||
* clause. Link the unit → its form so a `.dfm`/`.fmx` used only as a form
|
||||
* definition isn't orphaned, and editing the form surfaces its code-behind unit.
|
||||
*/
|
||||
function pascalFormEdges(ctx: ResolutionContext): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const allFiles = new Set(ctx.getAllFiles());
|
||||
for (const file of allFiles) {
|
||||
if (!/\.(dfm|fmx)$/i.test(file)) continue;
|
||||
const pasFile = file.replace(/\.(dfm|fmx)$/i, '.pas');
|
||||
if (!allFiles.has(pasFile)) continue;
|
||||
const formNode = ctx.getNodesInFile(file).find((n) => n.kind === 'file');
|
||||
const unitNode = ctx.getNodesInFile(pasFile).find((n) => n.kind === 'file');
|
||||
if (!formNode || !unitNode) continue;
|
||||
edges.push({
|
||||
source: unitNode.id,
|
||||
target: formNode.id,
|
||||
kind: 'references',
|
||||
line: unitNode.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: { synthesizedBy: 'pascal-form', registeredAt: pasFile },
|
||||
});
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* SvelteKit file-convention data flow. A route directory's `+page.svelte` (a
|
||||
* `component` node) receives its `data` from the sibling `+page.server.{ts,js}`
|
||||
* / `+page.{ts,js}` `load` function and posts forms to its `actions` — wired by
|
||||
* the framework BY FILE PATH, with no static import between them. So editing a
|
||||
* `load` shows no impact on the page it feeds, and the page looks like it has no
|
||||
* server-side dependency. Link the page component to its sibling loader's
|
||||
* `load` / `actions` (same for `+layout`). The pairing is path-deterministic
|
||||
* (same directory, matching `+page`/`+layout` prefix), so it's precise — but
|
||||
* it's a framework-convention edge, so provenance stays `heuristic`.
|
||||
*
|
||||
* Direction: page → load, so `getImpactRadius(load)` surfaces the page (editing
|
||||
* a loader's data shows the page it feeds) and the page's dependencies include
|
||||
* its loader.
|
||||
*/
|
||||
function svelteKitLoadEdges(ctx: ResolutionContext): Edge[] {
|
||||
const edges: Edge[] = [];
|
||||
const allFiles = new Set(ctx.getAllFiles());
|
||||
const HOOKS = new Set(['load', 'actions']);
|
||||
const HOOK_KINDS = new Set(['function', 'method', 'constant', 'variable']);
|
||||
for (const file of allFiles) {
|
||||
const m = file.match(/(.*\/)(\+(?:page|layout))\.svelte$/);
|
||||
if (!m) continue;
|
||||
const dir = m[1]!;
|
||||
const prefix = m[2]!;
|
||||
const page = ctx.getNodesInFile(file).find((n) => n.kind === 'component');
|
||||
if (!page) continue;
|
||||
for (const ext of ['.server.ts', '.server.js', '.ts', '.js']) {
|
||||
const loaderFile = `${dir}${prefix}${ext}`;
|
||||
if (!allFiles.has(loaderFile)) continue;
|
||||
for (const hook of ctx.getNodesInFile(loaderFile)) {
|
||||
if (!HOOK_KINDS.has(hook.kind) || !HOOKS.has(hook.name)) continue;
|
||||
edges.push({
|
||||
source: page.id,
|
||||
target: hook.id,
|
||||
kind: 'references',
|
||||
line: page.startLine,
|
||||
provenance: 'heuristic',
|
||||
metadata: {
|
||||
synthesizedBy: 'sveltekit-load',
|
||||
via: hook.name,
|
||||
registeredAt: `${loaderFile}:${hook.startLine ?? 0}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize dispatcher→callback edges (field observers + EventEmitters +
|
||||
* React re-render + JSX children + Vue templates + RN event channel +
|
||||
* Fabric native-impl + MyBatis Java↔XML + Gin middleware chain). Returns the
|
||||
* count added. Never throws into indexing — callers wrap in try/catch.
|
||||
* React re-render + JSX children + Vue templates + SvelteKit load + RN event
|
||||
* channel + Fabric native-impl + MyBatis Java↔XML + Gin middleware chain).
|
||||
* Returns the count added. Never throws into indexing — callers wrap in try/catch.
|
||||
*/
|
||||
export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): number {
|
||||
// Go implicit `implements` edges must be synthesized AND persisted first: the
|
||||
// interface-dispatch bridge below reads `implements` edges from the DB, and
|
||||
// Go has none statically. (Other languages already have static implements
|
||||
// edges from extraction, so they don't need this pre-pass.)
|
||||
const goImpl = goImplementsEdges(queries);
|
||||
if (goImpl.length > 0) queries.insertEdges(goImpl);
|
||||
|
||||
const fieldEdges = fieldChannelEdges(queries, ctx);
|
||||
const closureCollEdges = closureCollectionEdges(queries, ctx);
|
||||
const emitterEdges = eventEmitterEdges(ctx);
|
||||
const renderEdges = reactRenderEdges(queries, ctx);
|
||||
const jsxEdges = reactJsxChildEdges(ctx);
|
||||
const vueEdges = vueTemplateEdges(ctx);
|
||||
const svelteKitEdges = svelteKitLoadEdges(ctx);
|
||||
const pascalEdges = pascalFormEdges(ctx);
|
||||
const flutterEdges = flutterBuildEdges(queries, ctx);
|
||||
const cppEdges = cppOverrideEdges(queries);
|
||||
const ifaceEdges = interfaceOverrideEdges(queries);
|
||||
const kotlinExpectActual = kotlinExpectActualEdges(queries);
|
||||
const goGrpcEdges = goGrpcStubImplEdges(queries);
|
||||
const rnEventEdgesList = rnEventEdges(ctx);
|
||||
const fabricNativeEdges = fabricNativeImplEdges(ctx);
|
||||
const expoXPlatEdges = expoCrossPlatformEdges(queries);
|
||||
const rnXPlatEdges = rnCrossPlatformEdges(queries);
|
||||
const mybatisEdges = mybatisJavaXmlEdges(queries);
|
||||
const ginEdges = ginMiddlewareChainEdges(queries, ctx);
|
||||
|
||||
@@ -1214,12 +1621,17 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
||||
...renderEdges,
|
||||
...jsxEdges,
|
||||
...vueEdges,
|
||||
...svelteKitEdges,
|
||||
...pascalEdges,
|
||||
...flutterEdges,
|
||||
...cppEdges,
|
||||
...ifaceEdges,
|
||||
...kotlinExpectActual,
|
||||
...goGrpcEdges,
|
||||
...rnEventEdgesList,
|
||||
...fabricNativeEdges,
|
||||
...expoXPlatEdges,
|
||||
...rnXPlatEdges,
|
||||
...mybatisEdges,
|
||||
...ginEdges,
|
||||
]) {
|
||||
@@ -1229,5 +1641,5 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
||||
merged.push(e);
|
||||
}
|
||||
if (merged.length > 0) queries.insertEdges(merged);
|
||||
return merged.length;
|
||||
return merged.length + goImpl.length;
|
||||
}
|
||||
|
||||
@@ -54,9 +54,14 @@ import {
|
||||
* line as the keyword, which matches every real Expo Module declaration
|
||||
* style. Multi-line `AsyncFunction(\n"x"\n)` forms aren't a real shape in
|
||||
* the SDK; if any appear we'd extend the regex.
|
||||
*
|
||||
* The optional `<…>` covers Kotlin's GENERIC-typed declarations
|
||||
* (`AsyncFunction<Float>("getBatteryLevelAsync")`, `AsyncFunction<Int, String>(…)`)
|
||||
* — without it, every Android Expo Module method was silently dropped, so a JS
|
||||
* callsite resolved only to the iOS Swift impl and never the Android one.
|
||||
*/
|
||||
const EXPO_DECL_RE =
|
||||
/\b(Function|AsyncFunction|Property|Constants)\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g;
|
||||
/\b(Function|AsyncFunction|Property|Constants)\s*(?:<[^(]*>)?\s*\(\s*["']([A-Za-z_][A-Za-z0-9_]*)["']/g;
|
||||
|
||||
/**
|
||||
* Match the module name literal `Name("ExpoX")`. Used to enrich each emitted
|
||||
|
||||
@@ -48,10 +48,14 @@ export const djangoResolver: FrameworkResolver = {
|
||||
return null;
|
||||
},
|
||||
|
||||
// Let the ORM dynamic-dispatch ref reach resolve() despite no symbol being
|
||||
// named `_iterable_class` (it's a QuerySet attribute, not a declared method).
|
||||
// Let two ref shapes past resolveOne's "no possible match" pre-filter so they
|
||||
// reach resolution: the ORM dynamic-dispatch `_iterable_class` (a QuerySet
|
||||
// attribute, not a declared symbol), and a Django `include('app.urls')` module
|
||||
// path — a dotted module name with no symbol/import to match, which resolution
|
||||
// (resolvePythonAbsoluteModule) then maps to its `urls.py` file so the included
|
||||
// URLconf records a dependency on the root urlconf.
|
||||
claimsReference(name) {
|
||||
return name === '_iterable_class';
|
||||
return name === '_iterable_class' || name.endsWith('.urls');
|
||||
},
|
||||
|
||||
extract(filePath, content) {
|
||||
|
||||
@@ -99,8 +99,8 @@ function defaultObjcModuleName(className: string): string {
|
||||
function parseObjcRNExports(
|
||||
source: string,
|
||||
className: string | null
|
||||
): Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string }> {
|
||||
const results: Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string }> = [];
|
||||
): Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string; line: number }> {
|
||||
const results: Array<{ moduleName: string; jsName: string; nativeSelectorFirstKw: string; line: number }> = [];
|
||||
|
||||
// RCT_EXPORT_MODULE — one per file by convention. Capture the optional arg.
|
||||
const moduleMatch = source.match(/RCT_EXPORT_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)?\s*\)/);
|
||||
@@ -111,6 +111,12 @@ function parseObjcRNExports(
|
||||
(className ? defaultObjcModuleName(className) : null);
|
||||
if (!moduleName) return results;
|
||||
|
||||
const lineOf = (idx: number): number => {
|
||||
let line = 1;
|
||||
for (let i = 0; i < idx && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
|
||||
return line;
|
||||
};
|
||||
|
||||
// RCT_EXPORT_METHOD(selectorFirstKw:(args)…)
|
||||
// The first keyword (everything up to the first `:` or open paren) is the
|
||||
// JS-visible name. We don't try to parse full multi-keyword selectors —
|
||||
@@ -119,7 +125,7 @@ function parseObjcRNExports(
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = exportRegex.exec(source)) !== null) {
|
||||
const kw = m[1];
|
||||
if (kw) results.push({ moduleName, jsName: kw, nativeSelectorFirstKw: kw });
|
||||
if (kw) results.push({ moduleName, jsName: kw, nativeSelectorFirstKw: kw, line: lineOf(m.index) });
|
||||
}
|
||||
|
||||
// RCT_REMAP_METHOD(jsName, nativeSelectorFirstKw:(args)…)
|
||||
@@ -129,7 +135,7 @@ function parseObjcRNExports(
|
||||
const jsName = m[1];
|
||||
const nativeKw = m[2];
|
||||
if (jsName && nativeKw) {
|
||||
results.push({ moduleName, jsName, nativeSelectorFirstKw: nativeKw });
|
||||
results.push({ moduleName, jsName, nativeSelectorFirstKw: nativeKw, line: lineOf(m.index) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +361,48 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
|
||||
|
||||
export const reactNativeBridgeResolver: FrameworkResolver = {
|
||||
name: 'react-native-bridge',
|
||||
languages: ['javascript', 'typescript', 'tsx', 'jsx'],
|
||||
// objc/mm included so `extract()` sees the native files — `resolve()` still
|
||||
// only redirects JS callers (it returns null for native languages).
|
||||
languages: ['javascript', 'typescript', 'tsx', 'jsx', 'objc'],
|
||||
|
||||
/**
|
||||
* Extract `RCT_EXPORT_METHOD` / `RCT_REMAP_METHOD` declarations as method
|
||||
* nodes. These macros parse as a macro-expression (an ERROR node), NOT a
|
||||
* `method_definition`, so the ObjC extractor never made a node for them — the
|
||||
* iOS half of a native module was invisible, so a JS call couldn't resolve to
|
||||
* it and the cross-platform pairing had nothing to pair. The node is named by
|
||||
* the JS-visible name (the selector's first keyword, or the explicit
|
||||
* `RCT_REMAP_METHOD` JS name) so it matches the Android `@ReactMethod` method.
|
||||
*/
|
||||
extract(filePath, source) {
|
||||
if (!filePath.endsWith('.m') && !filePath.endsWith('.mm')) return { nodes: [], references: [] };
|
||||
if (!/RCT_EXPORT_MODULE\b/.test(source)) return { nodes: [], references: [] };
|
||||
const exports = parseObjcRNExports(source, findObjcClassName(source));
|
||||
const now = Date.now();
|
||||
const nodes: Node[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of exports) {
|
||||
if (seen.has(e.jsName)) continue;
|
||||
seen.add(e.jsName);
|
||||
nodes.push({
|
||||
id: `rn-export:${filePath}:${e.moduleName}.${e.jsName}`,
|
||||
kind: 'method',
|
||||
name: e.jsName,
|
||||
qualifiedName: `${filePath}::${e.moduleName}.${e.jsName}`,
|
||||
filePath,
|
||||
language: 'objc',
|
||||
startLine: e.line,
|
||||
endLine: e.line,
|
||||
startColumn: 0,
|
||||
endColumn: 0,
|
||||
isExported: true,
|
||||
docstring: `RCT_EXPORT_METHOD ${e.nativeSelectorFirstKw} (module ${e.moduleName})`,
|
||||
signature: `RCT_EXPORT_METHOD(${e.nativeSelectorFirstKw}:…)`,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
return { nodes, references: [] };
|
||||
},
|
||||
|
||||
/**
|
||||
* Detect: package.json depends on `react-native`, OR any source file
|
||||
|
||||
@@ -213,6 +213,24 @@ function resolveRelativeImport(
|
||||
const projectRoot = context.getProjectRoot();
|
||||
const extensions = EXTENSION_RESOLUTION[language] || [];
|
||||
|
||||
// Python dotted-relative imports (`from .certs import x`, `from ..pkg.mod
|
||||
// import y`): leading dots are PACKAGE levels (1 = current package), and the
|
||||
// remainder is a dotted submodule path. `path.resolve(dir, '.certs')` would
|
||||
// treat `.certs` as a literal hidden filename, so translate the Python form
|
||||
// to a real filesystem-relative path before resolving.
|
||||
if (language === 'python' && importPath.startsWith('.')) {
|
||||
const dots = importPath.length - importPath.replace(/^\.+/, '').length;
|
||||
const up = '../'.repeat(Math.max(0, dots - 1)); // 1 dot = current dir
|
||||
const rest = importPath.slice(dots).replace(/\./g, '/'); // 'sub.mod' -> 'sub/mod'
|
||||
const pyBase = path.resolve(fromDir, up + rest);
|
||||
const pyRel = path.relative(projectRoot, pyBase).replace(/\\/g, '/');
|
||||
for (const ext of extensions) {
|
||||
if (context.fileExists(pyRel + ext)) return pyRel + ext;
|
||||
}
|
||||
if (pyRel && context.fileExists(pyRel)) return pyRel;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try the path as-is first
|
||||
const basePath = path.resolve(fromDir, importPath);
|
||||
const relativePath = path.relative(projectRoot, basePath).replace(/\\/g, '/');
|
||||
@@ -1012,14 +1030,53 @@ export function resolveJvmImport(
|
||||
const candidates = context.getNodesByQualifiedName(`${pkg}::${sym}`);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Kotlin Multiplatform: an `expect` declaration and its `actual`s share one
|
||||
// FQN across source sets (commonMain / androidMain / appleMain). Taking the
|
||||
// first candidate let a single platform `actual` absorb every common-side
|
||||
// import, so the `expect` (the canonical API a commonMain file imports)
|
||||
// looked unused. Prefer the candidate CLOSEST to the importing file by
|
||||
// directory proximity — a commonMain import resolves to the commonMain
|
||||
// declaration — with the `expect` side as a tiebreak.
|
||||
const best = candidates.length === 1 ? candidates[0]! : pickClosestJvmCandidate(candidates, ref.filePath);
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: candidates[0]!.id,
|
||||
targetNodeId: best.id,
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'import',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the same-FQN candidate closest to `fromPath` by shared directory
|
||||
* prefix, preferring an `expect` declaration on a tie. Used to keep a Kotlin
|
||||
* Multiplatform `expect`/`actual` import resolving within the importer's own
|
||||
* source set instead of an arbitrary platform `actual`.
|
||||
*/
|
||||
function pickClosestJvmCandidate(candidates: Node[], fromPath: string): Node {
|
||||
const fromDirs = fromPath.split('/').slice(0, -1);
|
||||
const sharedPrefix = (p: string): number => {
|
||||
const d = p.split('/').slice(0, -1);
|
||||
let shared = 0;
|
||||
for (let i = 0; i < Math.min(fromDirs.length, d.length); i++) {
|
||||
if (fromDirs[i] === d[i]) shared++;
|
||||
else break;
|
||||
}
|
||||
return shared;
|
||||
};
|
||||
const isExpect = (n: Node): boolean => Array.isArray(n.decorators) && n.decorators.includes('expect');
|
||||
let best = candidates[0]!;
|
||||
let bestProx = sharedPrefix(best.filePath);
|
||||
for (let i = 1; i < candidates.length; i++) {
|
||||
const c = candidates[i]!;
|
||||
const prox = sharedPrefix(c.filePath);
|
||||
if (prox > bestProx || (prox === bestProx && isExpect(c) && !isExpect(best))) {
|
||||
best = c;
|
||||
bestProx = prox;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function resolveViaImport(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
@@ -1032,6 +1089,23 @@ export function resolveViaImport(
|
||||
// edge — resolveViaImport's symbol lookup below would search the
|
||||
// resolved file for a symbol named like the file extension and fail.
|
||||
if ((ref.language === 'c' || ref.language === 'cpp') && ref.referenceKind === 'imports') {
|
||||
// C/C++ quoted includes (`#include "X.h"`) resolve relative to the
|
||||
// INCLUDING file's own directory first (the C standard's quoted-include
|
||||
// search order). Prefer a same-directory header over an -I directory or a
|
||||
// same-named header on another platform (windows/code/RNCAsyncStorage.h vs
|
||||
// apple/.../RNCAsyncStorage.h) — the include-dir heuristic below would
|
||||
// otherwise pick an arbitrary same-named header, leaving the real local one
|
||||
// with no dependents.
|
||||
const slash = ref.filePath.lastIndexOf('/');
|
||||
const fromDir = slash >= 0 ? ref.filePath.slice(0, slash) : '';
|
||||
const siblingPath = path.posix.normalize(fromDir ? `${fromDir}/${ref.referenceName}` : ref.referenceName);
|
||||
const siblingBase = siblingPath.split('/').pop()!;
|
||||
const sibling = context
|
||||
.getNodesByName(siblingBase)
|
||||
.find((n) => n.kind === 'file' && n.filePath === siblingPath);
|
||||
if (sibling) {
|
||||
return { original: ref, targetNodeId: sibling.id, confidence: 0.92, resolvedBy: 'import' };
|
||||
}
|
||||
const resolvedPath = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
|
||||
if (!resolvedPath) return null;
|
||||
const basename = resolvedPath.split('/').pop()!;
|
||||
@@ -1074,6 +1148,55 @@ export function resolveViaImport(
|
||||
if (javaResult) return javaResult;
|
||||
}
|
||||
|
||||
// Python qualified access through an imported MODULE: `certs.where()` after
|
||||
// `from . import certs`, `mod.func()` after `import mod`. The receiver names a
|
||||
// submodule (a file), not a symbol, so the generic symbol lookup below would
|
||||
// search the *package* for `certs` instead of looking inside the module.
|
||||
if (ref.language === 'python') {
|
||||
const pyResult = resolvePythonModuleMember(ref, imports, context);
|
||||
if (pyResult) return pyResult;
|
||||
// Absolute dotted module import: `import conduit.apps.articles.signals`
|
||||
// (the standard Django AppConfig.ready() signal-registration pattern, and
|
||||
// any side-effect `import pkg.mod`). Map the dotted path to its file.
|
||||
const pyModResult = resolvePythonAbsoluteModule(ref, context);
|
||||
if (pyModResult) return pyModResult;
|
||||
}
|
||||
|
||||
// Rust qualified path: resolve the module prefix of `crate::m::Item` /
|
||||
// `self::sub::Item` / `super::m::func` to a file, then find the leaf symbol in
|
||||
// it. Disambiguates common-name `pub use self::read::read` re-exports that
|
||||
// name-matching would land on the wrong same-named symbol.
|
||||
if (ref.language === 'rust' && ref.referenceName.includes('::')) {
|
||||
const rustResult = resolveRustPathReference(ref, context);
|
||||
if (rustResult) return rustResult;
|
||||
}
|
||||
|
||||
// Lua / Luau `require(...)`: a dotted module path (`a.b.c` from
|
||||
// `require("a.b.c")`) or an instance-path leaf (`Signal` from
|
||||
// `require(script.Parent.Signal)`) — map it to a module file. There's no static
|
||||
// import statement, so the generic path-matcher can't bridge the dot↔slash /
|
||||
// leaf↔basename gap; resolve it explicitly to the module file.
|
||||
if ((ref.language === 'lua' || ref.language === 'luau') && ref.referenceKind === 'imports') {
|
||||
const luaResult = resolveLuaRequire(ref, context);
|
||||
if (luaResult) return luaResult;
|
||||
}
|
||||
|
||||
// Whole-module / namespace imports → link the importing file to the module
|
||||
// file. Python `from . import certs` / `import mod`, and TS/JS `import * as ns
|
||||
// from './x'` (so a namespace touched only via a value-member read still
|
||||
// records the dependency). A named TS/JS import returns null here and falls
|
||||
// through to symbol resolution below.
|
||||
if (
|
||||
ref.language === 'python' ||
|
||||
ref.language === 'typescript' ||
|
||||
ref.language === 'tsx' ||
|
||||
ref.language === 'javascript' ||
|
||||
ref.language === 'jsx'
|
||||
) {
|
||||
const moduleFile = resolveModuleImportToFile(ref, imports, context);
|
||||
if (moduleFile) return moduleFile;
|
||||
}
|
||||
|
||||
// Check if the reference name matches any import
|
||||
for (const imp of imports) {
|
||||
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
|
||||
@@ -1114,6 +1237,345 @@ export function resolveViaImport(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Python qualified reference whose receiver is an imported MODULE:
|
||||
* `certs.where()` after `from . import certs`, `mod.func()` after `import mod`
|
||||
* or `from pkg import mod`. The receiver names a submodule (a file), not a
|
||||
* symbol, so the generic symbol lookup in `resolveViaImport` can't follow it —
|
||||
* it would search the *package* for `certs`/`mod` instead of looking inside the
|
||||
* module. This is the Python half of the cross-package qualified-call problem
|
||||
* (cf. `resolveGoCrossPackageReference` for Go's `pkg.Func`, issue #388).
|
||||
*
|
||||
* Builds the module's dotted import path from the binding — `from . import
|
||||
* certs` → `.certs`; `from pkg import mod` → `pkg.mod`; `import mod` → `mod` —
|
||||
* resolves it to the module file, and finds the member defined there. Returns
|
||||
* null when no module file exists at that path, so attribute access on an
|
||||
* imported *value* (`helper.attr` where `helper` is a function) falls through
|
||||
* to the other strategies untouched.
|
||||
*/
|
||||
function resolvePythonModuleMember(
|
||||
ref: UnresolvedRef,
|
||||
imports: ImportMapping[],
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
const dotIdx = ref.referenceName.indexOf('.');
|
||||
if (dotIdx <= 0) return null;
|
||||
const receiver = ref.referenceName.substring(0, dotIdx);
|
||||
// The immediate member of the module (first segment after the receiver).
|
||||
const member = ref.referenceName.substring(dotIdx + 1).split('.')[0];
|
||||
if (!member) return null;
|
||||
|
||||
for (const imp of imports) {
|
||||
if (imp.localName !== receiver) continue;
|
||||
|
||||
// `import mod` / `import numpy as np` bind the module at `source` itself;
|
||||
// `from . import certs` / `from pkg import mod` bind a SUBMODULE whose
|
||||
// dotted path is the source joined with the imported name.
|
||||
const modulePath = imp.isNamespace
|
||||
? imp.source
|
||||
: imp.source.endsWith('.')
|
||||
? imp.source + imp.localName
|
||||
: imp.source + '.' + imp.localName;
|
||||
|
||||
const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
|
||||
if (!resolvedPath || resolvedPath === ref.filePath) continue;
|
||||
|
||||
// Find the member as a top-level definition in the module file. Exclude
|
||||
// `method` so `mod.foo` never lands on a same-named class method.
|
||||
const target = context.getNodesInFile(resolvedPath).find(
|
||||
(n) =>
|
||||
n.name === member &&
|
||||
(n.kind === 'function' ||
|
||||
n.kind === 'class' ||
|
||||
n.kind === 'variable' ||
|
||||
n.kind === 'constant')
|
||||
);
|
||||
if (target) {
|
||||
return { original: ref, targetNodeId: target.id, confidence: 0.85, resolvedBy: 'import' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a whole-MODULE import to that module's file (a file→file dependency).
|
||||
* The imported name is a module, not a symbol, so there's nothing to resolve to
|
||||
* — but importing a module IS a dependency on it. Covers:
|
||||
* - Python submodule imports — `from . import certs`, `from pkg import sub`;
|
||||
* - namespace imports — Python `import mod` / `import numpy as np`, and
|
||||
* TS/JS `import * as ns from './x'`.
|
||||
*
|
||||
* It is also the robust backstop for {@link resolvePythonModuleMember} and for
|
||||
* TS namespace usage: it records the dependency even when the used member is
|
||||
* re-exported elsewhere (requests' `certs.where`, re-exported from `certifi`),
|
||||
* the usage is module-level code that isn't extracted as a call, or a TS
|
||||
* namespace is touched only via a value-member read (`ns.SOME_CONST`).
|
||||
*
|
||||
* Only fires for dot-free `imports`-kind refs whose module path resolves to a
|
||||
* real file. A NAMED TS/JS import (`import { widget }`) is not a module, so it
|
||||
* returns null and normal symbol resolution handles it.
|
||||
*/
|
||||
/**
|
||||
* Resolve a Lua/Luau `require(...)` to its module file. The reference name is
|
||||
* either a dotted module path (`telescope.config` → `telescope/config.lua`) or a
|
||||
* Roblox instance-path leaf (`Signal` from `require(script.Parent.Signal)` →
|
||||
* `Signal.luau`). We try `<path>.lua|.luau` and `<path>/init.lua|.luau`, matched
|
||||
* by path suffix (the module root — `lua/`, `src/`, … — is project-specific).
|
||||
* Among suffix matches, the one sharing the longest directory prefix with the
|
||||
* requiring file wins (instance-path requires resolve within the same package).
|
||||
*/
|
||||
function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
||||
const name = ref.referenceName;
|
||||
if (!name) return null;
|
||||
const base = name.includes('.') ? name.replace(/\./g, '/') : name;
|
||||
const suffixes = [`${base}.lua`, `${base}.luau`, `${base}/init.lua`, `${base}/init.luau`];
|
||||
const files = context.getAllFiles();
|
||||
const shared = (a: string, b: string): number => {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
||||
return i;
|
||||
};
|
||||
for (const suffix of suffixes) {
|
||||
const matches = files.filter((f) => f === suffix || f.endsWith('/' + suffix));
|
||||
if (matches.length === 0) continue;
|
||||
matches.sort((x, y) => shared(y, ref.filePath) - shared(x, ref.filePath));
|
||||
const best = matches[0]!;
|
||||
if (best === ref.filePath) continue;
|
||||
const fileNode = context.getNodesInFile(best).find((n) => n.kind === 'file');
|
||||
if (fileNode) {
|
||||
// Confidence ≥ 0.9 so this deterministic path/suffix match wins over
|
||||
// name-matching, which otherwise resolves the require to the import node
|
||||
// itself (a same-name self-match).
|
||||
return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveModuleImportToFile(
|
||||
ref: UnresolvedRef,
|
||||
imports: ImportMapping[],
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
if (ref.referenceKind !== 'imports') return null;
|
||||
if (ref.referenceName.includes('.')) return null;
|
||||
|
||||
for (const imp of imports) {
|
||||
if (imp.localName !== ref.referenceName) continue;
|
||||
|
||||
let modulePath: string;
|
||||
if (imp.isNamespace || imp.isDefault) {
|
||||
// `import * as ns from './x'` (namespace) or `import x from './x'`
|
||||
// (default) — the dependency is on the MODULE FILE. A default import binds
|
||||
// a (possibly renamed) local to whatever the module's default export is
|
||||
// (`import articlesController from './article.controller'` ← `export
|
||||
// default router`), so the binding name can't be found as a symbol — link
|
||||
// the file the import resolves to instead. External modules don't resolve
|
||||
// (no file), so `import React from 'react'` creates no edge.
|
||||
modulePath = imp.source;
|
||||
} else if (ref.language === 'python') {
|
||||
// `from . import certs` — the imported NAME is a submodule of the source.
|
||||
modulePath = imp.source.endsWith('.')
|
||||
? imp.source + imp.localName
|
||||
: imp.source + '.' + imp.localName;
|
||||
} else {
|
||||
// A named TS/JS import binds a symbol, not a module — leave it alone.
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedPath = resolveImportPath(modulePath, ref.filePath, ref.language, context);
|
||||
if (resolvedPath && resolvedPath !== ref.filePath) {
|
||||
const fileNode = context.getNodesInFile(resolvedPath).find((n) => n.kind === 'file');
|
||||
if (fileNode) {
|
||||
return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
}
|
||||
}
|
||||
|
||||
// Python absolute `from a.b import submodule` (a FastAPI router aggregator's
|
||||
// `from app.api.routes import authentication`): resolveImportPath only maps
|
||||
// RELATIVE dotted paths to a file, so resolve the absolute dotted module
|
||||
// directly to its file node.
|
||||
if (ref.language === 'python') {
|
||||
const modFile = findPythonModuleFile(modulePath, context, ref.filePath);
|
||||
if (modFile) {
|
||||
return { original: ref, targetNodeId: modFile.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the file node for a Python dotted module path `a.b.c` — a module file
|
||||
* ending in `a/b/c.py`, or a package `a/b/c/__init__.py` (suffix-matched, so a
|
||||
* package rooted under `src/` etc. still resolves). Returns null for
|
||||
* stdlib/external modules (no matching repo file node), so `import os` creates
|
||||
* no edge. Shared by absolute `import a.b.c` and absolute `from a.b import c`
|
||||
* (where `c` is a submodule) resolution.
|
||||
*/
|
||||
function findPythonModuleFile(
|
||||
mod: string,
|
||||
context: ResolutionContext,
|
||||
excludeFilePath: string
|
||||
): Node | null {
|
||||
if (!mod || mod.startsWith('.')) return null; // relative imports handled elsewhere
|
||||
const rel = mod.replace(/\./g, '/');
|
||||
const lastSeg = mod.split('.').pop()!;
|
||||
const endsWith = (p: string, want: string): boolean => p === want || p.endsWith('/' + want);
|
||||
const moduleFile = context
|
||||
.getNodesByName(`${lastSeg}.py`)
|
||||
.find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}.py`));
|
||||
if (moduleFile) return moduleFile;
|
||||
const pkgFile = context
|
||||
.getNodesByName('__init__.py')
|
||||
.find((n) => n.kind === 'file' && n.filePath !== excludeFilePath && endsWith(n.filePath, `${rel}/__init__.py`));
|
||||
return pkgFile ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Python ABSOLUTE dotted module import (`import a.b.c`) to its file —
|
||||
* the Django `AppConfig.ready(): import myapp.signals` pattern and any
|
||||
* side-effect module import.
|
||||
*/
|
||||
function resolvePythonAbsoluteModule(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
if (ref.referenceKind !== 'imports') return null;
|
||||
// Only a DOTTED `import a.b.c` ref carries its full module path. A bare leaf
|
||||
// (`from app.api.routes import authentication`) is ambiguous on its own — three
|
||||
// `authentication.py` files may exist — so leave it to resolveModuleImportToFile,
|
||||
// which uses the import's source (`app.api.routes`) to build the full path.
|
||||
if (!ref.referenceName.includes('.')) return null;
|
||||
const hit = findPythonModuleFile(ref.referenceName, context, ref.filePath);
|
||||
return hit ? { original: ref, targetNodeId: hit.id, confidence: 0.9, resolvedBy: 'import' } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Rust qualified reference `A::B::C` by mapping the MODULE prefix
|
||||
* (`A::B`) to a file and finding the leaf symbol (`C`) in it. This is the Rust
|
||||
* analog of {@link resolvePythonModuleMember} / {@link resolveGoCrossPackageReference}
|
||||
* and the precise answer to common-name re-exports (`pub use self::read::read`)
|
||||
* that name-matching can't disambiguate. Returns null when the prefix isn't a
|
||||
* real module path (e.g. `Widget::new` — `Widget` is a struct, not a module),
|
||||
* so associated-function calls and enum-variant paths fall through untouched.
|
||||
*/
|
||||
function resolveRustPathReference(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
const segments = ref.referenceName.split('::').filter((s) => s.length > 0);
|
||||
if (segments.length < 2) return null;
|
||||
const leaf = segments[segments.length - 1]!;
|
||||
const modSegs = segments.slice(0, -1);
|
||||
|
||||
const file = resolveRustModuleFile(modSegs, ref.filePath, context);
|
||||
if (!file || file === ref.filePath) return null;
|
||||
|
||||
const target = context.getNodesInFile(file).find(
|
||||
(n) =>
|
||||
n.name === leaf &&
|
||||
(n.kind === 'function' ||
|
||||
n.kind === 'struct' ||
|
||||
n.kind === 'enum' ||
|
||||
n.kind === 'trait' ||
|
||||
n.kind === 'type_alias' ||
|
||||
n.kind === 'constant' ||
|
||||
n.kind === 'method' ||
|
||||
n.kind === 'class' ||
|
||||
n.kind === 'interface')
|
||||
);
|
||||
if (target) {
|
||||
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The crate-root directory (holds `lib.rs`/`main.rs`), walking up from a file. */
|
||||
function rustCrateRootDir(fromFileAbs: string, context: ResolutionContext): string | null {
|
||||
const projectRoot = context.getProjectRoot();
|
||||
const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
|
||||
let dir = path.dirname(fromFileAbs);
|
||||
for (let i = 0; i < 64; i++) {
|
||||
if (context.fileExists(toRel(path.join(dir, 'lib.rs'))) ||
|
||||
context.fileExists(toRel(path.join(dir, 'main.rs')))) {
|
||||
return dir;
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Directory under which the current file's module declares its SUBMODULES. */
|
||||
function rustSelfModuleDir(fromFileAbs: string): string {
|
||||
const base = path.basename(fromFileAbs);
|
||||
const dir = path.dirname(fromFileAbs);
|
||||
// mod.rs / lib.rs / main.rs own their directory; `foo.rs`'s submodules live in `foo/`.
|
||||
if (base === 'mod.rs' || base === 'lib.rs' || base === 'main.rs') return dir;
|
||||
return path.join(dir, base.replace(/\.rs$/, ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Rust module path (segments WITHOUT the leaf symbol) to the file of
|
||||
* the last module segment — `crate::a::b` → `<crate>/a/b.rs` (or `.../b/mod.rs`).
|
||||
* Anchors on `crate` / `self` / `super`; a bare path is tried crate-relative.
|
||||
*/
|
||||
function resolveRustModuleFile(
|
||||
segments: string[],
|
||||
fromFile: string,
|
||||
context: ResolutionContext
|
||||
): string | null {
|
||||
if (segments.length === 0) return null;
|
||||
const projectRoot = context.getProjectRoot();
|
||||
const fromAbs = path.join(projectRoot, fromFile);
|
||||
const toRel = (p: string) => path.relative(projectRoot, p).replace(/\\/g, '/');
|
||||
|
||||
// Walk a sequence of module segments down from `startDir`, mapping each to a
|
||||
// `<seg>.rs` or `<seg>/mod.rs` file. Returns the leaf module's file, or null
|
||||
// if `startDir` is null or any segment has no file on disk.
|
||||
const resolveUnder = (startDir: string | null, rest: string[]): string | null => {
|
||||
if (!startDir) return null;
|
||||
let dir = startDir;
|
||||
let targetFile: string | null = null;
|
||||
for (const seg of rest) {
|
||||
if (seg === 'self' || seg === 'crate' || seg === 'super') continue;
|
||||
const asFile = toRel(path.join(dir, seg + '.rs'));
|
||||
const asMod = toRel(path.join(dir, seg, 'mod.rs'));
|
||||
if (context.fileExists(asFile)) targetFile = asFile;
|
||||
else if (context.fileExists(asMod)) targetFile = asMod;
|
||||
else return null;
|
||||
dir = path.join(dir, seg);
|
||||
}
|
||||
return targetFile;
|
||||
};
|
||||
|
||||
const first = segments[0]!;
|
||||
if (first === 'crate') {
|
||||
return resolveUnder(rustCrateRootDir(fromAbs, context), segments.slice(1));
|
||||
}
|
||||
if (first === 'self') {
|
||||
return resolveUnder(rustSelfModuleDir(fromAbs), segments.slice(1));
|
||||
}
|
||||
if (first === 'super') {
|
||||
let supers = 0;
|
||||
while (segments[supers] === 'super') supers++;
|
||||
let dir: string | null = rustSelfModuleDir(fromAbs);
|
||||
for (let s = 0; s < supers && dir; s++) dir = path.dirname(dir);
|
||||
return resolveUnder(dir, segments.slice(supers));
|
||||
}
|
||||
// Bare path. In expression position (`submodule::item()` — the router-assembly
|
||||
// and general cross-module-call pattern) the prefix is a SUBMODULE of the
|
||||
// current module, i.e. 2018 `self::`-relative — so try self-relative FIRST.
|
||||
// Fall back to crate-relative for 2015-edition / crate-root items. External
|
||||
// crate paths (`serde::de::Error`) miss both and fall through to name-matching.
|
||||
return (
|
||||
resolveUnder(rustSelfModuleDir(fromAbs), segments) ??
|
||||
resolveUnder(rustCrateRootDir(fromAbs, context), segments)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Java/Kotlin reference whose receiver is the simple name of
|
||||
* an imported FQN: `Foo.bar(...)` where `import com.example.Foo;`. The
|
||||
|
||||
+124
-5
@@ -16,7 +16,7 @@ import {
|
||||
FrameworkResolver,
|
||||
ImportMapping,
|
||||
} from './types';
|
||||
import { matchReference } from './name-matcher';
|
||||
import { matchReference, sameLanguageFamily, crossesKnownFamily } from './name-matcher';
|
||||
import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs } from './import-resolver';
|
||||
import { detectFrameworks } from './frameworks';
|
||||
import { synthesizeCallbackEdges } from './callback-synthesizer';
|
||||
@@ -185,6 +185,10 @@ export class ReferenceResolver {
|
||||
private queries: QueryBuilder;
|
||||
private context: ResolutionContext;
|
||||
private frameworks: FrameworkResolver[] = [];
|
||||
// Per-`.razor`/`.cshtml`-file `@using` namespace set (own directives + folder
|
||||
// `_Imports.razor`, cascading to the project root). Used to disambiguate a
|
||||
// markup type ref to the right C# namespace.
|
||||
private razorUsingsCache = new Map<string, string[]>();
|
||||
// All per-resolver caches are LRU-bounded. Previously these were
|
||||
// unbounded Maps that grew with every distinct lookup and OOM'd on
|
||||
// codebases with 20k+ files (see issue: unbounded cache growth).
|
||||
@@ -560,6 +564,16 @@ export class ReferenceResolver {
|
||||
const receiver = name.substring(0, colonIdx);
|
||||
const member = name.substring(colonIdx + 2);
|
||||
if (this.knownNames.has(receiver) || this.knownNames.has(member)) return true;
|
||||
// Multi-segment path `a::b::c` (a Rust/C++ module call like
|
||||
// `database::profiles::find`) — the only segment that names a symbol is
|
||||
// the last (`c`); `member` above is `b::c`, which never matches a node
|
||||
// name, so without this the pre-filter drops the ref before the Rust path
|
||||
// resolver ever sees it. Mirror the dotted-name leaf check above.
|
||||
const lastColon = name.lastIndexOf('::');
|
||||
if (lastColon > colonIdx) {
|
||||
const tail = name.substring(lastColon + 2);
|
||||
if (tail && this.knownNames.has(tail)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For path-like references (e.g., "snippets/drawer-menu.liquid"), check the filename
|
||||
@@ -620,11 +634,25 @@ export class ReferenceResolver {
|
||||
const jvmImport = resolveJvmImport(ref, this.context);
|
||||
if (jvmImport) return jvmImport;
|
||||
|
||||
// Razor/Blazor: a markup or `@code` type ref resolves through the file's
|
||||
// `@using` namespaces (incl. folder `_Imports.razor`). This precisely
|
||||
// disambiguates a simple name that exists in several namespaces — e.g.
|
||||
// `CatalogBrand` resolving to `BlazorShared.Models::CatalogBrand` (the DTO,
|
||||
// which the `.razor` `@using`s) rather than the same-named domain entity.
|
||||
if (ref.language === 'razor') {
|
||||
const razorResult = this.resolveRazorUsing(ref);
|
||||
if (razorResult) return razorResult;
|
||||
}
|
||||
|
||||
const candidates: ResolvedRef[] = [];
|
||||
|
||||
// Strategy 1: Try framework-specific resolution
|
||||
// Strategy 1: Try framework-specific resolution. Cross-language bridges
|
||||
// are deliberately preserved (Drupal `routing.yml` → PHP controller, RN
|
||||
// JS → native `calls`) — `gateFrameworkLanguage` only drops a type/import
|
||||
// edge between two KNOWN families (see its doc), never a `calls` bridge or
|
||||
// a config↔code edge.
|
||||
for (const framework of this.frameworks) {
|
||||
const result = framework.resolve(ref, this.context);
|
||||
const result = this.gateFrameworkLanguage(framework.resolve(ref, this.context), ref);
|
||||
if (result) {
|
||||
if (result.confidence >= 0.9) return result; // High confidence, return immediately
|
||||
candidates.push(result);
|
||||
@@ -632,14 +660,14 @@ export class ReferenceResolver {
|
||||
}
|
||||
|
||||
// Strategy 2: Try import-based resolution
|
||||
const importResult = resolveViaImport(ref, this.context);
|
||||
const importResult = this.gateLanguage(resolveViaImport(ref, this.context), ref);
|
||||
if (importResult) {
|
||||
if (importResult.confidence >= 0.9) return importResult;
|
||||
candidates.push(importResult);
|
||||
}
|
||||
|
||||
// Strategy 3: Try name matching
|
||||
const nameResult = matchReference(ref, this.context);
|
||||
const nameResult = this.gateLanguage(matchReference(ref, this.context), ref);
|
||||
if (nameResult) {
|
||||
candidates.push(nameResult);
|
||||
}
|
||||
@@ -947,6 +975,97 @@ export class ReferenceResolver {
|
||||
const node = this.queries.getNodeById(nodeId);
|
||||
return node?.language || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop an import/name-strategy resolution that crosses a language family.
|
||||
* Two regimes (mirrors `applyLanguageGate`'s candidate filter):
|
||||
* - `references` (type usage): STRICT — a `Type.member` static read names a
|
||||
* same-family type, never a coincidentally same-named symbol in another
|
||||
* language. Drops any non-same-family target.
|
||||
* - `imports` (import binding / `#include`): both-known — a C++ `#include
|
||||
* "X.h"` must not resolve to a same-named ObjC header on another platform
|
||||
* (basename collision), but a singleton-family / SFC language (`vue` →
|
||||
* `.ts`) importing across is left alone.
|
||||
* Applies to the import (strategy 2) + name-match (strategy 3) results.
|
||||
*/
|
||||
/**
|
||||
* Collect the `@using` namespaces in scope for a `.razor`/`.cshtml` file: its
|
||||
* own `@using` directives plus every `_Imports.razor` from the file's folder up
|
||||
* to the project root (Razor `_Imports` cascade). Cached per file.
|
||||
*/
|
||||
private getRazorUsings(filePath: string): string[] {
|
||||
const cached = this.razorUsingsCache.get(filePath);
|
||||
if (cached) return cached;
|
||||
const usings = new Set<string>();
|
||||
const addFrom = (src: string | null): void => {
|
||||
if (!src) return;
|
||||
for (const m of src.matchAll(/^\s*@using\s+(?:static\s+)?([A-Za-z_][\w.]*)/gm)) usings.add(m[1]!);
|
||||
};
|
||||
addFrom(this.context.readFile(filePath));
|
||||
let dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
|
||||
// Walk up to the project root, reading each level's _Imports.razor.
|
||||
for (;;) {
|
||||
addFrom(this.context.readFile(dir ? `${dir}/_Imports.razor` : '_Imports.razor'));
|
||||
if (!dir) break;
|
||||
const slash = dir.lastIndexOf('/');
|
||||
dir = slash >= 0 ? dir.slice(0, slash) : '';
|
||||
}
|
||||
const arr = [...usings];
|
||||
this.razorUsingsCache.set(filePath, arr);
|
||||
return arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Razor/Blazor simple type ref through the file's `@using`
|
||||
* namespaces: `CatalogBrand` + `@using BlazorShared.Models` → the node whose
|
||||
* qualified name is `BlazorShared.Models::CatalogBrand`. Only resolves when the
|
||||
* `@using` set yields exactly ONE type (otherwise it stays ambiguous and falls
|
||||
* through to name-matching).
|
||||
*/
|
||||
private resolveRazorUsing(ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (ref.referenceName.includes('.') || ref.referenceName.includes('::')) return null;
|
||||
const usings = this.getRazorUsings(ref.filePath);
|
||||
if (usings.length === 0) return null;
|
||||
const found = new Map<string, Node>();
|
||||
for (const ns of usings) {
|
||||
for (const cand of this.context.getNodesByQualifiedName(`${ns}::${ref.referenceName}`)) {
|
||||
found.set(cand.id, cand);
|
||||
}
|
||||
}
|
||||
if (found.size !== 1) return null;
|
||||
const target = found.values().next().value!;
|
||||
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
}
|
||||
|
||||
private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (!result) return result;
|
||||
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
||||
if (!tgt || !ref.language) return result;
|
||||
if (ref.referenceKind === 'references' && !sameLanguageFamily(tgt, ref.language)) return null;
|
||||
if (ref.referenceKind === 'imports' && crossesKnownFamily(tgt, ref.language)) return null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a FRAMEWORK-strategy resolution that crosses two *known* language
|
||||
* families for a type-usage (`references`) or import-binding (`imports`)
|
||||
* edge. The framework strategy is intentionally ungated for cross-language
|
||||
* bridges, but those legitimate bridges are either `calls` edges (RN/Expo
|
||||
* JS → native) or config↔code edges whose config side (`yaml`/`blade`/…) is
|
||||
* not a known programming-language family. A `references`/`imports` edge
|
||||
* between two *known* families is always a coincidental name collision — the
|
||||
* React/Svelte/Vue PascalCase component resolvers name-match `getNodesByName`
|
||||
* without a language check, so a TS `<TestRunner>` ref happily matched a
|
||||
* Kotlin `class TestRunner`. Gating only the both-known-cross-family case
|
||||
* lets config bridges and `calls` bridges through untouched.
|
||||
*/
|
||||
private gateFrameworkLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
|
||||
if (!result) return result;
|
||||
if (ref.referenceKind !== 'references' && ref.referenceKind !== 'imports') return result;
|
||||
const tgt = this.getLanguageFromNodeId(result.targetNodeId);
|
||||
if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,13 @@ export function matchByFilePath(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
if (!ref.referenceName.includes('/')) return null;
|
||||
// Path-like (`a/b.liquid`) OR a bare filename ending in a short extension
|
||||
// (`Foo.h` — an Objective-C `#import "Foo.h"`, resolved to the header by
|
||||
// basename). A bare ref WITHOUT an extension is a symbol name, not a file, so
|
||||
// leave it to the symbol-matching strategies.
|
||||
if (!ref.referenceName.includes('/') && !/\.[A-Za-z][A-Za-z0-9]{0,3}$/.test(ref.referenceName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract the filename from the path
|
||||
const fileName = ref.referenceName.split('/').pop();
|
||||
@@ -38,12 +44,20 @@ export function matchByFilePath(
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches "src/snippets/foo.liquid")
|
||||
const suffixMatch = fileNodes.find(n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName));
|
||||
if (suffixMatch) {
|
||||
// Fall back to suffix match (e.g., ref="snippets/foo.liquid" matches
|
||||
// "src/snippets/foo.liquid"). When several files share the basename — a
|
||||
// `#include "RNCAsyncStorage.h"` with a same-named header on another platform
|
||||
// (windows/code/ vs apple/) — prefer the one in the includer's own directory,
|
||||
// then by directory proximity / same language family. A C/C++ include (and any
|
||||
// bare-filename import) resolves relative to the including file, not to an
|
||||
// arbitrary same-named header elsewhere in the tree.
|
||||
const suffixMatches = fileNodes.filter(
|
||||
n => n.qualifiedName.endsWith(ref.referenceName) || n.filePath.endsWith(ref.referenceName)
|
||||
);
|
||||
if (suffixMatches.length > 0) {
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: suffixMatch.id,
|
||||
targetNodeId: pickClosestFileNode(suffixMatches, ref).id,
|
||||
confidence: 0.85,
|
||||
resolvedBy: 'file-path',
|
||||
};
|
||||
@@ -62,6 +76,97 @@ export function matchByFilePath(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Among several file nodes that all match a bare include/import by basename,
|
||||
* pick the one closest to the referencing file: same directory first, then by
|
||||
* directory-tree proximity, with the same language family as a tiebreak. A
|
||||
* C/C++ `#include "X.h"` (and any bare-filename import) resolves relative to the
|
||||
* including file — not to an arbitrary same-named header on another platform.
|
||||
*/
|
||||
function pickClosestFileNode(candidates: Node[], ref: UnresolvedRef): Node {
|
||||
const dirOf = (p: string): string => {
|
||||
const i = p.lastIndexOf('/');
|
||||
return i >= 0 ? p.slice(0, i) : '';
|
||||
};
|
||||
const refDir = dirOf(ref.filePath);
|
||||
const sameDir = candidates.filter((c) => dirOf(c.filePath) === refDir);
|
||||
const pool = sameDir.length > 0 ? sameDir : candidates;
|
||||
let best = pool[0]!;
|
||||
let bestScore = -Infinity;
|
||||
for (const c of pool) {
|
||||
const score =
|
||||
computePathProximity(ref.filePath, c.filePath) +
|
||||
(sameLanguageFamily(c.language, ref.language) ? 5 : 0);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Language families that share a type system / runtime, so a same-language-only
|
||||
* reference may still resolve across them (a Kotlin `Foo.BAR` can name a Java
|
||||
* `Foo`). Anything not listed forms its own singleton family.
|
||||
*/
|
||||
const LANGUAGE_FAMILY: Record<string, string> = {
|
||||
java: 'jvm', kotlin: 'jvm', scala: 'jvm',
|
||||
swift: 'apple', objc: 'apple',
|
||||
typescript: 'web', tsx: 'web', javascript: 'web', jsx: 'web',
|
||||
c: 'c', cpp: 'c',
|
||||
// Razor/Blazor markup names C# types — same family so `@model Foo` /
|
||||
// `<MyComponent/>` resolve to their `.cs` class through the cross-family gate.
|
||||
csharp: 'dotnet', razor: 'dotnet',
|
||||
};
|
||||
export function sameLanguageFamily(a: string, b: string): boolean {
|
||||
if (a === b) return true;
|
||||
const fa = LANGUAGE_FAMILY[a];
|
||||
return fa !== undefined && fa === LANGUAGE_FAMILY[b];
|
||||
}
|
||||
/**
|
||||
* True when `lang` belongs to a known multi-language family (jvm/apple/web/c).
|
||||
* Languages not listed (php, python, go, ruby, rust, dart, …) and config
|
||||
* formats (yaml/xml/blade) form their own singleton families and return
|
||||
* `false` — used to leave config↔code framework bridges (whose config side is
|
||||
* never a known programming-language family) out of the cross-family gate.
|
||||
*/
|
||||
export function isKnownLanguageFamily(lang: string): boolean {
|
||||
return LANGUAGE_FAMILY[lang] !== undefined;
|
||||
}
|
||||
/**
|
||||
* True when `a` and `b` are two DIFFERENT *known* language families — the
|
||||
* signature of a coincidental cross-language name collision (a TS `import
|
||||
* React` matching a Swift `import React`, a C++ `#include "X.h"` matching a
|
||||
* same-named ObjC header on another platform). The both-*known* test is
|
||||
* deliberately weaker than {@link sameLanguageFamily}'s negation: a
|
||||
* single-file-component language that carries its own tag (`vue`/`svelte`)
|
||||
* importing a `.ts` module, or any singleton-family language (php/go/ruby/…),
|
||||
* returns `false` here and is left alone.
|
||||
*/
|
||||
export function crossesKnownFamily(a: string, b: string): boolean {
|
||||
return isKnownLanguageFamily(a) && isKnownLanguageFamily(b) && !sameLanguageFamily(a, b);
|
||||
}
|
||||
/**
|
||||
* Drop cross-language candidates from a name lookup. Two regimes:
|
||||
* - `references` (type-usage): a type named in language X resolves to a
|
||||
* SAME-family type, never a coincidentally same-named symbol in another
|
||||
* language (the Android `BatteryManager` system class vs a JS one). Strict
|
||||
* same-family filter — cross-language communication is `calls`, not refs.
|
||||
* - `imports` (import binding): an `import`/`#include` never crosses two
|
||||
* KNOWN families (TS `import React` ↮ Swift `import React`). Weaker
|
||||
* both-known filter so `.vue`/`.svelte` (own tag) importing `.ts` survives.
|
||||
*/
|
||||
function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
|
||||
if (ref.referenceKind === 'references') {
|
||||
return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
|
||||
}
|
||||
if (ref.referenceKind === 'imports') {
|
||||
return candidates.filter((c) => !crossesKnownFamily(c.language, ref.language));
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve a reference by exact name match
|
||||
*/
|
||||
@@ -69,7 +174,7 @@ export function matchByExactName(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
const candidates = context.getNodesByName(ref.referenceName);
|
||||
const candidates = applyLanguageGate(context.getNodesByName(ref.referenceName), ref);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
@@ -357,8 +462,16 @@ export function matchMethodCall(
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
// Parse method call patterns like "obj.method" or "Class::method"
|
||||
const dotMatch = ref.referenceName.match(/^(\w+)\.(\w+)$/);
|
||||
// Parse method call patterns like "obj.method" or "Class::method". The method
|
||||
// part allows trailing `:` keywords so Objective-C selectors resolve
|
||||
// (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other
|
||||
// languages' method refs, so this is a no-op for them.
|
||||
// The receiver allows dots (`builder.Services.AddCoreServices`) so a CHAINED
|
||||
// call resolves by its last segment — Strategy 3 below name-matches the method
|
||||
// (with its existing single-candidate / receiver-overlap guards). Without this
|
||||
// a multi-dot extension-method call (C# DI `builder.Services.AddCoreServices()`,
|
||||
// `Guard.Against.X()`) matched no pattern and never resolved.
|
||||
const dotMatch = ref.referenceName.match(/^([\w.]+)\.(\w+:?(?:\w+:)*)$/);
|
||||
const colonMatch = ref.referenceName.match(/^(\w+)::(\w+)$/);
|
||||
|
||||
const match = dotMatch || colonMatch;
|
||||
@@ -659,7 +772,7 @@ export function matchFuzzy(
|
||||
|
||||
// Filter to callable kinds only (function, method, class)
|
||||
const callableKinds = new Set(['function', 'method', 'class']);
|
||||
const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind));
|
||||
const callableCandidates = applyLanguageGate(candidates.filter((n) => callableKinds.has(n.kind)), ref);
|
||||
|
||||
// Prefer same-language matches
|
||||
const sameLanguageCandidates = callableCandidates.filter(n => n.language === ref.language);
|
||||
|
||||
Reference in New Issue
Block a user