feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API

- Adds Expo Router integration with a new Screens view and a Steps API to surface screens and their transitions.
- Extends codegraph extraction/resolution to handle namespace objects, React hook bindings for handlers, and Swift RN bridge evidence; introduces per-site guard arguments and trigger metadata, enabling richer flow analysis across JS ↔ native boundaries.
- Introduces UI and data-model changes to represent conditions as words (WHEN/AND/OR/NOT), display per-site call arguments, and show what fires a site (triggers). Adds new utilities (ui/conditions.ts) and updates ScreensView and StepsView to render scenarios with multiple sites and “ways” counts.
- Implements site readers for WHEN/ARGS/TRIGGER, and wiring to expose steps via API endpoints (including /api/steps); enhances tests to cover namespace resolution, useCallback-driven handlers, and inline RN event listeners.
- Updates styling and templates to reflect the new wording, scenario rows, and per-site details, including NOT instead of leading negation strings and multi-way links.
- Documents and reflects changes in changelog and design docs to describe Expo Router integration and the Steps surface.
This commit is contained in:
Colby McHenry
2026-08-28 10:21:46 -05:00
parent 873f133c96
commit e288d7645b
16 changed files with 1028 additions and 127 deletions
+64 -13
View File
@@ -12,7 +12,15 @@
import type CodeGraph from '../../index';
import type { Language } from '../../types';
import { guardLabel, guardsForFile, siteKey, supportsBranchGuards } from '../../graph/branch-guards';
import {
callArgumentsForFile,
guardLabel,
guardsForFile,
siteKey,
supportsBranchGuards,
triggersForFile,
type SiteTrigger,
} from '../../graph/branch-guards';
import { resolveProjectFile } from '../security';
import { findIndexedFile, hasDriftedOnDisk } from './source';
import type { WireEdge } from './wire';
@@ -81,15 +89,25 @@ export async function annotateWhen(cg: CodeGraph, projectRoot: string, batches:
* files yield no label, and the count of sites labelled is bounded so a wide
* walk cannot turn one request into a parse of the repository.
*/
export function createWhenReader(
cg: CodeGraph,
projectRoot: string,
maxSites = 600
): (caller: { filePath: string; language: Language }, site: { line?: number; column?: number }) => Promise<string> {
export interface SiteReader {
/** The conditions the site runs under, joined; '' when unconditional or unreadable. */
when(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string>;
/** What the site passes, abbreviated (`'userEmail', values.email`); null when unreadable. '' for an empty list. */
args(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<string | null>;
/** What fires the site — the JSX prop, `on*` option or runs-later call it is written under; null when nothing binds it. */
trigger(caller: { filePath: string; language: Language }, site: { line?: number; column?: number }): Promise<SiteTrigger | null>;
}
/**
* Both readings of one call site — WHEN it runs and WITH WHAT — for the
* endpoints that walk chains (Screens, Steps). One file resolution and one
* parsed tree serve both; drifted files yield nothing; one site budget bounds
* the whole pass.
*/
export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites = 600): SiteReader {
const files = new Map<string, { abs: string; language: Language } | null>();
let sites = 0;
return async (caller, site): Promise<string> => {
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return '';
const resolve = (caller: { filePath: string; language: Language }): { abs: string; language: Language } | null => {
const posix = caller.filePath.replace(/\\/g, '/');
let file = files.get(posix);
if (file === undefined) {
@@ -104,10 +122,43 @@ export function createWhenReader(
}
files.set(posix, file);
}
if (!file) return '';
sites++;
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
const g = (await guardsForFile(file.abs, file.language, [key])).get(siteKey(key));
return g ? guardLabel(g) : '';
return file;
};
return {
async when(caller, site) {
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return '';
const file = resolve(caller);
if (!file) return '';
sites++;
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
const g = (await guardsForFile(file.abs, file.language, [key])).get(siteKey(key));
return g ? guardLabel(g) : '';
},
async args(caller, site) {
if (!site.line || sites >= maxSites || !supportsBranchGuards(caller.language)) return null;
const file = resolve(caller);
if (!file) return null;
sites++;
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
return (await callArgumentsForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? null;
},
async trigger(caller, site) {
// Not counted against the budget: the tree is already parsed for the
// site's guards, and a trigger lookup is a walk up from one node.
if (!site.line || !supportsBranchGuards(caller.language)) return null;
const file = resolve(caller);
if (!file) return null;
const key = { line: site.line, column: typeof site.column === 'number' ? site.column : null };
return (await triggersForFile(file.abs, file.language, [key])).get(siteKey(key)) ?? null;
},
};
}
/** The `when` half of {@link createSiteReader}, for callers that read nothing else. */
export function createWhenReader(
cg: CodeGraph,
projectRoot: string,
maxSites = 600
): (caller: { filePath: string; language: Language }, site: { line?: number; column?: number }) => Promise<string> {
return createSiteReader(cg, projectRoot, maxSites).when;
}