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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}) },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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} | ||||