@lariocpt/flawless-react 0.0.0-stage → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.agents/skills/flawless-a11y/SKILL.md +113 -0
  2. package/.agents/skills/flawless-api/SKILL.md +159 -0
  3. package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
  4. package/.agents/skills/flawless-e2e/SKILL.md +155 -0
  5. package/.agents/skills/flawless-expo/SKILL.md +283 -0
  6. package/.agents/skills/flawless-findings/SKILL.md +116 -0
  7. package/.agents/skills/flawless-load/SKILL.md +112 -0
  8. package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
  9. package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
  10. package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
  11. package/.agents/skills/flawless-react/SKILL.md +403 -0
  12. package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
  13. package/.agents/skills/flawless-regen/SKILL.md +135 -0
  14. package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
  15. package/LICENSE +21 -0
  16. package/README.md +611 -2
  17. package/bin/flawless-react +4 -0
  18. package/package.json +67 -4
  19. package/scripts/postinstall.d.mts +46 -0
  20. package/scripts/postinstall.mjs +127 -0
  21. package/src/analyse/authShims.ts +220 -0
  22. package/src/analyse/bootability.ts +257 -0
  23. package/src/analyse/childBridge.ts +302 -0
  24. package/src/analyse/childrenIntoSlot.ts +66 -0
  25. package/src/analyse/compoundFamily.ts +100 -0
  26. package/src/analyse/dataExports.ts +200 -0
  27. package/src/analyse/extractChildAttrs.ts +112 -0
  28. package/src/analyse/extractDataDeps.ts +857 -0
  29. package/src/analyse/extractDisclosures.ts +186 -0
  30. package/src/analyse/extractFetchCalls.ts +81 -0
  31. package/src/analyse/extractFlows.ts +239 -0
  32. package/src/analyse/extractGuards.ts +270 -0
  33. package/src/analyse/extractHooks.ts +237 -0
  34. package/src/analyse/extractInteractions.ts +1421 -0
  35. package/src/analyse/extractLogging.ts +45 -0
  36. package/src/analyse/extractNativeA11y.ts +138 -0
  37. package/src/analyse/extractPredicates.ts +784 -0
  38. package/src/analyse/extractPropUsage.ts +162 -0
  39. package/src/analyse/extractProps.ts +1212 -0
  40. package/src/analyse/extractRenderMode.ts +165 -0
  41. package/src/analyse/extractRootTag.ts +117 -0
  42. package/src/analyse/extractState.ts +361 -0
  43. package/src/analyse/extractStaticText.ts +212 -0
  44. package/src/analyse/extractVariants.ts +186 -0
  45. package/src/analyse/harvestApiEndpoints.ts +284 -0
  46. package/src/analyse/hostProps.ts +185 -0
  47. package/src/analyse/hostTag.ts +72 -0
  48. package/src/analyse/htmlContentModel.ts +94 -0
  49. package/src/analyse/index.ts +744 -0
  50. package/src/analyse/mountPathCalls.ts +442 -0
  51. package/src/analyse/nativeContentModel.ts +129 -0
  52. package/src/analyse/nativeDialect.ts +83 -0
  53. package/src/analyse/nativeProps.ts +339 -0
  54. package/src/analyse/ownReturns.ts +31 -0
  55. package/src/analyse/parseFile.ts +919 -0
  56. package/src/analyse/pos.ts +8 -0
  57. package/src/analyse/providerWraps.ts +1977 -0
  58. package/src/analyse/pureModule.ts +88 -0
  59. package/src/analyse/reexportTable.ts +148 -0
  60. package/src/analyse/refs.ts +195 -0
  61. package/src/analyse/renderPath.ts +441 -0
  62. package/src/analyse/resolveDepReturns.ts +370 -0
  63. package/src/analyse/resolveIndexKeys.ts +98 -0
  64. package/src/analyse/resolveModule.ts +431 -0
  65. package/src/analyse/resolveTagRootTags.ts +103 -0
  66. package/src/analyse/serverActionReturns.ts +213 -0
  67. package/src/analyse/sourceCache.ts +263 -0
  68. package/src/analyse/spreadSiblings.ts +98 -0
  69. package/src/analyse/walkChildren.ts +98 -0
  70. package/src/analyse/zodSchema.ts +424 -0
  71. package/src/bin.ts +23 -0
  72. package/src/changed.ts +221 -0
  73. package/src/cli.ts +560 -0
  74. package/src/cliFlags.ts +281 -0
  75. package/src/core.ts +1229 -0
  76. package/src/docLine.ts +122 -0
  77. package/src/emitPaths.ts +51 -0
  78. package/src/expo/detect.ts +184 -0
  79. package/src/expo/refusal.ts +62 -0
  80. package/src/expo/routes.ts +145 -0
  81. package/src/index.ts +95 -0
  82. package/src/mcp/analysisTools.ts +720 -0
  83. package/src/mcp/infoTools.ts +135 -0
  84. package/src/mcp/protocol.ts +130 -0
  85. package/src/mcp/run.ts +269 -0
  86. package/src/mcp/runTools.ts +362 -0
  87. package/src/mcp/server.ts +279 -0
  88. package/src/mcp/spec.ts +86 -0
  89. package/src/mcp/support.ts +268 -0
  90. package/src/mcp/tools.ts +75 -0
  91. package/src/nextjs/apiRoutes.ts +116 -0
  92. package/src/nextjs/detect.ts +397 -0
  93. package/src/nextjs/emit.ts +106 -0
  94. package/src/nextjs/liveSpec.ts +79 -0
  95. package/src/nextjs/mswHandlers.ts +55 -0
  96. package/src/nextjs/providerWrapper.ts +603 -0
  97. package/src/nextjs/routes.ts +86 -0
  98. package/src/nextjs/swagger.ts +32 -0
  99. package/src/packageJson.ts +414 -0
  100. package/src/platform.ts +18 -0
  101. package/src/prompt.ts +132 -0
  102. package/src/reactCompiler.ts +165 -0
  103. package/src/reporters/findings.ts +156 -0
  104. package/src/reporters/report.ts +463 -0
  105. package/src/runner/runTests.ts +402 -0
  106. package/src/scaffold/adoptStyle.ts +181 -0
  107. package/src/scaffold/index.ts +253 -0
  108. package/src/scaffold/openapi.ts +183 -0
  109. package/src/scaffold/templates.ts +2379 -0
  110. package/src/scaffold-tests/index.ts +463 -0
  111. package/src/scaffold-tests/mockValues.ts +208 -0
  112. package/src/scaffold-tests/mocks.ts +794 -0
  113. package/src/scaffold-tests/nativeIndex.ts +52 -0
  114. package/src/scaffold-tests/nativeTemplates.ts +1528 -0
  115. package/src/scaffold-tests/postDispatch.ts +152 -0
  116. package/src/scaffold-tests/registry.ts +443 -0
  117. package/src/scaffold-tests/selectors.ts +493 -0
  118. package/src/scaffold-tests/selectorsNative.ts +273 -0
  119. package/src/scaffold-tests/stamp.ts +79 -0
  120. package/src/scaffold-tests/templates.ts +3578 -0
  121. package/src/skills/cli.ts +248 -0
  122. package/src/skills/compat.ts +166 -0
  123. package/src/skills/data.ts +192 -0
  124. package/src/skills/md.d.ts +7 -0
  125. package/src/skills/menu.ts +55 -0
  126. package/src/skills/spec.ts +227 -0
  127. package/src/suiteScripts.ts +110 -0
  128. package/src/tasks/a11yNative.ts +281 -0
  129. package/src/tasks/a11yScan.ts +67 -0
  130. package/src/tasks/biomeRunner.ts +301 -0
  131. package/src/tasks/biomeSweep.ts +166 -0
  132. package/src/tasks/e2eExpo.ts +436 -0
  133. package/src/tasks/e2eRoutes.ts +756 -0
  134. package/src/tasks/eslintReactRules.ts +67 -0
  135. package/src/tasks/index.ts +24 -0
  136. package/src/tasks/loadExpo.ts +273 -0
  137. package/src/tasks/loadPipes.ts +750 -0
  138. package/src/tasks/perfHarness.ts +145 -0
  139. package/src/tasks/staticPerfAnalysis.ts +510 -0
  140. package/src/tasks/types.ts +1 -0
  141. package/src/tasks/unitAndBrowser.ts +462 -0
  142. package/src/types.ts +1419 -0
  143. package/src/version.ts +12 -0
package/README.md CHANGED
@@ -1,3 +1,612 @@
1
- # Temporary Holding Version
1
+ # flawless-react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@lariocpt/flawless-react?label=npm&color=CB3837)](https://www.npmjs.com/package/@lariocpt/flawless-react)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
+
6
+ A multi-task quality kit for React components. Point it at a `.tsx` file (or a glob), pick which checks to run, and get back a runnable Playwright + Vitest suite, an accessibility scan, and a Biome-driven correctness report — with file:line findings for every issue.
7
+
8
+ Run it with no args for an interactive checkbox menu of seven tasks:
9
+
10
+ 1. Generate the **performance testing harness** (Playwright + React `<Profiler>` + react-scan).
11
+ 2. Generate **extensive unit + browser tests** (Vitest + @testing-library/react + Playwright functional).
12
+ 3. **Static React perf analysis** (rerender storms, missing memoisation, stale/extraneous hook deps, setState-in-render).
13
+ 4. **Accessibility scan** (web: Biome jsx-a11y rules; `--expo`: the native a11y analyser — file:line findings either way).
14
+ 5. **Functionally important React rules** (Biome — useExhaustiveDependencies, useHookAtTopLevel, useJsxKeyInIterable, and friends).
15
+ 6. **Route-level Playwright e2e** for Next.js pages, against the app's real dev server.
16
+ 7. **crust load pipes** for those routes and the GET APIs their components fetch.
17
+
18
+ Pass a single positional that resolves to a file or a glob (e.g. `src/**/*.tsx`) — each match becomes an entry component and runs against every selected task. A small `<Component>.flawless.json` is dropped next to every source file so issues surface where the code lives.
19
+
20
+ The tool itself is a thin scaffolder. The intelligence is the static analysis that decides what to import, what props to vary, and which children to walk into. The runtime measurement is done by React's built-in [`<Profiler>`](https://react.dev/reference/react/Profiler) for per-component `renderCount` / `renderTime` and [`react-scan`](https://github.com/aidenybai/react-scan) for detecting unnecessary rerenders. Memory profiling uses Chrome DevTools Protocol's `HeapProfiler.takeHeapSnapshot` and writes `.heapsnapshot` files to `<outDir>/heaps/`. They are large — tens of MB each — and one is taken either side of every scenario, so `--heaps off` turns the capture off entirely.
21
+
22
+ Optional flags let you `--provider <file>` to wrap mounted components in your Redux / React Query / theme provider, and `--api <openapi.json>` to scaffold a standalone Bun mock server alongside the host so the component's `fetch('/api/…')` calls hit deterministic example responses.
23
+
24
+ Once the suite is generated, it belongs to you. A re-run regenerates derived files (specs inside the out dir, configs, generated prose) so they track your source; the files meant to hold your work — `budgets.json`, `host/main.tsx`, `provider.fixtures.ts`, `mocks/handlers.ts`, `e2e/global-setup.ts`, and colocated specs — are write-once or kept unless you ask otherwise.
25
+
26
+ ## Contents
27
+
28
+ - [Why](#why)
29
+ - [Install](#install)
30
+ - [Quickstart](#quickstart)
31
+ - [CLI reference](#cli-reference)
32
+ - [What gets generated](#what-gets-generated)
33
+ - [Worked example](#worked-example)
34
+ - [Working with stores and APIs](#working-with-stores-and-apis)
35
+ - [Programmatic API](#programmatic-api)
36
+ - [Agent skills](#agent-skills)
37
+ - [MCP server](#mcp-server)
38
+ - [Limits & assumptions](#limits--assumptions)
39
+ - [Troubleshooting](#troubleshooting)
40
+ - [Further reading](#further-reading)
41
+ - [Development](#development)
42
+
43
+ ## Why
44
+
45
+ Manually wiring up a Playwright + `<Profiler>` + `react-scan` harness for a non-trivial component takes a couple of hours of fiddly boilerplate: bundle the component for the browser, host it, expose a JS API to drive renders, write the test scaffolding, define budgets, hand-pick prop variations, polyfill Node globals for CJS deps, set up Provider context, mock the backend. None of that work is interesting.
46
+
47
+ `flawless-react` reads the component's type signature, discovers its child components by walking the JSX, and produces all of that scaffolding mechanically. You get a runnable suite in seconds, and you can then **edit it like normal test code** — tighten budgets, add scenarios, mock data sources, swap in real fixtures.
48
+
49
+ ## Install
50
+
51
+ Two channels. They ship different things, so pick by whether the target machine has Bun.
52
+
53
+ **Standalone binary** — no Bun needed, nothing to install alongside it. The installer takes the
54
+ release asset for your OS and architecture (linux and macOS, x64 and arm64), verifies it against
55
+ the `SHA256SUMS` published beside it **while it is still a temp file with no execute bit**, and
56
+ installs to `~/.local/bin`.
57
+
58
+ ```bash
59
+ curl -fsSL https://raw.githubusercontent.com/lariocpt/flawless-react/main/install.sh | bash
60
+ ```
61
+
62
+ Pin a release with `--version v0.3.0`, choose the directory with `--dir <path>`. musl systems
63
+ (Alpine) have no published asset and the installer says so rather than installing a binary that
64
+ would fail to start.
65
+
66
+ The installer then offers to install the **agent skills** — the files that teach a coding agent
67
+ how to drive a generated suite — into the current directory. Decline with `n`; script it with
68
+ `--skills` (install without asking) or `--no-skills`. Nothing is installed without an explicit
69
+ yes, and over a pipe (CI) it only prints the command: `flawless-react skills install` does the
70
+ same thing at any later point, with a menu of which skills on a terminal.
71
+
72
+ **npm package** — `bun add -g @lariocpt/flawless-react`, or `npm i -g` the same name. This is the
73
+ source package: it carries `@biomejs/biome` as a real dependency, so **all five tasks work with
74
+ no further setup** on this channel. It needs Bun on the target machine — `bin/flawless-react` is a
75
+ Bun script and npm will not install Bun for you.
76
+
77
+ ```bash
78
+ bun add -g @lariocpt/flawless-react # then: flawless-react "src/**/*.tsx" --tasks perf-static
79
+ ```
80
+
81
+ Same thing from a checkout, if you would rather run the source:
82
+
83
+ ```bash
84
+ git clone https://github.com/lariocpt/flawless-react.git && cd flawless-react && bun install -g .
85
+ ```
86
+
87
+ One caveat worth knowing before you pick it: the binary is self-contained Bun, but it cannot
88
+ carry biome inside itself. `perf-harness`, `unit-browser` and `perf-static` work anywhere;
89
+ `react-rules` — and `a11y` on the web path — shell out to biome and will look for it in the
90
+ project you are scanning (`node_modules/.bin/biome`), every ancestor workspace root, and then
91
+ on `PATH`. It has to be **biome 2**: those sweeps write a biome 2 linter config and read its
92
+ JSON reporter, so a candidate of another major is walked past in favour of one that can read
93
+ it, and if none can, the task fails naming the version and path it refused rather than
94
+ reporting a sweep it never made. (`a11y` under `--expo` is the tool's own native analyser and
95
+ needs no biome.) Any React codebase that already lints with biome 2 satisfies that for free —
96
+ otherwise `bun add -d @biomejs/biome` in that project, or use the source package.
97
+
98
+ ## Quickstart
99
+
100
+ ```bash
101
+ bunx @lariocpt/flawless-react ./src/Dashboard.tsx --out .profile/dashboard
102
+ cd .profile/dashboard
103
+ bun install
104
+ bun run test # headless Chromium
105
+ bun run test:headed # visible Chromium
106
+ ```
107
+
108
+ You should see Playwright report five tests per analysed component (`mounts at defaults`, `variant matrix`, `click rerender`, `forced rerender × N`, `stress-click-rerender × 50`) and console lines with timing summaries. After the run, `<outDir>/heaps/` contains `.heapsnapshot` artefacts for every stress scenario, loadable in Chrome DevTools' Memory tab. Pass `--heaps off` to skip them.
109
+
110
+ ## Next.js mode
111
+
112
+ Pass `--nextjs [<path>]` to target an existing Next.js project. Pass nothing — or `--nextjs .` — to use the current directory.
113
+
114
+ ```bash
115
+ cd my-next-app
116
+ bunx @lariocpt/flawless-react app/page.tsx --nextjs
117
+ ```
118
+
119
+ In this mode flawless:
120
+
121
+ - detects whether you're on the **App Router** (`app/`) or **Pages Router** (`pages/`)
122
+ - auto-uses `app/layout.tsx` (or `pages/_app.tsx`) as the test-time provider, so context/css carries through
123
+ - walks `app/api/**/route.ts` (or `pages/api/**`) and emits `flawless/swagger.json` + `flawless/mocks/handlers.ts` (MSW)
124
+ - wires MSW into the host bundle for Playwright tests **and** into Vitest's setup for jsdom-based unit tests, so the same handlers intercept `fetch` at every layer
125
+ - merges its dev deps (`vitest`, `msw`, `@testing-library/*`, `@playwright/test`, …) into your project's `package.json` as `devDependencies`
126
+ - adds `flawless:unit` / `flawless:perf` / `flawless:browser` scripts so you can `bun run flawless:unit` etc. from your project root. A `flawless:*` script you have customised survives a re-run: only a value the tool recognises as one of its own past spellings upgrades; anything else is kept and named in the summary and REPORT.md (`> Kept script(s): ...`). Delete the key to take the tool's current command
127
+ - writes the runnable harness to **`<project-root>/flawless/`** — no separate npm project, no duplicated deps
128
+
129
+ Override the auto-detected app resolver with `--app-resolver <file>` if you have a custom wrapper. The generated `flawless/mocks/handlers.ts` is yours to edit — flawless won't overwrite it once it exists, so swap the empty `HttpResponse.json({})` bodies for realistic fixtures.
130
+
131
+ Three behaviours worth knowing before the first run:
132
+
133
+ - **The layout is usually unusable as a test provider.** An app-router `layout.tsx` is typically an async server component importing `next/font` or `next/headers`, none of which run under jsdom. flawless detects that and derives `flawless/provider.tsx` from the JSX chain enclosing `{children}`, keeping only `"use client"` components and literal attributes. Everything it leaves out is named where you will read it — a dropped attribute as a `TODO(flawless)` comment beside the wrapper, a dropped provider layer in a block at the top of the file with the reason and the fix (`--provider`, because `provider.tsx` itself is regenerated). `--force` regenerates it, so a change to the layout's provider chain reaches an existing suite — the values you supply live in `flawless/provider.fixtures.ts` beside it, which is never regenerated under any flag.
134
+ - **A pages-router `_app.tsx` is not a provider either.** It renders `<Component />` and never mentions `{children}`, so it cannot wrap a component the way a layout wraps its page; its provider chain is derived the same way, anchored on `<Component />`. A provider whose library is one the harness stubs to signed out (Clerk, next-auth, the Supabase auth helpers) is dropped with `auth` as its stated reason instead of being mounted against a session that does not exist. And a component that reads a context the app never supplies — React Query, React Router, react-hook-form — gets a generated provider for that library, so its spec mounts against an empty client rather than on `null`.
135
+ - **Point `--api` at a real spec.** Walking `app/api/**/route.ts` finds nothing useful when the API is a catch-all (Hono, tRPC). `--api openapi.json` (or `--swagger-url <url>`) puts real example bodies into both the MSW handlers and each generated fetch spec.
136
+ - **Generated files adopt your formatter.** Everything written is reformatted with the target project's own biome or prettier, and JSON merges preserve the file's existing indentation — generation produces an additive diff, not a reformat of your `package.json`.
137
+ - **Default entry for `--nextjs` is `components/**/*.tsx`.** With that glob the analysed set contains no pages, so e2e/load would emit zero specs. The tool now falls back to discovering `**/page.{tsx,jsx,ts,js}` under the router root, creating minimal trees so route e2e specs and load pipes are still emitted for app-router projects.
138
+
139
+ ## Expo / React Native mode
140
+
141
+ Pass `--expo [<path>]` to target an Expo project. Pass nothing — or `--expo .` — to use the current directory.
142
+
143
+ ```bash
144
+ cd my-expo-app
145
+ bunx @lariocpt/flawless-react --expo
146
+ ```
147
+
148
+ flawless reads the project off disk — Expo SDK major, the expo-router `app/` root, whether the React Compiler is on (`app.json`'s `experiments.reactCompiler`, or `babel-plugin-react-compiler`), whether NativeWind is wired through babel, and the port `expo start --web` serves on — then defaults the entry glob to the router root and writes to `<expoRoot>/flawless/`.
149
+
150
+ **Every task runs under `--expo`, or refuses by a measured decision — the mode is complete.** Today:
151
+
152
+ | task | under `--expo` |
153
+ |---|---|
154
+ | `unit-browser` | **runs** — jest-expo + `@testing-library/react-native` specs |
155
+ | `perf-static` | **runs** — pure AST analysis, and nothing in it is DOM-specific |
156
+ | `react-rules` | **runs** |
157
+ | `a11y` | **runs** — the native accessibility analyser (six rules over the parsed tree; see below) |
158
+ | `e2e` | **runs** — route specs against `expo start --web` (see below) |
159
+ | `load` | **runs** — API-only crust pipes for the GET endpoints the screens reach through the app's fetch wrapper (see below) |
160
+ | `perf-harness` | **refuses** — measured and declined, not deferred: on an expo-router app the population is route screens, which the browser host cannot mount (they need a navigator), so the measured ceiling on the dogfood app was ~0 of 32 components. The refusal points at the recorded evidence ([`docs/archive/2026-09-expo-probes.md`](docs/archive/2026-09-expo-probes.md)); the revisit condition is an adopter with a real `components/` directory of non-route components |
161
+
162
+ ### The native unit plane
163
+
164
+ `--expo --tasks unit-browser` writes `jest.config.cjs`, a write-once `jest.setup.ts`, and one spec per exported component under `<outDir>/unit/`, then merges the runner into the app's `package.json` as `flawless:unit`.
165
+
166
+ The version pairing is derived, not guessed. `jest-expo` is pinned to the project's **own** SDK major — `jest-expo@54` pairs with Expo SDK 54, and a fixed range breaks every other adopter. `jest` is pinned to `^29` because that is what jest-expo's own tooling uses; a bare `jest` resolves to 30 and the mismatch surfaces as `this._moduleMocker.clearMocksOnScope is not a function` from inside jest-runtime, which names nothing you could act on. `@testing-library/react-native` is **13**, not 14, because expo-router 6's testing-library is built against `react-test-renderer` and RNTL 14 replaced it.
167
+
168
+ Two spec shapes, chosen from the file:
169
+
170
+ - **A route screen** mounts through `renderRouter` from `expo-router/testing-library`, at its own URL. Not a preference: `useFocusEffect` reaches `useNavigation()`, which throws `Couldn't find a navigation object` outside a navigator, and `useLocalSearchParams` is empty. Mounted inside a real navigator the screen is *focused*, so focus effects actually run — and the spec can assert the loaded state instead of the loading skeleton.
171
+ - **Everything else** mounts through RNTL's `render()`. A `_layout` is a third case: `<Tabs>` and `<Stack>` call `useContextKey` and throw `No filename found` on their own, so a layout is mounted through its own navigator with one synthesised child.
172
+
173
+ Where the source names a literal route — `onPress={() => router.push("/items/new")}` — the spec asserts the resulting pathname. That is the React Native analogue of asserting a link's `href`, and the emitted `renderRouter` context carries a stub for every such target, because a push to a route the context does not know lands on expo-router's injected `+not-found` and would fail for the wrong reason.
174
+
175
+ **No emitted spec touches the network.** A module that resolves project-locally and performs `fetch()` is `jest.mock`ed with stubs. That is not a nicety: before it existed, the fixture suite made live HTTP calls and crashed a jest worker with `api_error 405` from a real server.
176
+
177
+ **An unaddressable control becomes a named skip, never a pass.** React Native has no implicit roles — `getByRole("button")` matches nothing on a bare `<Pressable>` — and there is no last-resort locator, so a control with no `testID`, no `accessibilityLabel` and no text under it is skipped with the reason and the fix. On the dogfood app this used to be 28 of 48 press targets, 12 of them icon-only. Because each is *also* an accessibility finding, the app answered them — 48 `accessibilityLabel` and 28 `accessibilityRole` props — and the count is now **3 of 70**. That is the mechanism working: the skip named the fix, the fix was worth making on its own merits, and the test came back for free.
178
+
179
+ ### The native a11y analyser
180
+
181
+ `--expo --tasks a11y` runs the tool's own analyser instead of biome, because biome structurally cannot say anything about React Native: all 29 of its a11y rules are HTML/ARIA, and over 31 real RN files carrying 82 unlabelled `<Pressable>`s they returned **zero** findings while scoring 3/3 on this repo's DOM offender fixture. A clean report that means "these rules do not apply" is worse than no report.
182
+
183
+ Six rules, each judged from the same parsed tree the rest of the analysis uses:
184
+
185
+ | rule | what it catches |
186
+ |---|---|
187
+ | `a11y/unnamed-control` | a press target that PROVABLY renders no text (icon-only or childless) and has no `accessibilityLabel` — silent to a screen reader and unaddressable to a spec; one label retires both |
188
+ | `a11y/missing-role` | a press target with no `accessibilityRole` — RN has no implicit roles, so its text is announced without "button" |
189
+ | `a11y/textinput-unlabelled` | a `TextInput` whose only name is a placeholder, which vanishes on input and is not a label |
190
+ | `a11y/icon-unhidden` | a free-standing vector icon exposed to the accessibility tree with nothing to announce (warn) — a render-prop icon (`tabBarIcon: () => <Ionicons/>`) stands down, because its owner is invisible to the file |
191
+ | `a11y/label-text-mismatch` | an `accessibilityLabel` that does not contain the visible text — voice-control users say what they see (WCAG 2.5.3) |
192
+ | `a11y/invalid-role` | a role value RN does not recognise — the union widens with `\| string`, so tsc never catches the typo |
193
+
194
+ Two structural properties matter more than the rule list. **The denominator**: the summary and `reports/a11y.json` always carry how many press targets, text inputs and icons were examined and how many rules ran, so a zero can never again be confused with "the rules do not apply". **Zero false positives over completeness**: every rule stands down where the source is not readable — `accessibilityLabel={t("back")}` is presence the analyser records and refuses to judge, and a pressable rendering `{item.name}` is never called unnamed, because it does announce at runtime.
195
+
196
+ ### Route e2e against Expo web
197
+
198
+ `--expo --tasks e2e` emits one Playwright spec per expo-router route, run against `expo start --web` — Metro under SDK 54's static output serves **per-route rendered HTML with real status semantics** (an unknown path answers 404), all of it measured before the wave was built ([`docs/archive/2026-09-expo-probes.md`](docs/archive/2026-09-expo-probes.md)).
199
+
200
+ The machinery is the Next.js flavour's — the same `params.json` id fixture (expo-router's `[param]` templates are byte-compatible), the same write-once `global-setup.ts` with the hand-work tally, the same `--grep`-able `GET <route>` titles for `--changed` narrowing — plus three Expo-specific facts baked in:
201
+
202
+ - **Auth is a cookie on the API origin.** On web the app calls its API with `credentials: "include"`, so the setup stub's worked example is a request-context login + `storageState`, verbatim. No token injection.
203
+ - **An auth wall has two faces, and the specs catch both.** A client-side redirect fails the landing assertion; a wall rendered *in place* keeps the URL and answers 200 — but the API's 401s land in the console assertion, and the **copy witness** (a third test asserting the screen's longest analysis-proven literal is visible) fails on any screen that is not the one the analysis described.
204
+ - **Two servers.** The emitted config boots Metro; your API is a prerequisite it cannot know how to start. Set `EXPO_PUBLIC_API_URL` before running — Metro inlines it into the bundle, and an unset value silently falls back to whatever the source hardcodes (on one dogfooded LAN, a production container).
205
+
206
+ ### API-only load pipes
207
+
208
+ `--expo --tasks load` emits crust load pipes for the GET endpoints the screens actually hit. An Expo app has no server-rendered page routes to load-test, so — unlike the Next.js flavour — the pipes are API endpoints only, and the harvest that finds them is what this wave adds.
209
+
210
+ A React Native screen never writes `fetch()`: it calls a named api function (`listBuildings()`) imported from `lib/api`, whose body calls a module-local wrapper with a literal path (`apiFetch("/api/buildings")`). So the direct-`fetch` harvest sees nothing — 0 endpoints on the whole dogfood app — and the endpoint URL lives two hops from the screen. The load harvest reads exactly that chain and no deeper: the screen must *call* the imported name; the name must resolve (through barrels) to a function whose body calls a **proven** fetch wrapper (its single `fetch()` addresses one of its own parameters — trusted for what it does, never for being named `apiFetch`) with a string-literal or template path. A call mediated by a store, a non-literal URL, or a wrapper two modules deep is dropped rather than guessed: a missing endpoint costs a pipe, a wrong one measures a URL the app never requests.
211
+
212
+ Everything downstream is the Next.js machinery reused verbatim — the pipe body, the merge-kept `routes.pipes`, `params.json` (the same ids the e2e task fills), `env.sh` — with one Expo-specific difference: the write-once `routes.setup.ts` probes a parameterless GET endpoint and fails on a **401**, because an API endpoint does not redirect to a login page the way a page route does, so the Next flavour's redirect check would pass on a dead session.
213
+
214
+ A refusal is a failed `TaskResult`, not a thrown error: it lands in `results[]`, in the `--ci` blob and in `REPORT.md`, so a machine can read it. A mixed selection still runs what it can — asking for one unported task does not cost you the ones that work.
215
+
216
+ Refusing is deliberate. Emitting the DOM suite for a React Native project would produce specs that run, pass, and touch nothing: `container.querySelector("Pressable")` matches no element, and `getByRole("button")` finds nothing because React Native has no implicit roles. A green suite that tested nothing is the one outcome this tool must never produce.
217
+
218
+ Two things measured on a real Expo app before any of this was written, both recorded in [`docs/archive/2026-08-expo-probes.md`](docs/archive/2026-08-expo-probes.md):
219
+
220
+ - **The `a11y` task was vacuously green on React Native.** Zero findings across 31 files carrying 82 `<Pressable>` elements with no `testID`, `accessibilityLabel` or `accessibilityRole` between them — while the same sweep found 3/3 on this repo's own offender fixture. All 29 rules are HTML/ARIA rules, and React Native has no `<img>`, `<a>` or `<button>`. It needed a different engine, not a different config — the native analyser above is that engine.
221
+ - **The React Compiler changes what `perf-static` should say, and it now listens.** On a project with `experiments.reactCompiler: true`, 136 of 168 findings were the three memoisation rules — work the compiler already does, and advice the React docs call contrary. `perf-static` reads the flag off the project's own configs in **every** mode (`app.json`, `next.config.*`, a babel config or a recognised bundler config naming `babel-plugin-react-compiler`) and suppresses those three rules when it is on, naming the config that proved it in `REPORT.md`. The other six rules are untouched: the compiler relies on correct dependency arrays rather than repairing wrong ones. Where nothing readable answers the question it runs all nine and says the memo findings may be pre-compiled noise — and it never infers the flag from a lockfile, because Next ships `babel-plugin-react-compiler` as a dependency of its own on apps where the compiler is off.
222
+
223
+ ## CLI reference
224
+
225
+ ```text
226
+ flawless-react <entry.tsx> [options]
227
+ ```
228
+
229
+ | Flag | Default | Effect |
230
+ | --- | --- | --- |
231
+ | `<entry.tsx>` | required | Path to the root component file. |
232
+ | `--out <dir>` | `<cwd>/.flawless` (`<project>/flawless` under `--nextjs`) | Output directory for the generated suite. |
233
+ | `--depth N` | `3` | Levels of locally-imported child components to follow (`0` = leaf only). |
234
+ | `--variants N` | `5` | Prop-variant rows in the generated test matrix. |
235
+ | `--changed [REF]` | off | After generating, run only the specs an edit affects — the changed components' unit/browser/profile specs, plus any e2e route whose page *renders* one of them. Defaults to comparing the worktree against `HEAD`. |
236
+ | `--heaps MODE` | `stress` | V8 heap snapshots in the profile suite: `off`, `stress`, `all`. Each snapshot is tens of MB and one is taken either side of every scenario, so `off` also removes an observer from inside the measurement. |
237
+ | `--include-children` | `true` | Walk transitive child components. Pass `--include-children=false` to skip. |
238
+ | `--props '<json>'` | none | Override the synthesized default props. Merged on top of the static-parse result. |
239
+ | `--provider <file>` | none | Path to a `.tsx` file that default-exports a wrapper component. The host renders every mounted component inside it — use this for Redux, React Query, theming, or any other context-based store. |
240
+ | `--expo [<path>]` | Treat `<path>` (or cwd) as an Expo project: read its SDK major, expo-router root, React Compiler and NativeWind settings, and place everything under `<expoRoot>/flawless/`. Mutually exclusive with `--nextjs`. |
241
+ | `--app-resolver <file>` | auto-detected under `--nextjs` | Alias for `--provider` in Next.js mode. Overrides the wrapper derived from `app/layout.tsx` or `pages/_app.tsx`; the file must default-export a wrapper component. |
242
+ | `--api <spec>` | none | Path to an OpenAPI / Swagger JSON or YAML file. Scaffolds a standalone Bun mock server (`mock/server.ts`) with static route handlers generated from the spec's first example responses; the host server transparently proxies all non-asset requests to it. Read by `perf-harness`, and by the MSW emit under `--nextjs` — it does nothing for a `--tasks unit-browser` run outside Next.js mode. |
243
+ | `--colocate` | off | Write unit specs beside their source as `<name>.flawless.test.tsx`. Existing colocated specs are kept unless `--force` — they're the ones you hand-harden. Each opens with a `// @flawless-generated sha256:…` stamp so `--force` can tell an untouched spec from one you edited. |
244
+ | `--ci` | off | Shorthand for `--no-interactive --json`. |
245
+ | `--force` | off | Regenerate the files a plain re-run keeps: colocated specs, e2e route specs, the derived provider wrapper. A colocated spec is only rewritten while its provenance stamp still matches — hand-edited (or pre-stamp) specs are kept and listed; delete one to regenerate it. A re-run already refreshes the out-dir harness; write-once files are never touched under any flag. |
246
+ | `--report` | off | Emit `scripts/report.mjs` so `bun run report` aggregates run output into a markdown summary. |
247
+ | `--tasks <ids>` | `perf-harness` | Comma-separated subset of `perf-harness,unit-browser,perf-static,a11y,react-rules,e2e,load`. Omitting it does **not** run all seven — a non-interactive run with no `--tasks` runs `perf-harness` alone. In a TTY you get the checkbox menu instead, with `perf-harness` and `perf-static` preselected. |
248
+ | `--roots <mode>` | `primary` | `exported` emits one tree per *exported* component — what a whole-app sweep needs, since a shadcn `card.tsx` exports seven of them. |
249
+ | `--exclude <glob>` | see below | Repeatable. Added to the defaults, which already skip `*.flawless.test.*`, `*.test.*`, `*.spec.*`, `*.stories.*`, `*.d.ts`, `node_modules`, `.next`, `dist`, `build`, `coverage` and `flawless/`. A non-default `--out` is not excluded automatically — add it yourself, or a second sweep analyses the suite it just wrote. |
250
+ | `--no-default-excludes` | off | Drop those defaults and use only what `--exclude` names. |
251
+ | `--no-interactive` | off | Never prompt. Required in CI and by any script. |
252
+ | `--run-tests` | off | Run the generated suites after writing them. Only `perf-harness`, `unit-browser` and `e2e` generate a runnable suite — ask for it with none of those selected and the run says so on stderr and records `testRun.mode: "none"`, rather than exiting 0 as if suites had passed. |
253
+ | `--json` | off | Print the machine-readable run result to stdout. |
254
+ | `--no-inline-summary` | off | Skip the per-component `<Component>.flawless.json` sidecars. |
255
+ | `--swagger-url <url>` | auto-probe under `--nextjs` | Fetch the OpenAPI spec over HTTP instead of reading a file. `off` disables the probe entirely. Only consulted in `--nextjs` mode; outside it the value is parsed and never read. |
256
+ | `-h, --help` | — | Print the flag reference above and exit 0. |
257
+ | `-V, --version` | — | Print the version and exit 0. |
258
+ | `-h, --help` | — | Show help. |
259
+ | `-V, --version` | — | Show version. |
260
+
261
+ With `--json` (or `--ci`) the CLI prints `{ exitCode, outDir, results, keptScripts, testRun?, errors? }` and nothing else on stdout — the human summary table is suppressed, and everything `--run-tests`/`--changed` suites say (banners, verdicts, the runners' own reporters) goes to stderr, so stdout always parses. `exitCode` mirrors the process exit code, suite failures included. Without the flag you get the table and no JSON.
262
+
263
+ Two of those fields exist so the exit code is *explicable*, not merely correct:
264
+
265
+ - **`testRun`** — what the test-run phase did. `{ mode: "generated" | "affected", green }` means suites **actually ran**, and `green` is what folded into the exit code; `{ mode: "none", reason }` means **zero suites ran** and says why — nothing selected generates a suite `--run-tests` executes, nothing was affected, or the selected tasks plan no run for the work that was. The invariant to rely on: `mode: "none"` if and only if nothing executed, so a `green` field never appears on a phase that ran nothing. Absent means the run never *reached* the phase — including a request blocked by a failed task, so read absent as "look at `results`", never as "no run was asked for". Without this field, exit 0 conflates *"the suites passed"* with *"no suite ran"*.
266
+ - **`errors`** — failures that belong to no task and so appear nowhere in `results`, such as a Next.js emit that threw. Omitted entirely on a clean run.
267
+
268
+ Suppressing stdout is not a vow of silence: **diagnostics still reach stderr under `--ci`** — a file the sweep skipped, an unparseable OpenAPI spec, a test-script collision, a failed Next.js emit. Those are the lines an unattended caller most needs, and they used to share the flag that silenced the table.
269
+
270
+ ## What gets generated
271
+
272
+ ```text
273
+ <outDir>/
274
+ ├── package.json # standalone deps: react, react-dom, react-scan, @playwright/test
275
+ ├── tsconfig.json
276
+ ├── playwright.config.ts # auto-boots host/server.ts (and mock/server.ts if --api)
277
+ ├── host/
278
+ │ ├── server.ts # Bun.serve over a Bun.build bundle of your component(s)
279
+ │ ├── _polyfill.ts # browser polyfills for process/global/setImmediate
280
+ │ ├── index.html # mounts the host bundle
281
+ │ ├── registry.ts # REGISTRY/FN_PROPS/WRAP_IN mount tables — regenerated every run
282
+ │ └── main.tsx # write-once wrapper: createRoot + <Profiler> + react-scan + window.__profile driver
283
+ ├── profile/
284
+ │ ├── _helpers.ts # render-info / scan-event harvesters, summarize() stats
285
+ │ ├── _heap.ts # CDP-driven heap-snapshot helpers (write to <outDir>/heaps/)
286
+ │ └── <Component>.profile.spec.ts # one per analysed component
287
+ ├── mock/ # only if --api was passed
288
+ │ ├── server.ts # Bun.serve on :5174, host proxies /api/* requests to it
289
+ │ └── routes.ts # static route handlers derived from the OpenAPI spec
290
+ ├── budgets.json # per-component mount/update budgets (warn-only by default)
291
+ ├── scripts/report.mjs # only if --report
292
+ ├── heaps/ # populated at test time — V8 .heapsnapshot files
293
+ └── README.md # how to run + customise the suite
294
+ ```
295
+
296
+ For a deeper walkthrough of each file's role, see [docs/generated-suite.md](docs/generated-suite.md).
297
+
298
+ ## Worked example
299
+
300
+ Take a small dashboard:
301
+
302
+ ```tsx
303
+ // src/Dashboard.tsx
304
+ import type { FC } from "react";
305
+ import { useMemo } from "react";
306
+ import { Hello } from "./Hello.tsx";
307
+ import { Card } from "./Card.tsx";
308
+
309
+ interface DashboardProps {
310
+ title: string;
311
+ count: number;
312
+ }
313
+
314
+ export const Dashboard: FC<DashboardProps> = ({ title, count }) => {
315
+ const items = useMemo(() => Array.from({ length: count }, (_, i) => i), [count]);
316
+ return (
317
+ <main>
318
+ <h1>{title}</h1>
319
+ <Hello name="alice" count={count} active variant="primary" />
320
+ {items.map((i) => <Card key={i} title={`card ${i}`} body="hi" />)}
321
+ </main>
322
+ );
323
+ };
324
+ ```
325
+
326
+ Run:
327
+
328
+ ```bash
329
+ bunx @lariocpt/flawless-react ./src/Dashboard.tsx
330
+ ```
331
+
332
+ The CLI prints something like:
333
+
334
+ ```json
335
+ {
336
+ "outDir": "/your/cwd/.flawless",
337
+ "results": [
338
+ { "id": "perf-harness", "ok": true, "outputs": ["…"], "findings": [], "summary": "scaffolded perf suite for 3 of 3 component(s): Dashboard, Hello, Card" }
339
+ ]
340
+ }
341
+ ```
342
+
343
+ Inside `.flawless/profile/` you get three spec files (`Dashboard.profile.spec.ts`, `Hello.profile.spec.ts`, `Card.profile.spec.ts`). Each spec contains:
344
+
345
+ - **`mounts at defaults`** — baseline mount; asserts `renderCount >= 1`; warns if `mountMs` (from `React.Profiler`'s `onRender` `actualDuration`) exceeds `budgets.json[name].mountMs`.
346
+ - **`variant matrix`** — for `Hello.variant: "primary" | "secondary"`, the analyser produces two variants; the test rerenders with each and warns on the p95 over `updateMs` budget.
347
+ - **`click rerender`** — clicks the target the analyser derived from your source (a test id, an `aria-label`, a role plus its accessible name, an `id`, or literal copy), opening any disclosure it sits behind first, and asserts the render count incremented; if `react-scan` flagged any unnecessary rerender on a prop-stable child, the spec logs a warning. When nothing in the source names a target, the scenario is a skip carrying the reason — never a click on whichever button happens to be first.
348
+ - **`forced rerender × N`** — cycles `--variants × 10` rerenders through the variant pool and prints `min / avg / p50 / p95 / max` from the `Profiler`-fed `renderTime.updates` array.
349
+ - **`stress-click-rerender × 50`** — clicks the primary button 50 times between a baseline and a final V8 heap snapshot (via Playwright's CDP `HeapProfiler.takeHeapSnapshot`). Both `.heapsnapshot` files land at `./heaps/<spec>__<test>__<label>.heapsnapshot` — deterministic names you can `open` straight from the terminal and drop into Chrome DevTools' Memory tab → Comparison view to spot retained objects. The other three scenarios are also bracketed by snapshots, but those are only persisted on failure to keep CI artefacts small.
350
+
351
+ After `bun install && bun run test`, the console shows lines like:
352
+
353
+ ```text
354
+ [Hello variants] {"min":0.4,"avg":1.1,"p50":0.9,"p95":1.8,"max":2.3,"n":2}
355
+ [Hello forced] {"min":0.3,"avg":0.7,"p50":0.6,"p95":1.1,"max":1.6,"n":10}
356
+ [Hello hooks] useState
357
+ [heap] stress-click-rerender: 11896752 -> 12595549 bytes (delta +698797) -> heaps/Hello-profile__Hello-stress-click-rerender-x-50__stress-click-rerender-after.heapsnapshot
358
+ ```
359
+
360
+ ## Working with stores and APIs
361
+
362
+ A lot of the wrapping is already derived per mount — each spec opens with the
363
+ providers its component needs, including generated shims for the common
364
+ library contexts (the generated-suite guide walks the whole wrap story). If
365
+ your component reads from a Redux/Jotai store, or a context the derivation
366
+ cannot reach, wrap it once and pass the wrapper to `--provider`, which renders
367
+ outside the derived nest:
368
+
369
+ ```tsx
370
+ // src/providers/AppProvider.tsx
371
+ import type { ReactNode } from "react";
372
+ import { Provider } from "react-redux";
373
+ import { QueryClientProvider } from "@tanstack/react-query";
374
+ import { store } from "../store";
375
+ import { queryClient } from "../query";
376
+
377
+ export default function AppProvider({ children }: { children: ReactNode }) {
378
+ return (
379
+ <Provider store={store}>
380
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
381
+ </Provider>
382
+ );
383
+ }
384
+ ```
385
+
386
+ ```bash
387
+ bunx @lariocpt/flawless-react ./src/Dashboard.tsx --provider ./src/providers/AppProvider.tsx
388
+ ```
389
+
390
+ If the component fetches data, point `--api` at your OpenAPI / Swagger spec:
391
+
392
+ ```bash
393
+ bunx @lariocpt/flawless-react ./src/Dashboard.tsx --api ./docs/api.yaml
394
+ ```
395
+
396
+ The scaffolder reads the spec at generation time, walks every `paths.<route>.<method>.responses` and emits static handlers in `mock/routes.ts` (priority: first `examples` → `example` → schema-synthesised default). The mock listens on port 5174; the host server (5173) transparently proxies every non-asset request to it, so `fetch('/api/users')` from your component hits the mock without any code changes. Edit `mock/routes.ts` freely after generation to add stateful logic or per-test overrides.
397
+
398
+ ## Programmatic API
399
+
400
+ ```ts
401
+ import { runFlawless, analyse, analyseTree } from "flawless-react";
402
+
403
+ const { results, trees, outDir } = await runFlawless({
404
+ entry: "src/components/Widget.tsx",
405
+ nextjs: ".",
406
+ tasks: ["unit-browser", "perf-static"],
407
+ apiSpec: "openapi.json",
408
+ colocate: true,
409
+ });
410
+ ```
411
+
412
+ It never calls `process.exit`; bad input throws `UsageError`. `analyse()` (entry → `ComponentTree`), `analyseTree()` (the static perf rules as a pure function), the scaffolders and `detectNextjs` are exported too, so custom reporting is a tree walk rather than CLI-output parsing. See [docs/e2e-and-api.md](docs/e2e-and-api.md).
413
+
414
+ ## What the generated unit specs actually assert
415
+
416
+ Everything is derived from static analysis, so the specs only claim what can be proven from the source:
417
+
418
+ - render at defaults, plus a variant matrix for literal-union props;
419
+ - one test per detected interaction, each asserting the **other** handler props were *not* called;
420
+ - a control behind a closed disclosure — a Radix `Dialog`/`Menu`/`Popover` content subtree is not in the DOM until its trigger is clicked — gets an open prelude: the spec clicks the trigger (by prop label, literal text, or its fixture child), clears the spies, then queries with `findBy*` retries;
421
+ - a named local handler is resolved to its declaration — `onClick={handleConfirm}` whose body calls exactly one prop handler *directly* is asserted as that prop, not as "no prop fired";
422
+ - `children` that flow into an `asChild`/Slot site are mounted as a `<span>`, never bare text — slot cloning rejects a text child;
423
+ - initial-state witnesses and handler→setter tests from the `useState`/`useReducer` map;
424
+ - one `describe` per `useEffect` fetch covering **success / loading / error / empty** through MSW overrides, with fixture bodies pulled from your OpenAPI spec;
425
+ - async server components get `await Component(props)` plus `vi.mock` stubs for their data imports (and no browser spec — they can't mount).
426
+
427
+ Anything that could not be derived is left as a `TODO(flawless)` marker rather than a silently-passing assertion. The workflow is: generate, run, fill the TODOs with real fixtures, rerun. With `--colocate`, regeneration won't touch specs you've hardened.
428
+
429
+ ## Agent skills
430
+
431
+ The repo ships **14 skills** under `.agents/skills/`, one per feature area:
432
+ `flawless-react` is the umbrella and router, and the rest go a level deeper —
433
+ `flawless-nextjs`, `flawless-unit-browser`, `flawless-perf-harness`,
434
+ `flawless-perf-static`, `flawless-a11y`, `flawless-react-rules`,
435
+ `flawless-findings`, `flawless-e2e`, `flawless-load`, `flawless-api-fixtures`,
436
+ `flawless-regen`, `flawless-api`, `flawless-expo`. Claude Code, Codex, Cursor and
437
+ OpenCode all read that directory. A skill is what stops an agent guessing at this
438
+ tool: how the browser plane differs from the unit plane, why a perf budget warns
439
+ instead of failing, which findings are suppressible, what a `--changed` run does
440
+ and does not rewrite.
441
+
442
+ ```bash
443
+ flawless-react skills list # what is installed, in both scopes
444
+ flawless-react skills install # pick them in a menu
445
+ flawless-react skills install --all # every skill, no menu (CI, scripts)
446
+ flawless-react skills install --select flawless-react,flawless-e2e
447
+ flawless-react skills install --global # into ~/.agents/skills, every project
448
+ ```
449
+
450
+ Nothing there needs a project, an entry or `--nextjs`: `skills` is the one
451
+ subcommand that touches no code at all, so a global install — or a one-shot
452
+ `bunx` / `npx` — can drop the corpus into `~/.agents/skills` and be finished.
453
+ Scope is a flag, never a menu choice; the menu picks skills, with everything you
454
+ do not already have selected.
455
+
456
+ They go to `.agents/skills/<name>/SKILL.md`, and each also gets its own link at
457
+ `.claude/skills/<name>` — Claude Code looks for skills there, and reads one file, so
458
+ it gets a link per skill and never a directory of things it was not asked to load.
459
+ That is why the install reports two counts:
460
+
461
+ ```
462
+ ✔ installed 14 → /repo/.agents/skills
463
+ linked 14 into /repo/.claude/skills for Claude Code
464
+ ```
465
+
466
+ Three rules worth knowing. **The install never overwrites**: a `SKILL.md` you have
467
+ edited since is kept, named, and is why you may need `--force`. **Nothing in the
468
+ Claude layer can fail an install** — the bytes are written first, and a `.claude`
469
+ directory that is not ours to change is left alone and reported, including one that
470
+ links somewhere else (`--force` included). **Skills are embedded in the binary**, so
471
+ this works from a published install on a machine with no checkout of this repo.
472
+
473
+ `npm install` asks whether to install them; an interactive
474
+ `flawless-react` run asks once at the end, and stops asking as soon as the project
475
+ has one. `FLAWLESS_NO_SKILLS=1` silences both.
476
+
477
+ ## MCP server
478
+
479
+ `flawless-react mcp` serves the same tool over the [Model Context
480
+ Protocol](https://modelcontextprotocol.io): JSON-RPC 2.0, one message per line on
481
+ stdio, so an agent in OpenCode, Claude Code, Codex or Cursor can generate a suite and
482
+ read the analyser's answer without anyone pasting terminal output into a chat.
483
+
484
+ ```json
485
+ { "mcpServers": { "flawless-react": { "command": "flawless-react", "args": ["mcp"] } } }
486
+ ```
487
+
488
+ Eight tools. `flawless_run` and `flawless_skills` change a project, so they run a
489
+ `flawless-react` child with `--ci` and return its real exit code and output;
490
+ `flawless_analyse`, `flawless_findings`, `flawless_project_status`, `flawless_tasks`,
491
+ `flawless_reference` and `flawless_check_docs` read only — files opened, nothing
492
+ written, nothing spawned. A nonzero exit is always an error result carrying the
493
+ child's own output, and any output cap announces itself rather than arriving as a
494
+ shorter answer.
495
+
496
+ `flawless-react mcp --help` lists them from the binary. Arguments, the two planes and
497
+ the honest limits: [docs/mcp.md](docs/mcp.md).
498
+
499
+ ## Limits & assumptions
500
+
501
+ The static analyser is deliberately narrow — it favours predictability over cleverness.
502
+
503
+ - **Component shape**: `const X: React.FC<Props> = …`, exported function declarations, JSX-returning arrows, anonymous `export default (…) => …` (named after the file), and `memo()` / `forwardRef()` wrappers. HOCs, decorators and dynamic component factories are still not detected.
504
+ - **Props type**: same-file types resolve directly; imported types are chased **two hops** — including types from the repo's own **workspace packages** (`@scope/shared`, resolved through the workspace map into the package's source, with barrel re-exports chased to the declaring file). Intersections (`A & B`), host-element props (`ComponentProps<"div">`), `ComponentProps<typeof LocalX>` and cva tables (`VariantProps<typeof buttonVariants>`) all resolve. `z.infer`/`z.input` aliases resolve by reading the zod schema **syntactically** (object/enum/primitive/array/coerce subset with `.optional`/`.nullable`/`.default`/`.partial`/`.extend`; type-invariant refinements like `.min`/`.email` are ignored; `.transform`, unions, records and dates bail). **Unions of non-literals**, `Omit`/`Pick`/`Partial` and anything in `node_modules` do not resolve — they land in `unresolvedProps` (the prop) and `unresolvedTypes` (the type that defeated it). Pass `--props '{…}'` or edit the spec's `DEFAULTS`.
505
+ - **What the specs assert**: literal copy the component renders unconditionally, the root tag, children and `className` passthrough when the value provably reaches the DOM, and that every cva option renders a *distinct* class list. Never a specific Tailwind token — `cn()` is usually tailwind-merge, which drops base classes a later variant conflicts with. The witness for distinctness is an element that carries a class, so a component whose render carries none at all (Radix's `Toast.Root` hands jsdom one without) skips that test with the reason instead of failing every option in the table.
506
+ - **Which render is described**: the JSX the component produces at the props the spec passes, not the last `return` in the body. A guard the analyser can decide is followed; one it cannot, that returns nothing, falls through to the tail and is flagged; one it cannot, that returns content, produces no description at all.
507
+ - **Mocks**: emitted from a component's OWN imports. Client specs mock an allowlist only (`next/navigation`, `next/headers`, `server-only`, `next/link`, `next/font/*`, and any `"use server"` module) — mocking a `cn` util would strip every className off the markup. A component that renders `<Link>` only through a child gets no `next/link` mock in its own spec. A server action whose source returns a single literal value is stubbed with that value rather than `{}`, and its TODO marker is dropped once every export of the module resolves. Data deps get the same courtesy from their declarations: a `Promise<T>` return annotation (or a single-`fetch` body joined to the OpenAPI example) becomes the mock's fixture, marked with an `/* inferred from … */` provenance comment — and the declaration may live in another workspace package, behind its barrel. An `interface X extends Y` merges its bases, own members last, and bails whole if any base does not resolve. An export wrapped in `cache()` or `unstable_cache()` is unwrapped — those hand back the same call signature, so the inner annotation is the effective return type; any other wrapper still bails, because it may not. A type re-exported from an import (`import type { User } …; export type { User };`) is chased through, which is how a package republishes a type it does not own. A helper that is called but never awaited into a variable — a formatter used inside JSX — is read from its declaration too, and stubbed **synchronously** when it is synchronous: `formatZAR(cents: number): string` mocked as `async () => ({})` renders every money value as an object.
508
+ - **How an element is addressed**: test id, then `aria-label`, then role + accessible name, then `id`, then literal copy. The last resort is the bare host tag — and only when it is the only element of its kind in that render, counting `<Button>` towards `button`. A tag that has competition names nothing, so the test becomes a skip that says so rather than a click on whichever element happened to be first.
509
+ - **How a form is submitted**: from the markup, never from likely wording. A host `<button>` inside a form submits it unless its `type` says otherwise; a component tag counts only with an explicit `type="submit"`, because shadcn and base-ui both render `type="button"` by default. No submit control in the source means no submit test.
510
+ - **How a field is filled**: from its `tag` and `type`. Checkboxes and radios are checked, `<select>`s selected, and hidden/file/range/color inputs skipped — a value typed into them asserts nothing. A change handler on a `<form>` is treated as the event delegation it is, not as a text box.
511
+ - **What a mount spec may assert about markup**: an unguarded component must render something. A guarded one — `if (!user) return null` behind a store hook — may legitimately render nothing, so the claim becomes conditional on the copy it would have printed. A third case is delegation: a component whose whole output is a Radix disclosure primitive (`Toast.Root`, `Dialog.Root`, `Popover.Root`…) has an empty container as a legitimate default, because the library decides nothing renders until something opens it. That spec skips the markup claim by name, and only when the container really is empty — the list is those primitives, not `@radix-ui/` wholesale, because Slot, Label and Switch render eagerly. What is always asserted is the mount itself: no console error, and `mount()` resolving means the host saw a committed render of that component by name. A component that owns literal copy and renders nothing still goes red, because that one IS broken.
512
+ - **What the perf and browser hosts cannot run**: they bundle the component without Next's router and without a database, so those get stand-ins — `next/link`, `next/image`, `next/script` (an inert `<script type="text/plain">` rendered in place: `src` is not fetched, inline code does not run, and third-party analytics stays out of a benchmark), `next/router` (navigation recorded, never performed) and a signed-out stub for the auth libraries. Database drivers — Prisma, `pg`, mongo, kysely, drizzle, the Supabase SDK — resolve to an inert proxy, so a component that queries one renders with no data. A named stub always outranks a package-pattern stub for the same specifier; the auth-helpers stub sitting unused behind a `@supabase/*` pattern match was five components timing out on `useSessionContext is not a function`. What is deliberately NOT invented is a chainable client: a component that awaits a real backend in a mount effect or a submit handler still rejects, and the plane says so. Green with fabricated rows is worse than red that names the cause.
513
+ - **What a mount spec may assert about the console**: a component that calls `console.error`, or `.error()` on an imported binding, reports errors by design. Its browser spec keeps the uncaught-exception and rendered-output assertions and drops the console one, naming the call it saw.
514
+ - **Where a compound part is mounted**: a root tag HTML confines to a particular parent (`<tbody>`, `<tr>`, `<option>`, `<li>`…) is mounted inside that chain in the browser host. That is the specification, not an inference about your app; without it React logs a nesting warning the harness itself provoked.
515
+ - **Type → default value**: primitives, arrays (always `[]`), inline object types (recursive), literals, and `Promise<T>` (synthesised unwrapped, re-wrapped at render time for Next.js `params`). A string prop defaults to `""` — except under a Next page's `params`, where an empty segment is a request Next cannot route, so it is filled with `flawless-<key>` instead. Same rule one layer down: a top-level string prop ending in `Id` never stays empty, because providers *validate* ids rather than render them — Liveblocks threw `RoomProvider id property is required` over the `""` the fixture passed through. Where no default can be synthesised at all, the prop is named under REPORT.md's `## Spec quality` — bucketed per component, and counted apart from `perf-static`'s findings, because it is a limit of this fixture table rather than a rerender risk in your app: the spec passes `undefined` until `--props` or that spec's `DEFAULTS` fills it.
516
+ - **What counts as closed**: a Radix-style `Dialog`/`Menu`/`Popover`/`Tooltip` content subtree gated by a trigger is content that renders only after a click. Static-copy assertions skip it entirely; interaction and form tests carry the gate and open it first (unit and browser specs — route e2e specs do not yet open dialogs). A root written `open`, `defaultOpen` or `open={true}` mounts its content right away, so nothing is gated; `open={expr}` stays gated, because the initial value is render state this structural pass cannot see.
517
+ - **How a binding is classified**: a prop handler becomes an assertion on that spy; an inline arrow becomes one on the prop it calls; a named local (`onClick={handleConfirm}`) is resolved to its declaration once, and only upgraded to a prop assertion when the body calls **exactly one** prop handler as a plain statement — two props claimed by one handler means it claims neither, and the test stays a canary that dispatches and asserts nothing fired.
518
+ - **Fixtures satisfy the component's own predicates.** A field the source compares with `===` to a literal takes that literal, and a field compared to one of the component's props takes that prop's value, read from the same fixture the spec's `DEFAULTS` carries. Without this a page that filters `m.status === "active"` rejects every synthesised row, calls `notFound()`, and its spec passes having rendered nothing — which is what 22 of 32 page specs did on the dogfood app. `!==` is not used: it says what a value must not be, which does not pick one out.
519
+ - **Variant matrix**: only union-of-literal props produce variants. The matrix is "pairwise" — vary one prop at a time around the defaults, not the full cartesian product.
520
+ - **Children**: relative imports, tsconfig `paths` aliases (with `extends` chains), **and** the repo's own workspace packages; member tags like `<UI.Badge/>`, namespace imports, and same-file components. Anything in `node_modules` is skipped. Depth defaults to 3, cycle-guarded.
521
+ - **Hooks**: matched by name pattern `/^use[A-Z]/`, with per-call-site detail — dependency arrays, callback reads, and effect features (fetch/timers/listeners/cleanup) — feeding the dependency findings and the generated fetch specs.
522
+ - **Everything bails rather than guesses.** Dependency findings skip hooks whose deps contain member access or spreads, or whose callback isn't inline; memoisation findings skip children with spread props or identifiers the analyser can't resolve. There is no type checker, so a clean report means "nothing this analysis can prove", not "nothing wrong".
523
+
524
+ ## Troubleshooting
525
+
526
+ **"could not locate a React component in …"**
527
+ The analyser didn't recognise the export shape. Refactor to a `const X: React.FC<Props> = …` declaration, or pass `--include-children=false --depth=0` and run the tool against the actual component file directly.
528
+
529
+ **`unresolvedProps` warning**
530
+ One or more props use an imported type, a generic, or a non-literal union. Either:
531
+
532
+ - Re-run with `--props '{"propName": "value", …}'`, or
533
+ - Edit the generated `profile/<Component>.profile.spec.ts` and add the missing fields to the `DEFAULTS` constant.
534
+
535
+ **Generated suite fails on `bun install` with a peer-dep mismatch**
536
+ The suite's `react`/`react-dom` follow the app's own major (React 17, 18 and 19 are all supported), and Testing Library follows the app too: a React ≤17 app gets `@testing-library/react@^12.1.5` plus `@testing-library/dom@^8.20.1`, because 13/14 peer-require `react ^18` and 14 hard-requires the `react-dom/client` entry React 17 does not have. A stale 13+ range left by an older scaffold run is repaired on the next one. If a mismatch persists, two React copies are in the tree — find the second one before touching the pins.
537
+
538
+ **`ERR_PNPM_IGNORED_BUILDS` and the app's own `pnpm run dev` dies**
539
+ pnpm ≥10 treats an unrecorded build-script decision as an error, and `msw` and `@playwright/test` — packages the dependency merge introduces — both ship postinstall scripts. The merge records the decision by writing `pnpm.ignoredBuiltDependencies` for its own deps (anything you approved via `onlyBuiltDependencies` stays approved), so the app boots. If it resurfaces after you hand-edited dependencies, add the offending package to that same list.
540
+
541
+ **Playwright can't find a browser**
542
+ First time you run a generated suite, you may need `bunx playwright install chromium`. If a Playwright upgrade pulled in a new browser version, run the same command again.
543
+
544
+ **`window.__profile` is undefined inside tests**
545
+ The host bundle didn't load. Check the Playwright stderr — `bun host/server.ts` should print `host listening on http://127.0.0.1:5173`. If it doesn't, `cd` into the suite and run `bun host/server.ts` directly to surface the build error. A spec that fails with `ReferenceError: <name> is not defined` while the host build printed *no* error is the same failure in disguise: a resolve plugin answered a bare specifier it should have left alone, and the module was silently replaced by one lacking that export.
546
+
547
+ **Component reads `useSelector` / `useContext` / etc. and throws "could not find … in the context"**
548
+ Most of these contexts are already wrapped by the derived nest (router, intl, query client, and your app's own convention-named providers); if the mount still throws, the provider is one the derivation cannot see. Pass `--provider <file>` pointing at a `.tsx` that default-exports a wrapper component including your Redux Provider, QueryClientProvider, ThemeProvider, etc. — it renders outside the derived nest, and both the host and the unit specs get it. Under `--nextjs` the auto-derived wrapper skips any provider layer whose module directly imports an auth library (`@clerk/*`, `next-auth`): the unit and browser planes replace those libraries with a signed-out stub, so a provider that gates its children on a session renders *nothing* and every spec silently inherits the blank screen — while the stub already satisfies the context reads auth-consuming components make. Supply your own `--provider` if you want that layer mounted under test.
549
+
550
+ **A module throws at import time under the suite ("… URL is required")**
551
+ Something reads an env var at module scope. The suite's Vitest config loads the app's `.env.test.local`, `.env.test`, `.env.local`, `.env`, and `.env.example` — in that order, first value wins, and `.env.example` rides last because template repos ship only the example and its placeholder values exist precisely to satisfy module-scope construction. Keys already in the environment beat all of them: export an override to redirect the suite.
552
+
553
+ **Every e2e route 404s or redirects while `/` loads fine in a browser**
554
+ The app's own middleware is an auth wall — Clerk's `auth.protect()`, a `next-auth` matcher — and with placeholder keys every route behind it answers 404 or bounces to sign-in. That is the app working, not the suite breaking: fill real credentials in `flawless/e2e/global-setup.ts`, or do not claim e2e coverage for the routes behind the wall.
555
+
556
+ **The e2e run dies with "Timed out waiting 120000ms from config.webServer"**
557
+ Same wall, one stage earlier: Playwright counts a server as up only on a 2xx or 3xx from the readiness URL, and it probes the app root. A root the middleware 404s is therefore a server that never starts, and you get a timeout with no spec run and no assertion — the `[WebServer]` log showing `GET / 404` over and over is the tell. Credentials make the root answer 200 or a sign-in redirect and the run proceeds. To drive a server you started yourself, export `FLAWLESS_E2E_BASEURL` (the emitted config drops its own `webServer` block for that) or repoint the config's readiness url at a route that answers.
558
+
559
+ **Component fetches `/api/...` and gets a 404**
560
+ Either pass `--api <openapi.json|yaml>` to scaffold a mock server, or edit the generated `mock/routes.ts` after the fact (when `--api` was passed) to add the missing route. Without `--api`, no mock server is generated and `/api/*` requests return 404 from the host server.
561
+
562
+ **Mock server says `YAML parsing requires Bun >= 1.2`**
563
+ Either upgrade Bun, or convert your spec to JSON: `bunx js-yaml api.yaml > api.json` and pass the JSON file to `--api`.
564
+
565
+ ## Further reading
566
+
567
+ - [docs/architecture.md](docs/architecture.md) — how the analyser and scaffolder fit together.
568
+ - [docs/generated-suite.md](docs/generated-suite.md) — anatomy of the emitted suite, file by file.
569
+ - [docs/extending.md](docs/extending.md) — patterns for editing generated specs without regretting it.
570
+ - [docs/e2e-and-api.md](docs/e2e-and-api.md) — route e2e, crust load pipes, and the programmatic API.
571
+ - [docs/mcp.md](docs/mcp.md) — the MCP server: eight tools for an agent, and what each one refuses to claim.
572
+
573
+ ## Development
574
+
575
+ ```bash
576
+ bun install
577
+ bun test # the full suite across analyse / scaffold / tasks / nextjs / core / cli
578
+ bun run check # biome lint + format check
579
+ bunx tsc --noEmit # type-check
580
+ ```
581
+
582
+ The repo layout:
583
+
584
+ ```text
585
+ src/
586
+ ├── analyse/ # ts.createSourceFile AST walk — no type checker
587
+ │ ├── parseFile.ts # every component in a file; export kind, async, "use client"
588
+ │ ├── resolveModule.ts # tsconfig paths + extends chains
589
+ │ ├── extractProps.ts # defaults, variants, positions, promise props
590
+ │ ├── extractState.ts # useState/useReducer/useRef map + setter call sites
591
+ │ ├── extractHooks.ts # hook names + per-call-site detail (deps, effects)
592
+ │ ├── extractPropUsage.ts # where each prop is used, classified
593
+ │ ├── extractChildAttrs.ts# value shapes passed to component children
594
+ │ ├── extractFetchCalls.ts# fetch URLs as :hole templates
595
+ │ ├── extractInteractions.ts
596
+ │ ├── refs.ts # shared ComponentScope + reference collection
597
+ │ └── walkChildren.ts # recursive tree, alias- and member-tag aware
598
+ ├── scaffold/ # perf harness templates + host-style adoption
599
+ ├── scaffold-tests/ # unit + browser spec templates
600
+ ├── nextjs/ # detection, api routes, mocks, provider wrapper, routes
601
+ ├── tasks/ # one module per TaskId (7)
602
+ ├── reporters/, runner/
603
+ ├── core.ts # runCore — the CLI's engine, no process.exit
604
+ ├── cli.ts # argparse + interactive menu
605
+ ├── index.ts # public programmatic API
606
+ └── types.ts
607
+ tests/ # fixtures/ includes a static Next.js app
608
+ ```
609
+
610
+ ## Licence
611
+
612
+ MIT — see [LICENSE](LICENSE) if present, otherwise treat as MIT.