Ver Fonte

fix(impact): same-dir C/C++ includes + KMP commonMain imports (multi-platform coverage)

Two resolution fixes that lift cross-file coverage on multi-platform native
modules (React Native Windows/Apple/Android side-by-side):

1. SAME-DIR C/C++ INCLUDE. `#include "Foo.h"` resolved by basename with no
   directory awareness, so when a module ships a same-named header per platform
   (windows/code/RNCAsyncStorage.h vs apple/.../RNCAsyncStorage.h) the includer
   landed on an arbitrary one — then the cross-family gate (082353e) nulled the
   wrong-family match, leaving the real local header with 0 dependents. Now C's
   quoted-include rule is honored: resolve relative to the including file's own
   directory FIRST (resolveViaImport C/C++ branch), with a same-dir/proximity
   preference also added to matchByFilePath's basename fallback (pickClosestFileNode).

2. KMP commonMain IMPORT. An `expect` decl and its `actual`s share one FQN
   across source sets; resolveJvmImport took candidates[0], so a single platform
   `actual` absorbed every common-side import and the `expect` looked unused.
   Now the same-FQN candidate CLOSEST to the importer (by shared dir prefix,
   `expect` as tiebreak) wins — a commonMain import resolves to the commonMain
   expect (pickClosestJvmCandidate).

Both are the same "prefer the closest declaration on a name collision" principle
as the 082353e gate. FAIR file-dependent coverage (authored source, excluding
generated/build/config/test/barrel/entry per methodology):
  async-storage   75.0% -> 97.4%  (residual: 1 KMP expect-decl frontier)
  rn-device-info  72.4% -> 95.2%  (residual: 1 REACT_METHOD-macro extraction frontier)
No regression: okhttp 75.9%->76.4%, kotlinx.coroutines 89.7% (neutral), cross-family
false edges still 0 on all. Node count unaffected (resolution-only). 2 regression
tests added. Full suite 1169 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Colby McHenry há 2 semanas atrás
pai
commit
529d822879

+ 1 - 0
CHANGELOG.md

@@ -18,6 +18,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++)
+- Native includes and Kotlin Multiplatform imports now resolve to the correct file in multi-platform projects. A C/C++ `#include "Foo.h"` now resolves to the header in the including file's own directory first (the C quoted-include rule), so when a module ships a same-named header per platform (a Windows, an Apple, and an Android `Foo.h` side by side) the local one correctly shows its dependents instead of an arbitrary other-platform header looking like the dependency. And a Kotlin Multiplatform `expect` declaration is no longer reported as having no dependents: a `commonMain` import now resolves to the `commonMain` `expect` (matched within the importing source set) rather than being absorbed by one platform's `actual`. (C/C++, Kotlin)
 - `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.

+ 66 - 0
__tests__/extraction.test.ts

@@ -3856,6 +3856,72 @@ describe('Cross-language type/import gate (RN name collisions)', () => {
   });
 });
 
+describe('Same-directory include + KMP import resolution', () => {
+  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 C/C++ #include resolves to the same-directory header, not a same-named one elsewhere', async () => {
+    // A multi-platform native module has a header of the same basename per
+    // platform. `windows/Provider.cpp`'s `#include "Storage.h"` means its OWN
+    // sibling header — not `apple/Storage.h` (which sorts first and so was
+    // picked arbitrarily before, leaving the real local header with 0 deps).
+    fs.mkdirSync(path.join(tempDir, 'apple'), { recursive: true });
+    fs.mkdirSync(path.join(tempDir, 'windows'), { recursive: true });
+    fs.writeFileSync(path.join(tempDir, 'apple', 'Storage.h'), `#pragma once\nstruct Storage { int n; };\n`);
+    fs.writeFileSync(path.join(tempDir, 'windows', 'Storage.h'), `#pragma once\nstruct Storage { int n; };\n`);
+    fs.writeFileSync(
+      path.join(tempDir, 'windows', 'Provider.cpp'),
+      `#include "Storage.h"\nint use() { Storage s; return s.n; }\n`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const winHeader = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('windows/Storage.h'));
+    const appleHeader = cg.getNodesByKind('file').find((n) => n.filePath.endsWith('apple/Storage.h'));
+    expect(winHeader, 'windows/Storage.h indexed').toBeDefined();
+    expect(appleHeader, 'apple/Storage.h indexed').toBeDefined();
+    const winDeps = [...cg.getImpactRadius(winHeader!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    const appleDeps = [...cg.getImpactRadius(appleHeader!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(winDeps.some((p) => p.endsWith('Provider.cpp')), 'same-dir header gets the includer').toBe(true);
+    expect(appleDeps.some((p) => p.endsWith('Provider.cpp')), 'other-platform header does NOT').toBe(false);
+  });
+
+  it('a Kotlin Multiplatform commonMain import resolves to the expect, not a platform actual', async () => {
+    const common = path.join(tempDir, 'src/commonMain/kotlin/app');
+    const android = path.join(tempDir, 'src/androidMain/kotlin/app');
+    fs.mkdirSync(common, { recursive: true });
+    fs.mkdirSync(android, { recursive: true });
+    fs.writeFileSync(path.join(common, 'Platform.kt'), `package app\nexpect class PlatformContext\n`);
+    fs.writeFileSync(path.join(android, 'Platform.android.kt'), `package app\nactual class PlatformContext\n`);
+    fs.writeFileSync(
+      path.join(common, 'Db.kt'),
+      `package app\nimport app.PlatformContext\nclass Db {\n  fun open(ctx: PlatformContext) {}\n}\n`
+    );
+
+    cg = CodeGraph.initSync(tempDir);
+    await cg.indexAll();
+    cg.resolveReferences();
+
+    const expectCtx = cg
+      .getNodesByKind('class')
+      .find((n) => n.name === 'PlatformContext' && n.filePath.endsWith('commonMain/kotlin/app/Platform.kt'));
+    expect(expectCtx, 'commonMain expect PlatformContext').toBeDefined();
+    const deps = [...cg.getImpactRadius(expectCtx!.id, 2).nodes.values()].map((n) => n.filePath ?? '');
+    expect(deps.some((p) => p.endsWith('Db.kt')), 'commonMain import lands on the expect, not the actual').toBe(true);
+  });
+});
+
 describe('Objective-C messages, class receivers, and #import', () => {
   let tempDir: string;
   let cg: CodeGraph;

+ 57 - 1
src/resolution/import-resolver.ts

@@ -1030,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
@@ -1050,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()!;

+ 41 - 4
src/resolution/name-matcher.ts

@@ -44,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',
     };
@@ -68,6 +76,35 @@ 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