@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
@@ -0,0 +1,750 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { outDirRel, underOutDir } from "../emitPaths.ts";
5
+ import { findRouterRoot, pageFileToRoute } from "../nextjs/routes.ts";
6
+ import type { ComponentTree, RunContext, Task, TaskResult } from "../types.ts";
7
+ import { ensureParamDefaults } from "./e2eRoutes.ts";
8
+ import { runExpoLoad } from "./loadExpo.ts";
9
+
10
+ /**
11
+ * Emit-only crust load pipes: page routes plus the API routes the analysed
12
+ * components actually fetch. Mirrors the CI-gate conventions from crust's
13
+ * docs — one assert per threshold, baseline comparison via stats --out.
14
+ * Works without crust on PATH (files are emitted; running them needs crust).
15
+ */
16
+ export const loadPipes: Task = {
17
+ id: "load",
18
+ label: "Generate crust load pipes",
19
+ description: "Load-test pipes for analysed routes + their API calls (crust)",
20
+ needsTree: true,
21
+ async run(ctx: RunContext): Promise<TaskResult> {
22
+ // The wave-3/5/6 shape: one TaskId, a sibling engine per platform. An Expo
23
+ // app has no server-rendered page routes to GET, so the native engine emits
24
+ // API pipes only — from the endpoints the screens reach through the app's
25
+ // fetch wrapper (ComponentInfo.apiCalls). B-16 wave 7.
26
+ if (ctx.expo) return runExpoLoad(ctx);
27
+ if (!ctx.nextjs) {
28
+ return {
29
+ id: "load",
30
+ ok: false,
31
+ outputs: [],
32
+ findings: [],
33
+ summary: "load task requires --nextjs mode",
34
+ error: "not a Next.js run",
35
+ };
36
+ }
37
+
38
+ // Bound after the guard: `ctx.nextjs` narrows for this body but not inside
39
+ // the closures below, where the property could have moved.
40
+ const nextjs = ctx.nextjs;
41
+ const loadDir = path.join(ctx.outDir, "load");
42
+ await mkdir(loadDir, { recursive: true });
43
+ const written: string[] = [];
44
+
45
+ const routerRoot = findRouterRoot(nextjs.root);
46
+ // Fallback for default Next.js mode where the entry glob is components/**,
47
+ // so ctx.trees contains no page files. Discover pages directly so load
48
+ // pipes still emit route targets.
49
+ let trees = ctx.trees;
50
+ const hasPage = trees.some((t) => pageFileToRoute(t.entry.filePath, nextjs.router, routerRoot));
51
+ if (!hasPage) {
52
+ const pageGlob = new Bun.Glob("**/page.{tsx,jsx,ts,js}");
53
+ for await (const file of pageGlob.scan({ cwd: routerRoot, absolute: true })) {
54
+ const route = pageFileToRoute(file, nextjs.router, routerRoot);
55
+ if (!route) continue;
56
+ const tree: ComponentTree = {
57
+ entry: {
58
+ componentName: "Page",
59
+ filePath: file,
60
+ importSpecifier: "",
61
+ propsShape: {},
62
+ variants: [],
63
+ hooks: [],
64
+ unresolvedProps: [],
65
+ },
66
+ children: [],
67
+ };
68
+ trees = [...trees, tree];
69
+ }
70
+ }
71
+ const targets = collectTargets(trees, nextjs.router, routerRoot);
72
+
73
+ // Every id these pipes reference gets a slot in params.json, including the
74
+ // API-route ones the e2e task never sees (/api/buildings/[buildingId] beside
75
+ // the page's /dashboard/buildings/[id]). Without the slot there is nowhere
76
+ // to put the value, and the pipe silently requests a malformed URL.
77
+ const paramsPath = path.join(ctx.outDir, "e2e", "params.json");
78
+ const needed = [...new Set(targets.flatMap((t) => t.params))].sort();
79
+ if (await ensureParamDefaults(paramsPath, needed)) written.push(paramsPath);
80
+
81
+ // Which ids the user has actually filled in. A pipe whose parameters are
82
+ // blank does not measure the route, it measures the app's 404 handler —
83
+ // GET /invite/ with an empty token answered 500 on every one of 500
84
+ // requests and was reported as the route failing. Silence is worse than a
85
+ // comment: this way the file itself says which id is missing.
86
+ const filled = await readFilledParams(paramsPath);
87
+
88
+ const pipesPath = path.join(loadDir, "routes.pipes");
89
+ let keptPipes = false;
90
+ let pipesText: string;
91
+ if (!existsSync(pipesPath)) {
92
+ pipesText = renderPipes(targets, ctx.outDir, nextjs.root, filled);
93
+ await Bun.write(pipesPath, pipesText);
94
+ written.push(pipesPath);
95
+ } else if (ctx.force) {
96
+ // Merged, not overwritten. A tuned rate, a hand-added POST flow and a pipe
97
+ // you commented out yourself cannot be recovered if lost, and --force is
98
+ // the only way to turn a gated pipe back on — so it has to do both.
99
+ pipesText = mergePipes(
100
+ await Bun.file(pipesPath).text(),
101
+ targets,
102
+ ctx.outDir,
103
+ nextjs.root,
104
+ filled,
105
+ );
106
+ await Bun.write(pipesPath, pipesText);
107
+ written.push(pipesPath);
108
+ } else {
109
+ pipesText = await Bun.file(pipesPath).text();
110
+ keptPipes = true;
111
+ }
112
+
113
+ // Write-once, like the e2e global-setup stub: a hand-written module that
114
+ // knows the app is better than this one, and a regeneration must never take
115
+ // it. Absent, it IS written — so a suite that predates the guard gets it,
116
+ // which a write-once file only manages because "absent" is the common case.
117
+ const setupPath = path.join(loadDir, "routes.setup.ts");
118
+ if (!existsSync(setupPath)) {
119
+ await Bun.write(setupPath, renderLoadSetup(targets, outDirRel(ctx.outDir, nextjs.root)));
120
+ written.push(setupPath);
121
+ }
122
+
123
+ const envPath = path.join(loadDir, "env.sh");
124
+ await Bun.write(envPath, loadEnvScript(outDirRel(ctx.outDir, nextjs.root)));
125
+ written.push(envPath);
126
+
127
+ // Regenerated under --force, unlike routes.pipes beside it: this README is
128
+ // generated prose with nothing of yours in it, so protecting it forever only
129
+ // means existing installs never learn what a later version worked out — such
130
+ // as that LOAD_BASE has to be a production build.
131
+ const readmePath = path.join(loadDir, "README.md");
132
+ if (!existsSync(readmePath) || ctx.force) {
133
+ await Bun.write(readmePath, loadReadme());
134
+ written.push(readmePath);
135
+ }
136
+
137
+ const crustPresent = Bun.which("crust") !== null || hasLocalCrust(nextjs.root);
138
+ const tally = countPipes(pipesText);
139
+ return {
140
+ id: "load",
141
+ ok: true,
142
+ outputs: written,
143
+ findings: [],
144
+ summary: keptPipes
145
+ ? `kept existing load/routes.pipes (--force to merge in ${targets.length} target(s))`
146
+ : `load pipes for ${targets.length} target(s) — ${describeTally(tally, filled, targets)}${
147
+ crustPresent ? " — run with bun run flawless:load" : " — crust not found on PATH"
148
+ }`,
149
+ };
150
+ },
151
+ };
152
+
153
+ export interface PipeTally {
154
+ live: number;
155
+ gated: number;
156
+ userCommented: number;
157
+ }
158
+
159
+ /**
160
+ * What the file on disk actually measures, which is not `targets.length`.
161
+ *
162
+ * A gated pipe measures nothing, so counting it as a covered target is the same
163
+ * mistake REPORT.md exists to prevent — this string is what lands in its Tasks
164
+ * table.
165
+ */
166
+ export function countPipes(text: string): PipeTally {
167
+ const tally: PipeTally = { live: 0, gated: 0, userCommented: 0 };
168
+ for (const item of parsePipes(text).body) {
169
+ if (item.kind !== "pipe") continue;
170
+ if (item.state === "live") tally.live += 1;
171
+ else if (item.state === "gated") tally.gated += 1;
172
+ else tally.userCommented += 1;
173
+ }
174
+ return tally;
175
+ }
176
+
177
+ export function describeTally(
178
+ tally: PipeTally,
179
+ filled: Set<string>,
180
+ targets: LoadTarget[],
181
+ ): string {
182
+ const parts = [`${tally.live} live`];
183
+ if (tally.gated > 0) {
184
+ const ids = [...new Set(targets.flatMap((t) => missingParams(t, filled)))].sort();
185
+ parts.push(`${tally.gated} commented out pending ids (${ids.join(", ")})`);
186
+ }
187
+ if (tally.userCommented > 0) parts.push(`${tally.userCommented} commented out by you`);
188
+ return parts.join(", ");
189
+ }
190
+
191
+ export interface LoadTarget {
192
+ name: string;
193
+ /** URL path with $VARS for dynamic segments. */
194
+ urlPath: string;
195
+ /**
196
+ * The dynamic segments this target needs, in the casing the source used.
197
+ *
198
+ * $LOAD_BUILDINGID cannot be turned back into `buildingId` — the shouting is
199
+ * lossy — and params.json is keyed by the source name. Deriving the key from
200
+ * the variable added a second, lowercased slot beside every existing one.
201
+ */
202
+ params: string[];
203
+ }
204
+
205
+ /** crust installed as a project dependency (bun run resolves node_modules/.bin). */
206
+ function hasLocalCrust(fromDir: string): boolean {
207
+ let dir = path.resolve(fromDir);
208
+ for (;;) {
209
+ if (existsSync(path.join(dir, "node_modules", ".bin", "crust"))) return true;
210
+ const parent = path.dirname(dir);
211
+ if (parent === dir) return false;
212
+ dir = parent;
213
+ }
214
+ }
215
+
216
+ export function collectTargets(
217
+ trees: ComponentTree[],
218
+ router: "app" | "pages",
219
+ routerRoot: string,
220
+ ): LoadTarget[] {
221
+ const out: LoadTarget[] = [];
222
+ const seen = new Set<string>();
223
+ const usedNames = new Set<string>();
224
+ const add = (name: string, urlPath: string, params: string[]): void => {
225
+ if (seen.has(urlPath)) return;
226
+ seen.add(urlPath);
227
+ // Two distinct routes can want the same name: slugify turns every dynamic
228
+ // segment into a bare `x`, so /api/a/[p]/b and /api/a/[q]/b both arrive as
229
+ // `api-a-x-b`. They would then share one last-*.json — each overwriting the
230
+ // other's numbers — and the merge, which keys on this name, would treat the
231
+ // second route as an update of the first and never emit a pipe for it.
232
+ //
233
+ // The first claimant keeps the plain name, so regenerating an existing suite
234
+ // does not rename anything already on disk.
235
+ let unique = name;
236
+ for (let n = 2; usedNames.has(unique); n++) unique = `${name}-${n}`;
237
+ usedNames.add(unique);
238
+ out.push({ name: unique, urlPath, params });
239
+ };
240
+
241
+ for (const tree of trees) {
242
+ const route = pageFileToRoute(tree.entry.filePath, router, routerRoot);
243
+ if (route) {
244
+ let p = route.urlPath;
245
+ for (const param of route.params) {
246
+ p = p.replace(`[...${param}]`, `$LOAD_${param.toUpperCase()}`);
247
+ p = p.replace(`[${param}]`, `$LOAD_${param.toUpperCase()}`);
248
+ }
249
+ add(route.specStem, p, route.params);
250
+ }
251
+ // API routes the UI actually calls (GET only — load-testing mutations by
252
+ // default would write junk into the target DB).
253
+ const walk = (t: ComponentTree): void => {
254
+ for (const f of t.entry.fetchCalls ?? []) {
255
+ if (f.method !== "GET") continue;
256
+ let p = f.urlTemplate;
257
+ for (const hole of f.holes) {
258
+ p = p.replace(`:${hole}`, `$LOAD_${hole.toUpperCase()}`);
259
+ }
260
+ add(slugify(p), p, f.holes);
261
+ }
262
+ for (const c of t.children) walk(c);
263
+ };
264
+ walk(tree);
265
+ }
266
+ return out;
267
+ }
268
+
269
+ export function slugify(p: string): string {
270
+ return (
271
+ p
272
+ .replace(/\$LOAD_[A-Z0-9_]+/g, "x")
273
+ .replace(/[^a-zA-Z0-9]+/g, "-")
274
+ .replace(/^-|-$/g, "") || "root"
275
+ );
276
+ }
277
+
278
+ /**
279
+ * The ids in e2e/params.json that hold a value.
280
+ *
281
+ * Written by the e2e task earlier in the same run, and merge-preserving across
282
+ * runs, so by the time load renders its pipes this is exactly the set of
283
+ * fixtures the user has supplied.
284
+ */
285
+ export async function readFilledParams(paramsPath: string): Promise<Set<string>> {
286
+ const out = new Set<string>();
287
+ if (!existsSync(paramsPath)) return out;
288
+ try {
289
+ const parsed = (await Bun.file(paramsPath).json()) as {
290
+ defaults?: Record<string, string>;
291
+ routes?: Record<string, Record<string, string>>;
292
+ };
293
+ for (const [k, v] of Object.entries(parsed.defaults ?? {})) {
294
+ if (typeof v === "string" && v !== "") out.add(k.toUpperCase());
295
+ }
296
+ for (const route of Object.values(parsed.routes ?? {})) {
297
+ for (const [k, v] of Object.entries(route)) {
298
+ if (typeof v === "string" && v !== "") out.add(k.toUpperCase());
299
+ }
300
+ }
301
+ } catch {
302
+ // Unreadable params.json: emit every pipe and let the run report the truth,
303
+ // rather than commenting out the whole file over a parse error.
304
+ }
305
+ return out;
306
+ }
307
+
308
+ /** First line of a header this tool wrote — the signal that it may be refreshed. */
309
+ const HEADER_MARKER = "# flawless-react load pipes — generated from the analysed component tree.";
310
+ /** Prefix of the gate comment, which identifies a pipe as commented out BY the tool. */
311
+ const GATE_MARKER = "# TODO(flawless): no value for ";
312
+
313
+ /**
314
+ * The ids a target needs that nothing has supplied.
315
+ *
316
+ * Compared in upper case, reported in the source's: `filled` is keyed by the
317
+ * shouting form the pipe uses, but the fix a reader has to make is in
318
+ * params.json, which is keyed by the name as written.
319
+ */
320
+ export function missingParams(t: LoadTarget, filled: Set<string>): string[] {
321
+ return t.params.filter((p) => !filled.has(p.toUpperCase()));
322
+ }
323
+
324
+ function renderPipeBody(t: LoadTarget, rel: string): string {
325
+ const statsOut = `${rel}/load/last-${t.name}.json`;
326
+ const baseline = `${rel}/load/baseline-${t.name}.json`;
327
+ return (
328
+ `load 10s 50/s | parallel 16 | GET $LOAD_BASE${t.urlPath} -H "$LOAD_AUTH" --timeout 2s | expect 200 ` +
329
+ `| stats --out ${statsOut} ` +
330
+ `| assert (async s => { const b = Bun.file("${baseline}"); return !(await b.exists()) || s.p95 < 2 * (await b.json()).summary.p95 })`
331
+ );
332
+ }
333
+
334
+ /**
335
+ * The routes the setup module can probe: static ones only.
336
+ *
337
+ * params.length === 0 also means urlPath carries no $VARS, so it is the literal
338
+ * route path. A dynamic route is excluded because an unfilled id would read as
339
+ * an auth failure; "/" because it redirects by design in most apps and would
340
+ * fail before the user has had a chance to record where to.
341
+ */
342
+ function guardTargets(targets: LoadTarget[]): LoadTarget[] {
343
+ return targets.filter((t) => t.params.length === 0 && t.urlPath !== "/");
344
+ }
345
+
346
+ /**
347
+ * The setup module crust imports before a single pipe runs.
348
+ *
349
+ * An expiring $LOAD_AUTH is THE failure mode of this plane, and it is silent.
350
+ * crust follows redirects, so a dead session turns every route into the login
351
+ * page answering 200: `expect 200` passes, `stats` reports healthy percentiles,
352
+ * and the file goes green having measured a login form. Reproduced on a real
353
+ * app — 20/20 status 200, p50 19.7ms, exit 0, every request served /login.
354
+ *
355
+ * Nothing downstream can see it. In load mode (`parallel N`) crust's record is
356
+ * {status, ms, url} and that url is the REQUESTED one, byte-identical whether
357
+ * the session is live or dead. Only a plain fetch shows where a request landed.
358
+ *
359
+ * A setup module rather than guard lines in routes.pipes: it fails once, before
360
+ * anything is measured, instead of N times among the results — and the first
361
+ * app to hit this had already been fixed BY HAND exactly this way, which is
362
+ * where the shape comes from.
363
+ *
364
+ * Every static route is probed, not one. Nothing in the analysis says which
365
+ * routes need a session, and an earlier cut of this picked a single static
366
+ * route, drew /forgot-password on the dogfood app, and would have passed with a
367
+ * dead cookie — a check that cannot fail. Dynamic routes are skipped: an
368
+ * unfilled id would read as an auth failure.
369
+ *
370
+ * WRITE-ONCE. A hand-written module that knows the app (an authenticated
371
+ * /api/me answering 401 is a sharper signal than a redirect) is strictly better
372
+ * than this one, and no regeneration may overwrite it. Absent, it is written —
373
+ * so suites that predate it do get the guard.
374
+ */
375
+ export function renderLoadSetup(targets: LoadTarget[], rel: string): string {
376
+ const routes = guardTargets(targets).map((t) => t.urlPath);
377
+ return `// Setup module for routes.pipes. test-pipes imports the sibling
378
+ // <name>.setup.ts and awaits its default export before any line runs.
379
+ //
380
+ // Why: crust follows redirects, so an expired LOAD_AUTH turns every route into
381
+ // a login page answering 200 — "expect 200" passes, stats reports healthy
382
+ // percentiles, and the whole file goes green having measured a login form.
383
+ // Nothing downstream can see it: a load-mode record carries the REQUESTED url,
384
+ // not the landed one. So the check has to happen here, before any measurement.
385
+ //
386
+ // This file is written once and never regenerated. Replace it with a check that
387
+ // knows your app if you have a better signal — an authenticated endpoint
388
+ // answering 401 is sharper than a redirect — and nothing will overwrite yours.
389
+
390
+ /** Static routes only: a dynamic one with an unfilled id would look like an auth failure. */
391
+ const ROUTES: string[] = ${JSON.stringify(routes)};
392
+ /** Every route the pipes target, so "no ids" can be told from "no dynamic routes". */
393
+ const TOTAL_ROUTES = ${targets.length};
394
+
395
+ export default async () => {
396
+ const base = process.env.LOAD_BASE;
397
+ const auth = process.env.LOAD_AUTH;
398
+ if (!base || !auth) {
399
+ throw new Error(
400
+ "routes.setup: LOAD_BASE and LOAD_AUTH are required — run via \`crust --env-file ${underOutDir(rel, "load/.env.load")}\` or export them",
401
+ );
402
+ }
403
+ const sep = auth.indexOf(":");
404
+ if (sep < 0) {
405
+ throw new Error(\`routes.setup: LOAD_AUTH must be a header line, e.g. "cookie: session=…" — got \${auth}\`);
406
+ }
407
+ const headers = { [auth.slice(0, sep).trim()]: auth.slice(sep + 1).trim() };
408
+
409
+ const paramsFile = Bun.file("${underOutDir(rel, "e2e/params.json")}");
410
+ if (!(await paramsFile.exists())) {
411
+ throw new Error(
412
+ "routes.setup: ${underOutDir(rel, "e2e/params.json")} not found — its ids are what these pipes target",
413
+ );
414
+ }
415
+ const params = (await paramsFile.json()) as {
416
+ defaults?: Record<string, string>;
417
+ routes?: Record<string, Record<string, string>>;
418
+ landing?: Record<string, string>;
419
+ };
420
+
421
+ // LOAD_<PARAM> ids, seeded here rather than by sourcing env.sh. That shim is a
422
+ // bash/zsh-portability script whose own comments record two incidents where it
423
+ // exported NOTHING silently — the pipes then ran against URLs with empty path
424
+ // segments and measured the 404 handler while reporting the application. This
425
+ // has one failure mode, and it is loud.
426
+ const merged: Record<string, string> = { ...(params.defaults ?? {}) };
427
+ for (const route of Object.values(params.routes ?? {})) {
428
+ for (const [k, v] of Object.entries(route)) if (v !== "") merged[k] = v;
429
+ }
430
+ let exported = 0;
431
+ for (const [k, v] of Object.entries(merged)) {
432
+ if (v === "") continue;
433
+ process.env[\`LOAD_\${k.toUpperCase()}\`] = v;
434
+ exported++;
435
+ }
436
+ if (exported === 0 && ROUTES.length < TOTAL_ROUTES) {
437
+ throw new Error(
438
+ "routes.setup: no ids in ${underOutDir(rel, "e2e/params.json")} — every dynamic route below would request a URL with an empty segment and measure the 404 handler",
439
+ );
440
+ }
441
+
442
+ // The same landing map the e2e specs use, so a route that redirects by design
443
+ // is recorded once rather than in two places that drift.
444
+ const landing: Record<string, string> = params.landing ?? {};
445
+
446
+ for (const route of ROUTES) {
447
+ const res = await fetch(new URL(route, base), { headers });
448
+ const landed = new URL(res.url).pathname;
449
+ const want = landing[route] ?? route;
450
+ if (!landed.startsWith(want)) {
451
+ throw new Error(
452
+ \`routes.setup: \${route} landed on \${landed} (status \${res.status}). Either LOAD_AUTH no longer \` +
453
+ \`authenticates, or this route redirects by design — record the destination under "landing" in \` +
454
+ \`${underOutDir(rel, "e2e/params.json")}. Every measurement below would be of \${landed}, not \${route}.\`,
455
+ );
456
+ }
457
+ }
458
+ };
459
+ `;
460
+ }
461
+
462
+ function gateComment(missing: string[], rel: string): string {
463
+ return (
464
+ `${GATE_MARKER}${missing.map((m) => `LOAD_${m.toUpperCase()}`).join(", ")}. ` +
465
+ `Fill ${missing.map((m) => `"${m}"`).join(", ")} in ${rel}/e2e/params.json`
466
+ );
467
+ }
468
+
469
+ function pipesHeader(rel: string): string[] {
470
+ return [
471
+ HEADER_MARKER,
472
+ "# Env contract (export before running, or provide a crust setup module):",
473
+ "# LOAD_BASE — e.g. http://localhost:3001. Point this at a PRODUCTION build",
474
+ "# (bun run build && bun run start), not at next dev: the dev",
475
+ "# server compiles routes on demand, so a load run against it",
476
+ "# measures the compiler rather than the app.",
477
+ '# LOAD_AUTH — a header line, e.g. "cookie: session=<value>" for a cookie',
478
+ '# session, or "authorization: Bearer <token>" for a token one.',
479
+ '# Use "accept: */*" if the route needs no auth.',
480
+ `# LOAD_<PARAM> — one per dynamic segment below (e.g. LOAD_ID, LOAD_BUILDINGID).`,
481
+ `# You do not set these by hand: routes.setup.ts seeds them from`,
482
+ `# ${underOutDir(rel, "e2e/params.json")} — the same ids the e2e specs use — and fails`,
483
+ `# loudly when a dynamic route has no id. Source ${underOutDir(rel, "load/env.sh")}`,
484
+ `# only when running crust directly, past the setup module.`,
485
+ "# Baseline gating: copy a good last-*.json to baseline-*.json; runs then fail",
486
+ "# when p95 degrades beyond 2x baseline. One assert per threshold.",
487
+ "# This header is regenerated by --force. Everything below it is merged, not",
488
+ "# replaced: an edited pipe, a pipe you commented out and a flow you added by",
489
+ "# hand all survive. Keep your own notes below this line.",
490
+ "# TODO(flawless): tune rate/duration to your app; add POST flows deliberately.",
491
+ ];
492
+ }
493
+
494
+ export function renderPipes(
495
+ targets: LoadTarget[],
496
+ outDir: string,
497
+ nextRoot: string,
498
+ filled: Set<string>,
499
+ ): string {
500
+ const rel = outDirRel(outDir, nextRoot);
501
+ const entries = targets.map((t) => {
502
+ const body = renderPipeBody(t, rel);
503
+ const missing = missingParams(t, filled);
504
+ if (missing.length === 0) return body;
505
+ // Commented out rather than dropped: the route is still visible, and filling
506
+ // the id in e2e/params.json plus a --force regeneration turns it back on.
507
+ return `${gateComment(missing, rel)}\n# ${body}`;
508
+ });
509
+ return `${pipesHeader(rel).join("\n")}\n\n${entries.join("\n\n")}\n`;
510
+ }
511
+
512
+ type PipeState = "live" | "gated" | "userCommented";
513
+
514
+ type BodyItem =
515
+ | { kind: "raw"; line: string }
516
+ | { kind: "pipe"; state: PipeState; name?: string; todo?: string; body: string };
517
+
518
+ interface ParsedPipes {
519
+ header: string[];
520
+ body: BodyItem[];
521
+ }
522
+
523
+ /**
524
+ * A pipe's stable identity: the `<name>` in `stats --out .../last-<name>.json`.
525
+ *
526
+ * Not the URL — that changes when a param is renamed — and not the line number,
527
+ * which changes whenever anything above it does.
528
+ */
529
+ function pipeName(body: string): string | undefined {
530
+ const out = /--out\s+(\S+)/.exec(body);
531
+ if (!out?.[1]) return undefined;
532
+ const base = out[1].split("/").pop() ?? "";
533
+ return /^last-(.+)\.json$/.exec(base)?.[1];
534
+ }
535
+
536
+ /**
537
+ * Split routes.pipes into the header, the pipes it declares, and everything
538
+ * else — which is preserved verbatim and in place, because a note you wrote and
539
+ * a POST flow you added by hand are indistinguishable from each other to a
540
+ * line-oriented parser, and both are yours.
541
+ */
542
+ export function parsePipes(text: string): ParsedPipes {
543
+ const lines = text.split("\n");
544
+ const header: string[] = [];
545
+ let i = 0;
546
+ if (lines[0]?.startsWith("#")) {
547
+ while (i < lines.length && lines[i]?.startsWith("#")) header.push(lines[i++] as string);
548
+ }
549
+
550
+ const body: BodyItem[] = [];
551
+ for (; i < lines.length; i++) {
552
+ const line = lines[i] as string;
553
+ const next = lines[i + 1];
554
+ if (line.startsWith(GATE_MARKER) && next?.startsWith("# load ")) {
555
+ const pipe = next.slice(2);
556
+ body.push({ kind: "pipe", state: "gated", name: pipeName(pipe), todo: line, body: pipe });
557
+ i++;
558
+ } else if (line.startsWith("load ")) {
559
+ body.push({ kind: "pipe", state: "live", name: pipeName(line), body: line });
560
+ } else if (line.startsWith("# load ")) {
561
+ const pipe = line.slice(2);
562
+ body.push({ kind: "pipe", state: "userCommented", name: pipeName(pipe), body: pipe });
563
+ } else {
564
+ body.push({ kind: "raw", line });
565
+ }
566
+ }
567
+ return { header, body };
568
+ }
569
+
570
+ function renderParsed(parsed: ParsedPipes): string {
571
+ const out = [...parsed.header];
572
+ for (const item of parsed.body) {
573
+ if (item.kind === "raw") {
574
+ out.push(item.line);
575
+ continue;
576
+ }
577
+ if (item.state === "live") out.push(item.body);
578
+ else if (item.state === "gated") out.push(item.todo as string, `# ${item.body}`);
579
+ else out.push(`# ${item.body}`);
580
+ }
581
+ return out.join("\n");
582
+ }
583
+
584
+ /**
585
+ * Fold this run's targets into the routes.pipes already on disk.
586
+ *
587
+ * Only ever toggles a pipe's comment state — the body is never rewritten, so a
588
+ * rate you tuned survives being gated and un-gated. Nothing is pruned: a stale
589
+ * generated pipe and a flow you added by hand look the same from here, and
590
+ * losing the second to tidy up the first is not a trade worth making.
591
+ */
592
+ export function mergePipes(
593
+ existing: string,
594
+ targets: LoadTarget[],
595
+ outDir: string,
596
+ nextRoot: string,
597
+ filled: Set<string>,
598
+ ): string {
599
+ const rel = outDirRel(outDir, nextRoot);
600
+ const parsed = parsePipes(existing);
601
+
602
+ const byName = new Map<string, Extract<BodyItem, { kind: "pipe" }>>();
603
+ for (const item of parsed.body) {
604
+ if (item.kind === "pipe" && item.name !== undefined && !byName.has(item.name)) {
605
+ byName.set(item.name, item);
606
+ }
607
+ }
608
+
609
+ const fresh: BodyItem[] = [];
610
+ for (const t of targets) {
611
+ const missing = missingParams(t, filled);
612
+ const found = byName.get(t.name);
613
+ if (!found) {
614
+ fresh.push({
615
+ kind: "pipe",
616
+ state: missing.length === 0 ? "live" : "gated",
617
+ name: t.name,
618
+ todo: missing.length === 0 ? undefined : gateComment(missing, rel),
619
+ body: renderPipeBody(t, rel),
620
+ });
621
+ continue;
622
+ }
623
+ // A pipe you commented out yourself was a decision; the tool does not undo it.
624
+ if (found.state === "userCommented") continue;
625
+ if (missing.length === 0) {
626
+ found.state = "live";
627
+ found.todo = undefined;
628
+ } else {
629
+ // Re-stated rather than left alone: filling one of two ids should leave a
630
+ // TODO naming only the one still missing.
631
+ found.state = "gated";
632
+ found.todo = gateComment(missing, rel);
633
+ }
634
+ }
635
+
636
+ // The header is generated prose, so a run that recognises its own marker
637
+ // refreshes it. Notes belong below it, where they are preserved.
638
+ if (parsed.header[0] === HEADER_MARKER) parsed.header = pipesHeader(rel);
639
+
640
+ // Normalise the trailing blank run before appending, so repeated --force runs
641
+ // do not accumulate empty lines at the end of the file.
642
+ while (
643
+ parsed.body.at(-1)?.kind === "raw" &&
644
+ (parsed.body.at(-1) as { line: string }).line === ""
645
+ ) {
646
+ parsed.body.pop();
647
+ }
648
+ for (const item of fresh) parsed.body.push({ kind: "raw", line: "" }, item);
649
+ parsed.body.push({ kind: "raw", line: "" });
650
+
651
+ return renderParsed(parsed);
652
+ }
653
+
654
+ function loadReadme(): string {
655
+ return `# flawless-react load pipes
656
+
657
+ Generated crust pipelines targeting the analysed pages and the GET API routes
658
+ their components fetch. Mutating endpoints are deliberately excluded — add
659
+ them by hand when you have a safe target environment.
660
+
661
+ Run: \`bun run flawless:load\` (crust must be installed — see the repo's crust
662
+ setup or https://github.com/lariocpt/crust).
663
+
664
+ Point \`LOAD_BASE\` at a PRODUCTION build — \`bun run build && bun run start\` —
665
+ not at \`next dev\`. The dev server compiles routes on demand, so the first
666
+ request to each one measures the compiler and every later one measures a warm
667
+ cache; neither is the number you want. \`routes.setup.ts\` seeds the dynamic-segment
668
+ ids from \`../e2e/params.json\` before any pipe runs — a missing id fails the run
669
+ naming the route, and an expired \`LOAD_AUTH\` fails it too, so a green run is
670
+ proven authenticated. Set \`LOAD_AUTH\` to a header line (\`cookie: session=…\`, or
671
+ \`accept: */*\` for a public app). Source \`load/env.sh\` only when running crust
672
+ directly, past the setup module.
673
+
674
+ Baselines: after a good run, copy \`last-<name>.json\` to \`baseline-<name>.json\`.
675
+ Subsequent runs fail when p95 exceeds 2x the baseline.
676
+
677
+ \`routes.pipes\` is yours after generation. \`--force\` merges this run's routes
678
+ into it rather than replacing it: a rate you tuned, a pipe you commented out and
679
+ a POST flow you added by hand all survive, and a pipe that was gated on a missing
680
+ id turns back on once you fill that id in \`../e2e/params.json\`.
681
+
682
+ This README is not yours — it is regenerated by \`--force\`, so that a later
683
+ version of flawless-react can tell you something it worked out after your suite
684
+ was generated. Put your own notes in \`routes.pipes\`, below its header.
685
+ `;
686
+ }
687
+
688
+ /**
689
+ * Exports LOAD_<PARAM> from the ids already filled in for the e2e task.
690
+ *
691
+ * The load pipes and the e2e specs need the same seeded ids. Two hand-maintained
692
+ * copies of the same eight UUIDs drift on the first change, and the drift is
693
+ * silent — a load run against a stale id just reports a 404 rate.
694
+ */
695
+ export function loadEnvScript(rel: string): string {
696
+ return `#!/usr/bin/env bash
697
+ # source ${underOutDir(rel, "load/env.sh")}
698
+ #
699
+ # Exports LOAD_<PARAM> for every id in ${underOutDir(rel, "e2e/params.json")}, so the load
700
+ # pipes and the e2e specs cannot disagree about which records they target. The
701
+ # bundled routes.setup.ts seeds those same ids before every pipe — this file is
702
+ # for running crust directly, or for any other tool that wants the ids as
703
+ # environment variables.
704
+ # Do not edit: this file is regenerated on every run. Set LOAD_BASE and
705
+ # LOAD_AUTH in your shell (or a .env you source alongside this); see README.md.
706
+ set -a
707
+ # Locate params.json without depending on which shell sourced this file. The
708
+ # shebang above does NOT apply to a sourced script, so \${BASH_SOURCE[0]} is
709
+ # empty under zsh — dirname then returned ".", the lookup missed, and every
710
+ # LOAD_<PARAM> silently went unset. The pipes still ran, requesting URLs with
711
+ # empty path segments: 22 of 33 measured the 404 handler and reported the app.
712
+ _self=""
713
+ if [ -n "\${BASH_SOURCE:-}" ]; then
714
+ _self="\${BASH_SOURCE[0]}"
715
+ elif [ -n "\${ZSH_VERSION:-}" ]; then
716
+ # Hidden behind eval so bash never parses zsh-only syntax.
717
+ eval '_self="\${(%):-%x}"'
718
+ fi
719
+ if [ -n "$_self" ] && [ -d "$(dirname "$_self")/../e2e" ]; then
720
+ _params="$(cd "$(dirname "$_self")/../e2e" && pwd)/params.json"
721
+ else
722
+ # Fall back to the working directory the pipes themselves assume: their
723
+ # --out ${underOutDir(rel, "load/last-*.json")} paths are relative to the project root.
724
+ # This has to track the real out dir — hardcoded "flawless", a run with
725
+ # --out qa left the one fallback that exists for this case resolving to a
726
+ # directory that was never created, so it silently exported nothing.
727
+ _params="$PWD/${underOutDir(rel, "e2e/params.json")}"
728
+ fi
729
+ if ! command -v jq >/dev/null 2>&1; then
730
+ # Without this the only signal is the shell's own "jq: command not found",
731
+ # which does not say what it costs: the pipes still run, requesting URLs with
732
+ # empty path segments, and report the 404 handler's numbers as the app's.
733
+ echo "flawless: jq not found — no LOAD_<PARAM> exported. The pipes would request URLs with empty path segments and measure your 404 handler. Install jq, or export the ids yourself." >&2
734
+ elif [ -f "$_params" ]; then
735
+ while IFS="=" read -r _k _v; do
736
+ [ -n "$_k" ] && [ -n "$_v" ] && export "LOAD_$(echo "$_k" | tr '[:lower:]' '[:upper:]')=$_v"
737
+ done < <(
738
+ # Defaults first, then per-route overrides — but only the ones actually
739
+ # filled in. Every route entry starts as "", and an unfiltered merge would
740
+ # let those blanks overwrite a default that IS set.
741
+ jq -r '(.defaults // {}) + (reduce (.routes // {} | to_entries[]) as $r
742
+ ({}; . + ($r.value | with_entries(select(.value != "")))))
743
+ | to_entries[] | select(.value != "") | "\\(.key)=\\(.value)"' "$_params"
744
+ )
745
+ else
746
+ echo "flawless: no e2e/params.json — set LOAD_<PARAM> yourself" >&2
747
+ fi
748
+ set +a
749
+ `;
750
+ }