@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.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flawless-expo
|
|
3
|
+
description: Work with flawless-react's Expo / React Native mode — what --expo detects, which tasks run and which refuse, the jest-expo + @testing-library/react-native unit plane, renderRouter vs render, the native selector ladder and why a skip is a skip, and the version pairing that has to be exact. Use when generating or fixing native specs, when a native suite is red, or when deciding whether a refusal is a bug or the design.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# flawless-expo
|
|
7
|
+
|
|
8
|
+
React Native is a **second element vocabulary**, not a Next.js variant. `<View>` is
|
|
9
|
+
capitalised and imported; there is no `<form>`, no implicit ARIA role, and
|
|
10
|
+
`className` is compiled away by babel before anything can observe it. Most of what
|
|
11
|
+
this skill records is the consequences of those four facts.
|
|
12
|
+
|
|
13
|
+
## What `--expo` detects
|
|
14
|
+
|
|
15
|
+
`detectExpo(root)` reads, off disk only:
|
|
16
|
+
|
|
17
|
+
| field | from | why it matters |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `sdkMajor` | the project's own `expo` range, else what is installed | jest-expo's major must **match** it |
|
|
20
|
+
| `routerRoot` | `app/` or `src/app/`, **only if** `expo-router` is a dependency | decides which files get the route template |
|
|
21
|
+
| `reactCompiler` | `app.json` `experiments.reactCompiler`, or `babel-plugin-react-compiler` | the three `perf-static` memo rules are suppressed, with the config that proved it named in REPORT.md |
|
|
22
|
+
| `nativewind` | `babel.config.js` mentioning nativewind | decides whether an unstyled host is honest to ship |
|
|
23
|
+
| `webPort` | the `web` then `start` script, default **8081** | Metro's port, not 3000 |
|
|
24
|
+
|
|
25
|
+
`sdkMajor` is `number | undefined` and **never guessed**. `latest`, `*`, a git url and
|
|
26
|
+
a `file:` specifier all produce *unknown*, and the run says so rather than substituting
|
|
27
|
+
a number — a jest-expo major that does not match the SDK fails deep inside
|
|
28
|
+
`expo-modules-core` with a message that names nothing.
|
|
29
|
+
|
|
30
|
+
## The version pairing, which has to be exact
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
jest-expo ~<the project's OWN sdk major>.0.0
|
|
34
|
+
jest ^29.7.0
|
|
35
|
+
@testing-library/react-native ^13.3.3 <- 13, NOT 14
|
|
36
|
+
react-test-renderer 19.1.0
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Each wrong answer fails somewhere unhelpful:
|
|
40
|
+
|
|
41
|
+
- **A bare `jest`** resolves to 30 while jest-expo 54's tooling is all `^29.2.1`, and the
|
|
42
|
+
mismatch surfaces as `this._moduleMocker.clearMocksOnScope is not a function` from
|
|
43
|
+
inside jest-runtime.
|
|
44
|
+
- **RNTL 14** replaced `react-test-renderer` with the `test-renderer` package, and
|
|
45
|
+
expo-router 6's own testing-library is built against the former.
|
|
46
|
+
|
|
47
|
+
If you are tempted to "upgrade" any of these, run the suite. That is what caught all of it.
|
|
48
|
+
|
|
49
|
+
## Three spec shapes, chosen from the file
|
|
50
|
+
|
|
51
|
+
**Route screen** → `renderRouter` from `expo-router/testing-library`, at its own URL.
|
|
52
|
+
Not a preference: `useFocusEffect` reaches `useNavigation()`, which throws
|
|
53
|
+
`Couldn't find a navigation object` outside a navigator, and `useLocalSearchParams`
|
|
54
|
+
is empty. Inside a real navigator the screen is **focused**, so focus effects run and
|
|
55
|
+
the spec can assert loaded state instead of the loading skeleton.
|
|
56
|
+
|
|
57
|
+
**`_layout`** → its own navigator with one synthesised child. `<Tabs>` and `<Stack>`
|
|
58
|
+
call `useContextKey` and throw `No filename found. This is likely a bug in expo-router`
|
|
59
|
+
when rendered alone.
|
|
60
|
+
|
|
61
|
+
**Everything else** → RNTL `render()`.
|
|
62
|
+
|
|
63
|
+
### The context is the OBJECT form, always
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
const CONTEXT = {
|
|
67
|
+
"_layout": Layout0,
|
|
68
|
+
"login": { default: LoginScreen },
|
|
69
|
+
"signup": { default: () => null }, // a stub for every literal push target
|
|
70
|
+
};
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
expo-router resolves the string and `{ appDir }` forms through
|
|
74
|
+
`path.resolve(process.cwd(), …)`, so a spec using either breaks the moment jest runs
|
|
75
|
+
from the monorepo root instead of the app directory. The stub leaves are not
|
|
76
|
+
decoration: a push to a route the context does not know lands on expo-router's
|
|
77
|
+
injected `+not-found`, and the pathname assertion then fails for the wrong reason.
|
|
78
|
+
|
|
79
|
+
### fireEvent in route specs, userEvent in component specs
|
|
80
|
+
|
|
81
|
+
`renderRouter` calls `jest.useFakeTimers()` internally. `userEvent`'s press timing
|
|
82
|
+
under fake timers is not something the emitter vouches for, so route specs use
|
|
83
|
+
`fireEvent`, which has no timers in it. Do not "upgrade" one to the other.
|
|
84
|
+
|
|
85
|
+
## The selector ladder
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
testID → accessibilityLabel → accessibilityRole (+name) → placeholder
|
|
89
|
+
→ displayValue → text → text fragment → undefined
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`placeholder` sits **above** text deliberately: a placeholder names *the element*,
|
|
93
|
+
text names a *child*.
|
|
94
|
+
|
|
95
|
+
**There is no last-resort rung, and that is a decision.** The DOM ladder bottoms out at
|
|
96
|
+
`container.querySelector("button")`; the two native candidates are both unsound.
|
|
97
|
+
`UNSAFE_getByType(Pressable)` compares component identity, and under NativeWind's
|
|
98
|
+
`jsxImportSource` the `Pressable` the source rendered went through `cssInterop`'s
|
|
99
|
+
wrapper while the one a spec imports is the bare component — so it matches zero
|
|
100
|
+
elements, and `fireEvent.press` on nothing is a green test that touched nothing.
|
|
101
|
+
"The first pressable in the tree" is wrong for the same reason the DOM ladder refuses
|
|
102
|
+
an ambiguous bare tag, only more often, because every screen is a stack of Pressables.
|
|
103
|
+
|
|
104
|
+
### A skip is not a gap in the tool
|
|
105
|
+
|
|
106
|
+
`getByRole("button")` matches **nothing** on a bare `<Pressable>`: RNTL's `getRole()`
|
|
107
|
+
reads the explicit `accessibilityRole` prop, else `"text"` for `Text`, else `"none"`.
|
|
108
|
+
There is no implicit-role table to lean on.
|
|
109
|
+
|
|
110
|
+
So a control with no `testID`, no `accessibilityLabel`, no `accessibilityRole` and no
|
|
111
|
+
text under it is a `test.skip` carrying the reason **and the fix**. The icon-only
|
|
112
|
+
wording wins over the ambiguous one when both apply, because one `accessibilityLabel`
|
|
113
|
+
resolves both — and the same absence is what makes the control silent to a screen
|
|
114
|
+
reader. **Answer a skip by adding the label to the app, not by loosening the ladder.**
|
|
115
|
+
|
|
116
|
+
## What a native spec does NOT assert
|
|
117
|
+
|
|
118
|
+
Three DOM assertions have no honest native equivalent and are dropped rather than faked:
|
|
119
|
+
|
|
120
|
+
- the root-tag `querySelector` — `rootTag` is a capitalised element name, and the only
|
|
121
|
+
RNTL equivalents couple the spec to RN internals;
|
|
122
|
+
- the `.flawless-cls` className-flow check — NativeWind compiles `className` into a
|
|
123
|
+
`style` through **babel**, so a sentinel class is unobservable in the jest-expo tree
|
|
124
|
+
*and* in the react-native-web DOM;
|
|
125
|
+
- anything asserting a specific style token, for the reason the DOM emitter refuses to
|
|
126
|
+
assert a Tailwind class.
|
|
127
|
+
|
|
128
|
+
## No emitted spec touches the network
|
|
129
|
+
|
|
130
|
+
A module that resolves project-locally and performs `fetch()` is `jest.mock`ed. Before
|
|
131
|
+
this existed the fixture suite made live HTTP calls and crashed a jest worker with
|
|
132
|
+
`api_error 405` from a real server.
|
|
133
|
+
|
|
134
|
+
Each stub's shape comes from the mocked function's own `Promise<T>` annotation, resolved
|
|
135
|
+
through workspace barrels and zod schemas. `Promise<void>` mocks to `undefined` with no
|
|
136
|
+
marker — that IS its realistic return value. Only an unresolvable shape falls back to
|
|
137
|
+
`{}`, and only that one keeps a `TODO(flawless)`. The fallback matters: `const { items }
|
|
138
|
+
= await listBuildings()` against `{}` binds `items` to undefined, and the next
|
|
139
|
+
`buildings.length` throws into the app's own ErrorBoundary, so the screen renders
|
|
140
|
+
"Something went wrong" and the failure looks like the component.
|
|
141
|
+
|
|
142
|
+
Collections are emptied for the default mock, so assertions derived from the initial
|
|
143
|
+
render hold. The loaded state gets its own test — see below.
|
|
144
|
+
|
|
145
|
+
The mock specifier is relativised to the **spec**, not the component — `app/login.tsx`
|
|
146
|
+
writes `"../lib/api"`, which from `flawless/unit/` names `flawless/lib/api`.
|
|
147
|
+
|
|
148
|
+
## The loaded state gets its own test, where that is fair
|
|
149
|
+
|
|
150
|
+
A separate test re-mocks ONE function to return a single row and asserts a synthesised
|
|
151
|
+
witness appears. Emitted only where three things hold, each checked against the AST:
|
|
152
|
+
|
|
153
|
+
1. **Mounting calls it** — reachable from a `useEffect`/`useFocusEffect` callback or the
|
|
154
|
+
component body, and not inside a branch, a `catch`, or a JSX event handler. A mutation
|
|
155
|
+
called from a submit handler is not a loader; a call behind `if (isTrustee)` does not run.
|
|
156
|
+
2. **What it returns is rendered as a list** — the result reaches a `useState` setter whose
|
|
157
|
+
state variable is mapped in JSX or handed to a list as `data`. A collection read to decide
|
|
158
|
+
a permission flag changes a boolean, not the screen.
|
|
159
|
+
3. **The row has a witness** — a string field THIS list's row renders as a JSX child. Not an
|
|
160
|
+
attribute value (`key={r.id}` is not displayed), and not a field rendered elsewhere in the
|
|
161
|
+
file over different data.
|
|
162
|
+
|
|
163
|
+
Where any fails, the emitter writes a `test.skip` naming which. On the dogfood app 14 of 31
|
|
164
|
+
screens qualify and all 14 pass.
|
|
165
|
+
|
|
166
|
+
An earlier version shipped this as a blanket skip, blaming "an external store the harness
|
|
167
|
+
does not reach". That was a guess and it was wrong — every cause was visible in the AST.
|
|
168
|
+
|
|
169
|
+
## `router.back()` needs a history entry
|
|
170
|
+
|
|
171
|
+
`renderRouter` mounts one screen at one URL with nothing behind it, so `GO_BACK` into an
|
|
172
|
+
empty stack fails with *The action 'GO_BACK' was not handled by any navigator*. A route
|
|
173
|
+
spec pushes its own URL once before the press. A component spec has no router to push into,
|
|
174
|
+
so the handler is a skip that says so.
|
|
175
|
+
|
|
176
|
+
## Colocation is impossible under `app/`
|
|
177
|
+
|
|
178
|
+
expo-router's `require.context` matches every `.[tj]sx?` under the router root, so a
|
|
179
|
+
colocated `app/login.flawless.test.tsx` would **register as a route**. Native specs for
|
|
180
|
+
route files always go to the out dir.
|
|
181
|
+
|
|
182
|
+
Related: check the app's own `test` script. A bare runner with no path filter — the
|
|
183
|
+
dogfood app's was `bun test --preload ./tests/setup.ts` — sweeps the generated specs
|
|
184
|
+
into the project suite and runs them under a runner they were not written for. The tool
|
|
185
|
+
warns; scope the script (`bun test tests`).
|
|
186
|
+
|
|
187
|
+
## The native a11y analyser (wave 5)
|
|
188
|
+
|
|
189
|
+
`a11y` runs under `--expo` since wave 5, through the tool's OWN analyser
|
|
190
|
+
(`src/tasks/a11yNative.ts`) rather than biome — biome structurally cannot judge RN:
|
|
191
|
+
all 29 of its a11y rules are HTML/ARIA, and over 31 real RN files with 82 unlabelled
|
|
192
|
+
`<Pressable>`s they returned zero while scoring 3/3 on the DOM `A11yOffender.tsx`.
|
|
193
|
+
|
|
194
|
+
Six rules: `a11y/unnamed-control` (provably-textless press target with no label —
|
|
195
|
+
fires only on icon-only or childless elements, never on dynamic children, which DO
|
|
196
|
+
announce at runtime), `a11y/missing-role`, `a11y/textinput-unlabelled` (a placeholder
|
|
197
|
+
is not a label), `a11y/icon-unhidden` (warn; a render-prop icon such as `tabBarIcon`
|
|
198
|
+
stands down — its owner is invisible to the file, and React Navigation's tab button
|
|
199
|
+
announces the title over it), `a11y/label-text-mismatch` (WCAG 2.5.3),
|
|
200
|
+
`a11y/invalid-role` (each role attr validated against ITS OWN union — "header" is an
|
|
201
|
+
accessibilityRole, "heading" is a role).
|
|
202
|
+
|
|
203
|
+
Three contracts to rely on:
|
|
204
|
+
|
|
205
|
+
- **The denominator.** The summary and `reports/a11y.json` always say how many press
|
|
206
|
+
targets / text inputs / icons were examined and how many rules ran. A zero without a
|
|
207
|
+
denominator is a bug, not a clean bill.
|
|
208
|
+
- **Presence-not-value stands rules down.** `accessibilityLabel={t("back")}` is recorded
|
|
209
|
+
as dynamic and never flagged. A missing finding on a dynamic attr is the design.
|
|
210
|
+
- **Skip ⇔ finding agreement.** An icon-only control with no handle is BOTH the unit
|
|
211
|
+
plane's `test.skip` and an `a11y/unnamed-control` finding — same retained element,
|
|
212
|
+
one `accessibilityLabel` retires both. Deliberate asymmetries: a `testID`-only
|
|
213
|
+
control is addressable to a spec but still flagged (a testID is not a name), and a
|
|
214
|
+
dynamic-text control is a ladder skip but NOT flagged.
|
|
215
|
+
|
|
216
|
+
## Route e2e against Expo web (wave 6)
|
|
217
|
+
|
|
218
|
+
`e2e` runs under `--expo` since wave 6: one Playwright spec per expo-router route,
|
|
219
|
+
against `expo start --web`. The Next.js flavour's machinery carries over
|
|
220
|
+
(params.json ids, write-once global-setup with the hand-work tally, `landing`
|
|
221
|
+
overrides, `GET <route>` describe titles for `--changed` narrowing); the Expo facts
|
|
222
|
+
baked in, each measured first (`docs/archive/2026-09-expo-probes.md`):
|
|
223
|
+
|
|
224
|
+
- Metro's statuses are REAL — unknown routes 404, per-route rendered HTML — so the
|
|
225
|
+
status assertion keeps teeth on a dev server.
|
|
226
|
+
- Auth is a **cookie on the API origin** (the app stores no token on web); the setup
|
|
227
|
+
stub's request-context login + storageState example is the whole job. Set
|
|
228
|
+
`EXPO_PUBLIC_API_URL` first — Metro inlines it, and the unset fallback is whatever
|
|
229
|
+
the source hardcodes.
|
|
230
|
+
- **Two auth-wall faces, three assertions.** A client redirect fails the landing
|
|
231
|
+
assertion; a wall rendered IN PLACE fails the console assertion (the API's 401s)
|
|
232
|
+
and the **copy witness** — a third test asserting the screen's longest
|
|
233
|
+
analysis-proven literal renders, the claim that cannot be true of a wall.
|
|
234
|
+
- An app whose cold start always lands on `/login` (no session hydration) is a
|
|
235
|
+
`landing` override in params.json — recorded truth, never a spec edit.
|
|
236
|
+
|
|
237
|
+
## API-only load pipes (wave 7)
|
|
238
|
+
|
|
239
|
+
`load` runs under `--expo` since wave 7, API-only — an Expo app has no
|
|
240
|
+
server-rendered page routes, so the pipes are the GET endpoints the screens hit.
|
|
241
|
+
The harvest is the new part: a React Native screen never has a bare `fetch()`
|
|
242
|
+
(`extractFetchCalls` finds 0 on the whole app), so the endpoint is read ONE hop
|
|
243
|
+
through the app's own fetch wrapper — screen calls `listBuildings()` →
|
|
244
|
+
`apiFetch("/api/buildings")`. The wrapper is detected structurally (its single
|
|
245
|
+
`fetch()` addresses one of its own parameters), never by name. A store-mediated
|
|
246
|
+
call, a non-literal URL, or a wrapper two modules deep is dropped — a missing
|
|
247
|
+
endpoint costs a pipe, a wrong one measures a URL the app never sends. Retained
|
|
248
|
+
at analysis time on `ComponentInfo.apiCalls` (native-only), judged at task time.
|
|
249
|
+
|
|
250
|
+
The pipe/params/env/merge machinery is the Next flavour's, reused verbatim. The
|
|
251
|
+
one difference: `routes.setup.ts` probes a parameterless GET endpoint and fails
|
|
252
|
+
on a **401**, because an API endpoint answers 401 at the same URL rather than
|
|
253
|
+
redirecting to a login page — so the Next flavour's redirect probe would pass on
|
|
254
|
+
a dead session. Mutations are excluded (a POST load-test writes junk into the DB).
|
|
255
|
+
|
|
256
|
+
## Tasks that refuse, and why that is the design
|
|
257
|
+
|
|
258
|
+
`perf-harness`
|
|
259
|
+
refuses as **measured-and-declined, not deferred**: on an expo-router app the
|
|
260
|
+
population is route screens, which the browser host cannot mount (navigator hooks;
|
|
261
|
+
renderRouter is jest-bound), so the measured ceiling on the dogfood app was ~0 of
|
|
262
|
+
32 components — the refusal points at `docs/archive/backlog-completed-2026-10.md` B-16 and the probes
|
|
263
|
+
ledger, and the revisit condition is an adopter with a real `components/` directory
|
|
264
|
+
of non-route components. And one runner reads differently rather than refusing:
|
|
265
|
+
|
|
266
|
+
- **`perf-static` suppresses its memo rules under the React Compiler.** On a project
|
|
267
|
+
with `experiments.reactCompiler: true`, 136 of 168 findings were
|
|
268
|
+
`perf/unstable-child-prop`, `perf/missing-memo-leaf` and
|
|
269
|
+
`perf/no-memoisation-with-children` — work the compiler already performs, so the
|
|
270
|
+
three are suppressed and REPORT.md names `app.json experiments.reactCompiler` as
|
|
271
|
+
the evidence. The hook-dependency, effect and `set-state-in-render` rules are
|
|
272
|
+
unaffected: the compiler relies on correct dependency arrays rather than
|
|
273
|
+
repairing wrong ones.
|
|
274
|
+
|
|
275
|
+
## Never
|
|
276
|
+
|
|
277
|
+
- Never drop `--expo` to make a task run. It will emit DOM artefacts for a React Native
|
|
278
|
+
project: a suite that runs, passes and tests nothing.
|
|
279
|
+
- Never mock `react-native`. That is exactly why an app whose own setup does it cannot
|
|
280
|
+
test screens; jest-expo exists so you do not have to.
|
|
281
|
+
- Never add a row to `NATIVE_TAG_TO_ROLE` unless the element's own React Native source
|
|
282
|
+
sets the role unconditionally. A fuller table means queries that match nothing.
|
|
283
|
+
- Never satisfy a skip by relaxing the selector ladder. Add the `accessibilityLabel`.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flawless-findings
|
|
3
|
+
description: Triage the findings flawless-react reports — REPORT.md's accounting, the reports/ JSON and sidecars, which findings are worth a commit versus a suppression, the cross-config trap that makes a `biome-ignore` read as unused, and the counts ledger across runs. Use after a run when working through a findings list, when a suppression shows up as "unused" in the project's own biome, or before quoting any coverage number from REPORT.md. For the rules themselves see flawless-perf-static, flawless-a11y and flawless-react-rules; for runtime numbers see flawless-perf-harness.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# triaging flawless findings
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bunx @lariocpt/flawless-react "src/**/*.tsx" --tasks perf-static,a11y,react-rules
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Three tasks scaffold nothing to run and only report: `perf-static` (the tool's own
|
|
13
|
+
rules), `a11y` (a biome sweep on web, the tool's native analyser under `--expo`) and
|
|
14
|
+
`react-rules` (a biome sweep with a curated rule set). Each has its own skill for the
|
|
15
|
+
rule-level detail — flawless-perf-static, flawless-a11y, flawless-react-rules. This
|
|
16
|
+
one is for reading a run and spending effort in the right order.
|
|
17
|
+
|
|
18
|
+
## Where a finding lives
|
|
19
|
+
|
|
20
|
+
Findings print as `file:line:col rule message` and land in `flawless/reports/*.json`:
|
|
21
|
+
the two biome sweeps write **one aggregate each** (`react-rules.json`,
|
|
22
|
+
`a11y.json`), while `perf-static` writes **one file per component that owns
|
|
23
|
+
findings** (`<Component>.perf-static.json`). A `<Component>.flawless.json` sidecar
|
|
24
|
+
lands beside each analysed source unless `--no-inline-summary`; its paths are
|
|
25
|
+
relative to the sidecar, and its `a11y` and `reactRules` entries name the run's
|
|
26
|
+
single aggregate rather than a per-component file.
|
|
27
|
+
|
|
28
|
+
Per-component files only appear for components with findings, so **stale
|
|
29
|
+
per-component reports from earlier runs linger in `reports/`.** Count from the task
|
|
30
|
+
summary or REPORT.md, never from a glob over the directory.
|
|
31
|
+
|
|
32
|
+
## Reading REPORT.md
|
|
33
|
+
|
|
34
|
+
Regenerated every run: it describes the **last run**, not the suite on disk.
|
|
35
|
+
|
|
36
|
+
- The components table accounts for every component in exactly one render-mode row
|
|
37
|
+
(client / rsc / compound-part / document-root / null), split by mountable or not.
|
|
38
|
+
- "Not covered, and why" names each gap. **A file count is not coverage.**
|
|
39
|
+
- "Imports that will not resolve" appears only on a run that wrote specs, and only
|
|
40
|
+
when an analysed import names a package nothing on disk answers for. It is why
|
|
41
|
+
those files fail at collect: **a file that never collected reports no tests.**
|
|
42
|
+
- The task table records what that run wrote, with a Status column saying where
|
|
43
|
+
perf-harness sat in the suite lifecycle (generated / refreshed / kept).
|
|
44
|
+
- Its hand-work table names the files only you can finish — `e2e/global-setup.ts`
|
|
45
|
+
while still the emitted stub, `e2e/params.json` while an id is blank.
|
|
46
|
+
|
|
47
|
+
## Triage order
|
|
48
|
+
|
|
49
|
+
Ordered by how much the tool trusts its own finding, not by count.
|
|
50
|
+
|
|
51
|
+
1. **`perf/set-state-in-render`** (error) — always real, fix first.
|
|
52
|
+
2. **`useHookAtTopLevel`** — a hook behind an early return breaks hook order at
|
|
53
|
+
runtime. Same class of defect.
|
|
54
|
+
3. **Hook-dependency findings** — see flawless-perf-static: `hook-extraneous-dependency`
|
|
55
|
+
usually means the dep is never read (remove it, or make the read real);
|
|
56
|
+
`hook-missing-dependency` wants `useCallback` plus the dep, not a suppression.
|
|
57
|
+
4. **a11y errors** — a control that is silent to a screen reader. `useAltText`,
|
|
58
|
+
`useButtonType`, `missing-role`, `unnamed-control`, `textinput-unlabelled`: each
|
|
59
|
+
message names the one attribute that retires it.
|
|
60
|
+
5. **`perf/unstable-child-prop`** — a real win when the receiving child is memoised
|
|
61
|
+
or heavy; leave inline arrows to plain DOM elements alone.
|
|
62
|
+
6. **`noArrayIndexKey`** — real for editable lists (a client-side id at creation,
|
|
63
|
+
stripped at submit); a reasoned suppression for server-static lists or where the
|
|
64
|
+
index *is* the domain identity.
|
|
65
|
+
7. **The advisory tail** — `no-memoisation-with-children`, `missing-memo-leaf`,
|
|
66
|
+
`noExplicitAny`, `noDoubleEquals`. Fix where profiling agrees
|
|
67
|
+
(`flawless-perf-harness`), or where the value is obvious. Do not chase to zero.
|
|
68
|
+
8. **`perf/unresolved-prop` is not on this ladder at all** — it is not a finding and
|
|
69
|
+
nothing here suppresses it. It is bucketed per component under REPORT.md's
|
|
70
|
+
`## Spec quality`, and its fix is `--props '{"name":1}'` or that spec's `DEFAULTS`,
|
|
71
|
+
never a suppression.
|
|
72
|
+
|
|
73
|
+
On a project with the React Compiler on, item 5 and both memo rules in item 7 are
|
|
74
|
+
suppressed by `perf-static` itself: the compiler memoises props and JSX at the call
|
|
75
|
+
site, and REPORT.md names the config that proved the flag. That ladder then starts at
|
|
76
|
+
item 6 for those rules — and a zero from them is the compiler's doing, not a clean
|
|
77
|
+
bill. See flawless-perf-static for the three states and what each prints.
|
|
78
|
+
|
|
79
|
+
## Suppressing honestly
|
|
80
|
+
|
|
81
|
+
A suppression is an **answer**, so it carries its reason:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: generic wrapper — every call site associates the control
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Placement matters: biome attaches the comment to the next line's diagnostic. For a
|
|
88
|
+
multi-line JSX element whose finding anchors on the `key=` attribute, put the comment
|
|
89
|
+
inside the tag directly above that attribute, not above the element.
|
|
90
|
+
|
|
91
|
+
**The cross-config trap.** The tool's sweeps run a scratch config containing only
|
|
92
|
+
their own rules, and your project's biome has a different set. A rule enabled in only
|
|
93
|
+
one of the two makes the suppression "unused" in the other. The tool's side is
|
|
94
|
+
handled — its sweeps drop `suppressions/*` diagnostics entirely, since reporting them
|
|
95
|
+
would punish you for answering another config's finding. **Your side is not:** if the
|
|
96
|
+
project config doesn't enable the rule you're suppressing, the project's own `biome
|
|
97
|
+
ci` flags the comment. The clean resolution is to enable the rule in the project
|
|
98
|
+
config — usually free, because the suppressed site was the only finding — so both
|
|
99
|
+
runs agree the suppression is legitimate, and to say that in the commit.
|
|
100
|
+
|
|
101
|
+
## Counts, not colors
|
|
102
|
+
|
|
103
|
+
Keep a ledger across runs (e.g. perf-static 246 / a11y 1 / react-rules 10) and
|
|
104
|
+
explain every delta. A burn-down commit should be able to say "a11y 1→0, react-rules
|
|
105
|
+
10→0" with one cause per line. **New findings after a regen are usually the analyser
|
|
106
|
+
seeing new components, not regressions** — check the components count in REPORT.md
|
|
107
|
+
before treating a rising total as a fall.
|
|
108
|
+
|
|
109
|
+
## Never
|
|
110
|
+
|
|
111
|
+
- Never suppress without a reason clause, and never to make a batch green.
|
|
112
|
+
- Never chase the advisory clusters to zero — that is churn, not performance.
|
|
113
|
+
- Never diff the reports/ directory to count findings; stale per-component files from
|
|
114
|
+
earlier runs survive. Use the task summaries or REPORT.md.
|
|
115
|
+
- Never quote REPORT.md's file counts as coverage of a suite you have not run since
|
|
116
|
+
the last change — the report is the last run's.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flawless-load
|
|
3
|
+
description: Run and maintain the crust load pipes flawless-react generates — routes.pipes, LOAD_BASE pointing at a production build, LOAD_AUTH as a header line, routes.setup.ts seeding LOAD_<PARAM> ids and proving the session, last-/baseline-<name>.json gating, and the anti-vacuous probes that decide whether a p95 describes a route or a login page. Use right before running `flawless:load`, when pipes report suspiciously fast p95s, when a run exits on the auth probe, or when a baseline needs arming. For route specs and the e2e login see flawless-e2e.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# flawless load pipes
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bunx @lariocpt/flawless-react "src/app/**/page.tsx" --nextjs . --tasks load
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`load` writes `flawless/load/routes.pipes` — crust pipelines for the analysed page
|
|
13
|
+
routes plus the GET endpoints the page's components actually fetch (mutations are
|
|
14
|
+
excluded on purpose). Crust runs them; nothing in this repo does.
|
|
15
|
+
|
|
16
|
+
Measuring anything but the real, production app is fiction, and every vacuous pass
|
|
17
|
+
in this plane comes from forgetting that: an unauthenticated dashboard request 302s
|
|
18
|
+
to a login page that answers **200**, so every pipe passes.
|
|
19
|
+
|
|
20
|
+
## Run it
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bun run build:web && PORT=3001 bunx next start & # PROD build on :3001
|
|
24
|
+
SESSION=$(jq -r '.cookies[] | select(.name=="session").value' flawless/e2e/.auth.json)
|
|
25
|
+
export LOAD_BASE=http://localhost:3001
|
|
26
|
+
export LOAD_AUTH="cookie: session=$SESSION"
|
|
27
|
+
bun run flawless:load # routes.setup.ts seeds the ids and proves the auth
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **`LOAD_BASE` must be a production build.** `next dev` compiles routes on demand,
|
|
31
|
+
so the first request to each route measures the compiler. Check the server banner
|
|
32
|
+
says `next start`. If the app's `start` script has no port flag, the default :3000
|
|
33
|
+
may collide with something else on the box — `PORT=3001` wins.
|
|
34
|
+
- **`LOAD_AUTH` is a header line** — for a cookie session, `cookie:
|
|
35
|
+
session=<value>` taken from the e2e run's `flawless/e2e/.auth.json`, not a Bearer
|
|
36
|
+
token. The e2e run is the cheapest way to get a real one: see flawless-e2e.
|
|
37
|
+
|
|
38
|
+
## `routes.setup.ts`: the module that refuses
|
|
39
|
+
|
|
40
|
+
You no longer source `env.sh`. `flawless/load/routes.setup.ts` runs first (crust
|
|
41
|
+
awaits a pipes file's sibling setup module), seeds every `LOAD_<PARAM>` from
|
|
42
|
+
`flawless/e2e/params.json`, and throws if it seeded none. The shell version needed
|
|
43
|
+
`jq` and, without it, exported nothing *silently* while 2/3 of the pipes measured
|
|
44
|
+
the 404 handler. The module has one failure mode and it is loud.
|
|
45
|
+
|
|
46
|
+
The same module probes every static route and **throws if one lands somewhere
|
|
47
|
+
else** — an expired cookie redirects to a login page that answers 200, so `expect
|
|
48
|
+
200` passes and the whole file reports healthy percentiles for a login form.
|
|
49
|
+
Measured, before the probe existed: 20/20 status 200, p50 19.7ms, exit 0, every
|
|
50
|
+
request served /login. A by-design redirect goes under `landing` in params.json.
|
|
51
|
+
|
|
52
|
+
The module is **write-once** — replace it with a check that knows your app and
|
|
53
|
+
nothing will overwrite yours.
|
|
54
|
+
|
|
55
|
+
## The probes, before you trust a number
|
|
56
|
+
|
|
57
|
+
A load pipe carries no landing assertion of its own (an e2e spec does) and crust
|
|
58
|
+
will happily report a fast, green p95 for 500 redirects.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
echo "$LOAD_ID" # must be non-empty
|
|
62
|
+
curl -s -H "$LOAD_AUTH" "$LOAD_BASE/dashboard/buildings/$LOAD_ID" | grep -ci "login"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The curl must return real page HTML (tens of KB) and the grep must find no login
|
|
66
|
+
form. The same trap wears a second face **after** the run: a pipe whose p95 is
|
|
67
|
+
suspiciously fast is measuring the 404 handler or a redirect, not the route. Real
|
|
68
|
+
SSR routes on a dev box sit around 10–15 ms p95, not 2 ms.
|
|
69
|
+
|
|
70
|
+
## Baselines
|
|
71
|
+
|
|
72
|
+
Each pipe gates p95 against `baseline-<name>.json`; a missing baseline is a pass by
|
|
73
|
+
design — the first run arms it. Arm one by copying a good `last-<name>.json`; after
|
|
74
|
+
that, a run fails when p95 doubles.
|
|
75
|
+
|
|
76
|
+
Refresh a baseline only when you can explain the shift in the commit body, and never
|
|
77
|
+
refresh a batch of them to make a red run green.
|
|
78
|
+
|
|
79
|
+
## Regenerating
|
|
80
|
+
|
|
81
|
+
`--force` **merges** `flawless/load/routes.pipes` rather than overwriting: edited
|
|
82
|
+
rates, hand-added flows, and pipes you commented out yourself all survive. A pipe
|
|
83
|
+
whose ids are unfilled is emitted **commented out**, above a line naming which key
|
|
84
|
+
in `flawless/e2e/params.json` to fill — a blank segment measures the app's 404
|
|
85
|
+
handler. The task summary reports live versus commented counts, so REPORT.md cannot
|
|
86
|
+
call a gated pipe covered.
|
|
87
|
+
|
|
88
|
+
## Under `--expo`
|
|
89
|
+
|
|
90
|
+
`load` RUNS under `--expo` since wave 7 — **API pipes only**. An Expo app has no
|
|
91
|
+
server-rendered page routes, so the pipes are the GET endpoints the screens hit,
|
|
92
|
+
harvested ONE hop through the app's fetch wrapper (screen → `listBuildings()` →
|
|
93
|
+
`apiFetch("/api/buildings")`; a bare `fetch()` never exists in a screen, so the
|
|
94
|
+
direct harvest finds 0).
|
|
95
|
+
|
|
96
|
+
- `LOAD_BASE` points at the **API**, not Metro — Metro serves the UI, not the
|
|
97
|
+
endpoints.
|
|
98
|
+
- `LOAD_AUTH` is a bearer or cookie header for that API.
|
|
99
|
+
- The write-once `routes.setup.ts` here probes a parameterless GET endpoint and
|
|
100
|
+
fails on a **401**, not on a redirect: an API 401 keeps the URL, so the
|
|
101
|
+
page-route redirect probe would pass vacuously on a dead session.
|
|
102
|
+
|
|
103
|
+
Mutations are excluded by default here too. See flawless-expo for the native plane's
|
|
104
|
+
other differences.
|
|
105
|
+
|
|
106
|
+
## Never
|
|
107
|
+
|
|
108
|
+
- Never run pipes against `next dev`, or without the curl probe.
|
|
109
|
+
- Never read "N pipes pass" as meaning anything until `$LOAD_ID` was proven
|
|
110
|
+
non-empty and the probe returned authed content.
|
|
111
|
+
- Never refresh a baseline without explaining the shift where the next reader will
|
|
112
|
+
find it.
|