Pārlūkot izejas kodu

fix(impact): gate cross-known-family references/imports (TS↔native name collisions)

The cross-language gate added in dbc4862 covered only `references` edges from
the import/name-match strategies. Two holes remained, both surfaced validating
react-native-async-storage (a TS `type TestRunner` colliding with a Kotlin
`class TestRunner`):

1. Framework strategy was ungated. The React PascalCase component resolver
   name-matches `getNodesByName` with no language check (its COMPONENT_KINDS
   includes `class`), so a TS ref to `TestRunner` resolved onto the Kotlin
   class at confidence 0.8 — outranking the cross-language-penalized (0.5) TS
   name-match. Same hole for Svelte/Vue.
2. `imports` edges were never gated (only `references`), so a TS `import React`
   matched a Swift `React`, and a C++ `#include "X.h"` matched a same-named
   ObjC header on another platform (basename collision).

Adds `crossesKnownFamily` (both sides in a KNOWN family — jvm/apple/web/c — and
different) and applies it:
- `gateFrameworkLanguage`: new gate on the framework strategy for
  references/imports. Both-known so config↔code bridges (yaml/blade side is not
  a known family) and `calls` bridges (different kind) are untouched.
- `gateLanguage` (strategies 2/3): extended to also gate `imports`. Both-known
  so a `.vue`/`.svelte` file (own language tag) importing a `.ts` module is left
  alone, while a C++→ObjC header basename collision is dropped.
- `applyLanguageGate` (name-match candidates): filters `imports` candidates too,
  so a ref RE-POINTS to the same-family target instead of merely dropping.

Validated: async-storage cross-known-family references/imports → 0 (the 6 TS
refs + 5 imports re-point to the TS type); rn-device-info JS↔native `calls`
bridges intact (91 JS→Java, 37 JS→ObjC, full Java↔ObjC↔C++ pairing). Node count
unaffected (edge-resolution change only). Two regression tests added (both fail
without the fix). Full suite 1167 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry 2 nedēļas atpakaļ
vecāks
revīzija
082353eb7f
4 mainītis faili ar 170 papildinājumiem un 18 dzēšanām
  1. 1 0
      CHANGELOG.md
  2. 89 0
      __tests__/extraction.test.ts
  3. 42 11
      src/resolution/index.ts
  4. 38 7
      src/resolution/name-matcher.ts

+ 1 - 0
CHANGELOG.md

@@ -17,6 +17,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - React Native native→JS events now connect through the common `sendEvent(context, "X", body)` wrapper. Many libraries (react-native-device-info and others) wrap the event emitter behind a helper whose `.emit(eventName, …)` takes a *variable*, so the matcher — which looked for `.emit("literal", …)` — missed it; the literal event name actually lives in the wrapper call. Now a native method that fires `sendEvent(…, "batteryLevelChanged", …)` links to the JS `addListener('batteryLevelChanged', …)` handler, so editing the native emitter surfaces the JS subscriber. (React Native)
 - 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 — both Expo Modules and classic NativeModules (`@ReactMethod` on Android, the matching method on iOS) — are now linked to each other, so a JS call that resolves to one platform still reaches the other and editing either platform's native code surfaces the JS 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)
+- A TypeScript/JavaScript reference or import no longer gets mis-linked to a same-named class in a native language. In a React Native / Expo repo that has both a TypeScript `TestRunner` type and a Kotlin `TestRunner` class, a TS reference to `TestRunner` — or an `import React` sitting next to a Swift `React` — used to resolve onto the native symbol (the component resolver matched any same-named class regardless of language, and import statements weren't language-checked at all). References and imports now stay within their language family, so they land on the right symbol while genuine cross-language bridges (JS↔native calls, config→code) are untouched. A C/C++ `#include "Foo.h"` likewise no longer resolves to a same-named header from another platform (an iOS Objective-C `Foo.h`). (React Native, Expo, TypeScript, C/C++)
 - `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.

+ 89 - 0
__tests__/extraction.test.ts

@@ -3767,6 +3767,95 @@ describe('Static-member / value-read references', () => {
   });
 });
 
+describe('Cross-language type/import gate (RN name collisions)', () => {
+  let tempDir: string;
+  let cg: CodeGraph;
+
+  beforeEach(() => {
+    tempDir = createTempDir();
+  });
+
+  afterEach(() => {
+    if (cg) cg.close();
+    if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+  });
+
+  it('a TS PascalCase type ref lands on the TS type, never a same-named native class', async () => {
+    // react-native-async-storage's example app has a TS `type TestRunner` AND a
+    // Kotlin `class TestRunner`. The React PascalCase resolver name-matched the
+    // Kotlin `class` (its COMPONENT_KINDS includes `class`) with no language
+    // check at confidence 0.8, outranking the (cross-language-penalized 0.5)
+    // TS name-match — so a TS ref to `TestRunner` crossed web→jvm. The ref here
+    // is intentionally NOT imported: a clean relative import would mask the bug
+    // by resolving via the import map before the framework strategy can win.
+    fs.writeFileSync(
+      path.join(tempDir, 'package.json'),
+      JSON.stringify({ dependencies: { 'react-native': '*' } })
+    );
+    fs.writeFileSync(
+      path.join(tempDir, 'useTests.ts'),
+      `export type TestRunner = { run: () => void };\n`
+    );
+    fs.writeFileSync(
+      path.join(tempDir, 'basic.tsx'),
+      `export function useBasicTest(r: TestRunner): TestRunner {\n  return r;\n}\n`
+    );
+    fs.writeFileSync(
+      path.join(tempDir, 'TestUtils.kt'),
+      `package app\nclass TestRunner {\n  fun run() {}\n}\n`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const ktRunner = cg
+      .getNodesByKind('class')
+      .find((n) => n.name === 'TestRunner' && n.filePath.endsWith('TestUtils.kt'));
+    expect(ktRunner, 'Kotlin TestRunner class').toBeDefined();
+    const ktDeps = [...cg.getImpactRadius(ktRunner!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(ktDeps.some((p) => p.endsWith('basic.tsx')), 'Kotlin class has NO TS dependent').toBe(false);
+
+    const tsRunner = cg.getNodesByKind('type_alias').find((n) => n.name === 'TestRunner');
+    expect(tsRunner, 'TS TestRunner type_alias').toBeDefined();
+    const tsDeps = [...cg.getImpactRadius(tsRunner!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(tsDeps.some((p) => p.endsWith('basic.tsx')), 'TS type captured the ref (re-pointed)').toBe(true);
+  });
+
+  it('gates a cross-family import name collision but keeps same-family imports', async () => {
+    // A TS `import { Widget }` that only matches a Swift `class Widget` must not
+    // create a web→apple dependency — but a sibling TS module imported by
+    // another TS file (same family) must still resolve (no over-gating).
+    fs.writeFileSync(path.join(tempDir, 'Widget.swift'), `class Widget {\n  func render() {}\n}\n`);
+    fs.writeFileSync(
+      path.join(tempDir, 'widget.ts'),
+      `import { Widget } from './native';\nexport function mount(w: Widget) {}\n`
+    );
+    fs.writeFileSync(path.join(tempDir, 'util.ts'), `export class Helper {}\n`);
+    fs.writeFileSync(
+      path.join(tempDir, 'app.ts'),
+      `import { Helper } from './util';\nexport const h = new Helper();\n`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const swiftWidget = cg
+      .getNodesByKind('class')
+      .find((n) => n.name === 'Widget' && n.filePath.endsWith('.swift'));
+    expect(swiftWidget, 'Swift Widget class').toBeDefined();
+    const wDeps = [...cg.getImpactRadius(swiftWidget!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(wDeps.some((p) => p.endsWith('widget.ts')), 'Swift class has NO TS dependent').toBe(false);
+
+    // Same-family control — the TS Helper must still see its TS dependent.
+    const helper = cg.getNodesByKind('class').find((n) => n.name === 'Helper');
+    expect(helper, 'TS Helper class').toBeDefined();
+    const hDeps = [...cg.getImpactRadius(helper!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(hDeps.some((p) => p.endsWith('app.ts')), 'same-family TS import preserved').toBe(true);
+  });
+});
+
 describe('Objective-C messages, class receivers, and #import', () => {
   let tempDir: string;
   let cg: CodeGraph;

+ 42 - 11
src/resolution/index.ts

@@ -16,7 +16,7 @@ import {
   FrameworkResolver,
   ImportMapping,
 } from './types';
-import { matchReference, sameLanguageFamily } 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';
@@ -622,11 +622,13 @@ export class ReferenceResolver {
 
     const candidates: ResolvedRef[] = [];
 
-    // 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).
+    // 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);
@@ -951,15 +953,44 @@ export class ReferenceResolver {
   }
 
   /**
-   * 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.
+   * 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.
    */
   private gateLanguage(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null {
-    if (!result || ref.referenceKind !== 'references') return result;
+    if (!result) return result;
     const tgt = this.getLanguageFromNodeId(result.targetNodeId);
-    if (tgt && ref.language && !sameLanguageFamily(tgt, ref.language)) return null;
+    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;
   }
 }

+ 38 - 7
src/resolution/name-matcher.ts

@@ -85,15 +85,46 @@ export function sameLanguageFamily(a: string, b: string): boolean {
   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`.
+ * 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;
-  return candidates.filter((c) => sameLanguageFamily(c.language, ref.language));
+  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;
 }
 
 /**