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:
1 parent
ac9580544b
commit
70fd5fefc2
42 files changed
+4257
-33
No files matched your search
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user