# The report card

squiggle card answers one question about a change: is it done? It runs the flows that reach the screens you changed, checks what they measured, and lists each problem with the line to fix. Your agent runs the same check with the report_card tool.

## Before you start

- Your app running in the simulator, launched with `squiggle up`.
- Saved flows in `.squiggle/flows/` that reach the screens you work on. The card only measures a screen through a flow.
- Squiggle has seen those screens before, in a flow run, a crawl or Studio. It learns which files each screen is built from by visiting it; `squiggle crawl --tour` visits them all.

## Run it

```shell
squiggle card
```

Output:

```text
report card: not green 2 changed file(s) → orders
  ✔ orders legibility 100/100 · 9/9 interactive elements are reliably targetable; 0 blocking issue(s)
  ✔ orders passed (orders): squiggle://evidence/mv6rxxns-1/412
  ✔ orders frameP95Ms: 11.8 of 16.7 ms, margin 4.9: squiggle://evidence/mv6rxxns-1/412
  ✗ orders rendersPerTap: 74 of 60 renders, margin -14: squiggle://evidence/mv6rxxns-1/415
    explain the cited interaction, then cut what rendersPerTap counts on orders
  ✗ list row OrderRow re-renders when its list does, with the same item (48 renders, from step 3): src/orders/OrderRow.tsx:12
    wrap OrderRow in memo, and do not hand the list state its rows do not draw (extraData)
  ✗ orders requests GET api.acme.shop/orders/:id 2 times a visit, planned once
    a second request for the same data: share one (a query cache, or one effect that fetches once)
  ! 2 screen(s) load at startup though the app does not open on them, 34.5 ms in all: (tabs)/explore 22.4 ms, settings 12.1 ms
    load each on its first visit: React.lazy for the import that pulls in its weight, or Expo Router async routes for all of them
  …
  3 pass · 1 warn · 3 fail · 3 skip
  48.2 s (tour 9.1 s, 1 flow(s))
```

The card takes minutes, not seconds, because it runs real flows. Each line is one row: a check, its verdict, the `file:line` or evidence it points at, and, for a row that failed or warned, the fix on the line below. It exits 0 when the card is green and 1 when it isn’t.

| Flag | What it does |
| --- | --- |
| `--files <a.tsx,b.tsx>` | The files your change touched. Without it, the card uses what your branch changed since it left origin/main, including edits you haven’t committed and new files. |
| `--base <ref>` | Without --files: compare with this ref instead of origin/main. |
| `--full` | Every screen Squiggle remembers and every saved flow, not only what your change reaches. |
| `--no-tour` | Skips the tour, and with it the legibility rows. |
| `--all-flows` | Runs every flow that reaches a changed screen, not the fewest that reach them all. |
| `--dry-run` | Prints the screens it would check, the flows it would run and the screens no flow reaches, then stops. Touches no device. |
| `--live` | Runs the flows against your real backend instead of stubs. See below. |
| `--json` | Prints the whole card as JSON. |

Every card is saved in full to `.squiggle/runs/card-<timestamp>.json`, and `--json` prints that path as `file`. A card that ran at least one flow is also recorded for `squiggle gate app`; see [Run in CI](https://squiggle.sh/docs/run/ci#keep-the-app-from-getting-worse).

## From your agent

The MCP tool `report_card` runs the same card. Have your agent call it with every file it edited when it thinks a feature is done, and fix rows until the card is green:

```shell
report_card { changedFiles: ["src/app/orders.tsx", "src/orders/OrderRow.tsx"] }
```

It takes `changedFiles`, `full`, `tour` (false skips the tour) and `live`, the same as the flags above. Leave out `changedFiles` and it uses the branch’s changes. It returns the failing rows first, at most 60; the counts and `green` are always the whole card’s, and `file` names the full card on disk. The agent kit’s `squiggle-build` skill walks an agent through this loop; see [Agents](https://squiggle.sh/docs/agents). When the agent has two plausible ways to build the feature, the skill has it build both and [bake them off](https://squiggle.sh/docs/run/bench#compare-two-implementations) before keeping one.

## What it checks

1. **Which screens.** The changed files, plus every file in your app that imports one of them, are matched against the files each remembered screen is built from. A screen you declared in `.squiggle/interfaces/` and Squiggle hasn’t seen yet is always included.
2. **A tour.** It opens those screens and scores each one for [legibility](https://squiggle.sh/docs/flows/selectors).
3. **The flows.** It runs the fewest saved flows that reach every changed screen, plus any flow your change edited. The first one starts the app cold, so the card measures your code’s startup.
4. **The results.** For each run, it checks the screens’ budgets, the detectors’ findings, wasted renders, render growth, each screen’s requests and the startup.

### How a flow reaches a screen

A flow covers a screen when it names the screen’s route (a `navigate`, a `waitFor` or an `assert` on the route, or its goal), or when it targets one of the screen’s own controls: one that only that screen has, or the control on another screen that opens it. Shared controls, such as a tab bar or a back button, don’t count, or tapping a tab would cover every screen. A flow that runs with `runWith:`, one with `expect:` (it fails on purpose), and one tagged `tags: [probe]` (it measures your tools, not a screen) aren’t used.

**A changed screen that no flow reaches fails the card.** The card can’t measure a screen it never sees, so write the flow first: reach the screen, do the thing it is for, and assert the result.

### Which screens your change owns

Your change owns a screen when it changed that screen’s own route file (`app/orders.tsx` for `orders`), or when you declared the screen in `.squiggle/interfaces/`. A screen it reaches only through a shared file, such as a layout, a store or a button, is still checked, but a budget it was already over, or a flow through it that fails, is a warning rather than a failure: that problem isn’t your change’s to fix. With `--full`, the card owns every screen.

## Green, warnings and pending

- **Green** means no row failed. It’s the only thing the exit code reflects.
- **Warnings** (`!`) are worth fixing but don’t block.
- **Pending** rows (`·`, counted as skip) are checks with no data, such as a budget nothing measured. Pending is never a pass, and never a failure either. The card always ends with a few pending rows for checks it doesn’t run yet.

## The rows

| Row | Fails when |
| --- | --- |
| `interface` | The screen doesn’t match its declaration in `.squiggle/interfaces/`: a declared control is missing, a testID isn’t declared, a control opens a different screen, or a role differs. Pending until the screen has been seen. The file format is in [Project folder and config](https://squiggle.sh/docs/reference/project#declare-a-screens-interface). |
| `coverage` | A changed screen that no saved flow reaches. |
| `flow` | A covering flow failed. The row names the step; run the flow on its own and read the first failed step. A flow that stopped before its first step reads “failed at start”, with the error and its code, such as “start (exit 2): no flow named … · E_NO_FLOW_FILE”. After any failed flow, the card relaunches the app for the next one, so one crash costs one row. A warning when the flow only crosses screens your change doesn’t own. |
| `budget` | A screen is over one of its [budgets](https://squiggle.sh/docs/performance/baselines#set-a-budget-per-screen), with the margin. A limit nothing measured is pending. |
| `finding` | A detector reported an error on the run, about a file your change reaches, or with no source at all. A warning-level finding warns. See [Findings](https://squiggle.sh/docs/performance/findings). |
| `legibility` | Your change wrote a control an agent can’t address, or the tour didn’t finish. |
| `render` | A render rule broke, or a component renders more than in the last green run. |
| `network` | A screen makes a request it didn’t, makes one more often, or waits deeper than in the last green run. |
| `startup` | Never fails. Warns when the cold start loads screens the app doesn’t open on. |
| `cliff` | The screen declares a size it must handle, and its last [cliff sweep](https://squiggle.sh/docs/performance#keep-a-screen-off-its-cliff) found frames dropping below it. The row names the component that rendered most there. Pending when the screen was never swept, when no sweep reached that size, or when its file, or that component’s, changed since. The card reads the last sweep and never runs one. When the size names the state that holds it, the row also says how many real sessions reach the cliff. |
| `state` | Never fails. With [state samples](https://squiggle.sh/docs/reference/sdk#turn-on-state-samples) on, warns with up to three states real sessions reach on the screen that no flow or save slot does, and the `squiggle product state --coverage --save-gap` command that saves a slot in that state. |
| `pending` | Never fails: a check the card doesn’t run yet. |

### Legibility

Each toured screen gets a score row, out of 100. It passes at 100, warns below, and fails when your change added a control with an error. A finding counts as your change’s only when your diff touched the lines of the element that control is written in. A control you didn’t touch, even in a file you changed, is the screen’s existing debt: it is counted on the score row (“N finding(s) outside this change”) and doesn’t fail the card. With `--full`, every error is a row.

### Renders

Every flow step records which components rendered and why. The card reads that for three patterns, in components with a line in your own code that your change reaches:

| Rule | What it reports, and the fix |
| --- | --- |
| `recreated context` | X is recreated on every render of Owner, so C re-renders with the same value. Fix: memoize the value Owner passes to X.Provider with useMemo. |
| `row on parent` | List row R re-renders when its list does, with the same item. Fix: wrap R in memo, and don’t hand the list state its rows don’t draw (extraData). |
| `store slice` | C re-renders when a store changes keys it doesn’t read. Fix: select only what C reads, for example useCart((s) => s.total). |

The store-slice rule works for Zustand stores in a development build on React 19, where Squiggle can tell which component read which key. A key read only in an event handler doesn’t count as read. Elsewhere the rule stays silent. A flow that breaks none of the rules gets one passing render row.

**Render growth** compares each component’s renders over the whole flow with the flow’s last green run. It fails when a component gained more than 2 renders and more than 25%, and names the first step that grew. To see why, read the step’s `flow.step.renders` line in the run’s file in `.squiggle/runs/`: it lists each component, how often it rendered, and what triggered it. Components from `node_modules` are left out.

### Network

The card learns each screen’s requests from the flow’s last green run: which endpoints, how many times a visit, and how deep the longest chain of requests that wait for each other goes. Ids in paths are read as `:id`. A run then fails a row when a screen:

- makes a request it didn’t make before. If the screen needs it, the next green run makes it part of the plan.
- makes a request more times a visit: usually a second fetch of the same data, which one shared query or effect fixes.
- waits deeper: a request now waits for another. Start them together, or fetch what the second needs in the first.

Fewer requests than before is never a row, and a screen with no plan yet gets one from its first green run. For a hard limit on depth, set the `waterfallDepth` [budget](https://squiggle.sh/docs/performance/baselines#set-a-budget-per-screen).

### Startup

The card starts the app cold before its first flow. The startup phases, `nativeLaunchMs`, `bundleLoadMs`, `firstRenderMs`, `firstSettleMs` and `firstDataMs`, are budget rows on the screen the app opens on, when you set budgets for them. A startup row then lists the screens the cold start loaded though the app didn’t open on them, with what each cost. Load each on its first visit instead: `React.lazy` for the import that pulls in its weight, or Expo Router’s async routes for all of them.

The startup row needs your Metro config wrapped in `withSquiggle` (see [Set up your app](https://squiggle.sh/docs/setup#wrap-your-metro-config)), and reads screens from Expo Router’s `app/` route files. Without either it is pending.

## Flows run offline

By default each flow runs against stubs built from its last green run that recorded response shapes, so the card needs no backend, and a flaky one fails nothing. See [Run a flow without the network](https://squiggle.sh/docs/run/settings#run-a-flow-without-the-network) for how stubs are built.

The first card for a flow has nothing to stub it from, so it runs that flow against your real backend with network capture on, and adds a pending network row saying so. If that run passes and the card fails none of its rows, the next card stubs from it.

A flow whose last green run made no request needs no backend, so it runs as it is, with no stubs and no pending row.

> **A flow passes alone but fails on the card**
>
> Stubbed responses keep the shape of the real ones but carry placeholder values (`title 1`, ids counting up from 1). A flow that asserts on text from your server, such as `visible: { text: "Order #2041" }`, passes against the real backend and fails on the card. Run the card with `--live` (or `live: true` from your agent), or assert on something your app writes, such as a testID.

## The last green run

Render growth, the network plan and the stubs all compare with the flow’s last green run: the newest run of that flow in `.squiggle/runs/` that passed, ran against the real backend with no stubs or chaos profile, and that no card failed. A run from a `--cliff` sweep never counts: it ran the flow at a size the flow doesn’t declare. Only the 400 newest run files are searched. A run the card failed never becomes the baseline, even when its flow passed, so a regression can’t become the next card’s plan. Clearing `.squiggle/runs/` starts the comparison over.

## Read a row’s fix

Work through the failing rows first. A row’s fix names the next step:

- A `file:line` is where to edit. The legibility, render and finding rows point at your code.
- A `squiggle://evidence/…` link names the session and tap the number came from. Your agent can check it with `verify_citations` and explain it with `explain`.
- `propose_fix { sessionId, insight }` on a finding row is the tool call that turns that finding into a fix plan.
- A coverage row’s fix is a flow to write. Save it in `.squiggle/flows/`; an agent can save it with `library_save`.

Fix the app, not the flow: changing an assert to make a row pass hides the problem the card found. Raise a budget only when you agree the new number is right.
