|
|
|
@@ -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, '/');
|
|
|
|
|
}
|