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
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+27
-6
@@ -388,16 +388,37 @@ export { main };
|
||||
});
|
||||
|
||||
describe('File dependency analysis', () => {
|
||||
it('should get file dependencies', () => {
|
||||
// Regression: getFileDependents/getFileDependencies used to follow
|
||||
// ONLY `imports` edges, which in this engine are same-file (a file → its
|
||||
// own local import declarations). That made both return [] for EVERY file,
|
||||
// so `codegraph affected` found no dependents on any language/framework.
|
||||
// They must follow the cross-file symbol graph instead (calls / references
|
||||
// / instantiates / extends / implements / ...).
|
||||
it('reports cross-file dependencies via the symbol graph, not just imports', () => {
|
||||
const deps = cg.getFileDependencies('src/main.ts');
|
||||
|
||||
expect(Array.isArray(deps)).toBe(true);
|
||||
// main() instantiates DerivedClass (derived.ts) and calls
|
||||
// processValue/doubleValue (utils.ts) — both are real dependencies.
|
||||
expect(deps).toContain('src/utils.ts');
|
||||
expect(deps).toContain('src/derived.ts');
|
||||
});
|
||||
|
||||
it('should get file dependents', () => {
|
||||
const dependents = cg.getFileDependents('src/utils.ts');
|
||||
it('reports cross-file dependents via the symbol graph, not just imports', () => {
|
||||
// utils.ts is used by main.ts (processValue/doubleValue calls); the old
|
||||
// imports-only implementation returned [] here.
|
||||
expect(cg.getFileDependents('src/utils.ts')).toContain('src/main.ts');
|
||||
});
|
||||
|
||||
expect(Array.isArray(dependents)).toBe(true);
|
||||
it('counts extends/implements as a dependency edge', () => {
|
||||
// derived.ts extends BaseClass / implements Printable, both in base.ts.
|
||||
expect(cg.getFileDependencies('src/derived.ts')).toContain('src/base.ts');
|
||||
expect(cg.getFileDependents('src/base.ts')).toContain('src/derived.ts');
|
||||
});
|
||||
|
||||
it('never lists a file as its own dependent or dependency', () => {
|
||||
for (const f of ['src/main.ts', 'src/utils.ts', 'src/base.ts', 'src/derived.ts']) {
|
||||
expect(cg.getFileDependents(f)).not.toContain(f);
|
||||
expect(cg.getFileDependencies(f)).not.toContain(f);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Node, Language } from '../src/types';
|
||||
import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types';
|
||||
import { reactNativeBridgeResolver } from '../src/resolution/frameworks/react-native';
|
||||
import { CodeGraph } from '../src';
|
||||
|
||||
/**
|
||||
* Mock ResolutionContext for the React Native bridge resolver.
|
||||
@@ -292,3 +296,47 @@ describe('React Native bridge resolver', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('React Native cross-platform pairing — end to end', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rn-xplat-')); });
|
||||
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
it('links the Android (@ReactMethod) and iOS (RCT_EXPORT_METHOD) impls of a JS-called method', async () => {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}');
|
||||
fs.writeFileSync(path.join(dir, 'index.ts'),
|
||||
"import { NativeModules } from 'react-native';\n" +
|
||||
"export function ping() { return NativeModules.RNThing.uniquePingMethod(); }\n");
|
||||
fs.writeFileSync(path.join(dir, 'RNThing.java'),
|
||||
"public class RNThing extends ReactContextBaseJavaModule {\n" +
|
||||
" @Override public String getName() { return \"RNThing\"; }\n" +
|
||||
" @ReactMethod public void uniquePingMethod(Callback cb) {}\n}\n");
|
||||
fs.writeFileSync(path.join(dir, 'RNThing.m'),
|
||||
"@implementation RNThing\n" +
|
||||
"RCT_EXPORT_MODULE()\n" +
|
||||
"RCT_EXPORT_METHOD(uniquePingMethod:(RCTResponseSenderBlock)cb) {}\n@end\n");
|
||||
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
const db = (cg as any).db.db;
|
||||
|
||||
// The iOS `RCT_EXPORT_METHOD` is extracted as an ObjC method node (the macro
|
||||
// parses as a macro-expression, not a method, so it had no node before).
|
||||
const objc = db.prepare(
|
||||
"SELECT * FROM nodes WHERE name='uniquePingMethod' AND language='objc' AND id LIKE 'rn-export:%'"
|
||||
).all();
|
||||
expect(objc).toHaveLength(1);
|
||||
|
||||
// The Java and ObjC impls of `uniquePingMethod` 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 json_extract(e.metadata,'$.synthesizedBy')='rn-cross-platform'
|
||||
AND s.name LIKE 'uniquePingMethod%' AND t.name LIKE 'uniquePingMethod%'
|
||||
AND s.language != t.language`
|
||||
).get();
|
||||
cg.close?.();
|
||||
expect(pair.c).toBeGreaterThanOrEqual(2); // java<->objc both directions
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,4 +123,38 @@ export function onMessage(listener: (m: any) => void) {
|
||||
expect(edge.target_name).toBe('onMessage');
|
||||
expect(['function', 'method']).toContain(edge.target_kind);
|
||||
});
|
||||
it('synthesizes an edge from a Java sendEvent(ctx, "X", body) wrapper to a JS handler', async () => {
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), '{"dependencies":{"react-native":"^0.74.0"}}');
|
||||
// The literal event name lives in the WRAPPER CALL, not in `.emit` (whose
|
||||
// first arg is the `eventName` VARIABLE) — the common react-native-device-info
|
||||
// shape that RN_JVM_EMIT_RE alone misses.
|
||||
fs.writeFileSync(path.join(dir, 'BatteryModule.java'),
|
||||
'public class BatteryModule extends ReactContextBaseJavaModule {\n' +
|
||||
' @Override public String getName() { return "BatteryModule"; }\n' +
|
||||
' public void onBatteryChanged() {\n' +
|
||||
' sendEvent(getReactApplicationContext(),\n' +
|
||||
' "myWrapperBatteryEvent", null);\n' +
|
||||
' }\n' +
|
||||
' private void sendEvent(ReactContext ctx, String eventName, Object data) {\n' +
|
||||
' ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, data);\n' +
|
||||
' }\n' +
|
||||
'}\n');
|
||||
fs.writeFileSync(path.join(dir, 'index.ts'),
|
||||
"function onBattery() {}\n" +
|
||||
"emitter.addListener('myWrapperBatteryEvent', onBattery);\n");
|
||||
|
||||
const cg = await CodeGraph.init(dir, { silent: true });
|
||||
await cg.indexAll();
|
||||
const db = (cg as any).db.db;
|
||||
const rows = db.prepare(
|
||||
"SELECT s.name source_name, s.language sl, t.name target_name FROM edges e " +
|
||||
"JOIN nodes s ON s.id=e.source JOIN nodes t ON t.id=e.target " +
|
||||
"WHERE json_extract(e.metadata,'$.synthesizedBy')='rn-event-channel' AND json_extract(e.metadata,'$.event')='myWrapperBatteryEvent'"
|
||||
).all();
|
||||
cg.close?.();
|
||||
expect(rows.length).toBeGreaterThanOrEqual(1);
|
||||
expect(rows[0].sl).toBe('java');
|
||||
expect(rows[0].source_name).toBe('onBatteryChanged');
|
||||
expect(rows[0].target_name).toBe('onBattery');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user