|
@@ -42,6 +42,10 @@ const MAX_JSX_CHILDREN = 30;
|
|
|
// event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
|
|
// event bindings (@click="fn" / v-on:click="fn"). PascalCase children (<VPNav/>)
|
|
|
// are already caught by JSX_TAG_RE via the SFC component node.
|
|
// 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;
|
|
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;
|
|
const VUE_HANDLER_RE = /(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)"/g;
|
|
|
// Composable/hook destructure: `const { close: closeSidebar } = useSidebarControl()`.
|
|
// Composable/hook destructure: `const { close: closeSidebar } = useSidebarControl()`.
|
|
|
// Captures the destructure body + the called composable; only `use*` calls qualify.
|
|
// 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('');
|
|
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 {
|
|
function sliceLines(content: string, startLine?: number, endLine?: number): string | null {
|
|
|
if (!startLine || !endLine) return null;
|
|
if (!startLine || !endLine) return null;
|
|
|
return content.split('\n').slice(startLine - 1, endLine).join('\n');
|
|
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)
|
|
// and are added below; their concrete-side nodes can be a `struct` (Swift)
|
|
|
// or an `object` (Scala) so the loop also iterates those kinds.
|
|
// or an `object` (Scala) so the loop also iterates those kinds.
|
|
|
const IFACE_OVERRIDE_LANGS = new Set([
|
|
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[] {
|
|
function interfaceOverrideEdges(queries: QueryBuilder): Edge[] {
|
|
|
const edges: Edge[] = [];
|
|
const edges: Edge[] = [];
|
|
|
const seen = new Set<string>();
|
|
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
|
|
// A composable's returned member may be a fn (`function close(){}`) or an
|
|
|
// arrow assigned to a const (`const close = () => {}`).
|
|
// arrow assigned to a const (`const close = () => {}`).
|
|
|
const RETURN_KINDS = new Set(['method', 'function', 'variable', 'constant']);
|
|
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()) {
|
|
for (const file of ctx.getAllFiles()) {
|
|
|
if (!file.endsWith('.vue')) continue;
|
|
if (!file.endsWith('.vue')) continue;
|
|
|
const content = ctx.readFile(file);
|
|
const content = ctx.readFile(file);
|
|
@@ -716,7 +880,18 @@ function vueTemplateEdges(ctx: ResolutionContext): Edge[] {
|
|
|
|
|
|
|
|
let m: RegExpExecArray | null;
|
|
let m: RegExpExecArray | null;
|
|
|
VUE_KEBAB_RE.lastIndex = 0;
|
|
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;
|
|
VUE_HANDLER_RE.lastIndex = 0;
|
|
|
while ((m = VUE_HANDLER_RE.exec(tpl))) {
|
|
while ((m = VUE_HANDLER_RE.exec(tpl))) {
|
|
|
const event = m[1]!;
|
|
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
|
|
// JVM source files in the consumer so we don't re-process JS emits
|
|
|
// (which `eventEmitterEdges` already handles).
|
|
// (which `eventEmitterEdges` already handles).
|
|
|
const RN_JVM_EMIT_RE = /\.emit\s*\(\s*"([^"]+)"\s*,/g;
|
|
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[] {
|
|
function rnEventEdges(ctx: ResolutionContext): Edge[] {
|
|
|
// Native dispatchers (source = the native method whose body sends the
|
|
// 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))) {
|
|
while ((m = RN_SWIFT_SEND_RE.exec(content))) {
|
|
|
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
|
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')) {
|
|
if (file.endsWith('.java') || file.endsWith('.kt')) {
|
|
|
- RN_JVM_EMIT_RE.lastIndex = 0;
|
|
|
|
|
let m: RegExpExecArray | null;
|
|
let m: RegExpExecArray | null;
|
|
|
|
|
+ RN_JVM_EMIT_RE.lastIndex = 0;
|
|
|
while ((m = RN_JVM_EMIT_RE.exec(content))) {
|
|
while ((m = RN_JVM_EMIT_RE.exec(content))) {
|
|
|
if (m[1]) addDispatcher(m[1], lineOf(m.index));
|
|
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
|
|
// 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'];
|
|
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[] {
|
|
function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
|
|
|
const edges: Edge[] = [];
|
|
const edges: Edge[] = [];
|
|
|
const seen = new Set<string>();
|
|
const seen = new Set<string>();
|
|
@@ -1183,25 +1499,116 @@ function ginMiddlewareChainEdges(queries: QueryBuilder, ctx: ResolutionContext):
|
|
|
return edges;
|
|
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 +
|
|
* 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 {
|
|
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 fieldEdges = fieldChannelEdges(queries, ctx);
|
|
|
const closureCollEdges = closureCollectionEdges(queries, ctx);
|
|
const closureCollEdges = closureCollectionEdges(queries, ctx);
|
|
|
const emitterEdges = eventEmitterEdges(ctx);
|
|
const emitterEdges = eventEmitterEdges(ctx);
|
|
|
const renderEdges = reactRenderEdges(queries, ctx);
|
|
const renderEdges = reactRenderEdges(queries, ctx);
|
|
|
const jsxEdges = reactJsxChildEdges(ctx);
|
|
const jsxEdges = reactJsxChildEdges(ctx);
|
|
|
const vueEdges = vueTemplateEdges(ctx);
|
|
const vueEdges = vueTemplateEdges(ctx);
|
|
|
|
|
+ const svelteKitEdges = svelteKitLoadEdges(ctx);
|
|
|
|
|
+ const pascalEdges = pascalFormEdges(ctx);
|
|
|
const flutterEdges = flutterBuildEdges(queries, ctx);
|
|
const flutterEdges = flutterBuildEdges(queries, ctx);
|
|
|
const cppEdges = cppOverrideEdges(queries);
|
|
const cppEdges = cppOverrideEdges(queries);
|
|
|
const ifaceEdges = interfaceOverrideEdges(queries);
|
|
const ifaceEdges = interfaceOverrideEdges(queries);
|
|
|
|
|
+ const kotlinExpectActual = kotlinExpectActualEdges(queries);
|
|
|
const goGrpcEdges = goGrpcStubImplEdges(queries);
|
|
const goGrpcEdges = goGrpcStubImplEdges(queries);
|
|
|
const rnEventEdgesList = rnEventEdges(ctx);
|
|
const rnEventEdgesList = rnEventEdges(ctx);
|
|
|
const fabricNativeEdges = fabricNativeImplEdges(ctx);
|
|
const fabricNativeEdges = fabricNativeImplEdges(ctx);
|
|
|
|
|
+ const expoXPlatEdges = expoCrossPlatformEdges(queries);
|
|
|
|
|
+ const rnXPlatEdges = rnCrossPlatformEdges(queries);
|
|
|
const mybatisEdges = mybatisJavaXmlEdges(queries);
|
|
const mybatisEdges = mybatisJavaXmlEdges(queries);
|
|
|
const ginEdges = ginMiddlewareChainEdges(queries, ctx);
|
|
const ginEdges = ginMiddlewareChainEdges(queries, ctx);
|
|
|
|
|
|
|
@@ -1214,12 +1621,17 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
|
|
...renderEdges,
|
|
...renderEdges,
|
|
|
...jsxEdges,
|
|
...jsxEdges,
|
|
|
...vueEdges,
|
|
...vueEdges,
|
|
|
|
|
+ ...svelteKitEdges,
|
|
|
|
|
+ ...pascalEdges,
|
|
|
...flutterEdges,
|
|
...flutterEdges,
|
|
|
...cppEdges,
|
|
...cppEdges,
|
|
|
...ifaceEdges,
|
|
...ifaceEdges,
|
|
|
|
|
+ ...kotlinExpectActual,
|
|
|
...goGrpcEdges,
|
|
...goGrpcEdges,
|
|
|
...rnEventEdgesList,
|
|
...rnEventEdgesList,
|
|
|
...fabricNativeEdges,
|
|
...fabricNativeEdges,
|
|
|
|
|
+ ...expoXPlatEdges,
|
|
|
|
|
+ ...rnXPlatEdges,
|
|
|
...mybatisEdges,
|
|
...mybatisEdges,
|
|
|
...ginEdges,
|
|
...ginEdges,
|
|
|
]) {
|
|
]) {
|
|
@@ -1229,5 +1641,5 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
|
|
|
merged.push(e);
|
|
merged.push(e);
|
|
|
}
|
|
}
|
|
|
if (merged.length > 0) queries.insertEdges(merged);
|
|
if (merged.length > 0) queries.insertEdges(merged);
|
|
|
- return merged.length;
|
|
|
|
|
|
|
+ return merged.length + goImpl.length;
|
|
|
}
|
|
}
|