Prechádzať zdrojové kódy

feat(impact): React Native / Expo cross-language bridges — generic Expo Functions, cross-platform pairing, type-ref precision

Validated the cross-language bridge graph on real RN/Expo repos (expo-battery,
react-native-device-info) and fixed three gaps:

- Generic-typed Expo Functions: Android Expo Modules write `AsyncFunction<Float>
  ("getBatteryLevelAsync")`, but the framework extractor's regex allowed no
  generic type parameter between the keyword and `(`, so EVERY Android Expo
  method was silently dropped — a JS call resolved only to the iOS Swift impl.
  The regex now accepts an optional `<…>`.
- Cross-platform pairing: an Expo Module exposes the same JS method from BOTH a
  Swift (iOS) and a Kotlin (Android) impl, but a JS callsite name-resolves to
  only one. New `expoCrossPlatformEdges` links the two platform impls of the same
  `<module>.<method>` (both directions), so a JS call reaching one reaches the
  other and editing either surfaces the JS caller.
- Cross-language type-reference precision: a `Type.member` static read in native
  code (`BatteryManager.EXTRA_LEVEL`, the Android system class) name-matched to a
  coincidentally same-named TS class. `references` (type-usage) edges are now
  gated to the same language family (JVM java/kotlin/scala, Apple swift/objc, web
  ts/js, C c/cpp) at the name-match + import strategies — framework resolvers are
  NOT gated, so deliberate config→code bridges (Drupal routing.yml→PHP, Spring
  @Value→YAML) and JS↔native `calls` bridges are unaffected.

The classic RN NativeModules bridge (`@ReactMethod`/`RCT_EXPORT_METHOD`) already
worked and is unchanged (118 JS→Java + JS→ObjC `calls` on device-info). Full
suite green; node count stable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 týždňov pred
rodič
commit
dbc4862940

+ 1 - 0
CHANGELOG.md

@@ -15,6 +15,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### Fixes
 
+- React Native / Expo cross-language bridges are more complete and more precise. An Expo Module method declared with a generic type — Android's `AsyncFunction<Float>("getBatteryLevelAsync")` — is now indexed (the `<Float>` used to defeat the matcher, so every Android Expo method was dropped and a JS call resolved only to the iOS Swift impl). The iOS and Android implementations of the same JS-visible method are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either surfaces the caller. And a `Type.member` static read in native code (e.g. Android's `BatteryManager.EXTRA_LEVEL`) no longer falsely links to a coincidentally same-named class in another language (a web `BatteryManager`) — type references stay within a language family, while genuine cross-language bridges (config→code, JS↔native calls) are unaffected. (React Native, Expo)
 - `codegraph affected` now reports the tests and files that actually depend on your changes. It used to follow only `import` statements — but those never cross file boundaries in CodeGraph's graph — so it returned **no affected tests for any change, in every language**. It now traces the real cross-file usage graph (calls, references, instantiations, and class `extends` / `implements`), so `git diff --name-only | codegraph affected` surfaces the test files that exercise the changed code. Circular-dependency detection, which had the same blind spot, now works too.
 - Blast radius, callers, and `codegraph affected` now recognize far more of the dependencies that were already in your code. A symbol now counts as a dependency whether it's called, used only in a type annotation inside a function body (`const items: Foo[] = []`), imported and placed in a registry array or passed as an argument, used as a JSX component, simply re-exported from a barrel (`export { X } from './x'`), or pulled in as a namespace (`import * as ns from '@/x'`) — including through tsconfig path aliases like `@/`. Previously only called, instantiated, or signature-typed symbols created a cross-file link, so a file that used a dependency in any other way could look like it depended on nothing — and the file that defined a widely-used symbol could look like nothing depended on it. The graph still indexes exactly the same symbols; it just connects the ones that were already there. (TypeScript/JavaScript)
 - The same completeness fix now applies to **Python**: a name brought in with `from module import X` is recorded as a dependency on that module even when `X` is only stored in a list/dict, passed as an argument, used as a decorator, or re-exported through an `__init__.py`. Previously Python linked only imports that were called or instantiated, so a module consumed purely by value — or only re-exported — looked like nothing depended on it.

+ 53 - 0
__tests__/expo-modules.test.ts

@@ -151,4 +151,57 @@ export async function impactAsync() {
     expect(callEdge.length).toBeGreaterThanOrEqual(1);
     expect(callEdge[0].target_id.startsWith('expo-module:')).toBe(true);
   });
+
+  it('extracts GENERIC-typed Kotlin AsyncFunction<T> and pairs the iOS + Android impls', async () => {
+    fs.writeFileSync(
+      path.join(dir, 'package.json'),
+      '{"dependencies":{"expo-modules-core":"^1.0.0"}}'
+    );
+    fs.mkdirSync(path.join(dir, 'ios'));
+    fs.writeFileSync(
+      path.join(dir, 'ios', 'BatteryModule.swift'),
+      `import ExpoModulesCore
+public class BatteryModule: Module {
+  public func definition() -> ModuleDefinition {
+    Name("ExpoBattery")
+    AsyncFunction("getBatteryLevelAsync") { () -> Float in return 1.0 }
+  }
+}
+`
+    );
+    fs.mkdirSync(path.join(dir, 'android'));
+    fs.writeFileSync(
+      path.join(dir, 'android', 'BatteryModule.kt'),
+      `import expo.modules.kotlin.modules.Module
+class BatteryModule : Module() {
+  override fun definition() = ModuleDefinition {
+    Name("ExpoBattery")
+    AsyncFunction<Float>("getBatteryLevelAsync") { 1.0f }
+  }
+}
+`
+    );
+
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const db = (cg as any).db.db;
+
+    // The Android (Kotlin) GENERIC AsyncFunction<Float> is extracted — before the
+    // fix the `<Float>` defeated the regex and it was silently dropped.
+    const kt = db.prepare(
+      "SELECT * FROM nodes WHERE name='getBatteryLevelAsync' AND language='kotlin' AND id LIKE 'expo-module:%'"
+    ).all();
+    expect(kt).toHaveLength(1);
+
+    // The iOS (Swift) and Android (Kotlin) impls of the same JS method are linked
+    // to each other, so a JS call that resolves to one platform reaches the other.
+    const pair = db.prepare(
+      `SELECT count(*) c FROM edges e
+       JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target
+       WHERE s.name='getBatteryLevelAsync' AND t.name='getBatteryLevelAsync'
+         AND s.language != t.language`
+    ).get();
+    cg.close?.();
+    expect(pair.c).toBeGreaterThanOrEqual(2); // swift->kotlin AND kotlin->swift
+  });
 });

+ 24 - 0
__tests__/extraction.test.ts

@@ -3741,6 +3741,30 @@ describe('Static-member / value-read references', () => {
       .filter((n) => n.name === 'this' || n.name === 'helper');
     expect(refTargets.length).toBe(0);
   });
+
+  it('does not link a static-member read across language families (coincidental name)', async () => {
+    // A native (Kotlin) `Build.VERSION` reads the Android system class — it must
+    // NOT link to a coincidentally same-named TS class (the cross-language false
+    // positive that name-matching produces; `references` edges are language-local).
+    fs.writeFileSync(
+      path.join(tempDir, 'Build.ts'),
+      `export class Build {\n  static version = 1;\n}\n`
+    );
+    fs.writeFileSync(
+      path.join(tempDir, 'Device.kt'),
+      `package app\nclass Device {\n  fun sdk(): Int = Build.VERSION\n}\n`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const tsBuild = cg.getNodesByKind('class').find((n) => n.name === 'Build' && n.filePath.endsWith('Build.ts'));
+    expect(tsBuild).toBeDefined();
+    // The Kotlin file is `app/Device.kt`; the TS Build must have NO dependent there.
+    const deps = [...cg.getImpactRadius(tsBuild!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(deps.some((p) => p.endsWith('Device.kt'))).toBe(false);
+  });
 });
 
 describe('Objective-C messages, class receivers, and #import', () => {

+ 47 - 0
src/resolution/callback-synthesizer.ts

@@ -1077,6 +1077,51 @@ 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;
+}
+
 function fabricNativeImplEdges(ctx: ResolutionContext): Edge[] {
   const edges: Edge[] = [];
   const seen = new Set<string>();
@@ -1336,6 +1381,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
   const goGrpcEdges = goGrpcStubImplEdges(queries);
   const rnEventEdgesList = rnEventEdges(ctx);
   const fabricNativeEdges = fabricNativeImplEdges(ctx);
+  const expoXPlatEdges = expoCrossPlatformEdges(queries);
   const mybatisEdges = mybatisJavaXmlEdges(queries);
   const ginEdges = ginMiddlewareChainEdges(queries, ctx);
 
@@ -1355,6 +1401,7 @@ export function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionCo
     ...goGrpcEdges,
     ...rnEventEdgesList,
     ...fabricNativeEdges,
+    ...expoXPlatEdges,
     ...mybatisEdges,
     ...ginEdges,
   ]) {

+ 6 - 1
src/resolution/frameworks/expo-modules.ts

@@ -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

+ 19 - 4
src/resolution/index.ts

@@ -16,7 +16,7 @@ import {
   FrameworkResolver,
   ImportMapping,
 } from './types';
-import { matchReference } from './name-matcher';
+import { matchReference, sameLanguageFamily } from './name-matcher';
 import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs } from './import-resolver';
 import { detectFrameworks } from './frameworks';
 import { synthesizeCallbackEdges } from './callback-synthesizer';
@@ -622,7 +622,9 @@ export class ReferenceResolver {
 
     const candidates: ResolvedRef[] = [];
 
-    // Strategy 1: Try framework-specific resolution
+    // Strategy 1: Try framework-specific resolution. NOT language-gated:
+    // framework resolvers deliberately bridge config↔code across languages
+    // (Drupal `routing.yml` → PHP controller, Spring `@Value` → YAML key).
     for (const framework of this.frameworks) {
       const result = framework.resolve(ref, this.context);
       if (result) {
@@ -632,14 +634,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 +949,19 @@ export class ReferenceResolver {
     const node = this.queries.getNodeById(nodeId);
     return node?.language || 'unknown';
   }
+
+  /**
+   * Drop a resolution that crosses a language family when the reference is
+   * `sameLanguageOnly` (a `Type.member` static read names a same-family type,
+   * never a coincidentally same-named symbol in another language). Covers ALL
+   * strategies (framework / import / name-match) at one chokepoint.
+   */
+  private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
+    if (!result || ref.referenceKind !== 'references') return result;
+    const tgt = this.getLanguageFromNodeId(result.targetNodeId);
+    if (tgt && ref.language && !sameLanguageFamily(tgt, ref.language)) return null;
+    return result;
+  }
 }
 
 /**

+ 30 - 2
src/resolution/name-matcher.ts

@@ -68,6 +68,34 @@ export function matchByFilePath(
   return null;
 }
 
+/**
+ * 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',
+};
+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];
+}
+/**
+ * Drop cross-family candidates for a `references` (type-usage) edge. A type used
+ * in language X — a field/param/return type, a `Type.member` static read — names
+ * a same-family type, never a coincidentally same-named symbol in another
+ * language (the Android `BatteryManager` system class vs a JS `BatteryManager`).
+ * Cross-language communication is modeled by `calls` bridges, not `references`.
+ */
+function applyLanguageGate(candidates: Node[], ref: UnresolvedRef): Node[] {
+  if (ref.referenceKind !== 'references') return candidates;
+  return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
+}
+
 /**
  * Try to resolve a reference by exact name match
  */
@@ -75,7 +103,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;
@@ -668,7 +696,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);