feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API
- Adds Expo Router integration with a new Screens view and a Steps API to surface screens and their transitions. - Extends codegraph extraction/resolution to handle namespace objects, React hook bindings for handlers, and Swift RN bridge evidence; introduces per-site guard arguments and trigger metadata, enabling richer flow analysis across JS ↔ native boundaries. - Introduces UI and data-model changes to represent conditions as words (WHEN/AND/OR/NOT), display per-site call arguments, and show what fires a site (triggers). Adds new utilities (ui/conditions.ts) and updates ScreensView and StepsView to render scenarios with multiple sites and “ways” counts. - Implements site readers for WHEN/ARGS/TRIGGER, and wiring to expose steps via API endpoints (including /api/steps); enhances tests to cover namespace resolution, useCallback-driven handlers, and inline RN event listeners. - Updates styling and templates to reflect the new wording, scenario rows, and per-site details, including NOT instead of leading negation strings and multi-way links. - Documents and reflects changes in changelog and design docs to describe Expo Router integration and the Steps surface.
This commit is contained in:
+357
-1
@@ -71,13 +71,34 @@ export function guardLabel(guards: readonly BranchGuard[]): string {
|
||||
|
||||
function renderGuard(g: BranchGuard): string {
|
||||
if (g.form === 'catch') return g.text;
|
||||
if (!g.negated) return g.text;
|
||||
// `if (!object?.id || !object?.name)` joined to the guard before it with
|
||||
// `&&` would read as two conditions: it keeps its parentheses.
|
||||
if (!g.negated) return hasTopLevelOr(g.text) ? `(${g.text})` : g.text;
|
||||
// `!x` negated reads back as `x`; a simple operand takes a bare `!`;
|
||||
// anything with operators is parenthesised so the negation is unambiguous.
|
||||
if (/^!(?![=])/.test(g.text) && isSimpleOperand(g.text.slice(1))) return g.text.slice(1);
|
||||
return isSimpleOperand(g.text) ? `!${g.text}` : `!(${g.text})`;
|
||||
}
|
||||
|
||||
/** A `||` outside every bracket and string — the condition is a disjunction as written. */
|
||||
function hasTopLevelOr(text: string): boolean {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!;
|
||||
if (quote !== null) {
|
||||
if (ch === '\\') i++;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"' || ch === '`') quote = ch;
|
||||
else if (ch === '(' || ch === '[' || ch === '{') depth++;
|
||||
else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1);
|
||||
else if (depth === 0 && ch === '|' && text[i + 1] === '|') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSimpleOperand(text: string): boolean {
|
||||
return /^[\w$.?!]+(?:\([^()]*\))?$/.test(text) && !/[=<>]/.test(text);
|
||||
}
|
||||
@@ -233,6 +254,341 @@ export function guardsForFileSync(
|
||||
/** The languages with rules here — what {@link warmBranchGuardGrammars} loads. */
|
||||
export const BRANCH_GUARD_LANGUAGES: readonly Language[] = ['typescript', 'tsx', 'javascript', 'jsx', 'swift'];
|
||||
|
||||
// =============================================================================
|
||||
// Call arguments — what a site passes
|
||||
// =============================================================================
|
||||
|
||||
/** Longest argument list kept before it is cut with an ellipsis. */
|
||||
const MAX_ARGS_TEXT = 96;
|
||||
/** Longest single argument (a string literal, a name) kept whole. */
|
||||
const MAX_ARG_TEXT = 40;
|
||||
/** Object keys listed before `…` stands for the rest. */
|
||||
const MAX_OBJECT_KEYS = 4;
|
||||
const CALL_TYPES: ReadonlySet<string> = new Set(['call_expression', 'new_expression']);
|
||||
const ARGUMENT_CONTAINERS: ReadonlySet<string> = new Set(['arguments', 'value_arguments', 'argument_list']);
|
||||
const STRING_TYPES: ReadonlySet<string> = new Set([
|
||||
'string',
|
||||
'template_string',
|
||||
'line_string_literal',
|
||||
'multi_line_string_literal',
|
||||
'raw_string_literal',
|
||||
]);
|
||||
const OBJECT_TYPES: ReadonlySet<string> = new Set(['object', 'object_expression']);
|
||||
const ARRAY_TYPES: ReadonlySet<string> = new Set(['array', 'array_literal', 'dictionary_literal']);
|
||||
const FUNCTION_TYPES: ReadonlySet<string> = new Set(['arrow_function', 'function_expression', 'function']);
|
||||
|
||||
/**
|
||||
* The arguments a call site passes, as written, abbreviated to what a reader
|
||||
* scans for: a string literal whole (a storage key, a URL, a message), a name
|
||||
* whole, an object as its keys (`{ email, password }`), an array as `[…]`, a
|
||||
* function as `() => …`, a nested call as `f(…)`. The conditions say WHEN a
|
||||
* step runs; this says WITH WHAT — `SecureStore.setItemAsync('userEmail',
|
||||
* values.email)` is a different fact from `SecureStore.setItemAsync`.
|
||||
*
|
||||
* Keyed by {@link siteKey} like the guards, read from the same cached tree.
|
||||
* A site that is not inside a call, or a language without rules, is absent.
|
||||
*/
|
||||
export async function callArgumentsForFile(
|
||||
absPath: string,
|
||||
language: Language,
|
||||
sites: readonly CallSite[]
|
||||
): Promise<Map<string, string>> {
|
||||
const out = new Map<string, string>();
|
||||
if (!supportsBranchGuards(language) || sites.length === 0) return out;
|
||||
const cached = await treeFor(absPath, language);
|
||||
if (!cached) return out;
|
||||
for (const site of sites) {
|
||||
const key = siteKey(site);
|
||||
if (out.has(key)) continue;
|
||||
const text = callArgumentsInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null);
|
||||
if (text !== null) out.set(key, text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** {@link callArgumentsForFile} over source text — the test surface. */
|
||||
export async function callArgumentsInSource(
|
||||
source: string,
|
||||
language: Language,
|
||||
line: number,
|
||||
column: number | null
|
||||
): Promise<string | null> {
|
||||
if (!supportsBranchGuards(language)) return null;
|
||||
const tree = await parse(source, language);
|
||||
if (!tree) return null;
|
||||
try {
|
||||
return callArgumentsInTree(tree.rootNode, source, line, column);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export function callArgumentsInTree(
|
||||
root: SyntaxNode,
|
||||
source: string,
|
||||
line: number,
|
||||
column: number | null
|
||||
): string | null {
|
||||
const row = line - 1;
|
||||
const col = column ?? firstNonBlankColumn(source, row);
|
||||
const start = innermostAt(root, row, col);
|
||||
if (!start) return null;
|
||||
// The site's position is on the callee (`setItemAsync` in
|
||||
// `SecureStore.setItemAsync(…)`): climb to the call it belongs to. A few
|
||||
// levels cover a member chain; further up would be another statement.
|
||||
let call: SyntaxNode | null = null;
|
||||
let node: SyntaxNode | null = start;
|
||||
for (let up = 0; node && up < 6; up++, node = node.parent) {
|
||||
if (CALL_TYPES.has(node.type)) {
|
||||
call = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!call) return null;
|
||||
const container = argumentsOf(call);
|
||||
if (!container) return null;
|
||||
if (container.type === 'lambda_literal') return '{ … }';
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < container.namedChildCount; i++) {
|
||||
const c = container.namedChild(i);
|
||||
if (!c || c.type === 'comment') continue;
|
||||
parts.push(abbreviateArgument(c, source));
|
||||
}
|
||||
const text = parts.join(', ');
|
||||
return text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** The node holding a call's arguments: the `arguments` field, a container child, or Swift's `call_suffix` contents. */
|
||||
function argumentsOf(call: SyntaxNode): SyntaxNode | null {
|
||||
const field = call.childForFieldName('arguments');
|
||||
if (field) return field;
|
||||
for (let i = 0; i < call.namedChildCount; i++) {
|
||||
const c = call.namedChild(i);
|
||||
if (!c) continue;
|
||||
if (ARGUMENT_CONTAINERS.has(c.type)) return c;
|
||||
if (c.type === 'call_suffix') {
|
||||
for (let j = 0; j < c.namedChildCount; j++) {
|
||||
const inner = c.namedChild(j);
|
||||
if (inner && (ARGUMENT_CONTAINERS.has(inner.type) || inner.type === 'lambda_literal')) return inner;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function abbreviateArgument(node: SyntaxNode, source: string): string {
|
||||
const type = node.type;
|
||||
if (STRING_TYPES.has(type)) return cut(collapse(node.text), MAX_ARG_TEXT);
|
||||
if (OBJECT_TYPES.has(type)) return objectKeys(node, source);
|
||||
if (ARRAY_TYPES.has(type)) return '[…]';
|
||||
if (FUNCTION_TYPES.has(type)) return '() => …';
|
||||
if (type === 'lambda_literal') return '{ … }';
|
||||
if (type === 'spread_element') return cut(collapse(node.text), MAX_ARG_TEXT);
|
||||
if (type === 'await_expression') {
|
||||
const inner = node.namedChild(0);
|
||||
return inner ? `await ${abbreviateArgument(inner, source)}` : 'await …';
|
||||
}
|
||||
if (CALL_TYPES.has(type)) {
|
||||
const callee = node.childForFieldName('function') ?? node.childForFieldName('constructor') ?? node.namedChild(0);
|
||||
const name = callee ? cut(collapse(callee.text), 28) : '';
|
||||
return `${type === 'new_expression' ? 'new ' : ''}${name}(…)`;
|
||||
}
|
||||
// Swift `label: value` — the label is half the meaning (`withName:`).
|
||||
if (type === 'value_argument') {
|
||||
const named: SyntaxNode[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) named.push(c);
|
||||
}
|
||||
if (named.length >= 2 && (named[0]!.type === 'simple_identifier' || named[0]!.type === 'value_argument_label')) {
|
||||
return `${named[0]!.text}: ${abbreviateArgument(named[named.length - 1]!, source)}`;
|
||||
}
|
||||
return named.length > 0 ? abbreviateArgument(named[named.length - 1]!, source) : cut(collapse(node.text), MAX_ARG_TEXT);
|
||||
}
|
||||
if (type === 'lambda_argument' || type === 'trailing_closure') return '{ … }';
|
||||
return cut(collapse(node.text), MAX_ARG_TEXT);
|
||||
}
|
||||
|
||||
/** `{ email, password, …}` — the keys an object literal passes, not its bulk. */
|
||||
function objectKeys(node: SyntaxNode, source: string): string {
|
||||
const keys: string[] = [];
|
||||
let more = 0;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (!c || c.type === 'comment') continue;
|
||||
let key: string | null = null;
|
||||
if (c.type === 'pair') key = c.childForFieldName('key')?.text ?? null;
|
||||
else if (c.type === 'shorthand_property_identifier' || c.type === 'shorthand_property_identifier_pattern') key = c.text;
|
||||
else if (c.type === 'spread_element') key = collapse(c.text);
|
||||
else if (c.type === 'method_definition') key = c.childForFieldName('name')?.text ?? null;
|
||||
if (key === null) continue;
|
||||
if (keys.length >= MAX_OBJECT_KEYS) {
|
||||
more++;
|
||||
continue;
|
||||
}
|
||||
keys.push(cut(key, 24));
|
||||
}
|
||||
void source;
|
||||
if (keys.length === 0) return '{…}';
|
||||
return `{ ${keys.join(', ')}${more > 0 ? ', …' : ''} }`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Triggers — what fires a site
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* What binds a call site to an event, when something does — the answer to
|
||||
* "at what point does this run": the JSX attribute the site sits under
|
||||
* (`onPress` of `<Button>`), the `on*` option it is written in (`onSubmit`
|
||||
* of `useFormik({…})`), or the runs-later call it is an argument of
|
||||
* (`useEffect`, `setTimeout`, `addListener('x')`, `.then`).
|
||||
*/
|
||||
export interface SiteTrigger {
|
||||
kind: 'prop' | 'option' | 'callback';
|
||||
/** `onPress`, `onSubmit`, `useEffect`, `addListener`. */
|
||||
name: string;
|
||||
/** `Button` for a prop, `useFormik` for an option, the first string argument for a callback; null when unknown. */
|
||||
of: string | null;
|
||||
}
|
||||
|
||||
/** Callees whose function argument runs LATER — a callback, not a call. Matched on the last segment. */
|
||||
const LATER_CALLEES: ReadonlySet<string> = new Set([
|
||||
'useEffect',
|
||||
'useLayoutEffect',
|
||||
'useFocusEffect',
|
||||
'useImperativeHandle',
|
||||
'setTimeout',
|
||||
'setInterval',
|
||||
'requestAnimationFrame',
|
||||
'requestIdleCallback',
|
||||
'runAfterInteractions',
|
||||
'addListener',
|
||||
'addEventListener',
|
||||
'on',
|
||||
'once',
|
||||
'subscribe',
|
||||
'then',
|
||||
'catch',
|
||||
'finally',
|
||||
'runOnJS',
|
||||
'runOnUI',
|
||||
'scheduleOnRN',
|
||||
]);
|
||||
/** The walk up never leaves the function the site belongs to — unless that function is inline. */
|
||||
const TRIGGER_BOUNDARIES: ReadonlySet<string> = new Set(['function_declaration', 'method_definition', 'class_declaration', 'class_body', 'program']);
|
||||
const MAX_TRIGGER_CLIMB = 24;
|
||||
|
||||
export async function triggersForFile(
|
||||
absPath: string,
|
||||
language: Language,
|
||||
sites: readonly CallSite[]
|
||||
): Promise<Map<string, SiteTrigger>> {
|
||||
const out = new Map<string, SiteTrigger>();
|
||||
if (!JS_FAMILY.has(language) || sites.length === 0) return out;
|
||||
const cached = await treeFor(absPath, language);
|
||||
if (!cached) return out;
|
||||
for (const site of sites) {
|
||||
const key = siteKey(site);
|
||||
if (out.has(key)) continue;
|
||||
const t = triggerInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null);
|
||||
if (t !== null) out.set(key, t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** {@link triggersForFile} over source text — the test surface. */
|
||||
export async function triggerInSource(
|
||||
source: string,
|
||||
language: Language,
|
||||
line: number,
|
||||
column: number | null
|
||||
): Promise<SiteTrigger | null> {
|
||||
if (!JS_FAMILY.has(language)) return null;
|
||||
const tree = await parse(source, language);
|
||||
if (!tree) return null;
|
||||
try {
|
||||
return triggerInTree(tree.rootNode, source, line, column);
|
||||
} finally {
|
||||
tree.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerInTree(root: SyntaxNode, source: string, line: number, column: number | null): SiteTrigger | null {
|
||||
const row = line - 1;
|
||||
const col = column ?? firstNonBlankColumn(source, row);
|
||||
let node: SyntaxNode | null = innermostAt(root, row, col);
|
||||
let prev: SyntaxNode | null = null;
|
||||
for (let up = 0; node && up < MAX_TRIGGER_CLIMB; up++, prev = node, node = node.parent) {
|
||||
const type = node.type;
|
||||
if (TRIGGER_BOUNDARIES.has(type)) return null;
|
||||
// A named handler is its own story: `const handleX = useCallback(() => …)`
|
||||
// binds a name, and whoever uses the name is the trigger of what is inside.
|
||||
if ((type === 'arrow_function' || type === 'function_expression') && node.parent) {
|
||||
const p = node.parent;
|
||||
if (p.type === 'variable_declarator') return null;
|
||||
if (p.type === 'arguments' && p.parent) {
|
||||
const callee = calleeName(p.parent);
|
||||
if (callee === 'useCallback' || callee === 'useMemo' || callee === 'useEffectEvent' || callee === 'useEvent') return null;
|
||||
}
|
||||
}
|
||||
if (type === 'jsx_attribute') {
|
||||
const name = node.namedChild(0);
|
||||
const element = node.parent;
|
||||
const tag = element ? element.childForFieldName('name') : null;
|
||||
return { kind: 'prop', name: name ? name.text : 'prop', of: tag ? collapseText(tag.text) : null };
|
||||
}
|
||||
if (type === 'pair') {
|
||||
const key = node.childForFieldName('key');
|
||||
const keyText = key ? key.text.replace(/^['"`]|['"`]$/g, '') : '';
|
||||
if (/^on[A-Z]\w*$/.test(keyText)) {
|
||||
// `useFormik({ onSubmit: … })`: the object is an argument of a call.
|
||||
const object = node.parent;
|
||||
const args = object?.parent;
|
||||
const call = args?.type === 'arguments' ? args.parent : null;
|
||||
return { kind: 'option', name: keyText, of: call && CALL_TYPES.has(call.type) ? calleeName(call) : null };
|
||||
}
|
||||
}
|
||||
if (type === 'arguments' && node.parent && CALL_TYPES.has(node.parent.type) && prev !== null) {
|
||||
const callee = calleeName(node.parent);
|
||||
if (callee !== null && LATER_CALLEES.has(callee)) {
|
||||
const first = node.namedChild(0);
|
||||
const of = first && STRING_TYPES.has(first.type) ? cut(collapseText(first.text), MAX_ARG_TEXT) : null;
|
||||
return { kind: 'callback', name: callee, of };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The last segment of a call's callee: `nativeEmitter.addListener` → `addListener`. */
|
||||
function calleeName(call: SyntaxNode): string | null {
|
||||
const callee = call.childForFieldName('function') ?? call.childForFieldName('constructor');
|
||||
if (!callee) return null;
|
||||
const text = collapseText(callee.text);
|
||||
const m = text.match(/([A-Za-z_$][\w$]*)\s*$/);
|
||||
return m ? m[1]! : text;
|
||||
}
|
||||
|
||||
function collapseText(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function collapse(text: string): string {
|
||||
return collapseText(text);
|
||||
}
|
||||
|
||||
function cut(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
function firstNonBlankColumn(source: string, row: number): number {
|
||||
const line = source.split('\n')[row] ?? '';
|
||||
const m = line.match(/\S/);
|
||||
return m ? (m.index ?? 0) : 0;
|
||||
}
|
||||
|
||||
/** Load the grammars {@link guardsForFileSync} needs; a no-op once loaded, never throws. */
|
||||
export async function warmBranchGuardGrammars(only?: readonly Language[]): Promise<void> {
|
||||
const wanted = BRANCH_GUARD_LANGUAGES.filter((l) => !only || only.includes(l));
|
||||
|
||||
@@ -71,7 +71,11 @@ export interface WireScreenSite {
|
||||
href: string;
|
||||
/** `push`, `replace`, `navigate`, or `return` for a helper's return value. */
|
||||
method: string;
|
||||
/** Branch conditions at this site alone. */
|
||||
/**
|
||||
* The conditions THIS site runs under — the whole chain's plus its own,
|
||||
* joined; '' when unconditional. A link with several sites is several
|
||||
* scenarios; the link's `when` is only their summary.
|
||||
*/
|
||||
when: string;
|
||||
}
|
||||
|
||||
@@ -243,6 +247,7 @@ export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<
|
||||
if (w && !whens.includes(w)) whens.push(w);
|
||||
}
|
||||
if (site.when && !whens.includes(site.when)) whens.push(site.when);
|
||||
site.when = whens.join(' && ');
|
||||
|
||||
const viaKey = via.map((v) => v.id).join('>');
|
||||
if (fromOrigin && start.path[0]!.node.id !== holder.id) {
|
||||
|
||||
+111
-23
@@ -40,7 +40,8 @@
|
||||
import type CodeGraph from '../../index';
|
||||
import type { Edge, Language, Node, UnresolvedReference } from '../../types';
|
||||
import { badRequest, intParam, notFound } from './respond';
|
||||
import { createWhenReader } from './when';
|
||||
import { createSiteReader } from './when';
|
||||
import type { SiteTrigger } from '../../graph/branch-guards';
|
||||
import { HUB_THRESHOLD, UNCERTAIN_BELOW, toNodeRef, type WireNodeRef } from './wire';
|
||||
|
||||
// =============================================================================
|
||||
@@ -56,6 +57,26 @@ export interface WireStepSite {
|
||||
line: number;
|
||||
/** `push /capture`, `calls`, `client.post` — what the site does, in a word or two. */
|
||||
text: string;
|
||||
/**
|
||||
* What the site passes, as written and abbreviated: `'userEmail',
|
||||
* values.email`, `'/auth/login', { email, password }`. '' for an empty
|
||||
* argument list; absent when the source could not be read.
|
||||
*/
|
||||
args?: string;
|
||||
/**
|
||||
* The conditions THIS site runs under — the whole chain's, joined; '' when
|
||||
* unconditional. A link with several sites is several scenarios (four
|
||||
* early returns that each go home), and the viewer lists them as rows with
|
||||
* the clauses they share factored out; the link's own `when` is only the
|
||||
* summary of all of them.
|
||||
*/
|
||||
when: string;
|
||||
}
|
||||
|
||||
/** What fires a step or a link: the event it is written under, and the function that writes it there. */
|
||||
export interface WireStepTrigger extends SiteTrigger {
|
||||
/** The function the binding is written in — `LoginButton` for its `onPress`. */
|
||||
in: string;
|
||||
}
|
||||
|
||||
export interface WireStep {
|
||||
@@ -82,6 +103,8 @@ export interface WireStep {
|
||||
event?: string;
|
||||
/** Every event that lands on this step, in the order the walk met them. */
|
||||
events?: string[];
|
||||
/** For a handler: what fires it — the first binding the walk met. */
|
||||
trigger?: WireStepTrigger;
|
||||
/** For a screen: its path and the component that renders it. */
|
||||
screen?: { path: string; component: WireNodeRef | null };
|
||||
/**
|
||||
@@ -105,6 +128,8 @@ export interface WireStepLink {
|
||||
synthesized: boolean;
|
||||
uncertain: boolean;
|
||||
sites: WireStepSite[];
|
||||
/** What fires the first site, when something binds it to an event. */
|
||||
trigger?: WireStepTrigger;
|
||||
}
|
||||
|
||||
export interface WireStepsPayload {
|
||||
@@ -145,8 +170,10 @@ const MAX_FOLD_DEPTH = 7;
|
||||
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;
|
||||
/** Call sites read for conditions and arguments per request. */
|
||||
const MAX_WHEN_SITES = 1600;
|
||||
/** Longest effect-box label before its argument list is cut. */
|
||||
const MAX_EFFECT_LABEL = 56;
|
||||
/**
|
||||
* 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
|
||||
@@ -252,8 +279,13 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
}
|
||||
}
|
||||
|
||||
const readWhen = createWhenReader(cg, projectRoot, MAX_WHEN_SITES);
|
||||
const whenAt = (caller: Node, site: { line?: number; column?: number }) => readWhen(caller, site);
|
||||
const reader = createSiteReader(cg, projectRoot, MAX_WHEN_SITES);
|
||||
const whenAt = (caller: Node, site: { line?: number; column?: number }) => reader.when(caller, site);
|
||||
const argsAt = (caller: Node, site: { line?: number; column?: number }) => reader.args(caller, site);
|
||||
const withArgs = async (site: WireStepSite, caller: Node, at: { line?: number; column?: number }): Promise<WireStepSite> => {
|
||||
const args = await argsAt(caller, at);
|
||||
return args === null ? site : { ...site, args };
|
||||
};
|
||||
|
||||
const steps = new Map<string, StepRecord>();
|
||||
const links = new Map<string, WireStepLink>();
|
||||
@@ -343,7 +375,8 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
chain: Node[],
|
||||
whens: string[],
|
||||
site: WireStepSite,
|
||||
edge: Edge | null
|
||||
edge: Edge | null,
|
||||
trigger: WireStepTrigger | null = null
|
||||
): void => {
|
||||
const meta = (edge?.metadata ?? {}) as Record<string, unknown>;
|
||||
const synthesized = edge?.provenance === 'heuristic';
|
||||
@@ -352,9 +385,11 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
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 stamped: WireStepSite = { ...site, when };
|
||||
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 (!existing.sites.some((s) => s.file === site.file && s.line === site.line)) existing.sites.push(stamped);
|
||||
if (!existing.trigger && trigger) existing.trigger = trigger;
|
||||
if (when !== existing.when) {
|
||||
if (!when || !existing.when) existing.when = '';
|
||||
else if (!existing.when.split(' || ').includes(when)) existing.when = `${existing.when} || ${when}`;
|
||||
@@ -371,8 +406,16 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
label: hopLabel(meta, synthesized),
|
||||
synthesized,
|
||||
uncertain: confidence !== null && confidence < UNCERTAIN_BELOW,
|
||||
sites: [site],
|
||||
sites: [stamped],
|
||||
...(trigger ? { trigger } : {}),
|
||||
});
|
||||
if (trigger && to.kind === 'trigger' && !to.trigger) to.trigger = trigger;
|
||||
};
|
||||
|
||||
/** What fires a site, with the function it is written in. */
|
||||
const triggerAt = async (caller: Node, at: { line?: number; column?: number }): Promise<WireStepTrigger | null> => {
|
||||
const t = await reader.trigger(caller, at);
|
||||
return t ? { ...t, in: caller.name } : null;
|
||||
};
|
||||
|
||||
// The anchor: a screen keeps its kind and explores from its component.
|
||||
@@ -454,8 +497,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
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);
|
||||
const at = { line: ref.line, column: ref.column };
|
||||
const when = await whenAt(fold.node, at);
|
||||
const site = await withArgs({ file: posix(fold.node.filePath), line: ref.line, text: ref.referenceName, when: '' }, fold.node, at);
|
||||
link(step, target, 'effect', fold.chain, [...fold.whens, when], site, null, await triggerAt(fold.node, at));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +531,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
kind: WireStepKind | null;
|
||||
linkKind: WireStepLinkKind;
|
||||
extra: Partial<WireStep>;
|
||||
trigger: WireStepTrigger | null;
|
||||
}
|
||||
const arrivals: Arrival[] = [];
|
||||
for (const e of edges) {
|
||||
@@ -496,8 +542,16 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
file: posix(fold.node.filePath),
|
||||
line: e.line ?? fold.node.startLine,
|
||||
text: siteText(e, meta, target),
|
||||
when: '',
|
||||
};
|
||||
|
||||
// What fires this hop, when the site is written under an event:
|
||||
// the JSX prop, the `on*` option, the runs-later call. Read for
|
||||
// every call-shaped hop, so a store action or an effect fired by
|
||||
// a tap says so on its link too.
|
||||
const isCall = e.kind === 'calls' || e.kind === 'instantiates' || (e.kind === 'references' && meta.fnRef === true);
|
||||
const trigger = isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
|
||||
|
||||
// What kind of step, if any, this edge arrives at.
|
||||
let kind: WireStepKind | null = null;
|
||||
let linkKind: WireStepLinkKind = 'calls';
|
||||
@@ -521,30 +575,44 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
} 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)
|
||||
) {
|
||||
// A store action fired straight from a tap stays a store
|
||||
// action; the tap is on its link.
|
||||
kind = 'store';
|
||||
linkKind = 'store';
|
||||
} else if (
|
||||
(target.kind === 'function' || target.kind === 'method') &&
|
||||
!looksLikeComponent(target) &&
|
||||
((e.kind === 'references' && meta.fnRef === true) || trigger !== null)
|
||||
) {
|
||||
// A handler: a function passed as a value (`onPress={handleX}`,
|
||||
// `addListener('x', handleX)`), or one called from under an
|
||||
// event binding (`onPress={() => handleLogin(values)}`,
|
||||
// `useFormik({ onSubmit: (v) => handleLogin(v) })`). A
|
||||
// component passed as a value (`memo(CaptureComponent)`) is a
|
||||
// render hop and folds like one.
|
||||
kind = 'trigger';
|
||||
linkKind = 'handler';
|
||||
if (trigger) extra.trigger = trigger;
|
||||
}
|
||||
}
|
||||
arrivals.push({ e, target, meta, site, kind, linkKind, extra });
|
||||
arrivals.push({ e, target, meta, site, kind, linkKind, extra, trigger });
|
||||
}
|
||||
|
||||
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);
|
||||
const at = { line: a.e.line, column: a.e.column };
|
||||
const when = await whenAt(fold.node, at);
|
||||
// A call-shaped hop says what it passes; a navigation already says
|
||||
// its href, a handler binding and an event channel pass nothing.
|
||||
const site = a.linkKind === 'bridge' || a.linkKind === 'store' || a.linkKind === 'calls' ? await withArgs(a.site, fold.node, at) : a.site;
|
||||
link(step, to, a.linkKind, fold.chain, [...fold.whens, when], site, a.e, a.trigger);
|
||||
if (to.root !== null && !explored.has(to.id)) {
|
||||
explored.add(to.id);
|
||||
queue.push(to);
|
||||
@@ -566,8 +634,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
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);
|
||||
const at = { line: e.line, column: e.column };
|
||||
const when = await whenAt(fold.node, at);
|
||||
const site = await withArgs({ file: posix(fold.node.filePath), line: e.line ?? fold.node.startLine, text: api, when: '' }, fold.node, at);
|
||||
link(step, to, 'effect', fold.chain, [...fold.whens, when], site, null, a.trigger);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -576,8 +646,9 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
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);
|
||||
const at = { line: e.line, column: e.column };
|
||||
const when = await whenAt(fold.node, at);
|
||||
link(step, known, 'calls', fold.chain, [...fold.whens, when], await withArgs(a.site, fold.node, at), e, a.trigger);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -605,6 +676,23 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
}
|
||||
}
|
||||
|
||||
// An effect box with ONE call behind it says what that call passes —
|
||||
// `axios.post('/auth/login', { email, password })` is the fact a reader
|
||||
// scans for; several calls list themselves in the panel instead.
|
||||
const sitesByStep = new Map<string, WireStepSite[]>();
|
||||
for (const l of links.values()) {
|
||||
const list = sitesByStep.get(l.to) ?? [];
|
||||
list.push(...l.sites);
|
||||
sitesByStep.set(l.to, list);
|
||||
}
|
||||
for (const step of steps.values()) {
|
||||
if (step.kind !== 'effect' || !step.effect || step.effect.apis.length !== 1) continue;
|
||||
const sites = sitesByStep.get(step.id) ?? [];
|
||||
if (sites.length !== 1 || sites[0]!.args === undefined) continue;
|
||||
const label = `${step.effect.api}(${sites[0]!.args})`;
|
||||
step.label = label.length > MAX_EFFECT_LABEL ? `${label.slice(0, MAX_EFFECT_LABEL - 2)}…)` : label;
|
||||
}
|
||||
|
||||
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),
|
||||
|
||||
+64
-13
@@ -12,7 +12,15 @@
|
||||
|
||||
import type CodeGraph from '../../index';
|
||||
import type { Language } from '../../types';
|
||||
import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
|
||||
import {
|
||||
callArgumentsForFile,
|
||||
guardLabel,
|
||||
guardsForFile,
|
||||
siteKey,
|
||||
supportsBranchGuards,
|
||||
triggersForFile,
|
||||
type SiteTrigger,
|
||||
} from '../../graph/branch-guards';
|
||||
import { resolveProjectFile } from '../security';
|
||||
import { findIndexedFile, hasDriftedOnDisk } from './source';
|
||||
import type { WireEdge } from './wire';
|
||||
@@ -81,15 +89,25 @@ export async function annotateWhen(cg: CodeGraph, projectRoot: string, batches:
|
||||
* files yield no label, and the count of sites labelled is bounded so a wide
|
||||
* walk cannot turn one request into a parse of the repository.
|
||||
*/
|
||||
export function createWhenReader(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
maxSites = 600
|
||||
): (caller: { filePath: string; language: Language }, site: { line?: number; column?: number }) => Promise<string> {
|
||||
export interface SiteReader {
|
||||
/** The conditions the site runs under, joined; '' when unconditional or unreadable. */
|
||||
when(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string>;
|
||||
/** What the site passes, abbreviated (`'userEmail', values.email`); null when unreadable. '' for an empty list. */
|
||||
args(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string | null>;
|
||||
/** What fires the site — the JSX prop, `on*` option or runs-later call it is written under; null when nothing binds it. */
|
||||
trigger(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<SiteTrigger | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both readings of one call site — WHEN it runs and WITH WHAT — for the
|
||||
* endpoints that walk chains (Screens, Steps). One file resolution and one
|
||||
* parsed tree serve both; drifted files yield nothing; one site budget bounds
|
||||
* the whole pass.
|
||||
*/
|
||||
export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites = 600): SiteReader {
|
||||
const files = new Map<string, { abs: string; language: Language } | null>();
|
||||
let sites = 0;
|
||||
return async (caller, site): Promise<string> => {
|
||||
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return '';
|
||||
const resolve = (caller: { filePath: string; language: Language }): { abs: string; language: Language } | null => {
|
||||
const posix = caller.filePath.replace(/\\/g, '/');
|
||||
let file = files.get(posix);
|
||||
if (file === undefined) {
|
||||
@@ -104,10 +122,43 @@ export function createWhenReader(
|
||||
}
|
||||
files.set(posix, file);
|
||||
}
|
||||
if (!file) return '';
|
||||
sites++;
|
||||
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
|
||||
const g = (await guardsForFile(file.abs, file.language, [key])).get(siteKey(key));
|
||||
return g ? guardLabel(g) : '';
|
||||
return file;
|
||||
};
|
||||
return {
|
||||
async when(caller, site) {
|
||||
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return '';
|
||||
const file = resolve(caller);
|
||||
if (!file) return '';
|
||||
sites++;
|
||||
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
|
||||
const g = (await guardsForFile(file.abs, file.language, [key])).get(siteKey(key));
|
||||
return g ? guardLabel(g) : '';
|
||||
},
|
||||
async args(caller, site) {
|
||||
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null;
|
||||
const file = resolve(caller);
|
||||
if (!file) return null;
|
||||
sites++;
|
||||
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
|
||||
return (await callArgumentsForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? null;
|
||||
},
|
||||
async trigger(caller, site) {
|
||||
// Not counted against the budget: the tree is already parsed for the
|
||||
// site's guards, and a trigger lookup is a walk up from one node.
|
||||
if (!site.line || !supportsBranchGuards(caller.language)) return null;
|
||||
const file = resolve(caller);
|
||||
if (!file) return null;
|
||||
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
|
||||
return (await triggersForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The `when` half of {@link createSiteReader}, for callers that read nothing else. */
|
||||
export function createWhenReader(
|
||||
cg: CodeGraph,
|
||||
projectRoot: string,
|
||||
maxSites = 600
|
||||
): (caller: { filePath: string; language: Language }, site: { line?: number; column?: number }) => Promise<string> {
|
||||
return createSiteReader(cg, projectRoot, maxSites).when;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user