feat(expo-router): add Expo Router support for screens and navigates

Introduce Expo Router integration: a new framework resolver, route-based screen nodes, and navigates edges, plus a /api/screens endpoint and a Screens UI view. Adds branch-guard-driven labeling of edges, resolution logic, and tests to cover extraction, resolution, and end-to-end flow. This enables CodeGraph UI to surface screens and transitions from Expo Router apps.
This commit is contained in:
Colby McHenry
2026-08-27 22:52:18 -05:00
parent ac9580544b
commit 70fd5fefc2
42 changed files with 4257 additions and 33 deletions
+1 -1
View File
@@ -1208,7 +1208,7 @@ export class ContextBuilder {
// Edge recovery: BFS with many entry points leaves most nodes disconnected.
// Discover edges between already-selected nodes to recover connectivity.
const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides'];
const recoveryKinds: EdgeKind[] = ['calls', 'extends', 'implements', 'references', 'overrides', 'navigates'];
const recoveredEdges = this.queries.findEdgesBetweenNodes(
[...finalNodes.keys()],
recoveryKinds,
+603
View File
@@ -0,0 +1,603 @@
/**
* Branch guards — the conditions under which a call site runs.
*
* An edge says `handlePress → openObjectDetail`. What a reader wants to know
* is that it happens **when `isCollected`** and **not while `isUploading`**:
*
* if (isUploading) return ← early-return guard: !isUploading
* if (isCollected) { ← if: isCollected
* openObjectDetail(item) ← the call site
*
* This module derives that from the AST at query time. Given a file, its
* language and a call site (line, column), it walks from the innermost node at
* that position up to the enclosing function boundary and records every
* branch it passes through: `if` / `else` / `else if`, the arms of a ternary,
* `switch` cases, the right side of `&&` / `||`, a `catch`, and — at each
* statement block on the way — the early exits that precede the site
* (`if (x) return`, Swift `guard x else { return }`).
*
* Nothing is stored in the index. The viewer and `codegraph_explore` already
* re-read source per request (drift checks, source windows, highlighting), the
* grammars are loaded in both processes, and a file parses in about a
* millisecond — so labels are computed where they are shown, from the source
* as it is now, and the index schema and the native kernel are untouched. A
* small LRU keeps the last few parsed trees so a Symbol view that asks about
* forty call sites in one file parses it once.
*
* Only what the AST states is reported. Loops are not conditions and are not
* listed; a condition that cannot be read (a language without rules here, a
* file that will not parse) yields no label rather than a wrong one.
*/
import * as fs from 'fs';
import type { Node as SyntaxNode, Tree } from 'web-tree-sitter';
import type { Language } from '../types';
import { getParser, loadGrammarsForLanguages } from '../extraction/grammars';
// =============================================================================
// Public shape
// =============================================================================
export type GuardForm = 'if' | 'else' | 'ternary' | 'case' | 'guard' | 'and' | 'or' | 'catch';
export interface BranchGuard {
/** The condition's source, whitespace-collapsed, outer parens dropped, capped in length. */
text: string;
/** The site runs when the condition is FALSE (an else arm, an early-return guard, `||`). */
negated: boolean;
form: GuardForm;
/** Line of the condition (1-based). */
line: number;
}
/** Longest condition text kept before it is cut with an ellipsis. */
const MAX_TEXT = 80;
const JS_FAMILY: ReadonlySet<Language> = new Set(['typescript', 'javascript', 'tsx', 'jsx']);
/** Languages with walk rules below. Others yield no guards (never a wrong one). */
export function supportsBranchGuards(language: Language | string | undefined | null): boolean {
return !!language && (JS_FAMILY.has(language as Language) || language === 'swift');
}
/**
* The label a rail or a flow connector prints: the conditions in execution
* order, joined with `&&`, each negated one written as `!x`. Empty when the
* site is unconditional.
*/
export function guardLabel(guards: readonly BranchGuard[]): string {
return guards.map(renderGuard).join(' && ');
}
function renderGuard(g: BranchGuard): string {
if (g.form === 'catch') return g.text;
if (!g.negated) return 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})`;
}
function isSimpleOperand(text: string): boolean {
return /^[\w$.?!]+(?:\([^()]*\))?$/.test(text) && !/[=<>]/.test(text);
}
// =============================================================================
// Trees, cached per file version
// =============================================================================
interface CachedTree {
key: string;
tree: Tree;
source: string;
}
const TREE_CACHE_SIZE = 8;
const treeCache = new Map<string, CachedTree>();
/**
* Files above this size are not parsed for labels. A 300 KB source file costs
* tens of milliseconds to parse, and a Symbol view is budgeted at 100 ms end
* to end; a call site in such a file simply shows no `when`.
*/
export const MAX_PARSE_BYTES = 256 * 1024;
/** The `web-tree-sitter` trees held above are native memory: evict explicitly. */
function remember(path: string, entry: CachedTree): void {
const old = treeCache.get(path);
if (old) old.tree.delete();
treeCache.delete(path);
treeCache.set(path, entry);
if (treeCache.size > TREE_CACHE_SIZE) {
const oldest = treeCache.keys().next().value as string;
treeCache.get(oldest)?.tree.delete();
treeCache.delete(oldest);
}
}
async function treeFor(absPath: string, language: Language): Promise<CachedTree | null> {
let stat: fs.Stats;
try {
stat = fs.statSync(absPath);
} catch {
return null;
}
const key = `${language}:${stat.mtimeMs}:${stat.size}`;
const hit = treeCache.get(absPath);
if (hit && hit.key === key) return hit;
if (stat.size > MAX_PARSE_BYTES) return null;
let source: string;
try {
source = fs.readFileSync(absPath, 'utf8');
} catch {
return null;
}
const tree = await parse(source, language);
if (!tree) return null;
const entry = { key, tree, source };
remember(absPath, entry);
return entry;
}
async function parse(source: string, language: Language): Promise<Tree | null> {
try {
await loadGrammarsForLanguages([language]);
const parser = getParser(language);
if (!parser) return null;
return parser.parse(source) ?? null;
} catch {
return null;
}
}
// =============================================================================
// Entry points
// =============================================================================
export interface CallSite {
line: number;
/** 0-based; null/undefined = the first non-blank column of the line. */
column?: number | null;
}
export function siteKey(site: CallSite): string {
return `${site.line}:${typeof site.column === 'number' ? site.column : ''}`;
}
/**
* Guards for many call sites in one file, keyed by {@link siteKey}. The file
* is parsed once (and cached across requests until it changes on disk). A
* language without rules, or a file that cannot be read or parsed, yields an
* empty map.
*/
export async function guardsForFile(
absPath: string,
language: Language,
sites: readonly CallSite[]
): Promise<Map<string, BranchGuard[]>> {
const out = new Map<string, BranchGuard[]>();
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;
out.set(key, guardsInTree(cached.tree.rootNode, cached.source, language, site.line, site.column ?? null));
}
return out;
}
/**
* Synchronous twin of {@link guardsForFile} for callers that cannot await
* (the explore text builder). It only serves languages whose grammar is
* ALREADY loaded — see {@link warmBranchGuardGrammars} — and yields an empty
* map otherwise, never a wrong label.
*/
export function guardsForFileSync(
absPath: string,
language: Language,
sites: readonly CallSite[]
): Map<string, BranchGuard[]> {
const out = new Map<string, BranchGuard[]>();
if (!supportsBranchGuards(language) || sites.length === 0) return out;
let stat: fs.Stats;
try {
stat = fs.statSync(absPath);
} catch {
return out;
}
const key = `${language}:${stat.mtimeMs}:${stat.size}`;
let cached = treeCache.get(absPath);
if (!cached || cached.key !== key) {
if (stat.size > MAX_PARSE_BYTES) return out;
const parser = getParser(language);
if (!parser) return out;
let source: string;
try {
source = fs.readFileSync(absPath, 'utf8');
} catch {
return out;
}
const tree = parser.parse(source);
if (!tree) return out;
cached = { key, tree, source };
remember(absPath, cached);
}
for (const site of sites) {
const k = siteKey(site);
if (!out.has(k)) out.set(k, guardsInTree(cached.tree.rootNode, cached.source, language, site.line, site.column ?? null));
}
return out;
}
/** The languages with rules here — what {@link warmBranchGuardGrammars} loads. */
export const BRANCH_GUARD_LANGUAGES: readonly Language[] = ['typescript', 'tsx', 'javascript', 'jsx', 'swift'];
/** 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));
if (wanted.length === 0) return;
try {
await loadGrammarsForLanguages(wanted);
} catch {
// Explore prints no `when` for that language; nothing else changes.
}
}
/** Guards for one site in source text — the test seam; production reads files. */
export async function guardsInSource(
source: string,
language: Language,
line: number,
column: number | null = null
): Promise<BranchGuard[]> {
if (!supportsBranchGuards(language)) return [];
const tree = await parse(source, language);
if (!tree) return [];
try {
return guardsInTree(tree.rootNode, source, language, line, column);
} finally {
tree.delete();
}
}
/**
* The walk. `line` is 1-based, `column` 0-based (null → first non-blank).
* Returns the guards outermost first — execution order, the way a reader
* would list them.
*/
export function guardsInTree(
root: SyntaxNode,
source: string,
language: Language,
line: number,
column: number | null
): BranchGuard[] {
const row = line - 1;
if (row < 0) return [];
let col = column ?? 0;
if (column === null) {
const text = source.split('\n')[row] ?? '';
const first = text.search(/\S/);
col = first < 0 ? 0 : first;
}
let node: SyntaxNode | null = innermostAt(root, row, col);
if (!node) return [];
const rules: Rules = language === 'swift' ? SWIFT : JS;
const found: BranchGuard[] = [];
// Innermost → outermost. `found` is reversed at the end, so within one level
// anything meant to read as OUTER must be pushed LATER.
while (node) {
const parent: SyntaxNode | null = node.parent;
if (!parent || rules.boundaries.has(parent.type)) break;
if (rules.inlineFunctions.has(parent.type)) {
const holder = parent.parent?.type ?? '';
if (rules.bindingParents.has(holder)) break;
node = parent;
continue;
}
rules.enclosing(parent, node, found);
if (rules.blocks.has(parent.type)) rules.earlyExits(parent, node, found);
node = parent;
}
found.reverse();
return found;
}
/**
* The innermost named node containing (row, col). `descendantForPosition` is
* the fast path, but some grammars (Swift's `statements`) answer with the
* container, so the result is refined by descending while a named child still
* contains the point.
*/
function innermostAt(root: SyntaxNode, row: number, col: number): SyntaxNode | null {
let node: SyntaxNode | null = root.descendantForPosition({ row, column: col });
if (!node) return null;
for (;;) {
let next: SyntaxNode | null = null;
const here: SyntaxNode = node;
for (let i = 0; i < here.namedChildCount; i++) {
const c: SyntaxNode = here.namedChild(i)!;
const s = c.startPosition;
const e = c.endPosition;
const afterStart = s.row < row || (s.row === row && s.column <= col);
const beforeEnd = e.row > row || (e.row === row && e.column > col);
if (afterStart && beforeEnd) {
next = c;
break;
}
}
if (!next) return node;
node = next;
}
}
// =============================================================================
// Language rules
// =============================================================================
interface Rules {
/**
* Node types the walk never climbs past: the function the site belongs to.
* An INLINE function — an arrow passed as an argument, a closure in an
* object literal, a trailing closure — is not a boundary: the conditions
* around its definition are the conditions under which it exists at all,
* which is what a reader asking "when does this run" wants. A function that
* is declared, or assigned to a name, starts its own story.
*/
boundaries: ReadonlySet<string>;
/** Function-expression types that are boundaries only when named/assigned. */
inlineFunctions: ReadonlySet<string>;
/** Parent types under which an inline function counts as named/assigned. */
bindingParents: ReadonlySet<string>;
/** Statement containers whose earlier children may be early exits. */
blocks: ReadonlySet<string>;
/** `parent` encloses `child` (the node the walk came up through): record any branch. */
enclosing(parent: SyntaxNode, child: SyntaxNode, out: BranchGuard[]): void;
/** `child` is a statement of block `parent`: record the exits before it. */
earlyExits(parent: SyntaxNode, child: SyntaxNode, out: BranchGuard[]): void;
}
function condText(node: SyntaxNode | null | undefined): string {
if (!node) return '';
let n: SyntaxNode = node;
// `(x)` — the parens are the statement's, not the condition's.
while (n.type === 'parenthesized_expression' && n.namedChildCount === 1) n = n.namedChild(0)!;
const text = n.text.replace(/\s+/g, ' ').trim();
return text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text;
}
function guard(form: GuardForm, cond: SyntaxNode | null | undefined, negated: boolean, text?: string): BranchGuard | null {
const t = text ?? condText(cond);
if (!t) return null;
return { text: t, negated, form, line: (cond ?? null) ? cond!.startPosition.row + 1 : 0 };
}
function push(out: BranchGuard[], g: BranchGuard | null): void {
if (g) out.push(g);
}
function isField(parent: SyntaxNode, field: string, child: SyntaxNode): boolean {
const f = parent.childForFieldName(field);
return !!f && f.id === child.id;
}
function lastNamed(node: SyntaxNode): SyntaxNode | null {
return node.namedChildCount > 0 ? node.namedChild(node.namedChildCount - 1) : null;
}
/** The named children of `parent` that come before `child`, in source order. */
function precedingSiblings(parent: SyntaxNode, child: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < parent.namedChildCount; i++) {
const s = parent.namedChild(i)!;
if (s.id === child.id) break;
out.push(s);
}
return out;
}
// ----------------------------------------------------------------------- JS --
const JS_EXITS = new Set(['return_statement', 'throw_statement', 'break_statement', 'continue_statement']);
/** A statement that always leaves the block: an exit, or a block ending in one. */
function jsAlwaysExits(node: SyntaxNode | null): boolean {
if (!node) return false;
if (JS_EXITS.has(node.type)) return true;
if (node.type === 'statement_block') return jsAlwaysExits(lastNamed(node));
return false;
}
const JS: Rules = {
boundaries: new Set([
'function_declaration',
'method_definition',
'generator_function_declaration',
'class_declaration',
'class_body',
'class',
'program',
]),
inlineFunctions: new Set(['arrow_function', 'function_expression', 'function', 'generator_function']),
bindingParents: new Set([
'variable_declarator',
'assignment_expression',
'export_statement',
'public_field_definition',
'field_definition',
'lexical_declaration',
]),
blocks: new Set(['statement_block', 'program', 'switch_case', 'switch_default']),
enclosing(parent, child, out) {
switch (parent.type) {
case 'if_statement': {
const cond = parent.childForFieldName('condition');
if (isField(parent, 'consequence', child)) push(out, guard('if', cond, false));
else if (isField(parent, 'alternative', child)) push(out, guard('else', cond, true));
return;
}
case 'ternary_expression': {
const cond = parent.childForFieldName('condition');
if (isField(parent, 'consequence', child)) push(out, guard('ternary', cond, false));
else if (isField(parent, 'alternative', child)) push(out, guard('ternary', cond, true));
return;
}
case 'switch_case':
case 'switch_default': {
// `child` is one of the case's body statements (not its value).
if (parent.type === 'switch_case' && isField(parent, 'value', child)) return;
const body = parent.parent; // switch_body
const stmt = body?.parent; // switch_statement
const subject = condText(stmt?.childForFieldName('value'));
if (parent.type === 'switch_default') push(out, guard('case', stmt?.childForFieldName('value'), false, subject ? `${subject}: default` : 'default'));
else {
const value = condText(parent.childForFieldName('value'));
push(out, guard('case', parent.childForFieldName('value'), false, subject ? `${subject} === ${value}` : value));
}
return;
}
case 'binary_expression': {
if (!isField(parent, 'right', child)) return;
const op = parent.childForFieldName('operator')?.text;
const left = parent.childForFieldName('left');
if (op === '&&') push(out, guard('and', left, false));
else if (op === '||') push(out, guard('or', left, true));
return;
}
case 'catch_clause':
if (!isField(parent, 'parameter', child)) push(out, guard('catch', null, false, 'on error'));
return;
default:
return;
}
},
earlyExits(parent, child, out) {
// Outer-most last (the list is reversed once at the end): walk the
// preceding statements backwards so the FIRST guard in the source ends up
// first in the final order.
const before = precedingSiblings(parent, child);
for (let i = before.length - 1; i >= 0; i--) {
const s = before[i]!;
if (s.type !== 'if_statement' || s.childForFieldName('alternative')) continue;
if (!jsAlwaysExits(s.childForFieldName('consequence'))) continue;
push(out, guard('guard', s.childForFieldName('condition'), true));
}
},
};
// -------------------------------------------------------------------- Swift --
function swiftAlwaysExits(node: SyntaxNode | null): boolean {
if (!node) return false;
if (node.type === 'control_transfer_statement') return true;
if (node.type === 'statements') return swiftAlwaysExits(lastNamed(node));
return false;
}
/** For a Swift `if`: is `child` after the `else` keyword? */
function afterElse(parent: SyntaxNode, child: SyntaxNode): boolean {
let seenElse = false;
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i)!;
if (c.id === child.id) return seenElse;
if (c.type === 'else') seenElse = true;
}
return false;
}
/** All `condition` fields of a Swift `if`/`guard`, joined — `if let x, y > 0`. */
function swiftConditions(node: SyntaxNode): { node: SyntaxNode | null; text: string } {
// The grammar labels several tokens of `if let x = y, z > 0` as `condition`
// (the binding's own pieces included), so the readable text is the SPAN from
// the first to the last of them, not the pieces joined.
const parts: SyntaxNode[] = [];
for (let i = 0; i < node.childCount; i++) {
if (node.fieldNameForChild(i) === 'condition') parts.push(node.child(i)!);
}
if (parts.length === 0) return { node: null, text: '' };
const first = parts[0]!;
const last = parts[parts.length - 1]!;
const raw = node.text.slice(first.startIndex - node.startIndex, last.endIndex - node.startIndex);
const text = raw.replace(/\s+/g, ' ').trim();
return { node: first, text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT - 1) + '…' : text };
}
const SWIFT: Rules = {
boundaries: new Set([
'function_declaration',
'init_declaration',
'deinit_declaration',
'class_declaration',
'protocol_declaration',
'computed_property',
'source_file',
]),
inlineFunctions: new Set(['lambda_literal']),
bindingParents: new Set(['property_declaration', 'assignment']),
blocks: new Set(['statements', 'function_body']),
enclosing(parent, child, out) {
switch (parent.type) {
case 'if_statement': {
const c = swiftConditions(parent);
if (parent.fieldNameForChild(indexOf(parent, child)) === 'condition') return;
push(out, guard(afterElse(parent, child) ? 'else' : 'if', c.node, afterElse(parent, child), c.text));
return;
}
case 'guard_statement': {
// Inside the guard's body the condition FAILED.
if (parent.fieldNameForChild(indexOf(parent, child)) === 'condition') return;
const c = swiftConditions(parent);
push(out, guard('else', c.node, true, c.text));
return;
}
case 'ternary_expression': {
const cond = parent.childForFieldName('condition');
if (isField(parent, 'if_true', child)) push(out, guard('ternary', cond, false));
else if (isField(parent, 'if_false', child)) push(out, guard('ternary', cond, true));
return;
}
case 'switch_entry': {
const stmt = parent.parent;
const subject = condText(stmt?.childForFieldName('expr'));
const pattern = parent.namedChildren.find((n) => n.type === 'switch_pattern');
if (pattern && pattern.id === child.id) return;
const isDefault = parent.children.some((n) => n.type === 'default_keyword');
const value = pattern ? condText(pattern) : '';
const text = isDefault ? (subject ? `${subject}: default` : 'default') : subject ? `${subject} == ${value}` : value;
push(out, guard('case', pattern ?? stmt?.childForFieldName('expr'), false, text));
return;
}
case 'catch_block':
push(out, guard('catch', null, false, 'on error'));
return;
default:
return;
}
},
earlyExits(parent, child, out) {
const before = precedingSiblings(parent, child);
for (let i = before.length - 1; i >= 0; i--) {
const s = before[i]!;
if (s.type === 'guard_statement') {
const c = swiftConditions(s);
push(out, guard('guard', c.node, false, c.text));
} else if (s.type === 'if_statement' && !s.children.some((n) => n.type === 'else')) {
const body = s.namedChildren.find((n) => n.type === 'statements') ?? null;
if (!swiftAlwaysExits(body)) continue;
const c = swiftConditions(s);
push(out, guard('guard', c.node, true, c.text));
}
}
},
};
function indexOf(parent: SyntaxNode, child: SyntaxNode): number {
for (let i = 0; i < parent.childCount; i++) if (parent.child(i)!.id === child.id) return i;
return -1;
}
+1 -1
View File
@@ -303,7 +303,7 @@ function handlerMethodOf(cg: CodeGraph, cls: Node): Node | null {
// Continuations
// =============================================================================
const CONTINUATION_KINDS = new Set(['calls', 'instantiates']);
const CONTINUATION_KINDS = new Set(['calls', 'instantiates', 'navigates']);
/**
* The calls recorded out of a symbol, minus the ones already on the path.
+14 -4
View File
@@ -193,8 +193,16 @@ export const FLOW_CALLABLE_KINDS: ReadonlySet<string> = new Set([
'function',
'component',
'constructor',
'route',
]);
/**
* Edge kinds a flow may ride. `navigates` is a screen transition (Expo Router
* `router.push('/x')` → the route node) — a hop in the user's flow exactly as
* a call is a hop in the program's.
*/
export const FLOW_EDGE_KINDS: ReadonlySet<string> = new Set(['calls', 'navigates']);
/**
* Node kinds that can be an endpoint of a SYNTHESIZED edge without being
* callable. An RTK thunk is `const X = createAsyncThunk(...)`, so a thunk →
@@ -458,8 +466,10 @@ function walkCalls(
if (id !== seed.id && named.has(id)) reached.push(id);
if (depth >= maxHops - 1) continue;
for (const c of cg.getCallees(id)) {
if (c.edge.kind !== 'calls' || parent.has(c.node.id)) continue;
const newStreak = named.has(c.node.id) ? 0 : streak + 1;
if (!FLOW_EDGE_KINDS.has(c.edge.kind) || parent.has(c.node.id)) continue;
// A route node is a connector, not a symbol the reader would have named:
// crossing one costs no bridge budget.
const newStreak = named.has(c.node.id) ? 0 : c.node.kind === 'route' ? streak : streak + 1;
if (newStreak > maxBridge) continue;
parent.set(c.node.id, { prev: id, edge: c.edge, node: c.node });
queue.push({ id: c.node.id, depth: depth + 1, streak: newStreak });
@@ -530,7 +540,7 @@ function walkBidirectional(
const next: Node[] = [];
for (const node of frontF) {
for (const c of cg.getCallees(node.id)) {
if (c.edge.kind !== 'calls' || forward.has(c.node.id)) continue;
if (!FLOW_EDGE_KINDS.has(c.edge.kind) || forward.has(c.node.id)) continue;
forward.set(c.node.id, { prev: node.id, edge: c.edge, node: c.node });
next.push(c.node);
}
@@ -542,7 +552,7 @@ function walkBidirectional(
const next: Node[] = [];
for (const node of frontB) {
for (const c of cg.getCallers(node.id)) {
if (c.edge.kind !== 'calls' || backward.has(c.node.id)) continue;
if (!FLOW_EDGE_KINDS.has(c.edge.kind) || backward.has(c.node.id)) continue;
backward.set(c.node.id, { next: node.id, edge: c.edge });
backNodes.set(c.node.id, c.node);
next.push(c.node);
+2 -2
View File
@@ -292,7 +292,7 @@ export class GraphTraverser {
// caller of the class. Without it, `callers <Class>` surfaced only the
// importing file (via `imports`) and missed every construction site —
// the opposite of "what breaks if I change this class?" (#774).
const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates']);
const incomingEdges = this.queries.getIncomingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates', 'navigates']);
if (incomingEdges.length === 0) return;
// Batch-fetch all caller nodes in one round-trip instead of one
@@ -347,7 +347,7 @@ export class GraphTraverser {
// (`Foo(...)` / `new Foo()`) has that class as a callee, so callers and
// callees stay inverses of each other and `trace` can cross the
// instantiation boundary (function → class → its methods) (#774).
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates']);
const outgoingEdges = this.queries.getOutgoingEdges(nodeId, ['calls', 'references', 'imports', 'instantiates', 'navigates']);
if (outgoingEdges.length === 0) return;
// Batch-fetch callee nodes (was N+1 — see getCallersRecursive note).
+34 -3
View File
@@ -40,6 +40,7 @@ import {
} from 'fs';
import { createHash } from 'crypto';
import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, CONFIG_LEAF_LANGUAGES } from '../utils';
import { guardLabel, guardsForFileSync, siteKey, supportsBranchGuards, warmBranchGuardGrammars } from '../graph/branch-guards';
import { findDynamicBoundaries, type BoundarySite } from '../graph/dynamic-boundary-report';
import { countImplementers } from '../graph/type-hierarchy';
import {
@@ -377,7 +378,7 @@ const ISOLATED_WEAK_KIND_WEIGHT = 0.08;
*/
const RELEVANCE_USAGE_EDGES: ReadonlySet<string> = new Set([
'calls', 'references', 'extends', 'implements', 'overrides',
'instantiates', 'returns', 'type_of', 'decorates',
'instantiates', 'returns', 'type_of', 'decorates', 'navigates',
]);
/**
@@ -2440,6 +2441,29 @@ export class ToolHandler {
* for ordinary static edges. Used by trace + the node trail so a synthesized
* hop reads as "registered via onUpdate at App.tsx:3148", not a bare arrow.
*/
/**
* The branch conditions a flow hop's call site runs under, read from the
* caller's source now (`graph/branch-guards.ts`); '' when unconditional,
* unreadable, or the grammar for that language is not loaded.
*/
private whenLabel(cg: CodeGraph, caller: Node, edge: Edge): string {
if (!edge.line || !supportsBranchGuards(caller.language)) return '';
try {
const rec = cg.getFile(caller.filePath);
if (!rec) return '';
const abs = validatePathWithinRoot(cg.getProjectRoot(), caller.filePath);
if (!abs) return '';
const st = statSync(abs);
// Drifted since the index: the recorded line may point elsewhere.
if (st.size !== rec.size || Math.floor(st.mtimeMs) !== Math.floor(rec.modifiedAt)) return '';
const site = { line: edge.line, column: typeof edge.column === 'number' ? edge.column : null };
const g = guardsForFileSync(abs, caller.language, [site]).get(siteKey(site));
return g ? guardLabel(g) : '';
} catch {
return '';
}
}
private synthEdgeNote(edge: Edge | null): { label: string; compact: string; registeredAt?: string } | null {
if (!edge || edge.provenance !== 'heuristic') return null;
const m = edge.metadata as Record<string, unknown> | undefined;
@@ -2704,7 +2728,11 @@ export class ToolHandler {
out.push('**Flow (call path among the symbols you queried)**', '');
for (let i = 0; i < best!.length; i++) {
const step = best![i]!;
if (step.edge) { const sy = this.synthEdgeNote(step.edge); out.push(`${sy ? sy.compact : step.edge.kind}`); }
if (step.edge) {
const sy = this.synthEdgeNote(step.edge);
const when = i > 0 ? this.whenLabel(cg, best![i - 1]!.node, step.edge) : '';
out.push(`${sy ? sy.compact : step.edge.kind}${when ? ` (when ${when})` : ''}`);
}
out.push(`${i + 1}. ${step.node.name} (${step.node.filePath}:${step.node.startLine})`);
}
out.push('');
@@ -3014,7 +3042,7 @@ export class ToolHandler {
const RANK_EDGES = new Set<string>([
'calls', 'references', 'extends', 'implements', 'overrides',
'instantiates', 'returns', 'type_of', 'imports',
'instantiates', 'returns', 'type_of', 'imports', 'navigates',
]);
const adj: number[][] = Array.from({ length: n }, () => []);
for (const e of edges) {
@@ -3948,6 +3976,9 @@ export class ToolHandler {
// Compute the flow spine once — used both to prepend the Flow section (below)
// and to gate adaptive source sizing: files on the spine get full source,
// off-spine peers skeletonize.
// The Flow section labels each hop with its branch conditions; that read
// is synchronous, so the grammars it needs are loaded here, once.
await warmBranchGuardGrammars();
const flow = this.buildFlowFromNamedSymbols(cg, matchQuery);
// Snapshot every ranked candidate's scoring inputs, in final sort order, so
+3
View File
@@ -28,6 +28,7 @@ import { isGeneratedFile } from '../extraction/generated-detection';
import { stripCommentsForRegex } from './strip-comments';
import { cFnPointerDispatchEdges } from './c-fnptr-synthesizer';
import { goframeRouteEdges } from './goframe-synthesizer';
import { expoRouterReturnEdges } from './expo-router-synthesizer';
import { createYielder, type MaybeYield } from './cooperative-yield';
const REGISTRAR_NAME = /^(on[A-Z]\w*|subscribe|addListener|addEventListener|register|watch|listen|addCallback)$/;
@@ -3610,6 +3611,8 @@ export const SYNTH_PASSES: SynthPassDef[] = [
run: (q, c, y, sub) => cFnPointerDispatchEdges(q, c, y, sub),
},
{ name: 'goframeEdges', gate: (has) => has('go'), run: (_q, c, y) => goframeRouteEdges(c, y) },
// `router.push(await helper())` — the helper's return literals are the screens.
{ name: 'expoRouterReturnEdges', gate: (has) => has(...JS_FAMILY), run: (_q, c, y) => expoRouterReturnEdges(c, y) },
{ name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) },
];
+184
View File
@@ -0,0 +1,184 @@
/**
* Expo Router — navigation whose destination comes back from a helper.
*
* router.push(await resolvePostLoginRoute())
*
* The argument is a call, not a string, so the resolver in
* `frameworks/expo-router.ts` (which binds literal hrefs) correctly leaves the
* `router.push` ref unresolved. But the destination is still static — it is
* written down inside the helper:
*
* const resolvePostLoginRoute = async () =>
* (await hasSeenWelcome()) ? '/home/' : '/welcome/'
*
* This pass finds every navigation call whose argument is a call to a project
* function, reads the screen-path literals out of that function's body, and
* synthesizes one `navigates` edge from the HELPER to each screen. The push
* site already has a plain `calls` edge to the helper, so the flow reads
* `fetchUser → resolvePostLoginRoute → /home`, and the fork the helper decides
* shows up as its two (or three) outgoing screens — which is the answer to
* "where does the app go after login".
*
* Edges are `provenance:'heuristic'`, `synthesizedBy:'expo-router-return'`,
* with `registeredAt` = the push site that made the helper's return value a
* destination. A helper is only read because a navigation call consumes it;
* a function that merely contains path-like strings is never touched. Nothing
* here runs on a project with no Expo Router screens.
*/
import type { Edge, Language, Node } from '../types';
import type { ResolutionContext } from './types';
import type { MaybeYield } from './cooperative-yield';
import { stripCommentsForRegex } from './strip-comments';
import {
matchRoute,
normalizeHrefPath,
readStringAt,
routeTable,
stringEnd,
toHref,
} from './frameworks/expo-router';
const JS_LANGS: ReadonlySet<Language> = new Set(['typescript', 'javascript', 'tsx', 'jsx']);
const JS_FILE = /\.(?:[cm]?[jt]sx?)$/;
/** `.push(await helper(` / `.navigate(obj.helper(` — a navigation call fed by a call. */
const NAV_FED_BY_CALL = /\.(push|replace|navigate|dismissTo)\(\s*(?:await\s+)?([A-Za-z_$][\w$.]*)\s*\(/g;
/** A helper yielding more distinct screens than this is a table, not a decision. */
const MAX_SCREENS_PER_HELPER = 8;
const HELPER_KINDS: ReadonlySet<string> = new Set(['function', 'method']);
interface NavSite {
file: string;
line: number;
method: string;
callee: string;
}
export async function expoRouterReturnEdges(ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
const table = routeTable(ctx);
if (table.exact.size === 0) return [];
// 1. Every navigation call whose argument is a call.
const sites: NavSite[] = [];
let scanned = 0;
for (const file of ctx.getAllFiles()) {
if (!JS_FILE.test(file)) continue;
if ((++scanned & 63) === 0) await onYield();
const source = ctx.readFile(file);
if (!source || !/\.(?:push|replace|navigate|dismissTo)\(/.test(source)) continue;
const stripped = stripCommentsForRegex(source, 'typescript');
NAV_FED_BY_CALL.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = NAV_FED_BY_CALL.exec(stripped)) !== null) {
const line = stripped.slice(0, m.index).split('\n').length;
sites.push({ file, line, method: m[1]!, callee: m[2]! });
}
}
if (sites.length === 0) return [];
// 2. Each callee → the project function it names → the screens in its body.
const edges: Edge[] = [];
const seen = new Set<string>();
const screensByHelper = new Map<string, Array<{ node: Node; href: string; line: number }> | null>();
for (const site of sites) {
await onYield();
const helper = resolveHelper(site, ctx);
if (!helper) continue;
let screens = screensByHelper.get(helper.id);
if (screens === undefined) {
screens = screensInBody(helper, ctx, table);
screensByHelper.set(helper.id, screens);
}
if (!screens) continue;
for (const s of screens) {
const key = `${helper.id}>${s.node.id}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
source: helper.id,
target: s.node.id,
kind: 'navigates',
line: s.line,
provenance: 'heuristic',
metadata: {
synthesizedBy: 'expo-router-return',
href: s.href,
navMethod: site.method,
registeredAt: `${site.file}:${site.line}`,
},
});
}
}
return edges;
}
/**
* The function `site.callee` names, seen from `site.file`: same file first,
* then the file its import points at, then a unique project-wide match.
* Ambiguity is a null — an edge onto the wrong `load()` is worse than none.
*/
function resolveHelper(site: NavSite, ctx: ResolutionContext): Node | null {
const segs = site.callee.split('.');
const bare = segs[segs.length - 1]!;
const head = segs[0]!;
const candidates = ctx
.getNodesByName(bare)
.filter((n) => HELPER_KINDS.has(n.kind) && JS_LANGS.has(n.language));
if (candidates.length === 0) return null;
const local = candidates.filter((n) => n.filePath === site.file);
if (local.length === 1) return local[0]!;
if (local.length > 1) return null;
const lang: Language = site.file.endsWith('x') ? 'tsx' : 'typescript';
const imported = ctx
.getImportMappings(site.file, lang)
.find((im) => im.localName === head || im.localName === bare);
if (imported?.resolvedPath) {
const viaImport = candidates.filter((n) => n.filePath === imported.resolvedPath);
if (viaImport.length === 1) return viaImport[0]!;
}
return candidates.length === 1 ? candidates[0]! : null;
}
/**
* The screens named by string literals in the helper's body — each literal
* that begins with `/`, resolved like an href would be. Null when the body
* cannot be read or names too many screens to be a decision.
*/
function screensInBody(
helper: Node,
ctx: ResolutionContext,
table: ReturnType<typeof routeTable>
): Array<{ node: Node; href: string; line: number }> | null {
const lines = ctx.getFileLines?.(helper.filePath) ?? ctx.readFile(helper.filePath)?.split(/\r?\n/);
if (!lines) return null;
const body = stripCommentsForRegex(
lines.slice(helper.startLine - 1, helper.endLine).join('\n'),
'typescript'
);
const found = new Map<string, { node: Node; href: string; line: number }>();
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '"' && ch !== "'" && ch !== '`') continue;
const start = i;
const literal = readStringAt(body, i);
i = stringEnd(body, i);
if (literal === null || !literal.startsWith('/')) continue;
const href = toHref(literal);
if (!href) continue;
const segs = normalizeHrefPath(href.path, helper.filePath);
if (segs === null) continue;
const route = matchRoute(segs, table);
if (!route || found.has(route.id)) continue;
found.set(route.id, {
node: route,
href: href.display,
line: helper.startLine + body.slice(0, start).split('\n').length - 1,
});
if (found.size > MAX_SCREENS_PER_HELPER) return null;
}
return found.size > 0 ? [...found.values()] : null;
}
+673
View File
@@ -0,0 +1,673 @@
/**
* Expo Router (React Native) — file-based screens and string-keyed navigation.
*
* Two things static extraction cannot see on its own, and that together are
* most of what "how does the app flow" means in an Expo app:
*
* 1. **A screen is a file, not a symbol.** Every file under `app/` (or
* `src/app/`) is a route: `app/object-detail.tsx` is `/object-detail`,
* `app/capture/index.tsx` is `/capture`, `app/item/[id].tsx` is `/item/[id]`,
* and `(group)` directories are invisible in the URL. `extract()` emits one
* `route` node per screen file, named by its path, with a `calls` ref to the
* file's default export so the route reaches the component that renders it.
*
* 2. **Navigation is a string.** `router.push('/object-detail?…')`,
* `router.navigate({ pathname: '/item/[id]', params })`, a template literal
* with the params interpolated — the extractor records each as a `calls` ref
* named `router.push` that resolves to nothing, because the target is a
* path, not an identifier. `resolve()` claims those refs, reads the argument
* off the source lines, matches it against the route table, and returns a
* **`navigates`** edge to the route node, carrying the href it read.
*
* Between them the graph gains `ItemCard → openObjectDetail → /object-detail →
* ObjectDetail`, which is the chain a reader asking "where does tapping an
* object go" needs and previously got "no path" for.
*
* Precision rests on the string resolving to a real screen file, not on the
* receiver being called `router`: `nav.push('/x')` from `const nav =
* useRouter()` binds, `list.push('/x')` where no such route exists does not.
* Anything the resolver cannot bind to exactly one screen — a computed path,
* an ambiguous dynamic match, a relative href from a non-screen file — is left
* unresolved rather than guessed. Silent beats wrong.
*
* Not covered yet (each needs the enclosing component, which `extract()` does
* not receive): `<Link href>`, `<Redirect href>`, and `Stack.Screen` /
* `Tabs.Screen` `name` props. `router.back()` / `dismiss()` have no target and
* are correctly skipped.
*/
import type { Language, Node } from '../../types';
import type {
FrameworkResolver,
ResolutionContext,
ResolvedRef,
UnresolvedRef,
} from '../types';
import { stripCommentsForRegex } from '../strip-comments';
// =============================================================================
// Route files
// =============================================================================
const ROUTE_LANGUAGES: readonly Language[] = ['typescript', 'javascript', 'tsx', 'jsx'];
/** The app directory: `app/` at the project root, or `src/app/`. First match wins. */
const APP_DIR = /(?:^|\/)(?:src\/)?app\//;
/** A screen file's extension, with an optional platform suffix (`.ios.tsx`). */
const ROUTE_EXT = /\.(?:(?:ios|android|native|web)\.)?(tsx|ts|jsx|js|mjs|cjs)$/;
/** Files under `app/` that define a screen (not a layout, not test, not html). */
export function routePathForFile(filePath: string): string | null {
const dir = APP_DIR.exec(filePath);
if (!dir) return null;
const rel = filePath.slice(dir.index + dir[0].length);
const ext = ROUTE_EXT.exec(rel);
if (!ext) return null;
const bare = rel.slice(0, ext.index);
if (bare.endsWith('.d') || /\.(?:test|spec|stories)$/.test(bare)) return null;
const segs = bare.split('/');
if (segs.includes('__tests__') || segs.includes('__mocks__')) return null;
const base = segs[segs.length - 1]!;
// `_layout` (and any other `_`-prefixed file) is not navigable. `+not-found`
// is a real screen; the other `+` files (`+html`, `+native-intent`) are not.
if (base.startsWith('_')) return null;
if (base.startsWith('+') && base !== '+not-found') return null;
const kept = segs.filter((s) => !(s.startsWith('(') && s.endsWith(')')));
if (kept[kept.length - 1] === 'index') kept.pop();
return '/' + kept.join('/');
}
function languageForFile(filePath: string): Language {
const ext = ROUTE_EXT.exec(filePath)?.[1];
switch (ext) {
case 'tsx':
return 'tsx';
case 'jsx':
return 'jsx';
case 'ts':
return 'typescript';
default:
return 'javascript';
}
}
const IDENT = '[A-Za-z_$][\\w$]*';
/**
* The name the file exports as its screen. Expo renders the DEFAULT export,
* so that is the only binding that matters; a wrapper (`memo(Screen)`,
* `observer(Screen)`, `React.forwardRef(Screen)`) is looked through to its
* first identifier argument. Anonymous defaults (`export default () => …`)
* have no name to bind and yield null.
*/
export function defaultExportName(stripped: string): { name: string; index: number } | null {
const patterns: RegExp[] = [
new RegExp(`export\\s+default\\s+(?:async\\s+)?function\\s*\\*?\\s*(${IDENT})`),
new RegExp(`export\\s+default\\s+class\\s+(${IDENT})`),
new RegExp(`export\\s+default\\s+(?:${IDENT}\\.)?${IDENT}\\s*\\(\\s*(${IDENT})\\s*[,)]`),
new RegExp(`export\\s+default\\s+(${IDENT})\\s*;?\\s*$`, 'm'),
new RegExp(`export\\s*\\{\\s*(${IDENT})\\s+as\\s+default\\s*\\}`),
];
for (const re of patterns) {
const m = re.exec(stripped);
if (m && m[1] && m[1] !== 'default') return { name: m[1], index: m.index };
}
return null;
}
// =============================================================================
// Reading the href out of a navigation call
// =============================================================================
/**
* The `router` methods that take a destination (`back`/`dismiss` take none),
* and a project's own wrappers around them: `safePush('/x')`,
* `guardedNavigate('/x')` — a camelCase name ending in the verb. A wrapper
* usually defers the real call through state the graph cannot follow
* (`pendingNav = { method, href }` … `router[method](href)`), so its NAME is
* the only static evidence; the argument resolving to a real screen is what
* makes the claim safe. Second group: the verb, when it came from a wrapper.
*/
export const NAV_METHOD = /(?:^|\.)(push|replace|navigate|dismissTo)$|^[a-z][A-Za-z]*(Push|Replace|Navigate)$/;
/** The verb a NAV_METHOD match names, lower-cased: `safePush` → `push`. */
export function navVerb(name: string): string | null {
const m = NAV_METHOD.exec(name);
if (!m) return null;
// A router method is already the verb (`dismissTo`); a wrapper's suffix
// (`safePush` → `Push`) is lower-cased to name the verb it stands in for.
return m[1] ?? m[2]!.toLowerCase();
}
/** Lines a single navigation call is allowed to span. */
const MAX_CALL_LINES = 12;
/** Placeholder for an interpolated `${…}` inside a template-literal href. */
const HOLE = '\u0000';
/** Index of the `)` matching the `(` at `open`, skipping string bodies; -1 if unbalanced. */
function matchParen(s: string, open: number): number {
let depth = 0;
for (let i = open; i < s.length; i++) {
const ch = s[i];
if (ch === '"' || ch === "'") {
const q = ch;
i++;
while (i < s.length && s[i] !== q) {
if (s[i] === '\\') i++;
i++;
}
continue;
}
if (ch === '`') {
i = skipTemplate(s, i);
continue;
}
if (ch === '(') depth++;
else if (ch === ')') {
depth--;
if (depth === 0) return i;
}
}
return -1;
}
/** Index of the closing backtick for the template starting at `open`. */
function skipTemplate(s: string, open: number): number {
let i = open + 1;
while (i < s.length) {
const ch = s[i];
if (ch === '\\') {
i += 2;
continue;
}
if (ch === '`') return i;
if (ch === '$' && s[i + 1] === '{') {
let depth = 0;
for (i = i + 1; i < s.length; i++) {
if (s[i] === '{') depth++;
else if (s[i] === '}') {
depth--;
if (depth === 0) break;
} else if (s[i] === '`') i = skipTemplate(s, i);
}
}
i++;
}
return s.length;
}
/** Index of the quote that closes the string literal opening at `at` (or the end of `s`). */
export function stringEnd(s: string, at: number): number {
const q = s[at];
if (q === '`') return skipTemplate(s, at);
for (let i = at + 1; i < s.length; i++) {
if (s[i] === '\\') i++;
else if (s[i] === q) return i;
}
return s.length;
}
/**
* A string literal (`'…'`, `"…"`, or a template) starting at `at`, as text
* with every `${…}` replaced by {@link HOLE}. Null when `at` is not a string.
*/
export function readStringAt(s: string, at: number): string | null {
const q = s[at];
if (q === '"' || q === "'") {
let out = '';
for (let i = at + 1; i < s.length; i++) {
const ch = s[i]!;
if (ch === '\\') {
out += s[i + 1] ?? '';
i++;
continue;
}
if (ch === q) return out;
out += ch;
}
return null;
}
if (q === '`') {
const end = skipTemplate(s, at);
let out = '';
for (let i = at + 1; i < end; i++) {
const ch = s[i]!;
if (ch === '\\') {
out += s[i + 1] ?? '';
i++;
continue;
}
if (ch === '$' && s[i + 1] === '{') {
let depth = 0;
for (i = i + 1; i < end; i++) {
if (s[i] === '{') depth++;
else if (s[i] === '}') {
depth--;
if (depth === 0) break;
}
}
out += HOLE;
continue;
}
out += ch;
}
return out;
}
return null;
}
export interface HrefLiteral {
/** The path part, `${…}` holes kept as {@link HOLE}; query and hash removed. */
path: string;
/** The literal as written, holes rendered as `${…}` — for the edge metadata. */
display: string;
/** The other arm of a `cond ? a : b` argument, when the argument was one. */
alternate?: HrefLiteral;
}
/** Index of the first `ch` at bracket depth 0 and outside strings, or -1. */
function indexAtDepth0(s: string, ch: string, from: number): number {
let depth = 0;
for (let i = from; i < s.length; i++) {
const c = s[i];
if (c === '"' || c === "'") {
const q = c;
i++;
while (i < s.length && s[i] !== q) {
if (s[i] === '\\') i++;
i++;
}
continue;
}
if (c === '`') {
i = skipTemplate(s, i);
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
else if (c === '?' && (s[i + 1] === '.' || s[i + 1] === '?')) i++; // `?.` / `??`
else if (depth === 0 && c === ch) return i;
}
return -1;
}
export function toHref(literal: string | null): HrefLiteral | null {
if (literal === null || literal.length === 0) return null;
const cut = literal.search(/[?#]/);
const path = cut < 0 ? literal : literal.slice(0, cut);
if (path.length === 0) return null;
return { path, display: literal.split(HOLE).join('${…}') };
}
/**
* The href in one argument expression: a string, a template, an `Href` object
* with a literal `pathname`, or a conditional whose two arms are each one of
* those (`cond ? \`/x?id=${id}\` : '/x'`). Anything else is not static.
*/
function parseHrefExpression(expr: string): HrefLiteral | null {
// `expr as any` / `expr satisfies Href` — a cast says nothing about the value.
let args = expr.trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, '');
// `(a ? b : c)` — unwrap one layer of grouping parens.
while (args.startsWith('(') && matchParen(args, 0) === args.length - 1) {
args = args.slice(1, -1).trim().replace(/\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/, '');
}
if (args.length === 0) return null;
const q = indexAtDepth0(args, '?', 0);
if (q > 0) {
const colon = indexAtDepth0(args, ':', q + 1);
if (colon > q) {
const yes = parseHrefExpression(args.slice(q + 1, colon));
const no = parseHrefExpression(args.slice(colon + 1));
if (yes && no) return { ...yes, alternate: no };
return null;
}
}
if (args[0] === '{') {
const key = /\bpathname\s*:\s*/.exec(args);
return key ? toHref(readStringAt(args, key.index + key[0].length)) : null;
}
return toHref(readStringAt(args, 0));
}
/**
* The destination of the navigation call at (`line`, `column`) in `lines`.
*
* Handles the three shapes Expo Router accepts: a string, a template literal
* (static prefix kept, interpolations become holes), and an `Href` object
* whose `pathname` is one of those. Anything else — a variable, a call, a
* spread — is not a literal and returns null.
*/
export function readHrefArgument(
lines: readonly string[],
line: number,
column: number,
method: string
): HrefLiteral | null {
const arg = firstArgumentText(lines, line, column, method);
return arg === null ? null : parseHrefExpression(arg);
}
/** The source text of the navigation call's first argument, or null when there is no call there. */
function firstArgumentText(
lines: readonly string[],
line: number,
column: number,
method: string
): string | null {
const first = line - 1;
if (first < 0 || first >= lines.length) return null;
const text = lines.slice(first, first + MAX_CALL_LINES).join('\n');
const nameAt = text.indexOf(method, Math.max(0, column));
if (nameAt < 0) return null;
let open = nameAt + method.length;
while (open < text.length && /\s/.test(text[open]!)) open++;
if (text[open] !== '(') return null;
const close = matchParen(text, open);
const args = text.slice(open + 1, close < 0 ? undefined : close);
// Only the first argument: a `,` at depth 0 ends it (`push(href, opts)`).
const comma = indexAtDepth0(args, ',', 0);
return comma < 0 ? args : args.slice(0, comma);
}
const CAST_TAIL = /\s+(?:as|satisfies)\s+[\w$.<>[\]|&\s]+$/;
/** A line that continues the previous statement rather than starting a new one. */
const CONTINUATION = /^[?:.)\]}`'"+&|]/;
/**
* The href a navigation call reaches through a local variable:
*
* const href = params.length ? `/barcode-scan?${q}` : '/barcode-scan'
* router.navigate(href as any)
*
* When the argument is a bare identifier, its most recent `const`/`let`
* declaration between `enclosingStart` and the call is read and its
* initializer parsed exactly like a literal argument would be. A reassignment
* in between, or an initializer that is not static, yields null.
*/
export function readHrefViaLocal(
lines: readonly string[],
line: number,
column: number,
method: string,
enclosingStart: number
): HrefLiteral | null {
const arg = firstArgumentText(lines, line, column, method);
if (arg === null) return null;
const ident = arg.trim().replace(CAST_TAIL, '');
if (!/^[A-Za-z_$][\w$]*$/.test(ident)) return null;
const decl = new RegExp(`\\b(?:const|let|var)\\s+${ident.replace(/\$/g, '\\$')}\\s*(?::[^=]*?)?=(?!=)`);
const reassign = new RegExp(`(?:^|[^.\\w$])${ident.replace(/\$/g, '\\$')}\\s*=(?!=)`);
const from = Math.max(0, enclosingStart - 1);
for (let i = line - 2; i >= from; i--) {
const text = lines[i]!;
const m = decl.exec(text);
if (!m) {
// The variable assigned again between declaration and use — not static.
if (reassign.test(text)) return null;
continue;
}
// The initializer: the rest of this line, plus continuation lines.
let init = text.slice(m.index + m[0].length);
for (let j = i + 1; j < line - 1 && j < i + MAX_CALL_LINES; j++) {
const next = lines[j]!;
if (!CONTINUATION.test(next.trimStart()) && balanced(init)) break;
init += '\n' + next;
}
return parseHrefExpression(init);
}
return null;
}
/** True when every bracket and template opened in `s` is closed. */
function balanced(s: string): boolean {
let depth = 0;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (c === '"' || c === "'" || c === '`') {
const end = stringEnd(s, i);
if (end >= s.length) return false;
i = end;
continue;
}
if (c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
}
return depth <= 0;
}
// =============================================================================
// Route table
// =============================================================================
interface RouteEntry {
node: Node;
segs: string[];
}
export interface RouteTable {
/** Identity of the node array the table was built from — rebuild when it changes. */
source: readonly Node[];
exact: Map<string, Node>;
dynamic: RouteEntry[];
}
const tables = new Map<string, RouteTable>();
export function routeTable(context: ResolutionContext): RouteTable {
const all = context.getNodesByKind('route');
const key = context.getProjectRoot();
const cached = tables.get(key);
if (cached && cached.source === all) return cached;
const exact = new Map<string, Node>();
const dynamic: RouteEntry[] = [];
for (const node of all) {
// Only this framework's own route nodes: the ones whose name IS the path
// derived from their file. Express/SvelteKit routes in the same project
// name themselves differently and never match.
if (routePathForFile(node.filePath) !== node.name) continue;
exact.set(node.name, node);
if (node.name.includes('[')) dynamic.push({ node, segs: node.name.split('/').slice(1) });
}
const table = { source: all, exact, dynamic };
tables.set(key, table);
return table;
}
/**
* Normalize an href path to the form route names use: leading `/`, no
* trailing `/`, no `(group)` segments, holes as a whole-segment `*`.
* A relative href is resolved against the screen the call sits in; from a
* non-screen file it has no base and returns null.
*/
export function normalizeHrefPath(path: string, fromFile: string): string[] | null {
let p = path;
if (!p.startsWith('/')) {
// Expo resolves `./x` and bare `x` against the DIRECTORY of the screen file
// the call sits in — `/capture` for both `capture/index.tsx` and
// `capture/review.tsx` — which is the route of that directory's index.
if (routePathForFile(fromFile) === null) return null;
const dirRoute = routePathForFile(fromFile.slice(0, fromFile.lastIndexOf('/') + 1) + 'index.tsx');
if (dirRoute === null) return null;
const parent = dirRoute.split('/').slice(1);
if (p.startsWith('./')) p = '/' + [...parent, p.slice(2)].join('/');
else if (p.startsWith('../')) {
const up: string[] = [...parent];
while (p.startsWith('../')) {
up.pop();
p = p.slice(3);
}
p = '/' + [...up, p].join('/');
} else p = '/' + [...parent, p].join('/');
}
const segs = p
.split('/')
.slice(1)
.filter((s) => s.length > 0 && !(s.startsWith('(') && s.endsWith(')')))
.map((s) => (s.includes(HOLE) ? '*' : decodeSegment(s)));
return segs;
}
function decodeSegment(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return s;
}
}
/**
* The single route the href segments denote, or null when none or several do.
*
* Scored so that a literal segment beats a wildcard for the same slot and a
* `[param]` slot accepts either; a tie between two routes is ambiguity, and
* ambiguity is a null, not a coin flip.
*/
export function matchRoute(segs: string[], table: RouteTable): Node | null {
const exact = table.exact.get('/' + segs.join('/'));
if (exact) return exact;
let best: { node: Node; score: number } | null = null;
let tied = false;
for (const entry of table.dynamic) {
const score = scoreMatch(segs, entry.segs);
if (score === null) continue;
if (best === null || score > best.score) {
best = { node: entry.node, score };
tied = false;
} else if (score === best.score) tied = true;
}
return best && !tied ? best.node : null;
}
function scoreMatch(href: string[], route: string[]): number | null {
let score = 0;
let i = 0;
for (let r = 0; r < route.length; r++) {
const seg = route[r]!;
if (seg.startsWith('[...') && seg.endsWith(']')) {
// Catch-all: needs at least one segment and takes the rest.
if (i >= href.length) return null;
score += href.length - i;
i = href.length;
continue;
}
if (i >= href.length) return null;
const h = href[i]!;
if (seg.startsWith('[') && seg.endsWith(']')) score += 2;
else if (h === seg) score += 3;
else if (h === '*') score += 1;
else return null;
i++;
}
return i === href.length ? score : null;
}
// =============================================================================
// The resolver
// =============================================================================
export const expoRouterResolver: FrameworkResolver = {
name: 'expo-router',
languages: [...ROUTE_LANGUAGES],
detect(context: ResolutionContext): boolean {
const packageJson = context.readFile('package.json');
if (packageJson) {
try {
const pkg = JSON.parse(packageJson);
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps['expo-router']) return true;
} catch {
// Not JSON — fall through to the layout check.
}
}
const files = context.getAllFiles();
const hasLayout = files.some((f) => /(?:^|\/)(?:src\/)?app\/_layout\.(?:tsx|jsx|ts|js)$/.test(f));
const hasExpoConfig = files.some((f) => /^app\.(?:json|config\.(?:js|ts))$/.test(f));
return hasLayout && hasExpoConfig;
},
claimsReference(name: string): boolean {
return NAV_METHOD.test(name);
},
extract(filePath: string, content: string) {
const routePath = routePathForFile(filePath);
if (routePath === null) return { nodes: [], references: [] };
const language = languageForFile(filePath);
const node: Node = {
id: `route:${filePath}:${routePath}`,
kind: 'route',
name: routePath,
qualifiedName: `${filePath}::route:${routePath}`,
filePath,
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
language,
isExported: true,
updatedAt: Date.now(),
};
const references: UnresolvedRef[] = [];
const stripped = stripCommentsForRegex(content, 'typescript');
const screen = defaultExportName(stripped);
if (screen) {
references.push({
fromNodeId: node.id,
referenceName: screen.name,
referenceKind: 'calls',
line: stripped.slice(0, screen.index).split('\n').length,
column: 0,
filePath,
language,
candidates: [screen.name],
});
}
return { nodes: [node], references };
},
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
if (ref.referenceKind !== 'calls') return null;
const method = navVerb(ref.referenceName);
if (!method) return null;
if (!ROUTE_LANGUAGES.includes(ref.language)) return null;
// The name to find on the line: `navigate` in `router.navigate(`, or the
// wrapper's own name in `safePush(`.
const callee = ref.referenceName.slice(ref.referenceName.lastIndexOf('.') + 1);
const lines =
context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? null;
if (!lines) return null;
let href = readHrefArgument(lines, ref.line, ref.column, callee);
if (!href) {
const enclosing = context.getNodeById?.(ref.fromNodeId);
const start = enclosing && enclosing.filePath === ref.filePath ? enclosing.startLine : Math.max(1, ref.line - 40);
href = readHrefViaLocal(lines, ref.line, ref.column, callee, start);
}
if (!href) return null;
const table = routeTable(context);
const segs = normalizeHrefPath(href.path, ref.filePath);
if (segs === null) return null;
const target = matchRoute(segs, table);
if (!target) return null;
if (href.alternate) {
// `cond ? a : b` — one edge can carry one destination. Both arms
// reaching the same screen (a query-string difference, typically) is a
// confident bind; two different screens is a fork this ref can't record.
const altSegs = normalizeHrefPath(href.alternate.path, ref.filePath);
if (altSegs === null || matchRoute(altSegs, table)?.id !== target.id) return null;
}
return {
original: ref,
targetNodeId: target.id,
confidence: 0.95,
resolvedBy: 'framework',
edgeKind: 'navigates',
metadata: { href: href.display, navMethod: method, ...(callee !== method ? { via: callee } : {}) },
};
},
};
+4
View File
@@ -26,6 +26,7 @@ import { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
import { swiftObjcBridgeResolver } from './swift-objc';
import { reactNativeBridgeResolver } from './react-native';
import { expoModulesResolver } from './expo-modules';
import { expoRouterResolver } from './expo-router';
import { fabricViewResolver } from './fabric';
import { cicsResolver } from './cics';
import { terraformResolver } from './terraform';
@@ -70,6 +71,8 @@ const FRAMEWORK_RESOLVERS: FrameworkResolver[] = [
reactNativeBridgeResolver,
// Expo Modules — Function/AsyncFunction/Property DSL on Swift/Kotlin
expoModulesResolver,
// Expo Router — `app/` screen files → route nodes; `router.push('/x')` → navigates edges
expoRouterResolver,
// React Native Fabric / Codegen view components — TS spec → component nodes
fabricViewResolver,
// CICS pseudo-conversational TRANSID hops (COBOL)
@@ -151,4 +154,5 @@ export { swiftUIResolver, uikitResolver, vaporResolver } from './swift';
export { swiftObjcBridgeResolver } from './swift-objc';
export { reactNativeBridgeResolver } from './react-native';
export { expoModulesResolver } from './expo-modules';
export { expoRouterResolver } from './expo-router';
export { fabricViewResolver } from './fabric';
+3 -1
View File
@@ -1068,7 +1068,8 @@ export class ReferenceResolver {
// traverse `references`, so registration sites surface with no
// graph-layer changes.
let kind: Edge['kind'] =
ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind;
ref.edgeKind ??
(ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind);
// Promote "extends" to "implements" when a class/struct targets an interface
if (kind === 'extends') {
@@ -1103,6 +1104,7 @@ export class ReferenceResolver {
line: ref.original.line,
column: ref.original.column,
metadata: {
...(ref.metadata ?? {}),
confidence: ref.confidence,
resolvedBy: ref.resolvedBy,
// The ORIGINAL reference text (and kind, when edge-kind promotion
+10 -1
View File
@@ -4,7 +4,7 @@
* Types for the reference resolution system.
*/
import { Language, Node, ReferenceKind } from '../types';
import { EdgeKind, Language, Node, ReferenceKind } from '../types';
/**
* An unresolved reference from extraction
@@ -43,6 +43,15 @@ export interface ResolvedRef {
confidence: number;
/** How it was resolved */
resolvedBy: 'exact-match' | 'import' | 'qualified-name' | 'framework' | 'fuzzy' | 'instance-method' | 'file-path' | 'function-ref';
/**
* Edge kind the edge should carry when it is NOT the ref's own kind a
* framework that turns a `calls` ref into a `navigates` edge, for example.
* The original kind is still recorded on the edge as `metadata.refKind`, so
* re-resolution after a target is removed reconstructs the ref faithfully.
*/
edgeKind?: EdgeKind;
/** Extra metadata the strategy wants persisted on the edge (`href`, …). */
metadata?: Record<string, unknown>;
}
/**
+1
View File
@@ -67,6 +67,7 @@ export const EDGE_KINDS = [
'instantiates', // Creates instance of class
'overrides', // Method overrides parent method
'decorates', // Decorator applied to symbol
'navigates', // Navigates to a screen/route (Expo Router `router.push('/x')`)
] as const;
export type EdgeKind = (typeof EDGE_KINDS)[number];
+34 -2
View File
@@ -34,7 +34,8 @@
*/
import type CodeGraph from '../../index';
import type { Edge, Node } from '../../types';
import type { Edge, Language, Node } from '../../types';
import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
import {
resolveNamedSymbolFlow,
normalizeToken,
@@ -344,6 +345,8 @@ function toFlowEdge(edge: Edge, upward: boolean): WireFlowEdge {
interface FileCache {
lines: string[] | null;
language: string;
/** Absolute path, when the file was read — what branch-guard parsing needs. */
abs?: string;
drift: boolean;
reason?: string;
}
@@ -378,6 +381,7 @@ function loadFile(
entry = {
lines: splitLines(fs.readFileSync(absolute, 'utf-8')),
language: found.record.language,
abs: absolute,
drift: false,
};
} catch {
@@ -444,6 +448,23 @@ async function windowFor(
};
}
/** The branch label for `edge`'s call site in `siteNode`'s file, or ''. */
async function whenAt(
cg: CodeGraph,
projectRoot: string,
cache: Map<string, FileCache>,
siteNode: Node,
edge: Edge
): Promise<string> {
if (!edge.line || !supportsBranchGuards(siteNode.language)) return '';
const file = loadFile(cg, projectRoot, cache, siteNode.filePath);
if (!file || file.drift || !file.abs) return '';
const site = { line: edge.line, column: typeof edge.column === 'number' ? edge.column : null };
const guards = await guardsForFile(file.abs, file.language as Language, [site]);
const g = guards.get(siteKey(site));
return g ? guardLabel(g) : '';
}
// =============================================================================
// Building the flows
// =============================================================================
@@ -494,9 +515,20 @@ async function toWireFlow(
backwards: true,
};
}
// The connector's condition: the call site is in the caller's file — the
// previous card going down, this card itself when the reader stepped up.
const wireEdge = step.edge === null ? null : toFlowEdge(step.edge, step.upward);
if (wireEdge && step.edge?.line) {
const siteNode = step.upward ? step.node : previous?.node;
const when = siteNode ? await whenAt(cg, projectRoot, cache, siteNode, step.edge) : '';
if (when) {
wireEdge.when = when;
wireEdge.label = `${wireEdge.label} · when ${when}`;
}
}
hops.push({
node: toNodeRef(step.node),
edge: step.edge === null ? null : toFlowEdge(step.edge, step.upward),
edge: wireEdge,
callRef,
source: await windowFor(
cg,
+12 -4
View File
@@ -56,6 +56,7 @@ import { buildRoutes } from './routes';
import { buildEntryPoints } from './entrypoints';
import { buildNodeRefs } from './nodes';
import { buildMap } from './map';
import { buildScreens } from './screens';
import { buildDeadCode } from './deadcode';
import { buildFlow } from './flow';
import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
@@ -196,6 +197,11 @@ const API_INDEX = {
description: 'The repository at module granularity: modules, cross-module links, cycles.',
params: ['root', 'depth'],
},
{
path: '/api/screens',
description: 'The app as screens and the transitions between them, each with the conditions it runs under.',
params: [],
},
{
path: '/api/flow',
description: 'The call path between symbols: one hop per card, opened at the calling line.',
@@ -261,6 +267,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method);
case '/api/map':
return ok(res, buildMap(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
case '/api/screens':
return ok(res, await buildScreens(session.acquire(), ctx.projectRoot), ctx.method);
case '/api/deadcode':
return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
case '/api/entrypoints':
@@ -278,7 +286,7 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
// the socket open, so it never goes through `ok()`.
return events.subscribe(req, res, ctx.method);
default:
return dispatchPathRoutes(route, res, ctx, session);
return await dispatchPathRoutes(route, res, ctx, session);
}
} catch (err) {
// A refusal from the read chokepoint is a 403 with the reason attached —
@@ -352,16 +360,16 @@ async function dispatchWrite(
* straight to an exact lookup, and anything that names nothing is a 404. File
* paths go through the read chokepoint before anything is opened.
*/
function dispatchPathRoutes(
async function dispatchPathRoutes(
route: string,
res: Parameters<UiApiHandler>[1],
ctx: UiRequestContext,
session: GraphSession
): boolean {
): Promise<boolean> {
const nodeId = suffixAfter(route, '/api/node/');
if (nodeId !== null) {
if (nodeId === '') throw badRequest('No symbol id was given. Use /api/node/<id>.');
return ok(res, buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
return ok(res, await buildNode(session.acquire(), ctx.projectRoot, nodeId), ctx.method);
}
// Before `/api/file/`: that prefix is not a prefix of this route, but keeping
+13 -3
View File
@@ -53,6 +53,7 @@ export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
'instantiates',
'extends',
'implements',
'navigates',
];
/**
@@ -61,7 +62,7 @@ export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
* A `references` edge to a type is real traffic but "Config → Config" is not
* an interesting row; calls and imports are what a reader wants named.
*/
const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates'];
const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates', 'navigates'];
/** Symbol pairs kept per link — the tooltip shows four (design spec §3.6). */
const TOP_PAIRS_PER_LINK = 4;
@@ -261,12 +262,17 @@ export function pickDefaultRoot(
if (total === 0) return '';
let best = '';
let bestSymbols = 0;
let second = 0;
for (const [dir, symbols] of [...byDir].sort((a, b) => a[0].localeCompare(b[0]))) {
if (symbols > bestSymbols) {
second = bestSymbols;
best = dir;
bestSymbols = symbols;
}
} else if (symbols > second) second = symbols;
}
// A second root holding a fifth of the code (a React Native app's `ios/`
// beside its `src/`) belongs on the picture: map the whole project.
if (second * 5 >= total) return '';
return bestSymbols * 2 > total ? best : '';
}
@@ -310,7 +316,7 @@ export function parseMapQuery(query: URLSearchParams): { root: string | null; de
export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
const started = Date.now();
const { root: requestedRoot, depth } = parseMapQuery(query);
let { root: requestedRoot, depth } = parseMapQuery(query);
const fileRecords = cg.getFiles().map((file) => {
const path = toPosixPath(file.path);
@@ -324,6 +330,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
});
const root = requestedRoot ?? pickDefaultRoot(fileRecords);
// Left to choose, and choosing the whole project (two substantial roots):
// one level deeper, so the boxes are `src/app` and `ios/CaptureView`, not
// `src` and `ios`.
if (requestedRoot === null && root === '' && !query.has('depth')) depth = 2;
const stats = cg.getStats();
const key = [
projectRoot,
+9 -1
View File
@@ -26,6 +26,7 @@ import { isTestFile } from '../../search/query-utils';
import { buildHierarchy, type WireOverride } from './hierarchy';
import { notFound } from './respond';
import { findIndexedFile, hasDriftedOnDisk } from './source';
import { annotateWhen } from './when';
import {
BLAST_DEPTH,
CALLER_EDGE_KINDS,
@@ -73,7 +74,7 @@ export interface WireMember extends WireNodeRef {
overrides?: WireOverride;
}
export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): unknown {
export async function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): Promise<unknown> {
const node = cg.getNode(nodeId);
if (!node) {
throw notFound(
@@ -146,6 +147,13 @@ export function buildNode(cg: CodeGraph, projectRoot: string, nodeId: string): u
const shownIncoming = incomingGroups.slice(0, MAX_INCOMING_GROUPS);
const shownOutgoing = outgoingGroups.slice(0, MAX_OUTGOING_GROUPS);
// Branch conditions per call site: the right rail's sites are all in this
// file; the left rail's are in each caller's own file.
await annotateWhen(cg, projectRoot, [
{ file: focalFile, edges: shownOutgoing.flatMap((r) => r.edges) },
...shownIncoming.map((r) => ({ file: r.node.file, edges: r.edges })),
]);
// Fan-in for the rail pills ("hub · N"), for the rows actually returned —
// one query, not one per row.
const fanInOf = cg.getFanIn([
+500
View File
@@ -0,0 +1,500 @@
/**
* `GET /api/screens` the app as a reader experiences it: screens, and the
* transitions between them, each labelled with what has to be true for it to
* happen.
*
* The graph already holds the pieces: a `route` node per screen file (Expo
* Router, and any framework that binds a route to the component that renders
* it), and a `navigates` edge from the function that pushes a path to the
* route it names. What a reader wants is neither of those nodes it is
* "from the Home screen, tapping an object card opens Object Detail, but only
* for a collected object". That sentence is three hops away from the edge:
*
* HomeScreen renders ItemsGrid renders ItemCard calls openObjectDetail navigates /object-detail
*
* So for every `navigates` edge this walks BACKWARDS from its source through
* `calls` edges (the JSX-render synthesizer's edges among them) until it
* reaches a component that a route renders. That component's screen is where
* the transition starts; the nodes passed on the way are the `via` chain, and
* the branch conditions at each call site along it (`graph/branch-guards.ts`)
* are joined into the link's `when`. A navigation whose walk reaches no screen
* within the hop cap a store action, a service that runs after login is
* kept as an `origin` rather than dropped: it is a real transition with a real
* trigger, just not a screen.
*
* Read from the graph at request time, never cached: the `when` labels are
* read from the source as it stands. Seventy-odd transitions and a few
* hundred guarded call sites resolve in tens of milliseconds.
*/
import type CodeGraph from '../../index';
import type { Edge, Language, Node } from '../../types';
import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
import { resolveProjectFile } from '../security';
import { findIndexedFile, hasDriftedOnDisk } from './source';
import { toNodeRef, type WireNodeRef } from './wire';
// =============================================================================
// Wire shapes
// =============================================================================
export interface WireScreen {
/** The route node's id — what a link's `from`/`to` name. */
id: string;
/** The screen's path: `/object-detail`, `/item/[id]`. */
path: string;
file: string;
line: number;
/** The component the route renders, when the graph bound one. */
component: WireNodeRef | null;
/** Transitions into and out of this screen. */
incoming: number;
outgoing: number;
}
/**
* A navigation whose start is not one screen: a function no screen reaches
* (a store action after login), or a component so many screens render (a
* top bar) that attributing its navigation to each of them would draw the
* same three arrows from every box.
*/
export interface WireScreenOrigin {
id: string;
node: WireNodeRef;
outgoing: number;
/** For shared chrome: how many screens render it. */
sharedBy?: number;
}
export interface WireScreenSite {
file: string;
line: number;
/** The href as written at the call, `${…}` for interpolations. */
href: string;
/** `push`, `replace`, `navigate`, or `return` for a helper's return value. */
method: string;
/** Branch conditions at this site alone. */
when: string;
}
export interface WireScreenLink {
id: string;
/** A screen id, or an origin id. */
from: string;
/** Always a screen id. */
to: string;
/** True when `from` is an origin, not a screen. */
fromOrigin: boolean;
/**
* The symbols the transition passes through, from just below the screen's
* component down to the one that holds the navigation call. Empty when the
* screen's own component navigates.
*/
via: WireNodeRef[];
/** Conditions along the whole chain, joined; '' when unconditional. */
when: string;
/** Every call site behind this link (same screen, same chain end). */
sites: WireScreenSite[];
/**
* The destination was inferred, not written at the call: it came back from
* a helper's return value. (A synthesized render hop on the way every
* parent child component step is one does not count: that would dash
* nearly every arrow.)
*/
synthesized: boolean;
}
export interface WireScreensPayload {
/** False when the graph holds no screen navigation at all. */
routed: boolean;
/** The route named `/`, when there is one. */
entry: string | null;
screens: WireScreen[];
origins: WireScreenOrigin[];
links: WireScreenLink[];
/** Navigations dropped because the backwards walk hit a cap. */
dropped: number;
index: { lastIndexedAt: number | null; edges: number; files: number };
timing: { elapsedMs: number };
}
// =============================================================================
// Caps
// =============================================================================
/** Hops walked back from a navigation call before giving up on a screen. */
const MAX_DEPTH = 7;
/** Callers expanded per node — a hub (`useToast`) is a dead end, not a path. */
const MAX_CALLERS_PER_NODE = 30;
/** Nodes visited per navigation. */
const MAX_VISITED = 800;
/** Call sites labelled with conditions per request. */
const MAX_WHEN_SITES = 600;
/**
* Edges walked backwards from a navigation call. `contains` because a handler
* declared inside a screen component (`function handleContinue() {…}` in the
* body) is reached from the component by containment, not by a call; a
* `references` edge is followed only when it passes the function as a value
* (`onPress={handleContinue}`), never for a type mention.
*/
const WALK_KINDS: Edge['kind'][] = ['calls', 'instantiates', 'contains', 'references'];
/** A component rendered by at least this many screens is chrome, not a screen's own behaviour. */
const SHARED_CHROME_MIN = 3;
// =============================================================================
// The endpoint
// =============================================================================
export async function buildScreens(cg: CodeGraph, projectRoot: string): Promise<WireScreensPayload> {
const started = Date.now();
const stats = cg.getStats();
const index = { lastIndexedAt: cg.getLastIndexedAt() ?? null, edges: stats.edgeCount, files: stats.fileCount };
const routes = cg.getNodesByKind('route');
const routeIds = routes.map((r) => r.id);
const navEdges = routeIds.length === 0 ? [] : cg.getIncomingEdgesTo(routeIds, ['navigates']);
if (navEdges.length === 0) {
return {
routed: false,
entry: null,
screens: [],
origins: [],
links: [],
dropped: 0,
index,
timing: { elapsedMs: Date.now() - started },
};
}
// Route → the component it renders; component → its route.
const routeById = new Map(routes.map((r) => [r.id, r]));
const routeByFile = new Map(routes.map((r) => [r.filePath, r.id]));
const renders = cg.getOutgoingEdgesFrom(routeIds, ['calls', 'instantiates']);
const componentIds = new Set(renders.map((e) => e.target));
const nodesById = cg.getNodesByIds([...componentIds, ...navEdges.map((e) => e.source)]);
const componentOf = new Map<string, Node>();
const screenOfComponent = new Map<string, string>();
for (const edge of renders) {
const component = nodesById.get(edge.target);
if (!component || componentOf.has(edge.source)) continue;
componentOf.set(edge.source, component);
screenOfComponent.set(component.id, edge.source);
}
const whenAt = makeWhenReader(cg, projectRoot);
const links = new Map<string, WireScreenLink>();
const origins = new Map<string, WireScreenOrigin>();
const counts = new Map<string, { incoming: number; outgoing: number }>();
const bump = (id: string, key: 'incoming' | 'outgoing') => {
const c = counts.get(id) ?? { incoming: 0, outgoing: 0 };
c[key]++;
counts.set(id, c);
};
let dropped = 0;
for (const nav of navEdges) {
const holder = nodesById.get(nav.source);
const target = routeById.get(nav.target);
if (!holder || !target) continue;
const meta = (nav.metadata ?? {}) as Record<string, unknown>;
const site: WireScreenSite = {
file: toPosix(holder.filePath),
line: nav.line ?? holder.startLine,
href: typeof meta.href === 'string' ? meta.href : target.name,
method: nav.provenance === 'heuristic' ? 'return' : typeof meta.navMethod === 'string' ? meta.navMethod : 'push',
when: nav.provenance === 'heuristic' ? '' : await whenAt(holder, nav),
};
let starts = await attribute(cg, holder, screenOfComponent, routeByFile, nodesById);
if (starts === null) {
dropped++;
continue;
}
starts = collapseSharedChrome(starts, origins);
const attributions =
starts.length > 0
? starts
: [{ screenId: null as string | null, path: [{ node: holder, edge: null }] as Array<{ node: Node; edge: Edge | null }> }];
for (const start of attributions) {
let fromId: string;
let fromOrigin = false;
if (start.screenId !== null) fromId = start.screenId;
else {
// The origin is the chain's head: the holder itself, or the shared
// component the chain was collapsed onto.
const head = start.path[0]!.node;
fromId = head.id;
fromOrigin = true;
if (!origins.has(head.id)) origins.set(head.id, { id: head.id, node: toNodeRef(head), outgoing: 0 });
}
// `path` is [screen component, …, holder]; `path[i].edge` is the call
// from `path[i-1]` into `path[i]`, so its site is in `path[i-1]`'s file.
// The component itself is not "via" — it IS the screen.
const via = start.path.slice(1).map((h) => toNodeRef(h.node));
const whens: string[] = [];
const synthesized = nav.provenance === 'heuristic';
for (let i = 1; i < start.path.length; i++) {
const edge = start.path[i]!.edge;
if (!edge) continue;
const w = await whenAt(start.path[i - 1]!.node, edge);
if (w && !whens.includes(w)) whens.push(w);
}
if (site.when && !whens.includes(site.when)) whens.push(site.when);
const viaKey = via.map((v) => v.id).join('>');
if (fromOrigin && start.path[0]!.node.id !== holder.id) {
// A collapsed chain: the origin's own name is not "via".
}
const id = `${fromId}${target.id}${viaKey}`;
const existing = links.get(id);
if (existing) {
existing.sites.push(site);
const mine = whens.join(' && ');
if (mine !== existing.when) {
// `if (x) push(A) else push(A)`: the two arms together are "always".
if (complementary(mine, existing.when)) existing.when = '';
else if (mine && existing.when) existing.when = `${existing.when} || ${mine}`;
else if (!mine) existing.when = '';
}
continue;
}
links.set(id, {
id,
from: fromId,
to: target.id,
fromOrigin,
via,
when: whens.join(' && '),
sites: [site],
synthesized,
});
bump(target.id, 'incoming');
if (fromOrigin) origins.get(fromId)!.outgoing++;
else bump(fromId, 'outgoing');
}
}
const screens: WireScreen[] = routes
.map((route) => {
const component = componentOf.get(route.id) ?? null;
const c = counts.get(route.id) ?? { incoming: 0, outgoing: 0 };
return {
id: route.id,
path: route.name,
file: toPosix(route.filePath),
line: route.startLine,
component: component ? toNodeRef(component) : null,
incoming: c.incoming,
outgoing: c.outgoing,
};
})
.sort((a, b) => a.path.localeCompare(b.path));
const entry = screens.find((s) => s.path === '/')?.id ?? null;
const ordered = [...links.values()].sort((a, b) => a.id.localeCompare(b.id));
return {
routed: true,
entry,
screens,
origins: [...origins.values()].sort((a, b) => a.node.name.localeCompare(b.node.name)),
links: ordered,
dropped,
index,
timing: { elapsedMs: Date.now() - started },
};
}
// =============================================================================
// Attribution: which screen does this navigation start from?
// =============================================================================
interface Attribution {
screenId: string | null;
/** [screen component, …, holder], each with the edge that led INTO it from the previous. */
path: Array<{ node: Node; edge: Edge | null }>;
}
/**
* Every screen whose component reaches `holder` through calls, each with the
* shortest chain (breadth-first). `[]` when none does within the caps but the
* walk completed; `null` when the walk was cut short a hub so wide the
* answer would be a guess.
*/
async function attribute(
cg: CodeGraph,
holder: Node,
screenOfComponent: Map<string, string>,
routeByFile: Map<string, string>,
known: Map<string, Node>
): Promise<Attribution[] | null> {
// The holder IS a screen component: the transition starts on that screen.
const own = screenOfComponent.get(holder.id);
if (own) return [{ screenId: own, path: [{ node: holder, edge: null }] }];
const parent = new Map<string, { prev: string | null; edge: Edge | null }>();
parent.set(holder.id, { prev: null, edge: null });
const nodes = new Map<string, Node>([[holder.id, holder]]);
let frontier = [holder.id];
const found: Attribution[] = [];
let truncated = false;
for (let depth = 0; depth < MAX_DEPTH && frontier.length > 0; depth++) {
const incoming = cg.getIncomingEdgesTo(frontier, WALK_KINDS);
const byTarget = new Map<string, Edge[]>();
for (const e of incoming) {
if (e.kind === 'references' && (e.metadata as Record<string, unknown> | undefined)?.fnRef !== true) continue;
const list = byTarget.get(e.target) ?? [];
list.push(e);
byTarget.set(e.target, list);
}
const nextIds: string[] = [];
const wanted = new Set<string>();
for (const [, edges] of byTarget) {
if (edges.length > MAX_CALLERS_PER_NODE) {
truncated = true;
continue;
}
for (const e of edges) if (!parent.has(e.source)) wanted.add(e.source);
}
if (parent.size + wanted.size > MAX_VISITED) truncated = true;
const fetched = wanted.size === 0 ? new Map<string, Node>() : cg.getNodesByIds([...wanted]);
for (const [, edges] of byTarget) {
if (edges.length > MAX_CALLERS_PER_NODE) continue;
for (const e of edges) {
if (parent.has(e.source)) continue;
const caller = fetched.get(e.source) ?? known.get(e.source);
// A file's top level or a route node is not a place a user is.
if (!caller || caller.kind === 'file' || caller.kind === 'route') continue;
parent.set(e.source, { prev: e.target, edge: e });
nodes.set(e.source, caller);
const screen = screenOfComponent.get(caller.id);
if (screen) {
found.push({ screenId: screen, path: pathFrom(caller.id, parent, nodes) });
continue; // a screen is where the walk stops
}
nextIds.push(e.source);
if (parent.size >= MAX_VISITED) break;
}
}
frontier = nextIds;
if (parent.size >= MAX_VISITED) {
truncated = true;
break;
}
}
if (found.length > 0) return found;
// No screen component reached, but the chain passed through a screen's
// FILE: a component that file defines for itself (a wrapper the render
// synthesizer did not see through) belongs to that screen. Nearest first,
// so the holder's own file wins over a helper's.
for (const [id] of parent) {
const node = nodes.get(id);
const screen = node ? routeByFile.get(node.filePath) : undefined;
if (screen) return [{ screenId: screen, path: pathFrom(id, parent, nodes) }];
}
return truncated ? null : [];
}
/**
* Shared chrome: when the same first-hop component carries this navigation
* to {@link SHARED_CHROME_MIN} or more screens, those attributions collapse
* into ONE from that component, marked with how many screens render it. A top
* bar's "Account settings" link is one fact about the top bar, not twelve
* facts about twelve screens.
*/
function collapseSharedChrome(starts: Attribution[], origins: Map<string, WireScreenOrigin>): Attribution[] {
const byFirstHop = new Map<string, Attribution[]>();
for (const s of starts) {
if (s.screenId === null || s.path.length < 2) continue;
const key = s.path[1]!.node.id;
byFirstHop.set(key, [...(byFirstHop.get(key) ?? []), s]);
}
const out: Attribution[] = [];
const collapsed = new Set<Attribution>();
for (const [, group] of byFirstHop) {
const screens = new Set(group.map((g) => g.screenId));
if (screens.size < SHARED_CHROME_MIN) continue;
const head = group[0]!.path[1]!.node;
const existing = origins.get(head.id);
if (existing) existing.sharedBy = Math.max(existing.sharedBy ?? 0, screens.size);
else origins.set(head.id, { id: head.id, node: toNodeRef(head), outgoing: 0, sharedBy: screens.size });
// One attribution, headed by the shared component, chain continuing below it.
out.push({ screenId: null, path: group[0]!.path.slice(1) });
for (const g of group) collapsed.add(g);
}
for (const s of starts) if (!collapsed.has(s)) out.push(s);
return out;
}
/** The chain from `start` down to the holder, following `prev` links. */
function pathFrom(
start: string,
parent: Map<string, { prev: string | null; edge: Edge | null }>,
nodes: Map<string, Node>
): Array<{ node: Node; edge: Edge | null }> {
const out: Array<{ node: Node; edge: Edge | null }> = [];
let id: string | null = start;
let edgeInto: Edge | null = null;
while (id !== null) {
const node = nodes.get(id)!;
out.push({ node, edge: edgeInto });
const step: { prev: string | null; edge: Edge | null } = parent.get(id)!;
edgeInto = step.edge;
id = step.prev;
}
return out;
}
// =============================================================================
// Conditions
// =============================================================================
/** `x` and `!x`, or `a && x` and `a && !x`. */
function complementary(a: string, b: string): boolean {
if (!a || !b) return false;
const pa = a.split(' && ');
const pb = b.split(' && ');
if (pa.length !== pb.length) return false;
let flips = 0;
for (let i = 0; i < pa.length; i++) {
if (pa[i] === pb[i]) continue;
if (pa[i] === `!${pb[i]}` || pb[i] === `!${pa[i]}`) flips++;
else return false;
}
return flips === 1;
}
function makeWhenReader(cg: CodeGraph, projectRoot: string) {
const files = new Map<string, { abs: string; language: Language } | null>();
let sites = 0;
return async (caller: Node, edge: Edge): Promise<string> => {
if (!edge.line || sites >= MAX_WHEN_SITES || !supportsBranchGuards(caller.language)) return '';
const posix = toPosix(caller.filePath);
let file = files.get(posix);
if (file === undefined) {
file = null;
const found = findIndexedFile(cg, posix);
if (found && !hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) {
try {
file = { abs: resolveProjectFile(projectRoot, found.storedPath), language: found.record.language as Language };
} catch {
file = null;
}
}
files.set(posix, file);
}
if (!file) return '';
sites++;
const site = { line: edge.line, column: typeof edge.column === 'number' ? edge.column : null };
const g = (await guardsForFile(file.abs, file.language, [site])).get(siteKey(site));
return g ? guardLabel(g) : '';
};
}
function toPosix(p: string): string {
return p.replace(/\\/g, '/');
}
+75
View File
@@ -0,0 +1,75 @@
/**
* `when` on a wire edge the branch conditions its call site sits under,
* read from the source at request time (see `src/graph/branch-guards.ts`).
*
* The viewer groups a symbol's edges into relations; this annotates the edges
* of a set of relations in one pass, parsing each file once. Files that
* drifted since the index sync are skipped: the recorded line no longer
* reliably points at the call, and a label at the wrong line is worse than
* none. The pass is bounded so a hub with hundreds of callers cannot turn one
* Symbol view into a parse of the repository.
*/
import type CodeGraph from '../../index';
import type { Language } from '../../types';
import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
import { resolveProjectFile } from '../security';
import { findIndexedFile, hasDriftedOnDisk } from './source';
import type { WireEdge } from './wire';
/** Distinct files parsed per request, and sites labelled per request. */
const MAX_FILES = 24;
const MAX_SITES = 400;
/**
* Wall-clock allowance for the whole pass. The Symbol view answers in under
* 100 ms; batches are taken in order (the focal file first), and once the
* budget is spent the remaining rails simply carry no `when`. The parsed
* trees are cached, so the next view of the same neighbourhood is cheaper.
*/
const BUDGET_MS = 40;
export interface WhenBatch {
/** POSIX project-relative path of the file the call sites are in. */
file: string;
edges: WireEdge[];
}
export async function annotateWhen(cg: CodeGraph, projectRoot: string, batches: readonly WhenBatch[]): Promise<void> {
const byFile = new Map<string, WireEdge[]>();
for (const batch of batches) {
const bucket = byFile.get(batch.file);
if (bucket) bucket.push(...batch.edges);
else byFile.set(batch.file, [...batch.edges]);
}
let files = 0;
let sites = 0;
const started = Date.now();
for (const [file, edges] of byFile) {
if (files >= MAX_FILES || sites >= MAX_SITES) return;
if (files > 0 && Date.now() - started > BUDGET_MS) return;
const found = findIndexedFile(cg, file);
if (!found || !supportsBranchGuards(found.record.language)) continue;
if (hasDriftedOnDisk(projectRoot, found.storedPath, found.record)) continue;
let abs: string;
try {
abs = resolveProjectFile(projectRoot, found.storedPath);
} catch {
continue;
}
const withLine = edges.filter((e) => typeof e.line === 'number' && e.line > 0);
if (withLine.length === 0) continue;
files++;
sites += withLine.length;
const guards = await guardsForFile(
abs,
found.record.language as Language,
withLine.map((e) => ({ line: e.line!, column: typeof e.col === 'number' ? e.col : null }))
);
for (const edge of withLine) {
const g = guards.get(siteKey({ line: edge.line!, column: typeof edge.col === 'number' ? edge.col : null }));
const label = g ? guardLabel(g) : '';
if (label) edge.when = label;
}
}
}
+7
View File
@@ -181,6 +181,12 @@ export interface WireEdge {
via?: string;
registeredAt?: string;
valueRef?: boolean;
/**
* The conditions the call site runs under (`!isUploading && isCollected`),
* read from the source at request time see `graph/branch-guards.ts`.
* Absent when the site is unconditional or the language has no rules.
*/
when?: string;
}
export function toWireEdge(edge: Edge): WireEdge {
@@ -339,6 +345,7 @@ export const CALLER_EDGE_KINDS: ReadonlySet<EdgeKind> = new Set<EdgeKind>([
'references',
'imports',
'instantiates',
'navigates',
]);
export { rel as toPosixPath };