From e288d7645baed7f1f784bd3af6fed102d1c4c65e Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Fri, 28 Aug 2026 10:21:46 -0500 Subject: [PATCH] feat(expo-router): add Expo Router support for Screens and navigations and introduce Steps API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- CHANGELOG.md | 4 +- __tests__/branch-guards.test.ts | 71 ++++- __tests__/ui-conditions.test.ts | 69 +++++ __tests__/ui-screens-model.test.ts | 2 +- __tests__/ui-steps-api.test.ts | 18 +- __tests__/ui-steps-model.test.ts | 2 +- docs/design/codegraph-ui-design-spec.md | 21 +- src/graph/branch-guards.ts | 358 +++++++++++++++++++++++- src/ui-server/api/screens.ts | 7 +- src/ui-server/api/steps.ts | 134 +++++++-- src/ui-server/api/when.ts | 77 ++++- ui/src/lib/conditions.ts | 188 +++++++++++++ ui/src/lib/screens-model.ts | 63 +---- ui/src/lib/wire.ts | 5 + ui/src/views/ScreensView.svelte | 58 +++- ui/src/views/StepsView.svelte | 78 ++++-- 16 files changed, 1028 insertions(+), 127 deletions(-) create mode 100644 __tests__/ui-conditions.test.ts create mode 100644 ui/src/lib/conditions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 10d717e..4f53b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features -- **A Steps tab in `codegraph ui` — what happens from here.** Pick a screen (or search any symbol and choose *What happens from here*) and the viewer draws everything it sets in motion as typed steps: the handlers wired to its taps and listeners, the calls that cross into native code, the native events that come back, the store actions it writes, and the calls that leave the app into the network, storage, the device or telemetry — one box per step, an arrow for every way one leads to the next, and on each arrow the condition under which it happens. The plumbing between two steps (hooks, helpers, the components in between) is folded into the arrow and listed in the side panel, exactly as the Screens tab folds a tap's chain into one transition. Any step is the next anchor, any link opens as a Flow strip, a cap the walk hit is announced on the step it hit it at, and the picture travels in the URL. React Native + Expo apps get the full picture today; any project gets handlers, stores and calls that leave the index. +- **A Steps tab in `codegraph ui` — what happens from here.** Pick a screen (or search any symbol and choose *What happens from here*) and the viewer draws everything it sets in motion as typed steps: the handlers wired to its taps and listeners, the calls that cross into native code, the native events that come back, the store actions it writes, and the calls that leave the app into the network, storage, the device or telemetry — one box per step, an arrow for every way one leads to the next, and on each arrow the condition under which it happens. The plumbing between two steps (hooks, helpers, the components in between) is folded into the arrow and listed in the side panel, exactly as the Screens tab folds a tap's chain into one transition — and every call the panel lists says what it passes, read from the source as written (`SecureStore.setItemAsync('userEmail', values.email)`, `axios.post('/auth/login', { email, password })`), so a step is not just *that* something was stored or sent but *what*. Any step is the next anchor, any link opens as a Flow strip, a cap the walk hit is announced on the step it hit it at, and the picture travels in the URL. React Native + Expo apps get the full picture today; any project gets handlers, stores and calls that leave the index. - **React Native apps: Swift native modules and their events connect end to end.** A JS call like `captureView.finalizeCaptureSession()` — where `captureView` is bound to `NativeModules.CaptureView` and the module is a Swift class exposed through an `RCT_EXTERN_MODULE` shim — now resolves to the Swift method itself instead of stopping at the constant, so `codegraph_explore`, the Flow strip and the Steps view follow the code into native. Native → JS events now also land on listeners written inline (`addListener('onZipComplete', (data) => { … })`), attributed to the component that registers them. Re-index after upgrading to pick the new edges up. @@ -30,6 +30,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **The Map covers a multi-root project.** A React Native app's `ios/` beside its `src/` — or any second root holding a fifth of the code — is now on the picture, one level deeper, instead of the map silently drawing only the larger root. +- **Conditions read as words, and each scenario gets its own row.** In the Screens and Steps tabs a condition is now written the way you would say it — `WHEN NOT (isUploadInProgress || elapsed < 5000) AND user?.organization_id`, the joining words set apart from the code — with the code inside each guard left as written, and a transition or step with several call sites (four early returns that each go home) is listed as four rows, the clauses they all share said once above them, instead of one string joined with `||`. A guard that is itself an either/or keeps its parentheses everywhere conditions are shown, including `codegraph_explore`'s Flow section. + - **Every call now says when it happens.** In `codegraph ui`, a symbol's callee and caller rails and the Flow strip's connectors carry the branch conditions the call site sits under — `when !isUploading && isCollected` — and `codegraph_explore`'s Flow section prints the same on each hop (`↓ calls (when isCollected)`). The conditions come from the `if` / `else` / ternary / `switch` / `&&` branches around the call, the early returns before it (`if (busy) return` reads as `!busy`), and Swift's `guard`; an inline callback inherits the conditions of the place it is defined. Read from the source as it is now, never stored: nothing about your index changes. TypeScript, JavaScript and Swift today. - **Expo Router apps: screens and navigation are in the graph.** Every screen file under `app/` (or `src/app/`) is now a route node named by its path — `/object-detail`, `/item/[id]`, with `(group)` folders stripped — linked to the component it renders. Calls like `router.push('/object-detail?…')`, `router.navigate({ pathname: '/item/[id]', params })`, template-literal hrefs, an href held in a local `const`, and `router.push(await pickRoute())` where the helper returns screen paths (one edge per screen it can return) resolve to the screen they open as a new `navigates` edge that remembers the href, so "where does tapping this go" and "who opens this screen" are one hop in `codegraph_explore`, `callers`, and the viewer's Flow strip instead of a dead end at a string. Re-index after upgrading to pick the new edges up. diff --git a/__tests__/branch-guards.test.ts b/__tests__/branch-guards.test.ts index 3c6c16c..c7a67e7 100644 --- a/__tests__/branch-guards.test.ts +++ b/__tests__/branch-guards.test.ts @@ -4,7 +4,7 @@ import * as os from 'os'; import * as path from 'path'; import { CodeGraph } from '../src'; import { initGrammars } from '../src/extraction/grammars'; -import { guardsInSource, guardLabel, supportsBranchGuards } from '../src/graph/branch-guards'; +import { callArgumentsInSource, guardsInSource, guardLabel, supportsBranchGuards } from '../src/graph/branch-guards'; import { buildNode } from '../src/ui-server/api/node'; import { buildFlow } from '../src/ui-server/api/flow'; @@ -48,6 +48,21 @@ export function ItemCard(props) { expect(await labelAt(handlePress, 'openObjectDetail(')).toBe('!isUploading && isCollected'); }); + it('keeps a disjunctive guard in parentheses, so the join stays unambiguous', async () => { + const src = ` +function go(object) { + if (isUploading) return + if (!object?.id || !object?.name) { + bail() + return + } + proceed() +} +`; + expect(await labelAt(src, 'bail(')).toBe('!isUploading && (!object?.id || !object?.name)'); + expect(await labelAt(src, 'proceed(')).toBe('!isUploading && !(!object?.id || !object?.name)'); + }); + it('turns each earlier early-return into a negated guard, in source order', async () => { expect(await labelAt(handlePress, 'handleAddToQueue(')).toBe('!isUploading && !isCollected && queueHasItems'); expect(await labelAt(handlePress, 'handleStartCapture(')).toBe('!isUploading && !isCollected && !queueHasItems'); @@ -238,3 +253,57 @@ describe('branch guards: on the wire', () => { cg.close(); }); }); + + +// ============================================================================= +// Call arguments — what a site passes +// ============================================================================= + +async function argsAt(src: string, needle: string, language: 'tsx' | 'typescript' | 'swift' = 'tsx') { + const line = lineOf(src, needle); + const column = src.split('\n')[line - 1]!.indexOf(needle); + return callArgumentsInSource(src, language, line, column); +} + +describe('call arguments', () => { + const login = ` +async function handleLogin(values) { + await SecureStore.setItemAsync('userEmail', values.email) + const res = await client.post('/auth/login', { email: values.email, password, ...rest }) + Alert.alert(i18n.t('error_login_failed'), err.message, [{ text: 'OK' }]) + router.push({ pathname: '/item/[id]', params: { id } }) + captureView.finalizeCaptureSession() + run(() => go(), async (x) => x, new Thing(1)) + const big = fetch(\`/api/\${id}\`, { method: 'POST', headers, body, mode, cache, credentials }) +} +`; + + it('keeps literals and names whole, folds objects to their keys, arrays and functions to a shape', async () => { + expect(await argsAt(login, 'SecureStore.setItemAsync(')).toBe("'userEmail', values.email"); + expect(await argsAt(login, 'client.post(')).toBe("'/auth/login', { email, password, ...rest }"); + expect(await argsAt(login, 'Alert.alert(')).toBe('i18n.t(…), err.message, […]'); + expect(await argsAt(login, 'router.push(')).toBe('{ pathname, params }'); + expect(await argsAt(login, 'run(')).toBe('() => …, () => …, new Thing(…)'); + expect(await argsAt(login, 'fetch(')).toBe('`/api/${id}`, { method, headers, body, mode, … }'); + }); + + it('an empty argument list is an empty string; a position outside a call is null', async () => { + expect(await argsAt(login, 'captureView.finalizeCaptureSession(')).toBe(''); + expect(await argsAt(login, 'async function handleLogin')).toBeNull(); + }); + + it('Swift: labels stay with their values, a trailing closure is a shape', async () => { + const src = ` +class CaptureEvents { + func emitZipComplete(result: ZipResult) { + sendEvent(withName: "onZipComplete", body: ["zipURL": result.url]) + tracker.setup(side: side, angle: 45) + DispatchQueue.main.async { finish() } + } +} +`; + expect(await argsAt(src, 'sendEvent(', 'swift')).toBe('withName: "onZipComplete", body: […]'); + expect(await argsAt(src, 'tracker.setup(', 'swift')).toBe('side: side, angle: 45'); + expect(await argsAt(src, 'DispatchQueue.main.async', 'swift')).toBe('{ … }'); + }); +}); diff --git a/__tests__/ui-conditions.test.ts b/__tests__/ui-conditions.test.ts new file mode 100644 index 0000000..1429ab8 --- /dev/null +++ b/__tests__/ui-conditions.test.ts @@ -0,0 +1,69 @@ +/** + * Conditions as a reader says them: the joins we add (`&&` between guards, + * `||` between a link's scenarios, `!(…)` around a negated guard) become + * and / or / not, the code inside a guard stays code, and a link with several + * call sites is several scenarios with their shared clauses said once. + */ +import { describe, it, expect } from 'vitest'; +import { clauseWords, clauses, restWords, scenarios, splitTop, whenWords } from '../ui/src/lib/conditions'; + +describe('conditions', () => { + it('splits at the top level only, respecting brackets and strings', () => { + expect(splitTop('a && (b || c) && "x && y" && d', ' && ')).toEqual(['a', '(b || c)', '"x && y"', 'd']); + expect(splitTop('a && b || c && d', ' || ')).toEqual(['a && b', 'c && d']); + expect(clauses('!busy && isCollected')).toEqual(['!busy', 'isCollected']); + // A merged condition has no single innermost clause: it comes back whole. + expect(clauses('a && b || c')).toEqual(['a && b || c']); + }); + + it('says NOT for our negations and leaves the code inside alone', () => { + expect(clauseWords('!busy')).toBe('NOT busy'); + expect(clauseWords('!user?.organization_id')).toBe('NOT user?.organization_id'); + expect(clauseWords('!(isUploadInProgress || elapsed < 5000)')).toBe('NOT (isUploadInProgress || elapsed < 5000)'); + // `!(a) || b` is not a negated whole: untouched. + expect(clauseWords('!(a) || b')).toBe('!(a) || b'); + expect(clauseWords('(!object?.id || !object?.name)')).toBe('(!object?.id || !object?.name)'); + expect(clauseWords('selectedDetectionItems.length === 1')).toBe('selectedDetectionItems.length === 1'); + }); + + it('words a whole condition: AND within a scenario, OR between scenarios', () => { + expect(whenWords('!(busy || late) && user?.organization_id && !object?.id')).toBe( + 'NOT (busy || late) AND user?.organization_id AND NOT object?.id' + ); + expect(whenWords('!x && y || !x && !y')).toBe('NOT x AND y OR NOT x AND NOT y'); + // The same guard met twice along a chain is said once. + expect(whenWords('ctl && !(!ctl || done) && !(!ctl || done) && ready')).toBe('ctl AND NOT (!ctl || done) AND ready'); + expect(scenarios([{ when: 'a && a && b' }]).common).toEqual(['a', 'b']); + expect(whenWords('')).toBe(''); + }); + + it('factors the clauses every scenario shares, and keeps each row’s own tail', () => { + const sites = [ + { line: 248, when: '!(busy || late) && !user?.organization_id' }, + { line: 257, when: '!(busy || late) && user?.organization_id && (!object?.id || !object?.name)' }, + { line: 292, when: '!(busy || late) && user?.organization_id && !(!object?.id || !object?.name) && items.length === 1' }, + { line: 306, when: '!(busy || late) && user?.organization_id && !(!object?.id || !object?.name) && !items.length' }, + ]; + const sc = scenarios(sites); + expect(sc.common).toEqual(['!(busy || late)']); + expect(sc.rows.map((r) => r.rest)).toEqual([ + ['!user?.organization_id'], + ['user?.organization_id', '(!object?.id || !object?.name)'], + ['user?.organization_id', '!(!object?.id || !object?.name)', 'items.length === 1'], + ['user?.organization_id', '!(!object?.id || !object?.name)', '!items.length'], + ]); + expect(restWords(sc.rows[0]!.rest, true)).toBe('AND NOT user?.organization_id'); + expect(restWords(sc.rows[2]!.rest, true)).toBe( + 'AND user?.organization_id AND NOT (!object?.id || !object?.name) AND items.length === 1' + ); + }); + + it('one site is one scenario with nothing left to say; no shared prefix says when', () => { + expect(scenarios([{ when: 'a && b' }])).toEqual({ common: ['a', 'b'], rows: [{ site: { when: 'a && b' }, rest: [] }] }); + const sc = scenarios([{ when: 'a' }, { when: 'b' }, { when: '' }]); + expect(sc.common).toEqual([]); + expect(restWords(sc.rows[0]!.rest, false)).toBe('WHEN a'); + expect(restWords(sc.rows[2]!.rest, false)).toBe('always'); + expect(scenarios([])).toEqual({ common: [], rows: [] }); + }); +}); diff --git a/__tests__/ui-screens-model.test.ts b/__tests__/ui-screens-model.test.ts index bd399ec..9912bde 100644 --- a/__tests__/ui-screens-model.test.ts +++ b/__tests__/ui-screens-model.test.ts @@ -190,7 +190,7 @@ describe('edgeLabel', () => { const collect = edgeLabel([link('/home', '/capture/collect', `${chain}guide.dontShowAgain.captureGuide`)]); const intro = edgeLabel([link('/home', '/guide', `${chain}!guide.dontShowAgain.captureGuide`)]); expect(collect).toBe('…guide.dontShowAgain.captureGuide'); - expect(intro).toBe('…!guide.dontShowAgain.captureGuide'); + expect(intro).toBe('…NOT guide.dontShowAgain.captureGuide'); // The whole point: two arms of a fork no longer read the same. expect(collect).not.toBe(intro); }); diff --git a/__tests__/ui-steps-api.test.ts b/__tests__/ui-steps-api.test.ts index 5d7474d..ee4a11f 100644 --- a/__tests__/ui-steps-api.test.ts +++ b/__tests__/ui-steps-api.test.ts @@ -73,6 +73,7 @@ beforeAll(async () => { ' const handleZipComplete = useCallback(async (data: { uri: string }) => {\n' + ' setZipUri(data.uri)\n' + ' await uploadARCapture(data.uri)\n' + + " Alert.alert('Uploaded', data.uri, [{ text: 'OK' }])\n" + " if (unlimited) router.replace('/')\n" + ' }, [unlimited])\n' + ' useEffect(() => {\n' + @@ -192,6 +193,7 @@ describe('buildSteps', () => { const link = (from: string, to: string) => payload.links.find((l) => l.from === byLabel.get(from)!.id && l.to === byLabel.get(to)!.id); + const req = link('handleZipComplete', 'client.post +1'); expect(link('/capture/review', 'handleApprove')?.kind).toBe('handler'); expect(link('handleApprove', 'finalizeCaptureSession')?.kind).toBe('bridge'); const evt = link('finalizeCaptureSession', 'handleZipComplete'); @@ -200,14 +202,26 @@ describe('buildSteps', () => { expect(evt?.via.map((v) => v.name)).toEqual(['emitZipComplete']); expect(evt?.when).toBe('result'); expect(evt?.label).toContain('event onZipComplete'); - expect(link('handleZipComplete', 'setZipUri')?.kind).toBe('store'); - const req = link('handleZipComplete', 'client.post +1'); + const storeLink = link('handleZipComplete', 'setZipUri'); + expect(storeLink?.kind).toBe('store'); + // Every call-shaped site says what it passes. + expect(storeLink?.sites[0]?.args).toBe('data.uri'); + expect(link('handleApprove', 'finalizeCaptureSession')?.sites[0]?.args).toBe(''); + // One call behind an effect box: the box says it. Several: the panel does. + const alert = payload.steps.find((s) => s.kind === 'effect' && s.effect?.category === 'device')!; + expect(alert.label).toBe("Alert.alert('Uploaded', data.uri, […])"); + expect(network.label).toBe('client.post +1'); + expect(req?.sites.map((s) => `${s.text}(${s.args})`)).toEqual(["client.post('/frames', { uri })", "client.get('/frames/status')"]); expect(req?.kind).toBe('effect'); expect(req?.via.map((v) => v.name)).toEqual(['uploadARCapture']); const nav = link('handleZipComplete', '/'); expect(nav?.kind).toBe('navigates'); expect(nav?.when).toBe('unlimited'); expect(nav?.sites[0]?.text).toBe('replace /'); + // Every site carries the whole condition it runs under — one scenario each. + expect(nav?.sites[0]?.when).toBe('unlimited'); + expect(evt?.sites[0]?.when).toBe('result'); + expect(storeLink?.sites[0]?.when).toBe(''); // Rows: the anchor on 0, then one more step away each. The listener is // registered BY the screen (`addListener('onZipComplete', handleZipComplete)`), diff --git a/__tests__/ui-steps-model.test.ts b/__tests__/ui-steps-model.test.ts index aa34bb7..963de23 100644 --- a/__tests__/ui-steps-model.test.ts +++ b/__tests__/ui-steps-model.test.ts @@ -70,7 +70,7 @@ describe('steps model', () => { const edges = [...model.edges.values()]; expect(edges).toHaveLength(6); const toBridge = edges.find((e) => e.to === bridge.id)!; - expect(toBridge.label).toBe('!busy'); + expect(toBridge.label).toBe('NOT busy'); expect(toBridge.kind).toBe('bridge'); const toEvent = edges.find((e) => e.to === event.id)!; expect(toEvent.synthesized).toBe(true); diff --git a/docs/design/codegraph-ui-design-spec.md b/docs/design/codegraph-ui-design-spec.md index d41b112..fe6c8ee 100644 --- a/docs/design/codegraph-ui-design-spec.md +++ b/docs/design/codegraph-ui-design-spec.md @@ -460,7 +460,13 @@ whole app; so is a native event that lands in a COMPONENT (the capture overlay t another screen's body — `cut: 'component'`). A bridge or event step needs evidence — a bridge resolver's edge or a synthesized channel's; a plain name-matched call across the families (`arr.flat()` landing on a Swift `flat`) is neither drawn nor walked. Effects are one box per (function, category), labelled by the first call and counting the rest (`client.post +1`), the calls -listed in the panel. Caps, each announced: depth in steps (default 8, ≤ 14, `cut: 'depth'` on the step it stopped +listed in the panel. Every call-shaped site (a store action, a bridge call, an effect, a plain call to a step) also +carries **what it passes** — `graph/branch-guards.ts`'s `callArgumentsForFile`, read from the same cached tree as the +guards: string literals and names whole, an object as its keys (`{ email, password }`), arrays `[…]`, functions +`() => …`, nested calls `f(…)`, Swift labels kept (`withName: "onZipComplete"`), ≤ 96 chars — printed on the panel's +site rows (`SecureStore.setItemAsync('userEmail', values.email) · index.tsx:226`) and in the tooltip, and an effect +box with exactly one call behind it wears it as its label (`axios.post('/auth/login', { email, password })`, ≤ 56). +The conditions say when a step runs; the arguments say with what. Caps, each announced: depth in steps (default 8, ≤ 14, `cut: 'depth'` on the step it stopped at, drawn with `name …`), fan-out per node (80), folded nodes per step (300), steps per picture (120 default, ≤ 400); hubs (fan-in ≥ 40) and shared chrome (a component rendered by ≥ 5 parents — higher than the Screens view's 3, which attributes navigations rather than deciding what to walk into) are dead ends, counted in `truncated`. A step several @@ -530,6 +536,19 @@ the same question about the same graph. them. **Prepared, not published**: `"private": true` is the guard and `scripts/pack-npm.sh` only packs it under `CODEGRAPH_PACK_UI=1`. +### 3.14 Conditions, as a reader says them (`ui/src/lib/conditions.ts`) +A `when` arrives from the graph as code joined by OUR operators — guards along a chain joined with ` && `, a negated +guard wrapped `!(…)`, a link's several call sites joined with ` || ` — and those joins render as words: **WHEN**, +**AND**, **OR**, **NOT**, set in capitals at weight 600 in the condition's own mono (no tracking — they are words in a +sentence, not labels), so the joins read at a glance and the code between them reads as code. The code inside one +guard stays code (`isUploadInProgress || elapsed < 5000` is what the source +says; a guard that is itself a disjunction keeps its parentheses, `graph/branch-guards.ts` adds them). A link with +several call sites is several **scenarios**, never one long condition: the panel prints the clauses every site shares +once (`WHEN NOT (busy || late)`), then one row per site with its own tail (`AND NOT user?.organization_id` · site · +file:line), or `always`; the connector's pill counts them (`4 ways · 4 conditional`) instead of quoting them. Both the +Screens and the Steps view use this; a site's `when` on the wire is the whole condition for that site, the link's +`when` only their summary. + ## 5. Copy rules Sentence case; controls say what happens ("Read as flow", "Clear"); counts always visible next to folds; honesty phrases fixed: "No test reaches this within 3 caller hops", "Reached by tests · N files within 3 hops", "Uncertain · N name-only matches, confidence < 0.6", diff --git a/src/graph/branch-guards.ts b/src/graph/branch-guards.ts index 064639b..27af24a 100644 --- a/src/graph/branch-guards.ts +++ b/src/graph/branch-guards.ts @@ -71,13 +71,34 @@ export function guardLabel(guards: readonly BranchGuard[]): string { function renderGuard(g: BranchGuard): string { if (g.form === 'catch') return g.text; - if (!g.negated) return g.text; + // `if (!object?.id || !object?.name)` joined to the guard before it with + // `&&` would read as two conditions: it keeps its parentheses. + if (!g.negated) return hasTopLevelOr(g.text) ? `(${g.text})` : g.text; // `!x` negated reads back as `x`; a simple operand takes a bare `!`; // anything with operators is parenthesised so the negation is unambiguous. if (/^!(?![=])/.test(g.text) && isSimpleOperand(g.text.slice(1))) return g.text.slice(1); return isSimpleOperand(g.text) ? `!${g.text}` : `!(${g.text})`; } +/** A `||` outside every bracket and string — the condition is a disjunction as written. */ +function hasTopLevelOr(text: string): boolean { + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < text.length; i++) { + const ch = text[i]!; + if (quote !== null) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') quote = ch; + else if (ch === '(' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === ']' || ch === '}') depth = Math.max(0, depth - 1); + else if (depth === 0 && ch === '|' && text[i + 1] === '|') return true; + } + return false; +} + function isSimpleOperand(text: string): boolean { return /^[\w$.?!]+(?:\([^()]*\))?$/.test(text) && !/[=<>]/.test(text); } @@ -233,6 +254,341 @@ export function guardsForFileSync( /** The languages with rules here — what {@link warmBranchGuardGrammars} loads. */ export const BRANCH_GUARD_LANGUAGES: readonly Language[] = ['typescript', 'tsx', 'javascript', 'jsx', 'swift']; +// ============================================================================= +// Call arguments — what a site passes +// ============================================================================= + +/** Longest argument list kept before it is cut with an ellipsis. */ +const MAX_ARGS_TEXT = 96; +/** Longest single argument (a string literal, a name) kept whole. */ +const MAX_ARG_TEXT = 40; +/** Object keys listed before `…` stands for the rest. */ +const MAX_OBJECT_KEYS = 4; +const CALL_TYPES: ReadonlySet = new Set(['call_expression', 'new_expression']); +const ARGUMENT_CONTAINERS: ReadonlySet = new Set(['arguments', 'value_arguments', 'argument_list']); +const STRING_TYPES: ReadonlySet = new Set([ + 'string', + 'template_string', + 'line_string_literal', + 'multi_line_string_literal', + 'raw_string_literal', +]); +const OBJECT_TYPES: ReadonlySet = new Set(['object', 'object_expression']); +const ARRAY_TYPES: ReadonlySet = new Set(['array', 'array_literal', 'dictionary_literal']); +const FUNCTION_TYPES: ReadonlySet = new Set(['arrow_function', 'function_expression', 'function']); + +/** + * The arguments a call site passes, as written, abbreviated to what a reader + * scans for: a string literal whole (a storage key, a URL, a message), a name + * whole, an object as its keys (`{ email, password }`), an array as `[…]`, a + * function as `() => …`, a nested call as `f(…)`. The conditions say WHEN a + * step runs; this says WITH WHAT — `SecureStore.setItemAsync('userEmail', + * values.email)` is a different fact from `SecureStore.setItemAsync`. + * + * Keyed by {@link siteKey} like the guards, read from the same cached tree. + * A site that is not inside a call, or a language without rules, is absent. + */ +export async function callArgumentsForFile( + absPath: string, + language: Language, + sites: readonly CallSite[] +): Promise> { + const out = new Map(); + if (!supportsBranchGuards(language) || sites.length === 0) return out; + const cached = await treeFor(absPath, language); + if (!cached) return out; + for (const site of sites) { + const key = siteKey(site); + if (out.has(key)) continue; + const text = callArgumentsInTree(cached.tree.rootNode, cached.source, site.line, site.column ?? null); + if (text !== null) out.set(key, text); + } + return out; +} + +/** {@link callArgumentsForFile} over source text — the test surface. */ +export async function callArgumentsInSource( + source: string, + language: Language, + line: number, + column: number | null +): Promise { + if (!supportsBranchGuards(language)) return null; + const tree = await parse(source, language); + if (!tree) return null; + try { + return callArgumentsInTree(tree.rootNode, source, line, column); + } finally { + tree.delete(); + } +} + +export function callArgumentsInTree( + root: SyntaxNode, + source: string, + line: number, + column: number | null +): string | null { + const row = line - 1; + const col = column ?? firstNonBlankColumn(source, row); + const start = innermostAt(root, row, col); + if (!start) return null; + // The site's position is on the callee (`setItemAsync` in + // `SecureStore.setItemAsync(…)`): climb to the call it belongs to. A few + // levels cover a member chain; further up would be another statement. + let call: SyntaxNode | null = null; + let node: SyntaxNode | null = start; + for (let up = 0; node && up < 6; up++, node = node.parent) { + if (CALL_TYPES.has(node.type)) { + call = node; + break; + } + } + if (!call) return null; + const container = argumentsOf(call); + if (!container) return null; + if (container.type === 'lambda_literal') return '{ … }'; + const parts: string[] = []; + for (let i = 0; i < container.namedChildCount; i++) { + const c = container.namedChild(i); + if (!c || c.type === 'comment') continue; + parts.push(abbreviateArgument(c, source)); + } + const text = parts.join(', '); + return text.length > MAX_ARGS_TEXT ? `${text.slice(0, MAX_ARGS_TEXT - 1)}…` : text; +} + +/** The node holding a call's arguments: the `arguments` field, a container child, or Swift's `call_suffix` contents. */ +function argumentsOf(call: SyntaxNode): SyntaxNode | null { + const field = call.childForFieldName('arguments'); + if (field) return field; + for (let i = 0; i < call.namedChildCount; i++) { + const c = call.namedChild(i); + if (!c) continue; + if (ARGUMENT_CONTAINERS.has(c.type)) return c; + if (c.type === 'call_suffix') { + for (let j = 0; j < c.namedChildCount; j++) { + const inner = c.namedChild(j); + if (inner && (ARGUMENT_CONTAINERS.has(inner.type) || inner.type === 'lambda_literal')) return inner; + } + return c; + } + } + return null; +} + +function abbreviateArgument(node: SyntaxNode, source: string): string { + const type = node.type; + if (STRING_TYPES.has(type)) return cut(collapse(node.text), MAX_ARG_TEXT); + if (OBJECT_TYPES.has(type)) return objectKeys(node, source); + if (ARRAY_TYPES.has(type)) return '[…]'; + if (FUNCTION_TYPES.has(type)) return '() => …'; + if (type === 'lambda_literal') return '{ … }'; + if (type === 'spread_element') return cut(collapse(node.text), MAX_ARG_TEXT); + if (type === 'await_expression') { + const inner = node.namedChild(0); + return inner ? `await ${abbreviateArgument(inner, source)}` : 'await …'; + } + if (CALL_TYPES.has(type)) { + const callee = node.childForFieldName('function') ?? node.childForFieldName('constructor') ?? node.namedChild(0); + const name = callee ? cut(collapse(callee.text), 28) : ''; + return `${type === 'new_expression' ? 'new ' : ''}${name}(…)`; + } + // Swift `label: value` — the label is half the meaning (`withName:`). + if (type === 'value_argument') { + const named: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) named.push(c); + } + if (named.length >= 2 && (named[0]!.type === 'simple_identifier' || named[0]!.type === 'value_argument_label')) { + return `${named[0]!.text}: ${abbreviateArgument(named[named.length - 1]!, source)}`; + } + return named.length > 0 ? abbreviateArgument(named[named.length - 1]!, source) : cut(collapse(node.text), MAX_ARG_TEXT); + } + if (type === 'lambda_argument' || type === 'trailing_closure') return '{ … }'; + return cut(collapse(node.text), MAX_ARG_TEXT); +} + +/** `{ email, password, …}` — the keys an object literal passes, not its bulk. */ +function objectKeys(node: SyntaxNode, source: string): string { + const keys: string[] = []; + let more = 0; + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (!c || c.type === 'comment') continue; + let key: string | null = null; + if (c.type === 'pair') key = c.childForFieldName('key')?.text ?? null; + else if (c.type === 'shorthand_property_identifier' || c.type === 'shorthand_property_identifier_pattern') key = c.text; + else if (c.type === 'spread_element') key = collapse(c.text); + else if (c.type === 'method_definition') key = c.childForFieldName('name')?.text ?? null; + if (key === null) continue; + if (keys.length >= MAX_OBJECT_KEYS) { + more++; + continue; + } + keys.push(cut(key, 24)); + } + void source; + if (keys.length === 0) return '{…}'; + return `{ ${keys.join(', ')}${more > 0 ? ', …' : ''} }`; +} + +// ============================================================================= +// Triggers — what fires a site +// ============================================================================= + +/** + * What binds a call site to an event, when something does — the answer to + * "at what point does this run": the JSX attribute the site sits under + * (`onPress` of ` - {#if link.when}
when {link.when}
{/if} + {#if sc.common.length > 0}
{@render words(commonTokens(sc.common))}
{/if} {#if link.via.length > 0}
via {viaText(link)}
{/if} - {#each link.sites as site (site.file + site.line)} - {site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line} + {#if sc.rows.length > 1}
{sc.rows.length} ways
{/if} + {#each sc.rows as row (row.site.file + row.site.line)} +
1}> + {#if sc.rows.length > 1}
{@render words(restTokens(row.rest, sc.common.length > 0))}
{/if} + {row.site.method} {row.site.href} · {row.site.file.slice(row.site.file.lastIndexOf('/') + 1)}:{row.site.line} +
{/each} {/each} @@ -433,6 +444,7 @@

Goes to {lists.goesTo.length}

{#if lists.goesTo.length === 0}

No navigation leaves this screen.

{/if} {#each lists.goesTo as link (link.id)} + {@const sc = scenarios(link.sites)}
onRowHover(null)} > - {#if link.when}
when {link.when}
{/if} + {#if sc.common.length > 0}
{@render words(commonTokens(sc.common))}
{/if} {#if link.via.length > 0}
via {viaText(link)}
{/if} - {#each link.sites as site (site.file + site.line)} - {site.method} {site.href} · {site.file.slice(site.file.lastIndexOf('/') + 1)}:{site.line} + {#if sc.rows.length > 1}
{sc.rows.length} ways
{/if} + {#each sc.rows as row (row.site.file + row.site.line)} +
1}> + {#if sc.rows.length > 1}
{@render words(restTokens(row.rest, sc.common.length > 0))}
{/if} + {row.site.method} {row.site.href} · {row.site.file.slice(row.site.file.lastIndexOf('/') + 1)}:{row.site.line} +
{/each}
{/each} @@ -716,10 +732,24 @@ font: 400 11.5px var(--mono); margin-top: 2px; } + /* The joins we add — WHEN, AND, OR, NOT — a little bolder than the code between them. */ + .kw { + font-weight: 600; + } .via { font: 400 11px var(--mono); margin-top: 2px; } + .ways { + font: 500 11px var(--sans); + margin-top: 6px; + } + /* One scenario per row under a transition: its own tail of conditions, then its site. */ + .scenario.many { + margin: 4px 0 0 8px; + padding-left: 8px; + border-left: 1px solid var(--rule-soft); + } .site { display: block; font: 400 11px var(--mono); diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte index 7c78ac6..196e7b0 100644 --- a/ui/src/views/StepsView.svelte +++ b/ui/src/views/StepsView.svelte @@ -31,6 +31,7 @@ import { fileHref, flowHref, navigate, stepsHref, symbolHref } from '../lib/navigation'; import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model'; import { hoverPill, nearestEdge, placeLabels } from '../lib/screens-model'; + import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions'; import { buildStepsModel, kindWord, @@ -280,7 +281,7 @@ /** The words a panel row puts on its line: the arrow, and the whole condition. */ function fullText(link: WireStepLink): string { const arriving = selected !== null && link.to === selected && link.from !== selected; - return `${arriving ? '←' : '→'} ${link.when || 'always'}`; + return `${arriving ? '←' : '→'} ${whenWords(link.when) || 'always'}`; } function rowHot(link: WireStepLink): boolean { @@ -310,8 +311,17 @@ function basename(file: string): string { return file.slice(file.lastIndexOf('/') + 1); } + + /** `SecureStore.setItemAsync('userEmail', values.email)` — the site, with what it passes when that could be read. */ + function siteWords(site: { text: string; args?: string }): string { + return site.args === undefined ? site.text : `${site.text}(${site.args})`; + } +{#snippet words(tokens: WordToken[])} + {#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}{t.text}{:else}{t.text}{/if}{/each} +{/snippet} +