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:
@@ -0,0 +1,723 @@
|
||||
/**
|
||||
* `GET /api/steps` — what happens from here: a screen, a handler or any
|
||||
* symbol as the ANCHOR, and everything it sets in motion drawn as typed steps.
|
||||
*
|
||||
* The Screens view (`screens.ts`) is already a picture of steps with one step
|
||||
* type: it folds `HomeScreen → ItemsGrid → ItemCard → openObjectDetail` into
|
||||
* one arrow labelled with its condition, because the reader wants the
|
||||
* transition, not the plumbing. This endpoint keeps that fold and widens the
|
||||
* set of things worth a box. Walking FORWARD from the anchor over calls,
|
||||
* renders, handler bindings and navigations, a node is a step when it is:
|
||||
*
|
||||
* - a **screen** (a route reached over a `navigates` edge),
|
||||
* - a **trigger** — a function wired as a value (`onPress={handleX}`,
|
||||
* `addListener('x', handleX)`), the user's or the platform's way in,
|
||||
* - a **bridge** call — the language changes under the call, JS → native
|
||||
* (the React Native bridge resolver's edges, or any family crossing),
|
||||
* - a native **event** landing back in JS (`sendEvent(withName:)` → the
|
||||
* listener, via the RN event channel),
|
||||
* - a **store** action — a function in a store file, the state it writes,
|
||||
* - an **effect** — a call that leaves the index into the network, storage,
|
||||
* the device or telemetry, drawn as its own box beside the function that
|
||||
* makes it.
|
||||
*
|
||||
* Everything else — hooks, helpers, services, the components between a
|
||||
* screen and its handlers — is `via`: listed on the link, never a box. The
|
||||
* branch conditions along the folded chain join into the link's `when`, read
|
||||
* from the source at request time exactly as the Screens view reads them.
|
||||
*
|
||||
* The picture is finite because it is ANCHORED and CAPPED, not because the
|
||||
* graph is small: a bounded depth in steps, a bounded fan-out per node, a
|
||||
* bounded number of nodes folded per step, and hubs and shared chrome (a top
|
||||
* bar rendered on ten screens) are dead ends rather than paths. Every cap
|
||||
* that fired is reported on the step it fired at, so a short picture never
|
||||
* reads as "nothing else happens here".
|
||||
*
|
||||
* Read from the graph at request time, never cached: the `when` labels and
|
||||
* the effect sites are read from the source as it stands.
|
||||
*/
|
||||
|
||||
import type CodeGraph from '../../index';
|
||||
import type { Edge, Language, Node, UnresolvedReference } from '../../types';
|
||||
import { badRequest, intParam, notFound } from './respond';
|
||||
import { createWhenReader } from './when';
|
||||
import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire';
|
||||
|
||||
// =============================================================================
|
||||
// Wire shapes
|
||||
// =============================================================================
|
||||
|
||||
export type WireStepKind = 'anchor' | 'screen' | 'trigger' | 'bridge' | 'event' | 'store' | 'effect';
|
||||
|
||||
export type WireStepLinkKind = 'calls' | 'navigates' | 'handler' | 'bridge' | 'event' | 'store' | 'effect';
|
||||
|
||||
export interface WireStepSite {
|
||||
file: string;
|
||||
line: number;
|
||||
/** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface WireStep {
|
||||
/** The node's id, or `effect:<function id>:<api>` for a call leaving the index. */
|
||||
id: string;
|
||||
kind: WireStepKind;
|
||||
/** The step the picture starts from. A screen anchor keeps `kind: 'screen'`. */
|
||||
anchor: boolean;
|
||||
/** Null only for an effect, which is a call site rather than a symbol. */
|
||||
node: WireNodeRef | null;
|
||||
/** `/capture/review`, `handleApproveAllImages`, `client.post`. */
|
||||
label: string;
|
||||
/** The component for a screen, the file for a symbol, the category and caller for an effect. */
|
||||
sub: string;
|
||||
/** Steps from the anchor: the row. */
|
||||
depth: number;
|
||||
/**
|
||||
* Why the walk did not go on from this step, when it did not: a cap it hit
|
||||
* (`depth`, `fan-out`, `folded`, `steps`), or `screen` — another screen is
|
||||
* a chapter of its own, drawn but not entered unless `through` asks.
|
||||
*/
|
||||
cut: 'depth' | 'fan-out' | 'folded' | 'steps' | 'screen' | 'component' | null;
|
||||
/** The event name a native event step arrived on (`onZipComplete`) — the first, when several land here. */
|
||||
event?: string;
|
||||
/** Every event that lands on this step, in the order the walk met them. */
|
||||
events?: string[];
|
||||
/** For a screen: its path and the component that renders it. */
|
||||
screen?: { path: string; component: WireNodeRef | null };
|
||||
/**
|
||||
* For an effect: the calls one function makes into one category — `api` is
|
||||
* the first, `apis` all of them — and the function that makes them.
|
||||
*/
|
||||
effect?: { api: string; apis: string[]; category: string; by: WireNodeRef; line: number };
|
||||
}
|
||||
|
||||
export interface WireStepLink {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
kind: WireStepLinkKind;
|
||||
/** The symbols folded between the two steps, in order. */
|
||||
via: WireNodeRef[];
|
||||
/** Conditions along the whole chain, joined; '' when unconditional. */
|
||||
when: string;
|
||||
/** How the last hop was established when it was not a plain call — `via rn-event-channel · registered at file:line`. */
|
||||
label: string;
|
||||
synthesized: boolean;
|
||||
uncertain: boolean;
|
||||
sites: WireStepSite[];
|
||||
}
|
||||
|
||||
export interface WireStepsPayload {
|
||||
anchor: WireNodeRef;
|
||||
/** Other symbols that share the anchor's name, when it was given by name. */
|
||||
ambiguous: WireNodeRef[];
|
||||
steps: WireStep[];
|
||||
links: WireStepLink[];
|
||||
depth: number;
|
||||
limit: number;
|
||||
/** Screens reached from the anchor were entered rather than drawn as boundaries. */
|
||||
through: boolean;
|
||||
truncated: {
|
||||
/** Steps not added because the picture reached `limit`. */
|
||||
steps: number;
|
||||
/** Folded walks that stopped at a hub (fan-in ≥ the hub threshold). */
|
||||
hubs: number;
|
||||
/** Folded walks that stopped at shared chrome (a component rendered by several screens). */
|
||||
chrome: number;
|
||||
};
|
||||
index: { lastIndexedAt: number | null; edges: number; files: number };
|
||||
timing: { elapsedMs: number };
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Caps
|
||||
// =============================================================================
|
||||
|
||||
export const DEFAULT_DEPTH = 8;
|
||||
export const MAX_DEPTH = 14;
|
||||
export const DEFAULT_LIMIT = 120;
|
||||
export const MAX_LIMIT = 400;
|
||||
/** Nodes folded while exploring from ONE step before the walk stops. */
|
||||
const MAX_FOLDED_PER_STEP = 300;
|
||||
/** Hops of folded plumbing between two steps. */
|
||||
const MAX_FOLD_DEPTH = 7;
|
||||
/** Outgoing edges followed from one node; past this the node is a god function and the rest is announced. */
|
||||
const MAX_FANOUT = 80;
|
||||
/** Unresolved-reference scans (for effects) per request. */
|
||||
const MAX_EFFECT_SCANS = 800;
|
||||
/** Call sites labelled with conditions per request. */
|
||||
const MAX_WHEN_SITES = 800;
|
||||
/**
|
||||
* A component rendered by this many distinct parents is chrome (a top bar, a
|
||||
* button), not a screen's own behaviour. Higher than the Screens view's 3: that
|
||||
* one attributes navigations, where three screens sharing a link is already
|
||||
* chrome; this one decides what to WALK INTO, and a capture component shared
|
||||
* by three capture flows is the screen's whole body.
|
||||
*/
|
||||
const SHARED_CHROME_MIN = 5;
|
||||
|
||||
/** Edges walked forward. `contains` only function → function (a hook's handlers); `references` only function-as-value. */
|
||||
const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'navigates', 'references', 'contains'];
|
||||
|
||||
// =============================================================================
|
||||
// Classification
|
||||
// =============================================================================
|
||||
|
||||
const JS_FAMILY: ReadonlySet<Language> = new Set<Language>(['javascript', 'typescript', 'tsx', 'jsx']);
|
||||
const NATIVE_FAMILY: ReadonlySet<Language> = new Set<Language>(['swift', 'objc', 'java', 'kotlin']);
|
||||
|
||||
/** JS → native is a bridge call; native → JS is an event. Anything else is one family. */
|
||||
export function crossing(from: Language, to: Language): 'bridge' | 'event' | null {
|
||||
if (JS_FAMILY.has(from) && NATIVE_FAMILY.has(to)) return 'bridge';
|
||||
if (NATIVE_FAMILY.has(from) && JS_FAMILY.has(to)) return 'event';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file that holds state: a store, a slice, a reducer. The graph has no
|
||||
* "store" kind — a Zustand action is an ordinary function node — so the file
|
||||
* is the evidence, and the legend says so.
|
||||
*/
|
||||
export const STORE_FILE = /(?:^|\/)(?:stores?|storage|state|slices?|reducers?)\/|\.(?:store|storage|slice|reducer)\.[cm]?[jt]sx?$/i;
|
||||
|
||||
export function isStoreFile(file: string): boolean {
|
||||
return STORE_FILE.test(file.replace(/\\/g, '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls that leave the index and change something outside the process. A
|
||||
* curated table, deliberately: "any call into a package" is every `Date` and
|
||||
* `Math.max`, and a box for each would bury the ones that matter. Matched on
|
||||
* the reference text as written at the call.
|
||||
*/
|
||||
export const EFFECTS: ReadonlyArray<{ category: string; test: RegExp }> = [
|
||||
{
|
||||
category: 'network',
|
||||
test: /^(?:fetch|axios|ky|got|superagent|XMLHttpRequest|WebSocket)$|^(?:axios|api|client|http|https|httpClient|apiClient|instance|request|agent|graphql|apollo|supabase)\.(?:get|post|put|patch|delete|head|request|query|mutate|rpc|invoke)$|^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask)$/,
|
||||
},
|
||||
{
|
||||
category: 'storage',
|
||||
test: /^(?:AsyncStorage|SecureStore|MMKV|localStorage|sessionStorage|indexedDB|UserDefaults|Keychain|KeychainAccess|FileSystem|RNFS|FileManager|fs|fsp)\b/,
|
||||
},
|
||||
{
|
||||
category: 'device',
|
||||
test: /^(?:Linking|Share|Clipboard|Notifications|Camera|ImagePicker|MediaLibrary|Haptics|Alert|Vibration|Location|Geolocation|Permissions|UIApplication|AVCaptureSession|AVAudioSession|CLLocationManager|UNUserNotificationCenter)\b/,
|
||||
},
|
||||
{
|
||||
category: 'telemetry',
|
||||
test: /^(?:DdRum|DdLogs|DdTrace|DdSdkReactNative|CustomerIO|Sentry|Bugsnag|analytics|Analytics|crashlytics|Crashlytics|mixpanel|Mixpanel|amplitude|Amplitude|posthog|PostHog|LDClient|ldClient)\b/,
|
||||
},
|
||||
];
|
||||
|
||||
export function effectCategory(referenceName: string): string | null {
|
||||
for (const e of EFFECTS) if (e.test.test(referenceName)) return e.category;
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// The endpoint
|
||||
// =============================================================================
|
||||
|
||||
interface Fold {
|
||||
node: Node;
|
||||
/** [first folded node, …, this node]; empty for the step's own root. */
|
||||
chain: Node[];
|
||||
whens: string[];
|
||||
}
|
||||
|
||||
interface StepRecord extends WireStep {
|
||||
/** Where exploration from this step begins: a screen's component, otherwise the node itself. */
|
||||
root: Node | null;
|
||||
}
|
||||
|
||||
export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLSearchParams): Promise<WireStepsPayload> {
|
||||
const started = Date.now();
|
||||
const depthCap = intParam(query, 'depth', { min: 1, max: MAX_DEPTH, default: DEFAULT_DEPTH });
|
||||
const limit = intParam(query, 'limit', { min: 20, max: MAX_LIMIT, default: DEFAULT_LIMIT });
|
||||
const through = query.get('through') === '1';
|
||||
const stats = cg.getStats();
|
||||
const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
|
||||
|
||||
const { anchor, ambiguous } = resolveAnchor(cg, query);
|
||||
|
||||
// Route → the component it renders, and the routes by id.
|
||||
const routes = cg.getNodesByKind('route');
|
||||
const renders = routes.length === 0 ? [] : cg.getOutgoingEdgesFrom(routes.map((r) => r.id), ['calls', 'instantiates']);
|
||||
const componentOf = new Map<string, Node>();
|
||||
if (renders.length > 0) {
|
||||
const components = cg.getNodesByIds(renders.map((e) => e.target));
|
||||
for (const edge of renders) {
|
||||
const c = components.get(edge.target);
|
||||
if (c && !componentOf.has(edge.source)) componentOf.set(edge.source, c);
|
||||
}
|
||||
}
|
||||
|
||||
const readWhen = createWhenReader(cg, projectRoot, MAX_WHEN_SITES);
|
||||
const whenAt = (caller: Node, site: { line?: number; column?: number }) => readWhen(caller, site);
|
||||
|
||||
const steps = new Map<string, StepRecord>();
|
||||
const links = new Map<string, WireStepLink>();
|
||||
const truncated = { steps: 0, hubs: 0, chrome: 0 };
|
||||
let effectScans = 0;
|
||||
const fanIn = new Map<string, number>();
|
||||
const chromeParents = new Map<string, number>();
|
||||
const fileScopeRefs = new Map<string, Edge[]>();
|
||||
|
||||
const stepFor = (node: Node, kind: WireStepKind, depth: number, extra: Partial<WireStep> = {}): StepRecord | null => {
|
||||
const existing = steps.get(node.id);
|
||||
if (existing) {
|
||||
// A listener the screen registers is a handler when first met, and the
|
||||
// native event's landing when the walk arrives from the other side —
|
||||
// the second is the fuller fact, and it names the event.
|
||||
if (existing.kind === 'trigger' && kind === 'event') {
|
||||
existing.kind = 'event';
|
||||
if (extra.event) existing.event = extra.event;
|
||||
}
|
||||
if (kind === 'event' && extra.event) {
|
||||
existing.events = existing.events ?? (existing.event ? [existing.event] : []);
|
||||
if (!existing.events.includes(extra.event)) existing.events.push(extra.event);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
if (steps.size >= limit) {
|
||||
truncated.steps++;
|
||||
return null;
|
||||
}
|
||||
const isRoute = node.kind === 'route';
|
||||
const record: StepRecord = {
|
||||
id: node.id,
|
||||
kind: isRoute ? 'screen' : kind,
|
||||
anchor: false,
|
||||
node: toNodeRef(node),
|
||||
label: isRoute ? node.name : node.name,
|
||||
sub: isRoute ? (componentOf.get(node.id)?.name ?? posix(node.filePath)) : posix(node.filePath),
|
||||
depth,
|
||||
cut: null,
|
||||
...extra,
|
||||
root: isRoute ? (componentOf.get(node.id) ?? null) : node,
|
||||
};
|
||||
if (kind === 'event' && extra.event) record.events = [extra.event];
|
||||
if (isRoute) record.screen = { path: node.name, component: componentOf.has(node.id) ? toNodeRef(componentOf.get(node.id)!) : null };
|
||||
steps.set(node.id, record);
|
||||
return record;
|
||||
};
|
||||
|
||||
// One box per (function, category): `uploadARCapture` makes one network
|
||||
// call, three storage calls and three telemetry calls — three boxes, each
|
||||
// listing its calls, not seven.
|
||||
const effectStep = (by: Node, ref: { referenceName: string; line: number }, category: string, depth: number): StepRecord | null => {
|
||||
const id = `effect:${by.id}:${category}`;
|
||||
const existing = steps.get(id);
|
||||
if (existing) {
|
||||
const apis = existing.effect!.apis;
|
||||
if (!apis.includes(ref.referenceName)) {
|
||||
apis.push(ref.referenceName);
|
||||
existing.label = `${apis[0]} +${apis.length - 1}`;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
if (steps.size >= limit) {
|
||||
truncated.steps++;
|
||||
return null;
|
||||
}
|
||||
const record: StepRecord = {
|
||||
id,
|
||||
kind: 'effect',
|
||||
anchor: false,
|
||||
node: null,
|
||||
label: ref.referenceName,
|
||||
sub: `${category} · ${by.name}`,
|
||||
depth,
|
||||
cut: null,
|
||||
effect: { api: ref.referenceName, apis: [ref.referenceName], category, by: toNodeRef(by), line: ref.line },
|
||||
root: null,
|
||||
};
|
||||
steps.set(id, record);
|
||||
return record;
|
||||
};
|
||||
|
||||
const link = (
|
||||
from: StepRecord,
|
||||
to: StepRecord,
|
||||
kind: WireStepLinkKind,
|
||||
chain: Node[],
|
||||
whens: string[],
|
||||
site: WireStepSite,
|
||||
edge: Edge | null
|
||||
): void => {
|
||||
const meta = (edge?.metadata ?? {}) as Record<string, unknown>;
|
||||
const synthesized = edge?.provenance === 'heuristic';
|
||||
const confidence = typeof meta.confidence === 'number' ? meta.confidence : null;
|
||||
const via = chain.map(toNodeRef);
|
||||
const viaKey = via.map((v) => v.id).join('>');
|
||||
const id = `${from.id} ${to.id} ${viaKey}`;
|
||||
const when = whens.filter((w, i) => w && whens.indexOf(w) === i).join(' && ');
|
||||
const existing = links.get(id);
|
||||
if (existing) {
|
||||
if (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(site);
|
||||
if (when !== existing.when) {
|
||||
if (!when || !existing.when) existing.when = '';
|
||||
else if (!existing.when.split(' || ').includes(when)) existing.when = `${existing.when} || ${when}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
links.set(id, {
|
||||
id,
|
||||
from: from.id,
|
||||
to: to.id,
|
||||
kind,
|
||||
via,
|
||||
when,
|
||||
label: hopLabel(meta, synthesized),
|
||||
synthesized,
|
||||
uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
|
||||
sites: [site],
|
||||
});
|
||||
};
|
||||
|
||||
// The anchor: a screen keeps its kind and explores from its component.
|
||||
const first = stepFor(anchor, 'anchor', 0)!;
|
||||
first.anchor = true;
|
||||
const queue: StepRecord[] = [first];
|
||||
/** Steps whose exploration has been queued — each is explored once, from the first row it appears on. */
|
||||
const explored = new Set<string>([first.id]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const step = queue.shift()!;
|
||||
if (step.root === null) continue;
|
||||
// Another screen is a chapter of its own: the Screens view draws the way
|
||||
// between screens, and a picture that walked on through Home would be the
|
||||
// whole app. Drawn as a boundary, entered on request.
|
||||
if (step.kind === 'screen' && !step.anchor && !through) {
|
||||
step.cut = 'screen';
|
||||
continue;
|
||||
}
|
||||
// A native event that lands in a COMPONENT — the capture overlay taking
|
||||
// `onCaptureProgress` — lands on another screen's body: its picture is
|
||||
// that screen's, not this one's. A boundary too, entered on request.
|
||||
if (step.kind === 'event' && !step.anchor && !through && looksLikeComponent(step.root)) {
|
||||
step.cut = 'component';
|
||||
continue;
|
||||
}
|
||||
if (step.depth >= depthCap) {
|
||||
// Something to explore, and no room in the picture for it.
|
||||
if (cg.getOutgoingEdgesFrom([step.root.id], WALK_KINDS).length > 0) step.cut = 'depth';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Breadth-first through the plumbing until the next steps.
|
||||
const visited = new Set<string>([step.root.id]);
|
||||
let frontier: Fold[] = [{ node: step.root, chain: [], whens: [] }];
|
||||
for (let hop = 0; hop <= MAX_FOLD_DEPTH && frontier.length > 0; hop++) {
|
||||
const next: Fold[] = [];
|
||||
const ids = frontier.map((f) => f.node.id);
|
||||
const outgoing = cg.getOutgoingEdgesFrom(ids, WALK_KINDS);
|
||||
const bySource = new Map<string, Edge[]>();
|
||||
for (const e of outgoing) {
|
||||
const list = bySource.get(e.source) ?? [];
|
||||
list.push(e);
|
||||
bySource.set(e.source, list);
|
||||
}
|
||||
// `const Memoized = memo(CaptureComponent)`: the wrapper is a component
|
||||
// node with no edges of its own — the inner component is referenced
|
||||
// from the FILE scope, at the wrapper's line. Lend the wrapper those
|
||||
// references, so the screen that renders `<Memoized/>` walks on into
|
||||
// what the component does.
|
||||
for (const fold of frontier) {
|
||||
if (fold.node.kind !== 'component' || (bySource.get(fold.node.id)?.length ?? 0) > 0) continue;
|
||||
for (const e of fileScopeFnRefsWithin(cg, fold.node, fileScopeRefs)) {
|
||||
const list = bySource.get(fold.node.id) ?? [];
|
||||
list.push({ ...e, source: fold.node.id });
|
||||
bySource.set(fold.node.id, list);
|
||||
}
|
||||
}
|
||||
const targetIds = new Set<string>();
|
||||
for (const list of bySource.values()) for (const e of list) targetIds.add(e.target);
|
||||
const targets = targetIds.size === 0 ? new Map<string, Node>() : cg.getNodesByIds([...targetIds]);
|
||||
// Hubs and chrome are judged on the nodes about to be entered.
|
||||
const unknownFanIn = [...targetIds].filter((id) => !fanIn.has(id));
|
||||
if (unknownFanIn.length > 0) for (const [id, n] of cg.getFanIn(unknownFanIn)) fanIn.set(id, n);
|
||||
|
||||
for (const fold of frontier) {
|
||||
// Effects made by this node, folded or not.
|
||||
if (effectScans < MAX_EFFECT_SCANS) {
|
||||
effectScans++;
|
||||
let refs: UnresolvedReference[] = [];
|
||||
try {
|
||||
refs = cg.getUnresolvedReferencesFrom(fold.node.id);
|
||||
} catch {
|
||||
refs = [];
|
||||
}
|
||||
for (const ref of [...refs].sort((a, b) => a.line - b.line || a.column - b.column)) {
|
||||
if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'instantiates') continue;
|
||||
const category = effectCategory(ref.referenceName);
|
||||
if (category === null) continue;
|
||||
const target = effectStep(fold.node, ref, category, step.depth + 1);
|
||||
if (target === null) continue;
|
||||
const when = await whenAt(fold.node, { line: ref.line, column: ref.column });
|
||||
link(step, target, 'effect', fold.chain, [...fold.whens, when], { file: posix(fold.node.filePath), line: ref.line, text: ref.referenceName }, null);
|
||||
}
|
||||
}
|
||||
|
||||
let edges = (bySource.get(fold.node.id) ?? []).slice();
|
||||
edges = edges.filter((e) => {
|
||||
const meta = (e.metadata ?? {}) as Record<string, unknown>;
|
||||
if (e.kind === 'references') return meta.fnRef === true;
|
||||
if (e.kind === 'contains') {
|
||||
const t = targets.get(e.target);
|
||||
return (fold.node.kind === 'function' || fold.node.kind === 'method') && !!t && (t.kind === 'function' || t.kind === 'method');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
edges.sort((a, b) => (a.line ?? 0) - (b.line ?? 0) || a.target.localeCompare(b.target));
|
||||
if (edges.length > MAX_FANOUT) {
|
||||
step.cut = 'fan-out';
|
||||
edges = edges.slice(0, MAX_FANOUT);
|
||||
}
|
||||
|
||||
// Two passes: first every edge that arrives at a step, then the rest —
|
||||
// so a node that IS a step (a handler wired to a tap) is never also
|
||||
// folded as plumbing by the `contains` edge from the same component.
|
||||
interface Arrival {
|
||||
e: Edge;
|
||||
target: Node;
|
||||
meta: Record<string, unknown>;
|
||||
site: WireStepSite;
|
||||
kind: WireStepKind | null;
|
||||
linkKind: WireStepLinkKind;
|
||||
extra: Partial<WireStep>;
|
||||
}
|
||||
const arrivals: Arrival[] = [];
|
||||
for (const e of edges) {
|
||||
const target = targets.get(e.target);
|
||||
if (!target || target.kind === 'file' || target.id === fold.node.id) continue;
|
||||
const meta = (e.metadata ?? {}) as Record<string, unknown>;
|
||||
const site: WireStepSite = {
|
||||
file: posix(fold.node.filePath),
|
||||
line: e.line ?? fold.node.startLine,
|
||||
text: siteText(e, meta, target),
|
||||
};
|
||||
|
||||
// What kind of step, if any, this edge arrives at.
|
||||
let kind: WireStepKind | null = null;
|
||||
let linkKind: WireStepLinkKind = 'calls';
|
||||
const extra: Partial<WireStep> = {};
|
||||
if (target.kind === 'route') {
|
||||
kind = 'screen';
|
||||
linkKind = 'navigates';
|
||||
} else {
|
||||
// A language change under the code is a step only on evidence: a
|
||||
// bridge resolver's edge (`bridge`, or a framework resolution), or
|
||||
// a synthesized channel's. A plain name-matched call across the
|
||||
// families (`arr.flat()` landing on a Swift `flat`) is noise, and
|
||||
// is neither drawn nor walked.
|
||||
const cross = crossing(fold.node.language, target.language);
|
||||
const evidenced = e.provenance === 'heuristic' || meta.bridge === 'react-native' || meta.resolvedBy === 'framework';
|
||||
if (cross !== null && !evidenced) continue;
|
||||
if (cross === 'event') {
|
||||
kind = 'event';
|
||||
linkKind = 'event';
|
||||
if (typeof meta.event === 'string') extra.event = meta.event;
|
||||
} else if (cross === 'bridge') {
|
||||
kind = 'bridge';
|
||||
linkKind = 'bridge';
|
||||
} else if (e.kind === 'references' && meta.fnRef === true && !looksLikeComponent(target)) {
|
||||
// A function passed as a value is a handler — unless it is a
|
||||
// component (`memo(CaptureComponent)`, `component={Home}`),
|
||||
// which is a render hop and folds like one.
|
||||
kind = 'trigger';
|
||||
linkKind = 'handler';
|
||||
} else if (
|
||||
(target.kind === 'function' || target.kind === 'method') &&
|
||||
isStoreFile(target.filePath) &&
|
||||
!isStoreFile(fold.node.filePath)
|
||||
) {
|
||||
kind = 'store';
|
||||
linkKind = 'store';
|
||||
}
|
||||
}
|
||||
arrivals.push({ e, target, meta, site, kind, linkKind, extra });
|
||||
}
|
||||
|
||||
for (const a of arrivals) {
|
||||
if (a.kind === null) continue;
|
||||
const to = stepFor(a.target, a.kind, step.depth + 1, a.extra);
|
||||
if (to === null) continue;
|
||||
const when = await whenAt(fold.node, { line: a.e.line, column: a.e.column });
|
||||
link(step, to, a.linkKind, fold.chain, [...fold.whens, when], a.site, a.e);
|
||||
if (to.root !== null && !explored.has(to.id)) {
|
||||
explored.add(to.id);
|
||||
queue.push(to);
|
||||
}
|
||||
}
|
||||
|
||||
for (const a of arrivals) {
|
||||
if (a.kind !== null) continue;
|
||||
const { e, target, meta } = a;
|
||||
|
||||
// A call through a VALUE the effect table knows — `client.post` on
|
||||
// the axios instance the project made itself resolves to the
|
||||
// `client` constant, not to anything outside the index. The call
|
||||
// text is the evidence: the call is the effect, the constant is not
|
||||
// a place to walk into.
|
||||
if (e.kind === 'calls' && (target.kind === 'constant' || target.kind === 'variable')) {
|
||||
const api = typeof meta.refName === 'string' ? meta.refName : null;
|
||||
const category = api === null ? null : effectCategory(api);
|
||||
if (api !== null && category !== null) {
|
||||
const to = effectStep(fold.node, { referenceName: api, line: e.line ?? fold.node.startLine }, category, step.depth + 1);
|
||||
if (to === null) continue;
|
||||
const when = await whenAt(fold.node, { line: e.line, column: e.column });
|
||||
link(step, to, 'effect', fold.chain, [...fold.whens, when], { file: posix(fold.node.filePath), line: e.line ?? fold.node.startLine, text: api }, null);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Already a step, reached here by a plain call: a link, not a fold.
|
||||
const known = steps.get(target.id);
|
||||
if (known) {
|
||||
if (known.id !== step.id) {
|
||||
const when = await whenAt(fold.node, { line: e.line, column: e.column });
|
||||
link(step, known, 'calls', fold.chain, [...fold.whens, when], a.site, e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plumbing: fold it and keep walking, unless it is a dead end.
|
||||
if (visited.has(target.id)) continue;
|
||||
if ((fanIn.get(target.id) ?? 0) >= HUB_THRESHOLD) {
|
||||
truncated.hubs++;
|
||||
continue;
|
||||
}
|
||||
if (meta.synthesizedBy === 'jsx-render' && isSharedChrome(cg, target, chromeParents)) {
|
||||
truncated.chrome++;
|
||||
continue;
|
||||
}
|
||||
if (visited.size >= MAX_FOLDED_PER_STEP) {
|
||||
step.cut = step.cut ?? 'folded';
|
||||
continue;
|
||||
}
|
||||
visited.add(target.id);
|
||||
const when = await whenAt(fold.node, { line: e.line, column: e.column });
|
||||
next.push({ node: target, chain: [...fold.chain, target], whens: [...fold.whens, when] });
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
}
|
||||
|
||||
const ordered = [...steps.values()].sort((a, b) => a.depth - b.depth || a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
||||
return {
|
||||
anchor: toNodeRef(anchor),
|
||||
ambiguous,
|
||||
steps: ordered.map(({ root: _root, ...step }) => step),
|
||||
links: [...links.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
||||
depth: depthCap,
|
||||
limit,
|
||||
through,
|
||||
truncated,
|
||||
index,
|
||||
timing: { elapsedMs: Date.now() - started },
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The anchor: `anchor=<id>`, or `symbol=<name>` resolved to the most
|
||||
* screen-like symbol of that name — a route first, then a component or
|
||||
* function, then a method — with the rest reported as `ambiguous`.
|
||||
*/
|
||||
function resolveAnchor(cg: CodeGraph, query: URLSearchParams): { anchor: Node; ambiguous: WireNodeRef[] } {
|
||||
const id = query.get('anchor');
|
||||
if (id !== null && id.trim() !== '') {
|
||||
const node = cg.getNode(id);
|
||||
if (!node) throw notFound(`No symbol with id "${id}" in this index.`, 'It may have moved in a re-index; open it from search or the Screens view.');
|
||||
return { anchor: node, ambiguous: [] };
|
||||
}
|
||||
const name = query.get('symbol');
|
||||
if (name === null || name.trim() === '') throw badRequest('Give the picture an anchor: ?anchor=<node id> or ?symbol=<name>.');
|
||||
const rank: Record<string, number> = { route: 0, component: 1, function: 2, method: 3, class: 4, constant: 5, variable: 6 };
|
||||
const matches = cg
|
||||
.getNodesByName(name.trim())
|
||||
.filter((n) => n.kind !== 'file' && n.kind !== 'import' && n.kind !== 'export')
|
||||
.sort((a, b) => (rank[a.kind] ?? 9) - (rank[b.kind] ?? 9) || a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine);
|
||||
const anchor = matches[0];
|
||||
if (!anchor) throw notFound(`Nothing in this index is named "${name}".`, 'Try the search box; names are matched exactly.');
|
||||
return { anchor, ambiguous: matches.slice(1, 9).map(toNodeRef) };
|
||||
}
|
||||
|
||||
/** How many distinct parents render this node as a JSX child. Memoised per request. */
|
||||
function renderParents(cg: CodeGraph, node: Node, memo: Map<string, number>): number {
|
||||
let parents = memo.get(node.id);
|
||||
if (parents === undefined) {
|
||||
const incoming = cg.getIncomingEdgesTo([node.id], ['calls']);
|
||||
const sources = new Set<string>();
|
||||
for (const e of incoming) {
|
||||
if ((e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'jsx-render') sources.add(e.source);
|
||||
}
|
||||
parents = sources.size;
|
||||
memo.set(node.id, parents);
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/** A component rendered by several distinct parents is chrome. */
|
||||
function isSharedChrome(cg: CodeGraph, component: Node, memo: Map<string, number>): boolean {
|
||||
return renderParents(cg, component, memo) >= SHARED_CHROME_MIN;
|
||||
}
|
||||
|
||||
/** A React component, by the convention that names one: a PascalCase function in a JS-family file. */
|
||||
function looksLikeComponent(node: Node): boolean {
|
||||
if (node.kind === 'component') return true;
|
||||
if (node.kind !== 'function') return false;
|
||||
return JS_FAMILY.has(node.language) && /^[A-Z]/.test(node.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Function-as-value references made at a file's top level within a node's
|
||||
* lines — what `const Memoized = memo(CaptureComponent)` leaves behind: the
|
||||
* reference belongs to the file scope, the wrapper node spans the line.
|
||||
*/
|
||||
function fileScopeFnRefsWithin(cg: CodeGraph, node: Node, memo: Map<string, Edge[]>): Edge[] {
|
||||
let refs = memo.get(node.filePath);
|
||||
if (refs === undefined) {
|
||||
const file = cg.getNodesInFile(node.filePath).find((n) => n.kind === 'file');
|
||||
refs = file
|
||||
? cg.getOutgoingEdgesFrom([file.id], ['references']).filter((e) => (e.metadata as Record<string, unknown> | undefined)?.fnRef === true)
|
||||
: [];
|
||||
memo.set(node.filePath, refs);
|
||||
}
|
||||
return refs.filter((e) => typeof e.line === 'number' && e.line >= node.startLine && e.line <= node.endLine);
|
||||
}
|
||||
|
||||
/** `push /capture`, `renders <Button>`, `via rn-event-channel`, `calls`. */
|
||||
function siteText(edge: Edge, meta: Record<string, unknown>, target: Node): string {
|
||||
if (edge.kind === 'navigates') {
|
||||
const method = edge.provenance === 'heuristic' ? 'returns' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push';
|
||||
return `${method} ${typeof meta.href === 'string' ? meta.href : target.name}`;
|
||||
}
|
||||
if (meta.synthesizedBy === 'jsx-render') return `renders <${target.name}>`;
|
||||
if (edge.kind === 'references') return `passes ${target.name}`;
|
||||
if (edge.kind === 'contains') return `defines ${target.name}`;
|
||||
if (edge.kind === 'instantiates') return `new ${target.name}`;
|
||||
if (meta.bridge === 'react-native') return `bridge ${typeof meta.module === 'string' ? meta.module + '.' : ''}${target.name}`;
|
||||
if (typeof meta.synthesizedBy === 'string') return `via ${meta.synthesizedBy}`;
|
||||
return `calls ${target.name}`;
|
||||
}
|
||||
|
||||
/** The words on a hop that was not a plain call — the Flow strip's connector label, in short. */
|
||||
function hopLabel(meta: Record<string, unknown>, synthesized: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (typeof meta.synthesizedBy === 'string') parts.push(`via ${meta.synthesizedBy}`);
|
||||
else if (synthesized) parts.push('inferred');
|
||||
if (typeof meta.event === 'string') parts.push(`event ${meta.event}`);
|
||||
if (meta.bridge === 'react-native') parts.push(`React Native bridge${typeof meta.module === 'string' ? ` · ${meta.module}` : ''}`);
|
||||
if (typeof meta.registeredAt === 'string') parts.push(`registered at ${meta.registeredAt}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function posix(p: string): string {
|
||||
return p.replace(/\\/g, '/');
|
||||
}
|
||||
Reference in New Issue
Block a user