feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
Introduce Expo Router integration with a new Screens view and API to surface screens and transitions, plus a new Steps API and UI to depict typed steps from anchors or symbols. Extend codegraph’s extraction and resolution to handle namespace objects (export default NAME, two-statement forms, and default bindings) and React hook bindings for handlers, improving accuracy of flows across JS ↔ native boundaries. Add Swift/React Native bridge receiver evidence (RCT_EXTERN_MODULE, RCT_EXTERN_METHOD) and related resolution logic, with tests covering namespace-object resolution, useCallback-driven handlers, and inline RN event listeners. Update UI to include a Steps tab and associated components (StepsView, StepNode, ScreenEdge) and wire navigation to expose steps-based exploration via /api/steps and UI routes. Documentation and changelog reflect the new Expo Router integration and steps surface capabilities.
This commit is contained in:
@@ -1406,10 +1406,13 @@ async function vueTemplateEdges(ctx: ResolutionContext, onYield: MaybeYield): Pr
|
||||
* DeviceEventEmitter.addListener("locationUpdate", handler);
|
||||
*
|
||||
* Synthesize: native dispatch site → JS handler, keyed by the literal
|
||||
* event name. Only matches NAMED handlers (the existing `ON_RE` named-
|
||||
* capture form). Inline arrow handlers like `addListener('x', d => …)`
|
||||
* aren't named at extraction time and would need link-through-body
|
||||
* support; matches the deliberate scope of the in-language synthesizer.
|
||||
* event name. A NAMED handler (`addListener('x', handleX)`) is the target
|
||||
* when it is a node; an unnamed one — a parameter passed through, or an
|
||||
* inline `(data) => {…}` written in a `useEffect` — is attributed to the
|
||||
* enclosing function, where the event demonstrably lands. (The in-language
|
||||
* synthesizer stays named-only; this channel pairs across a language
|
||||
* boundary on a literal, which is the evidence that makes the wider
|
||||
* attribution safe.)
|
||||
*
|
||||
* Provenance `'heuristic'`, synthesizedBy `'rn-event-channel'`.
|
||||
*/
|
||||
@@ -1518,6 +1521,9 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
|
||||
// function (the abstraction layer), giving a reachability-correct
|
||||
// hop even when the actual user-side handler lives one call up.
|
||||
const ADDLISTENER_ANY = /\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*([A-Za-z_][\w.]*)/g;
|
||||
// The inline form: `.addListener('x', (data) => {…})` / `function () {…}`.
|
||||
const ADDLISTENER_INLINE =
|
||||
/\.(?:on|once|addListener)\(\s*['"]([^'"]+)['"]\s*,\s*(?:async\s*)?(?:\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>|function\s*\()/g;
|
||||
ADDLISTENER_ANY.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ADDLISTENER_ANY.exec(content))) {
|
||||
@@ -1561,6 +1567,23 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
|
||||
map.set(targetId, `${file}:${lineOf(m.index)}`);
|
||||
jsHandlersByEvent.set(event, map);
|
||||
}
|
||||
// Inline listeners — the shape a React component registers inside a
|
||||
// `useEffect`: `nativeEmitter.addListener('onCaptureComplete', (data) =>
|
||||
// { … router.push('/review') })`. There is no handler symbol to name,
|
||||
// so the subscription is attributed to the enclosing function exactly
|
||||
// as the unnamed-argument form above is: the native event lands in that
|
||||
// component, and its body is where a reader looks next. This channel
|
||||
// only — the in-language synthesizer keeps its named-handler policy.
|
||||
ADDLISTENER_INLINE.lastIndex = 0;
|
||||
while ((m = ADDLISTENER_INLINE.exec(content))) {
|
||||
const event = m[1];
|
||||
if (!event) continue;
|
||||
const enclosing = enclosingFn(nodesInFile, lineOf(m.index));
|
||||
if (!enclosing) continue;
|
||||
const map = jsHandlersByEvent.get(event) ?? new Map<string, string>();
|
||||
if (!map.has(enclosing.id)) map.set(enclosing.id, `${file}:${lineOf(m.index)}`);
|
||||
jsHandlersByEvent.set(event, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,25 @@
|
||||
* receiver is the default export, not literally `NativeModules.<Mod>`,
|
||||
* so name-by-method-only is what actually resolves in practice).
|
||||
*
|
||||
* **Swift modules via `RCT_EXTERN_MODULE`** — the shape an app's OWN native
|
||||
* code usually takes: a Swift class exposed through a thin `.m` shim.
|
||||
* - `@interface RCT_EXTERN_MODULE(ClassName, RCTSuperclass)` names the
|
||||
* Swift class, which is also the JS module (`NativeModules.ClassName`);
|
||||
* `RCT_EXTERN_REMAP_MODULE(jsName, ClassName, Super)` renames it.
|
||||
* - `RCT_EXTERN_METHOD(selector:(args)…)` exposes the Swift method named
|
||||
* by the selector's first keyword; `RCT_EXTERN_REMAP_METHOD(jsName,
|
||||
* selector…)` under another JS name. The implementation is the Swift
|
||||
* `@objc func` of that name on the class or one of its extensions — a
|
||||
* real node from the Swift extractor, so no synthetic node is minted.
|
||||
*
|
||||
* **Receiver evidence.** `captureView.finalizeCaptureSession()` where
|
||||
* `const captureView = NativeModules.CaptureView` names the module as surely
|
||||
* as `NativeModules.CaptureView.finalizeCaptureSession()` does: both resolve
|
||||
* at 0.95 to that module's method — ahead of the import resolver, which
|
||||
* would otherwise land the call on the `captureView` constant and the flow
|
||||
* would stop one hop short of native. A bare `.method()` with no module
|
||||
* evidence keeps the by-name match at 0.6.
|
||||
*
|
||||
* **Not covered** (deferred to a follow-up phase, per design doc §6):
|
||||
* - Fabric view components (`RCT_EXPORT_VIEW_PROPERTY` / Codegen view
|
||||
* specs) — these connect JSX props to native renderers, a different
|
||||
@@ -59,7 +78,7 @@ interface NativeMethod {
|
||||
/** Per-context lazy map cache. */
|
||||
const nativeMethodMaps: WeakMap<
|
||||
ResolutionContext,
|
||||
{ byJsName: Map<string, NativeMethod[]> }
|
||||
{ byJsName: Map<string, NativeMethod[]>; aliases: Map<string, string> }
|
||||
> = new WeakMap();
|
||||
|
||||
// ─── Native-side extraction ─────────────────────────────────────────────────
|
||||
@@ -154,6 +173,87 @@ function findObjcClassName(source: string): string | null {
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `RCT_EXTERN_MODULE` / `RCT_EXTERN_METHOD` — the `.m` shim that exposes a
|
||||
* Swift class. One entry per exposed method, each naming the Swift class the
|
||||
* implementation lives on (the module name is the class name unless
|
||||
* `RCT_EXTERN_REMAP_MODULE` says otherwise).
|
||||
*/
|
||||
export function parseObjcRNExterns(
|
||||
source: string
|
||||
): Array<{ moduleName: string; className: string; jsName: string; nativeSelectorFirstKw: string; line: number }> {
|
||||
const results: Array<{ moduleName: string; className: string; jsName: string; nativeSelectorFirstKw: string; line: number }> = [];
|
||||
const remap = source.match(
|
||||
/RCT_EXTERN_REMAP_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/
|
||||
);
|
||||
const plain = source.match(/RCT_EXTERN_MODULE\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/);
|
||||
const moduleName = remap?.[1] ?? plain?.[1] ?? null;
|
||||
const className = remap?.[2] ?? plain?.[1] ?? null;
|
||||
if (!moduleName || !className) return results;
|
||||
|
||||
const lineOf = (idx: number): number => {
|
||||
let line = 1;
|
||||
for (let i = 0; i < idx && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
|
||||
return line;
|
||||
};
|
||||
|
||||
const methodRegex = /RCT_EXTERN_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = methodRegex.exec(source)) !== null) {
|
||||
const kw = m[1];
|
||||
if (kw) results.push({ moduleName, className, jsName: kw, nativeSelectorFirstKw: kw, line: lineOf(m.index) });
|
||||
}
|
||||
const remapRegex =
|
||||
/RCT_EXTERN_REMAP_METHOD\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*,\s*([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
while ((m = remapRegex.exec(source)) !== null) {
|
||||
const jsName = m[1];
|
||||
const nativeKw = m[2];
|
||||
if (jsName && nativeKw) {
|
||||
results.push({ moduleName, className, jsName, nativeSelectorFirstKw: nativeKw, line: lineOf(m.index) });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local names bound to a native module on the JS side — the receiver evidence
|
||||
* `resolve()` trusts:
|
||||
*
|
||||
* const captureView = NativeModules.CaptureView
|
||||
* export const { CaptureEvents } = NativeModules
|
||||
* const { Geo: geolocation } = NativeModules
|
||||
*
|
||||
* Collected across the project by name: an alias is nearly always an exported
|
||||
* constant imported elsewhere under the same name. A name bound to two
|
||||
* different modules is dropped as ambiguous rather than guessed.
|
||||
*/
|
||||
export function collectNativeModuleAliases(
|
||||
source: string,
|
||||
aliases: Map<string, string>,
|
||||
ambiguous: Set<string>
|
||||
): void {
|
||||
const bind = (alias: string, moduleName: string): void => {
|
||||
if (ambiguous.has(alias)) return;
|
||||
const prior = aliases.get(alias);
|
||||
if (prior !== undefined && prior !== moduleName) {
|
||||
aliases.delete(alias);
|
||||
ambiguous.add(alias);
|
||||
return;
|
||||
}
|
||||
aliases.set(alias, moduleName);
|
||||
};
|
||||
const direct = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=;]+)?=\s*NativeModules\.([A-Z][\w$]*)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = direct.exec(source)) !== null) bind(m[1]!, m[2]!);
|
||||
const destructured = /\b(?:const|let|var)\s*\{([^}]+)\}\s*=\s*NativeModules\b/g;
|
||||
while ((m = destructured.exec(source)) !== null) {
|
||||
for (const part of m[1]!.split(',')) {
|
||||
const entry = part.trim().match(/^([A-Za-z_$][\w$]*)(?:\s*:\s*([A-Za-z_$][\w$]*))?$/);
|
||||
if (entry) bind(entry[2] ?? entry[1]!, entry[1]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Java/Kotlin source file for `@ReactMethod` annotated methods
|
||||
* and the surrounding class's `getName()` return value (the JS-visible
|
||||
@@ -260,16 +360,19 @@ const RN_EMITTER_BUILTINS = new Set([
|
||||
'stopObserving',
|
||||
]);
|
||||
|
||||
function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]> } {
|
||||
function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, NativeMethod[]>; aliases: Map<string, string> } {
|
||||
const cached = nativeMethodMaps.get(context);
|
||||
if (cached) return cached;
|
||||
|
||||
const byJsName = new Map<string, NativeMethod[]>();
|
||||
const aliases = new Map<string, string>();
|
||||
const ambiguousAliases = new Set<string>();
|
||||
const allFiles = context.getAllFiles();
|
||||
// Pre-index native methods by name for fast lookup when matching to
|
||||
// their bridge exports.
|
||||
const objcMethodsByFirstKw = new Map<string, Node[]>();
|
||||
const jvmMethodsByName = new Map<string, Node[]>();
|
||||
const swiftMethodsByName = new Map<string, Node[]>();
|
||||
for (const node of context.getNodesByKind('method')) {
|
||||
if (node.language === 'objc') {
|
||||
const firstKw = node.name.includes(':') ? node.name.split(':')[0] : node.name;
|
||||
@@ -282,6 +385,10 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
|
||||
const arr = jvmMethodsByName.get(node.name);
|
||||
if (arr) arr.push(node);
|
||||
else jvmMethodsByName.set(node.name, [node]);
|
||||
} else if (node.language === 'swift') {
|
||||
const arr = swiftMethodsByName.get(node.name);
|
||||
if (arr) arr.push(node);
|
||||
else swiftMethodsByName.set(node.name, [node]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +413,33 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
|
||||
if (arr) arr.push(entry);
|
||||
else byJsName.set(exp.jsName, [entry]);
|
||||
}
|
||||
// Swift-backed module: the shim names the class; the implementation is
|
||||
// the Swift method of that name on the class or one of its extensions.
|
||||
// Class-scoped, so a same-named method on another Swift type (a
|
||||
// `syncSettings` on `CaptureSettings` beside the one on `CaptureView`)
|
||||
// is never the answer.
|
||||
if (/RCT_EXTERN_(?:REMAP_)?MODULE\b/.test(source)) {
|
||||
for (const ext of parseObjcRNExterns(source)) {
|
||||
if (RN_EMITTER_BUILTINS.has(ext.jsName)) continue;
|
||||
const candidates = (swiftMethodsByName.get(ext.nativeSelectorFirstKw) ?? [])
|
||||
.filter((c) => c.qualifiedName.split('::').includes(ext.className))
|
||||
.sort((a, b) => a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
|
||||
const node = candidates[0];
|
||||
if (!node) continue;
|
||||
const entry: NativeMethod = { moduleName: ext.moduleName, jsName: ext.jsName, node };
|
||||
const arr = byJsName.get(ext.jsName);
|
||||
if (arr) arr.push(entry);
|
||||
else byJsName.set(ext.jsName, [entry]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// JS side: the local names bound to `NativeModules.<Module>`.
|
||||
if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
|
||||
const source = context.readFile(file);
|
||||
if (source && source.includes('NativeModules')) {
|
||||
collectNativeModuleAliases(source, aliases, ambiguousAliases);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy bridge — Java/Kotlin side.
|
||||
@@ -352,7 +486,7 @@ function buildRNMaps(context: ResolutionContext): { byJsName: Map<string, Native
|
||||
}
|
||||
}
|
||||
|
||||
const result = { byJsName };
|
||||
const result = { byJsName, aliases };
|
||||
nativeMethodMaps.set(context, result);
|
||||
return result;
|
||||
}
|
||||
@@ -406,7 +540,7 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
|
||||
|
||||
/**
|
||||
* Detect: package.json depends on `react-native`, OR any source file
|
||||
* uses the `RCT_EXPORT_MODULE` / `RCT_EXPORT_METHOD` /
|
||||
* uses the `RCT_EXPORT_MODULE` / `RCT_EXTERN_MODULE` /
|
||||
* `TurboModuleRegistry` markers. Either signal is enough — different
|
||||
* libraries split the JS package from the native code (`react-native-svg`'s
|
||||
* apple/ + android/ directories vs its src/), so we don't require both.
|
||||
@@ -423,7 +557,7 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
|
||||
if (!f) continue;
|
||||
if (f.endsWith('.mm') || f.endsWith('.m')) {
|
||||
const src = context.readFile(f);
|
||||
if (src && /RCT_EXPORT_MODULE\b/.test(src)) return true;
|
||||
if (src && /RCT_EXPORT_MODULE\b|RCT_EXTERN_(?:REMAP_)?MODULE\b/.test(src)) return true;
|
||||
}
|
||||
if (f.endsWith('.ts') || f.endsWith('.tsx')) {
|
||||
const src = context.readFile(f);
|
||||
@@ -454,28 +588,52 @@ export const reactNativeBridgeResolver: FrameworkResolver = {
|
||||
}
|
||||
|
||||
// JS callsites of `obj.method()` reach the resolver as either
|
||||
// `obj.method` (qualified) or `method` (bare). Strip a single dot
|
||||
// prefix to get the JS-visible method name.
|
||||
const name = ref.referenceName.includes('.')
|
||||
? ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1)
|
||||
: ref.referenceName;
|
||||
// `obj.method` (qualified) or `method` (bare). Strip the receiver to
|
||||
// get the JS-visible method name — and keep it, as evidence.
|
||||
const raw = ref.referenceName;
|
||||
const lastDot = raw.lastIndexOf('.');
|
||||
const name = lastDot >= 0 ? raw.slice(lastDot + 1) : raw;
|
||||
|
||||
const maps = buildRNMaps(context);
|
||||
const entries = maps.byJsName.get(name);
|
||||
if (!entries || entries.length === 0) return null;
|
||||
|
||||
// Prefer the iOS (ObjC) target over Android when both exist — iOS is
|
||||
// the conventional first-class platform for RN library docs and most
|
||||
// graph queries. We still record only one edge; a JVM-only resolution
|
||||
// is fine when no ObjC target exists.
|
||||
const objc = entries.find((e) => e.node.language === 'objc');
|
||||
const target = objc ?? entries[0];
|
||||
// iOS first — the conventional first-class platform for RN library docs
|
||||
// and most graph queries; one edge is recorded either way.
|
||||
const pick = (list: NativeMethod[]): NativeMethod | undefined =>
|
||||
list.find((e) => e.node.language === 'objc') ??
|
||||
list.find((e) => e.node.language === 'swift') ??
|
||||
list[0];
|
||||
|
||||
// Receiver evidence: `NativeModules.Mod.method`, or an alias the project
|
||||
// bound to `NativeModules.Mod`. The module is named, so the match is to
|
||||
// THAT module's method at a confidence the import resolver cannot beat
|
||||
// — and a module that has no such method is not ours to guess at.
|
||||
if (lastDot >= 0) {
|
||||
const receiver = raw.slice(0, lastDot);
|
||||
const direct = receiver.match(/^NativeModules\.([A-Z][\w$]*)$/);
|
||||
const moduleName = direct ? direct[1]! : maps.aliases.get(receiver) ?? null;
|
||||
if (moduleName !== null) {
|
||||
const exact = pick(entries.filter((e) => e.moduleName === moduleName));
|
||||
if (!exact) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: exact.node.id,
|
||||
confidence: 0.95,
|
||||
resolvedBy: 'framework',
|
||||
metadata: { bridge: 'react-native', module: moduleName },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const target = pick(entries);
|
||||
if (!target) return null;
|
||||
return {
|
||||
original: ref,
|
||||
targetNodeId: target.node.id,
|
||||
confidence: 0.6,
|
||||
resolvedBy: 'framework',
|
||||
metadata: { bridge: 'react-native', module: target.moduleName },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -79,6 +79,27 @@ interface FileExportIndex {
|
||||
byName: Map<string, Node>;
|
||||
defaultComponent: Node | undefined;
|
||||
defaultFnClass: Node | undefined;
|
||||
/**
|
||||
* The node an `export default NAME` statement names, exported at its
|
||||
* declaration or not — the precise answer where `defaultFnClass` is a
|
||||
* guess. `const Home = () => …; export default Home` and the namespace
|
||||
* object `const UploadApi = { uploadARCapture }; export default UploadApi`
|
||||
* are both invisible to the `isExported` index above: neither declaration
|
||||
* has an `export_statement` ancestor.
|
||||
*/
|
||||
defaultBinding: Node | undefined;
|
||||
}
|
||||
|
||||
const DEFAULT_BINDING_KINDS = new Set<string>(['function', 'class', 'component', 'constant', 'variable']);
|
||||
const DEFAULT_EXPORT_BINDING_RE = /^[ \t]*export\s+default\s+([A-Za-z_$][\w$]*)\s*;?[ \t]*$/m;
|
||||
const JS_FAMILY_FILE = /\.(?:[cm]?[jt]sx?)$/;
|
||||
|
||||
/** The identifier `export default NAME` names in a JS-family file, or null. */
|
||||
function defaultExportBinding(filePath: string, context: ResolutionContext): string | null {
|
||||
if (!JS_FAMILY_FILE.test(filePath)) return null;
|
||||
const source = context.readFile(filePath);
|
||||
if (!source || !source.includes('export default')) return null;
|
||||
return source.match(DEFAULT_EXPORT_BINDING_RE)?.[1] ?? null;
|
||||
}
|
||||
const fileExportIndexes = new WeakMap<ResolutionContext, Map<string, FileExportIndex>>();
|
||||
|
||||
@@ -90,13 +111,20 @@ function getFileExportIndex(filePath: string, context: ResolutionContext): FileE
|
||||
}
|
||||
let idx = perFile.get(filePath);
|
||||
if (!idx) {
|
||||
idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined };
|
||||
for (const n of context.getNodesInFile(filePath)) {
|
||||
idx = { byName: new Map(), defaultComponent: undefined, defaultFnClass: undefined, defaultBinding: undefined };
|
||||
const nodesInFile = context.getNodesInFile(filePath);
|
||||
for (const n of nodesInFile) {
|
||||
if (!n.isExported) continue;
|
||||
if (!idx.byName.has(n.name)) idx.byName.set(n.name, n);
|
||||
if (idx.defaultComponent === undefined && n.kind === 'component') idx.defaultComponent = n;
|
||||
if (idx.defaultFnClass === undefined && (n.kind === 'function' || n.kind === 'class')) idx.defaultFnClass = n;
|
||||
}
|
||||
const bound = defaultExportBinding(filePath, context);
|
||||
if (bound !== null) {
|
||||
idx.defaultBinding = nodesInFile
|
||||
.filter((n) => n.name === bound && DEFAULT_BINDING_KINDS.has(n.kind))
|
||||
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
|
||||
}
|
||||
perFile.set(filePath, idx);
|
||||
}
|
||||
return idx;
|
||||
@@ -1558,6 +1586,8 @@ export function resolveViaImport(
|
||||
if (member) {
|
||||
const literalMember = resolveObjectLiteralMember(targetNode, member, ref, context, 0.9, 'import');
|
||||
if (literalMember) return literalMember;
|
||||
const aliasMember = resolveObjectLiteralAlias(targetNode, member, ref, context);
|
||||
if (aliasMember) return aliasMember;
|
||||
}
|
||||
}
|
||||
// An imported VALUE (singleton constant / shared instance) called
|
||||
@@ -1717,6 +1747,80 @@ function resolveLuaRequire(ref: UnresolvedRef, context: ResolutionContext): Reso
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `UploadApi.uploadARCapture()` where `UploadApi` is a NAMESPACE OBJECT — the
|
||||
* default-export façade most React Native API layers are written as:
|
||||
*
|
||||
* import { uploadARCapture } from './frames'
|
||||
* const UploadApi = { uploadARCapture, createFolder }
|
||||
* export default UploadApi
|
||||
*
|
||||
* The member is a shorthand (or `key: ident`) property whose value is a
|
||||
* binding of the object's file, not a function defined inside the literal,
|
||||
* so containment (`resolveObjectLiteralMember`) finds nothing and the call
|
||||
* landed on the constant — every cross-file caller of the API function went
|
||||
* missing. Read the literal's source, take the binding the member names, and
|
||||
* resolve it where the object's file would: a symbol declared there, else
|
||||
* through its own imports. Calls accept callable targets only.
|
||||
*/
|
||||
function resolveObjectLiteralAlias(
|
||||
container: Node,
|
||||
member: string,
|
||||
ref: UnresolvedRef,
|
||||
context: ResolutionContext
|
||||
): ResolvedRef | null {
|
||||
if (container.kind !== 'constant' && container.kind !== 'variable') return null;
|
||||
if (!JS_FAMILY_FILE.test(container.filePath)) return null;
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(member)) return null;
|
||||
const lines = context.getFileLines?.(container.filePath) ?? context.readFile(container.filePath)?.split('\n');
|
||||
if (!lines) return null;
|
||||
const extent = lines.slice(container.startLine - 1, container.endLine).join('\n');
|
||||
const brace = extent.indexOf('{');
|
||||
if (brace < 0) return null;
|
||||
const body = extent.slice(brace);
|
||||
const keyed = new RegExp(`[{,\\s]${member}\\s*:\\s*([A-Za-z_$][\\w$]*)\\s*[,}]`);
|
||||
const shorthand = new RegExp(`[{,\\s]${member}\\s*[,}]`);
|
||||
const k = body.match(keyed);
|
||||
const binding = k ? k[1]! : shorthand.test(body) ? member : null;
|
||||
if (binding === null) return null;
|
||||
|
||||
const callable = (n: Node) => n.kind === 'function' || n.kind === 'method' || n.kind === 'class';
|
||||
const accepts =
|
||||
ref.referenceKind === 'calls'
|
||||
? callable
|
||||
: (n: Node) => callable(n) || n.kind === 'constant' || n.kind === 'variable' || n.kind === 'component';
|
||||
|
||||
// Declared in the object's own file, outside the literal.
|
||||
const local = context
|
||||
.getNodesInFile(container.filePath)
|
||||
.filter((n) => n.name === binding && n.id !== container.id && accepts(n))
|
||||
.sort((a, b) => a.startLine - b.startLine || a.startColumn - b.startColumn)[0];
|
||||
if (local) return { original: ref, targetNodeId: local.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
|
||||
// Imported into the object's file.
|
||||
for (const imp of context.getImportMappings(container.filePath, container.language)) {
|
||||
if (imp.localName !== binding || imp.isNamespace) continue;
|
||||
const resolvedPath = resolveImportPath(imp.source, container.filePath, container.language, context);
|
||||
if (!resolvedPath) continue;
|
||||
const target = findExportedSymbol(
|
||||
resolvedPath,
|
||||
{
|
||||
isDefault: imp.isDefault,
|
||||
isNamespace: false,
|
||||
exportedName: imp.isDefault ? 'default' : imp.exportedName,
|
||||
memberName: null,
|
||||
},
|
||||
container.language,
|
||||
context,
|
||||
new Set()
|
||||
);
|
||||
if (target && accepts(target)) {
|
||||
return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveModuleImportToFile(
|
||||
ref: UnresolvedRef,
|
||||
imports: ImportMapping[],
|
||||
@@ -2152,7 +2256,9 @@ function findExportedSymbolWalk(
|
||||
// `.ts`/`.tsx` `export default fn`/`class` case. Without the component
|
||||
// branch, an `export { default as X } from './X.svelte'` barrel never
|
||||
// resolves and the component shows a false 0 callers (#629).
|
||||
const direct = exportIndex.defaultComponent ?? exportIndex.defaultFnClass;
|
||||
// A component file IS its default export; otherwise the statement that
|
||||
// names the binding beats the first-exported-function guess.
|
||||
const direct = exportIndex.defaultComponent ?? exportIndex.defaultBinding ?? exportIndex.defaultFnClass;
|
||||
if (direct) return direct;
|
||||
} else if (want.isNamespace && want.memberName) {
|
||||
const direct = exportIndex.byName.get(want.memberName);
|
||||
|
||||
Reference in New Issue
Block a user