@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/src/core.ts ADDED
@@ -0,0 +1,1229 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { findUnresolvableImports } from "./analyse/bootability.ts";
4
+ import { analyseFile, createAnalyseSharedContext } from "./analyse/index.ts";
5
+ import { deriveContextProviderWraps, libraryProviderShimsFor } from "./analyse/providerWraps.ts";
6
+ import { affectedSpecs, changedFiles, findRepoRoot } from "./changed.ts";
7
+ import { outDirRel, underOutDir } from "./emitPaths.ts";
8
+ import { detectExpo } from "./expo/detect.ts";
9
+ import {
10
+ detectComponentsGlob,
11
+ detectNextjs,
12
+ ensureFormatterIgnores,
13
+ ignoreEntries,
14
+ ignoreRootFor,
15
+ mergeIntoNextPackageJson,
16
+ updateGitignore,
17
+ } from "./nextjs/detect.ts";
18
+ import { emitNextjsMocks } from "./nextjs/emit.ts";
19
+ import { fetchLiveSwagger, loadLocalSpec } from "./nextjs/liveSpec.ts";
20
+ import {
21
+ dropReasonLabel,
22
+ ensureClientProviderWrapper,
23
+ isUsableUnitProvider,
24
+ } from "./nextjs/providerWrapper.ts";
25
+ import { appReactMajor } from "./packageJson.ts";
26
+ import { configRootFor, detectReactCompiler } from "./reactCompiler.ts";
27
+ import { printFindings, writeInlineSummaries, writeSummaryJson } from "./reporters/findings.ts";
28
+ import { type UnparseableFile, writeReportMarkdown } from "./reporters/report.ts";
29
+ import { runAffectedTests, runGeneratedTests } from "./runner/runTests.ts";
30
+ import { adoptProjectStyle } from "./scaffold/adoptStyle.ts";
31
+ import { restampGenerated } from "./scaffold-tests/stamp.ts";
32
+ import { EXPO_SCRIPT, JEST_CONFIG, NEXT_SCRIPT, VITEST_CONFIG } from "./suiteScripts.ts";
33
+ import { findTask, TASKS } from "./tasks/index.ts";
34
+ import type {
35
+ ComponentTree,
36
+ ExpoContext,
37
+ HeapMode,
38
+ NextjsApiRoute,
39
+ NextjsContext,
40
+ PropsShape,
41
+ RootMode,
42
+ RunContext,
43
+ Task,
44
+ TaskId,
45
+ TaskResult,
46
+ } from "./types.ts";
47
+
48
+ import { UsageError } from "./types.ts";
49
+
50
+ // Re-exported so `import { UsageError } from "…/core.ts"` keeps working — the
51
+ // class moved to types.ts so leaf modules can throw it without a cycle.
52
+ export { UsageError };
53
+
54
+ export interface CoreOptions {
55
+ /** Entry file path or glob. */
56
+ pattern: string;
57
+ outDir?: string;
58
+ taskIds?: TaskId[];
59
+ depth?: number;
60
+ variants?: number;
61
+ heaps?: HeapMode;
62
+ includeChildren?: boolean;
63
+ force?: boolean;
64
+ report?: boolean;
65
+ propsOverride?: PropsShape;
66
+ /** Explicit provider wrapper (.tsx default export). Used verbatim. */
67
+ provider?: string;
68
+ apiSpec?: string;
69
+ inlineSummary?: boolean;
70
+ /** Next.js project root; enables Next.js mode. */
71
+ nextjs?: string;
72
+ /** Expo project root; enables Expo mode. Mutually exclusive with `nextjs`. */
73
+ expo?: string;
74
+ swaggerUrl?: string;
75
+ colocate?: boolean;
76
+ runTests?: boolean;
77
+ /** Run only the specs affected by files changed since this ref (true = HEAD). */
78
+ changed?: string | true;
79
+ /** "exported" emits a tree per exported component in each entry file. */
80
+ roots?: RootMode;
81
+ /** Extra globs to skip, appended to DEFAULT_EXCLUDES. */
82
+ exclude?: readonly string[];
83
+ useDefaultExcludes?: boolean;
84
+ /**
85
+ * Everything this run writes to STDOUT — the progress narration AND the
86
+ * per-file findings listing (`printFindings`). Default on. False is what
87
+ * --json/--ci and the programmatic API pass: stdout then belongs to the
88
+ * caller — a JSON blob that must parse whole — so nothing may go there.
89
+ *
90
+ * It is NOT a vow of silence. Diagnostics go to stderr and are gated by
91
+ * `quiet`, which is a separate decision: see below.
92
+ */
93
+ log?: boolean;
94
+ /**
95
+ * Suppress `runCore`'s OWN diagnostics — the `warn()` lines: a skipped file,
96
+ * an unparseable spec, a script collision, a failed Next.js emit. Default
97
+ * off, so they always speak, including under --ci.
98
+ *
99
+ * Sharing one flag with `log` is how `--ci`, the mode built for machines,
100
+ * became the mode that lost "warning: skipping <file>" and "could not parse
101
+ * <path> as OpenAPI JSON — ignoring". Whether stdout is the caller's and
102
+ * whether the run may report a problem at all are different questions.
103
+ *
104
+ * Deliberately narrow, and NOT "silence stderr": the suite runner's own
105
+ * output (`› bun run flawless:unit`, `failed suites: …`, and every child
106
+ * suite's streams) is routed by a different flag, and task-level warnings
107
+ * like perfHarness's migration notice go through `console.warn` with no gate
108
+ * at all. If you need a fully silent process, redirect fd 2 yourself.
109
+ */
110
+ quiet?: boolean;
111
+ }
112
+
113
+ export interface CoreResult {
114
+ exitCode: 0 | 1;
115
+ outDir: string;
116
+ results: TaskResult[];
117
+ trees: ComponentTree[];
118
+ nextjs: NextjsContext | undefined;
119
+ /** Path of the provider wrapper actually used for unit specs, if any. */
120
+ provider: string | undefined;
121
+ /**
122
+ * What the test-run phase did.
123
+ *
124
+ * - "affected" | "generated": suites actually RAN — that is the promise
125
+ * these two arms make, and `green` is what folded into exitCode. A phase
126
+ * that spawned nothing never wears them (FR-042/FR-043).
127
+ * - "none": zero suites ran; `reason` says why — no selected task generates
128
+ * one --run-tests executes, nothing was affected, or the selected tasks
129
+ * plan no run for the work that was.
130
+ * - absent: the run never REACHED the test-run phase. FR-046: that is not
131
+ * the same as "nothing was requested". It also covers a request blocked by
132
+ * a failed task, which exits 1 and is explained by `results`. Read absent
133
+ * as "look elsewhere", never as "no run was asked for".
134
+ *
135
+ * The invariant a consumer can rely on: `mode: "none"` if and only if zero
136
+ * suites executed, and a `green` field only ever appears on a phase that ran
137
+ * something.
138
+ *
139
+ * The whole point is that exit 0 has two meanings — "the suites passed" and
140
+ * "no suite ran" — and a caller reading only the exit code cannot tell them
141
+ * apart. That is the pair FR-017 hid between, and B-14 found `--run-tests`
142
+ * over a selection of non-runnable tasks reproducing it one flag over.
143
+ *
144
+ * Deliberately NOT set when a task failed: that run exits 1, so it is already
145
+ * telling the truth, and the failing TaskResult says which task.
146
+ */
147
+ testRun?: { mode: "affected" | "generated"; green: boolean } | { mode: "none"; reason: string };
148
+ /**
149
+ * Failures that belong to no task and so appear nowhere in `results`
150
+ * (FR-041). Today's only producer is a Next.js emit that threw: it sets the
151
+ * exit code to 1 while every TaskResult still says ok:true, so without this
152
+ * a --ci consumer saw a red run whose blob contained no account of it.
153
+ * Empty on a clean run; with `testRun`, every red run is now explicable from
154
+ * the blob alone.
155
+ */
156
+ errors: string[];
157
+ /**
158
+ * flawless:* scripts whose existing package.json value the merge did not
159
+ * recognize as the tool's own and therefore KEPT (FR-034). Durable on
160
+ * purpose: the log line is suppressed under --ci/--json and programmatic
161
+ * runs, exactly the modes an agent refreshes in.
162
+ */
163
+ keptScripts: string[];
164
+ }
165
+
166
+ /**
167
+ * A command that reproduces this run, for REPORT.md's header.
168
+ *
169
+ * Rebuilt from the options rather than captured from argv. argv carries the
170
+ * interpreter and the absolute path of the script — `bun /home/…/src/bin.ts` —
171
+ * and REPORT.md is committed, timestamp-free, and checked out under a different
172
+ * root on every machine. An absolute path in it would churn `git status`
173
+ * everywhere except the machine that generated it, which is the same failure the
174
+ * no-timestamp rule exists to prevent, arriving through a different door.
175
+ *
176
+ * So: the published bin name, never the interpreter; every path relative, never
177
+ * absolute; and only the options that differ from their defaults, so two runs
178
+ * that did the same thing produce the same line.
179
+ */
180
+ export function reproCommand(opts: CoreOptions, root: string, taskIds: TaskId[]): string {
181
+ // Only absolutes are rewritten. A pattern the caller wrote as `src/**/*.tsx`
182
+ // is already relative to where they ran it, and resolving it against `root`
183
+ // just to relativise it again would turn a correct string into a guess.
184
+ const rel = (p: string): string => {
185
+ const r = path.isAbsolute(p) ? path.relative(root, p) : p;
186
+ const posix = r.split(path.sep).join("/");
187
+ return posix === "" ? "." : posix;
188
+ };
189
+ // Globs and spaces need quoting or the invoking shell expands them before
190
+ // flawless sees them — which is a different run.
191
+ const quote = (s: string): string => (/[\s*?[\]{}()|$]/.test(s) ? `"${s}"` : s);
192
+
193
+ // Defensive: a caller that reaches here without a pattern gets a line that
194
+ // is honest about what it does not know, never a TypeError from path.relative.
195
+ const parts = [
196
+ "flawless-react",
197
+ opts.pattern === undefined ? "<entry>" : quote(rel(opts.pattern)),
198
+ ];
199
+ const flag = (name: string, value?: string): void => {
200
+ parts.push(name);
201
+ if (value !== undefined) parts.push(value);
202
+ };
203
+
204
+ if (opts.nextjs !== undefined) flag("--nextjs", quote(rel(opts.nextjs)));
205
+ if (opts.outDir !== undefined) flag("--out", quote(rel(opts.outDir)));
206
+ // The CLI's no-flag default is perf-harness alone, so anything else has to be
207
+ // spelled out or the line reproduces a different run.
208
+ if (!(taskIds.length === 1 && taskIds[0] === "perf-harness")) {
209
+ flag("--tasks", taskIds.join(","));
210
+ }
211
+ if (opts.roots !== undefined && opts.roots !== "primary") flag("--roots", opts.roots);
212
+ if (opts.apiSpec !== undefined) flag("--api", quote(rel(opts.apiSpec)));
213
+ if (opts.provider !== undefined) flag("--provider", quote(rel(opts.provider)));
214
+ if (opts.swaggerUrl !== undefined) flag("--swagger-url", quote(opts.swaggerUrl));
215
+ if (opts.depth !== undefined && opts.depth !== 3) flag("--depth", String(opts.depth));
216
+ if (opts.variants !== undefined && opts.variants !== 5) {
217
+ flag("--variants", String(opts.variants));
218
+ }
219
+ if (opts.heaps !== undefined && opts.heaps !== "stress") flag("--heaps", opts.heaps);
220
+ if (opts.includeChildren === false) flag("--include-children=false");
221
+ // Relativised like any other path: an exclude is usually already relative
222
+ // (`**/*.stories.tsx`), but nothing stops a caller passing an absolute one.
223
+ for (const g of opts.exclude ?? []) flag("--exclude", quote(rel(g)));
224
+ if (opts.useDefaultExcludes === false) flag("--no-default-excludes");
225
+ if (opts.propsOverride !== undefined) {
226
+ flag("--props", `'${JSON.stringify(opts.propsOverride)}'`);
227
+ }
228
+ if (opts.colocate === true) flag("--colocate");
229
+ if (opts.force === true) flag("--force");
230
+ if (opts.report === true) flag("--report");
231
+ if (opts.inlineSummary === false) flag("--no-inline-summary");
232
+ if (opts.changed !== undefined) {
233
+ flag("--changed", opts.changed === true ? undefined : quote(opts.changed));
234
+ }
235
+ if (opts.runTests === true) flag("--run-tests");
236
+ return parts.join(" ");
237
+ }
238
+
239
+ export async function runCore(opts: CoreOptions): Promise<CoreResult> {
240
+ const log = (line: string): void => {
241
+ if (opts.log !== false) process.stdout.write(`${line}\n`);
242
+ };
243
+ // Deliberately NOT `opts.log`: stdout purity and permission to report a
244
+ // problem are separate decisions (B-13). Gating both on one flag made --ci
245
+ // silent about work it skipped.
246
+ const warn = (line: string): void => {
247
+ if (opts.quiet !== true) process.stderr.write(`${line}\n`);
248
+ };
249
+
250
+ let pattern: string | undefined = opts.pattern;
251
+ let outDir = opts.outDir;
252
+ const taskIds: TaskId[] =
253
+ opts.taskIds === undefined || opts.taskIds.length === 0 ? ["perf-harness"] : opts.taskIds;
254
+
255
+ let expoCtx: ExpoContext | undefined;
256
+ let nextCtx: NextjsContext | undefined;
257
+ let liveSpec: unknown;
258
+ const providerExplicit = opts.provider !== undefined;
259
+ let providerOverride = opts.provider;
260
+
261
+ // Belt and braces: the CLI already refuses both flags, but runCore is a public
262
+ // API (src/index.ts) and a programmatic caller reaches here without parseArgs.
263
+ if (opts.nextjs !== undefined && opts.expo !== undefined) {
264
+ throw new UsageError(
265
+ "nextjs and expo are mutually exclusive: a project is a Next.js app or an Expo app, not both.",
266
+ );
267
+ }
268
+
269
+ if (opts.expo !== undefined) {
270
+ expoCtx = await detectExpo(opts.expo); // throws UsageError on bad input
271
+ if (outDir === undefined) outDir = path.join(expoCtx.root, "flawless");
272
+ else assertOutDirInsideProject(outDir, expoCtx.root, "--expo");
273
+ if (pattern === undefined && expoCtx.routerRoot !== undefined) {
274
+ pattern = path.join(expoCtx.routerRoot, "**/*.tsx");
275
+ log(`expo: no entry passed — defaulting to ${path.relative(expoCtx.root, pattern)}`);
276
+ }
277
+ log(
278
+ `expo: ${expoCtx.root} (sdk=${expoCtx.sdkMajor ?? "unknown"}` +
279
+ `, expo-router=${expoCtx.routerMajor ?? "none"}` +
280
+ `, nativewind=${expoCtx.nativewind}` +
281
+ `, web-port=${expoCtx.webPort})`,
282
+ );
283
+ }
284
+
285
+ if (opts.nextjs !== undefined) {
286
+ nextCtx = await detectNextjs(opts.nextjs); // throws UsageError on bad input
287
+ if (outDir === undefined) outDir = path.join(nextCtx.root, "flawless");
288
+ else assertOutDirInsideProject(outDir, nextCtx.root, "--nextjs");
289
+ if (providerOverride === undefined) providerOverride = nextCtx.appResolver;
290
+ if (pattern === undefined) {
291
+ const detected = detectComponentsGlob(nextCtx.root);
292
+ if (detected) {
293
+ pattern = detected;
294
+ log(`nextjs: no entry passed — defaulting to ${path.relative(nextCtx.root, detected)}`);
295
+ }
296
+ }
297
+ log(
298
+ `nextjs: ${nextCtx.root} (router=${nextCtx.router}, api-routes=${nextCtx.apiRoutes.length}, app-resolver=${nextCtx.appResolver ?? "<none>"})`,
299
+ );
300
+
301
+ if (opts.swaggerUrl !== "off") {
302
+ const live = await fetchLiveSwagger(opts.swaggerUrl);
303
+ if (live.ok && live.routes) {
304
+ mergeRoutes(nextCtx, live.routes);
305
+ liveSpec = live.rawSpec;
306
+ log(
307
+ `nextjs: enriched routes from live OpenAPI at ${live.url} (paths=${live.routes.length})`,
308
+ );
309
+ } else if (opts.swaggerUrl !== undefined) {
310
+ warn(
311
+ `nextjs: live spec at ${live.url} unavailable (${live.error}); using static AST output`,
312
+ );
313
+ }
314
+ }
315
+
316
+ // A local OpenAPI file beats both the AST walk and the live spec — it is
317
+ // the only reliable source when the API is a catch-all (Hono, tRPC, ...).
318
+ if (opts.apiSpec !== undefined) {
319
+ const local = await loadLocalSpec(opts.apiSpec);
320
+ if (local) {
321
+ mergeRoutes(nextCtx, local.routes);
322
+ liveSpec = local.rawSpec;
323
+ log(`nextjs: enriched routes from ${opts.apiSpec} (paths=${local.routes.length})`);
324
+ } else {
325
+ warn(`nextjs: could not parse ${opts.apiSpec} as OpenAPI JSON — ignoring`);
326
+ }
327
+ }
328
+
329
+ // The auto-detected app resolver is usually an async server layout that
330
+ // cannot run under jsdom — derive a client-safe wrapper instead. An
331
+ // explicit --provider is respected verbatim (warn when it looks unusable).
332
+ if (providerOverride !== undefined && !isUsableUnitProvider(providerOverride)) {
333
+ if (providerExplicit) {
334
+ warn(
335
+ `provider: ${providerOverride} cannot mount under jsdom (async, server-only imports, or a pages \`_app\` rendering the route element) — unit specs will fail`,
336
+ );
337
+ } else {
338
+ const wrapped = ensureClientProviderWrapper(
339
+ nextCtx,
340
+ outDir as string,
341
+ providerOverride,
342
+ opts.force === true,
343
+ );
344
+ if (wrapped) {
345
+ providerOverride = wrapped.path;
346
+ const droppedText =
347
+ wrapped.dropped.length === 0
348
+ ? ""
349
+ : ` — dropped ${wrapped.dropped
350
+ .map((d) => `${d.name} (${dropReasonLabel(d.reason)})`)
351
+ .join(", ")}`;
352
+ log(
353
+ wrapped.created
354
+ ? `nextjs: app resolver cannot mount under jsdom — derived ${path.relative(nextCtx.root, wrapped.path)} (providers: ${wrapped.included.join(", ") || "none mounted"}${droppedText})`
355
+ : `nextjs: using existing ${path.relative(nextCtx.root, wrapped.path)}`,
356
+ );
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ if (pattern === undefined) {
363
+ throw new UsageError(
364
+ "missing entry path or glob. Pass a .tsx path, a glob, or run in a TTY for the interactive menu. See --help.",
365
+ );
366
+ }
367
+
368
+ outDir = path.resolve(outDir ?? path.join(process.cwd(), ".flawless"));
369
+
370
+ // Read once, here, for every mode. `perf-static`'s three memo-family rules
371
+ // name work the React Compiler does by itself, and web mode had no route to
372
+ // this fact at all — so the dogfood app's web side reported 127 findings
373
+ // recommending the compiler's own output be written by hand. It is logged in
374
+ // every state, including `off`: a task that quietly changes what it reports
375
+ // because of a flag nobody saw read is the same lie wearing a quieter outfit.
376
+ const reactCompiler = detectReactCompiler(
377
+ expoCtx?.root ?? nextCtx?.root ?? configRootFor(outDir),
378
+ );
379
+ log(`react-compiler=${reactCompiler.state} (${reactCompiler.evidence})`);
380
+
381
+ const files = await expandGlob(pattern, {
382
+ ...(opts.exclude ? { exclude: opts.exclude } : {}),
383
+ ...(opts.useDefaultExcludes === undefined
384
+ ? {}
385
+ : { useDefaultExcludes: opts.useDefaultExcludes }),
386
+ });
387
+ if (files.length === 0) {
388
+ throw new UsageError(`no files matched: ${pattern}`);
389
+ }
390
+
391
+ const trees: ComponentTree[] = [];
392
+ const unparseable: UnparseableFile[] = [];
393
+ // One parse cache + resolver for the whole sweep: a leaf shared by many
394
+ // entries (ui/card.tsx under a dogfood sweep) parses once, not once per
395
+ // entry. Per-entry state (visited sets) stays inside analyseFile.
396
+ const analyseShared = createAnalyseSharedContext();
397
+ for (const file of files) {
398
+ try {
399
+ trees.push(
400
+ ...(await analyseFile(
401
+ {
402
+ entry: file,
403
+ depth: opts.depth ?? 3,
404
+ includeChildren: opts.includeChildren ?? true,
405
+ roots: opts.roots ?? "primary",
406
+ },
407
+ analyseShared,
408
+ )),
409
+ );
410
+ } catch (err) {
411
+ // Also recorded so REPORT.md can account for it. A stderr line is lost in
412
+ // a hundred-file sweep, and a component that vanished silently is exactly
413
+ // what makes a coverage number meaningless.
414
+ unparseable.push({ file, reason: (err as Error).message });
415
+ warn(`warning: skipping ${file}: ${(err as Error).message}`);
416
+ }
417
+ }
418
+ if (trees.length === 0) {
419
+ throw new UsageError("no analysable components found");
420
+ }
421
+
422
+ // Derived before any task runs: the context providers a mount needs are
423
+ // frequently imported by NO analysed component (the route layout imports
424
+ // SidebarProvider, and route files are not component entries), so the host
425
+ // cannot find them in the trees — it needs this map instead.
426
+ const providerWraps = await deriveContextProviderWraps(trees, analyseShared.cache, (c) =>
427
+ libraryProviderShimsFor([c.info.filePath]),
428
+ );
429
+
430
+ const firstTree = trees[0];
431
+ if (firstTree && opts.propsOverride) {
432
+ firstTree.entry.propsShape = { ...firstTree.entry.propsShape, ...opts.propsOverride };
433
+ }
434
+
435
+ // An unknown id used to be filtered away, so a typo ran ZERO tasks and
436
+ // still exited 0 with an empty results array — a green run that tested
437
+ // nothing, which is the one outcome this tool must never produce.
438
+ const unknownTaskIds = taskIds.filter((id) => findTask(id) === undefined);
439
+ if (unknownTaskIds.length > 0) {
440
+ throw new UsageError(
441
+ `unknown task id(s): ${unknownTaskIds.join(", ")}. Valid ids: ${TASKS.map((t) => t.id).join(", ")}.`,
442
+ );
443
+ }
444
+ const selected = taskIds.map((id) => findTask(id)).filter((t): t is Task => Boolean(t));
445
+
446
+ const ctx: RunContext = {
447
+ trees,
448
+ outDir,
449
+ variants: opts.variants ?? 5,
450
+ heaps: opts.heaps ?? "stress",
451
+ force: opts.force ?? false,
452
+ report: opts.report ?? false,
453
+ provider: providerOverride,
454
+ apiSpec: opts.apiSpec,
455
+ inlineSummary: opts.inlineSummary ?? true,
456
+ nextjs: nextCtx,
457
+ ...(expoCtx ? { expo: expoCtx } : {}),
458
+ reactCompiler,
459
+ colocate: opts.colocate ?? false,
460
+ selectedTasks: taskIds,
461
+ // Changed-mode reaches the tasks so perf-harness can keep an existing
462
+ // suite instead of churning it — the affected-run gate below stays on
463
+ // !anyFailed, and a kept harness is not a failure.
464
+ ...(opts.changed === undefined ? {} : { changed: opts.changed }),
465
+ ...(providerWraps.size > 0 ? { providerWraps } : {}),
466
+ };
467
+
468
+ const results: TaskResult[] = [];
469
+ let anyFailed = false;
470
+ // FR-041: failures that belong to no task, and so appear nowhere in
471
+ // `results`. A caller reading the blob saw exitCode 1 with every task ok:true
472
+ // and no account of why; the stderr line was the only record, and before B-13
473
+ // even that was suppressed under --ci.
474
+ const runErrors: string[] = [];
475
+ for (const task of selected) {
476
+ try {
477
+ const r = await task.run(ctx);
478
+ results.push(r);
479
+ if (!r.ok) anyFailed = true;
480
+ if (opts.log !== false) printFindings(r.findings);
481
+ } catch (err) {
482
+ anyFailed = true;
483
+ results.push({
484
+ id: task.id,
485
+ ok: false,
486
+ outputs: [],
487
+ findings: [],
488
+ summary: "task threw",
489
+ error: (err as Error).message,
490
+ });
491
+ }
492
+ }
493
+
494
+ const nextjsWritten: string[] = [];
495
+ // Kept flawless:* scripts must outlive the log line: --ci/--json and
496
+ // programmatic runs suppress logging, and those are exactly the modes an
497
+ // agent refreshes in (the FR-034 dogfood bite happened under one).
498
+ let keptScripts: string[] = [];
499
+ if (nextCtx) {
500
+ try {
501
+ const nm = await emitNextjsMocks(nextCtx, outDir, { liveSpec, force: opts.force });
502
+ nextjsWritten.push(...nm.written, nm.swaggerPath);
503
+ log(
504
+ `nextjs: wrote ${nm.written.length} mock file(s) under ${path.relative(process.cwd(), outDir)}/mocks/`,
505
+ );
506
+ // POSIX separators, like every other emitted path (loadPipes.ts,
507
+ // scaffold/templates.ts, scaffold-tests/mocks.ts). These land in a file;
508
+ // the rest of path.relative's uses in this file are log lines.
509
+ const outRel = outDirRel(outDir, nextCtx.root);
510
+ // React 17 apps get @testing-library/react 12 — 13+ peer-require
511
+ // react ^18 and hard-require the react-dom 18 client entry at load.
512
+ // Mirrors suitePackageJson's branch.
513
+ const appMajor = appReactMajor(path.join(nextCtx.root, "package.json"));
514
+ const legacyRtl = appMajor !== undefined && appMajor <= 17;
515
+ const scriptMerge = await mergeIntoNextPackageJson(
516
+ nextCtx.root,
517
+ {},
518
+ {
519
+ msw: "^2.6.0",
520
+ vitest: "^2.1.0",
521
+ jsdom: "^25.0.0",
522
+ "@testing-library/react": legacyRtl ? "^12.1.5" : "^16.0.0",
523
+ ...(legacyRtl ? { "@testing-library/dom": "^8.20.1" } : {}),
524
+ "@testing-library/user-event": "^14.5.0",
525
+ "@testing-library/jest-dom": "^6.5.0",
526
+ "@playwright/test": "^1.49.0",
527
+ "react-scan": "^0.4.3",
528
+ },
529
+ {
530
+ // Every one of these is gated on the task that emits what it points
531
+ // at. This block runs on EVERY --nextjs invocation, so declaring them
532
+ // unconditionally wrote `flawless:perf` — and unit, and browser — into
533
+ // the app's package.json after a `--tasks load` run that never emitted
534
+ // a playwright.config.ts or a profile/ for them to find. `bun run
535
+ // flawless:perf` then failed on a config the user never asked for and
536
+ // cannot produce without regenerating. Same defect as the standalone
537
+ // suite's missing `test:perf`, from the other direction: there the
538
+ // script was absent, here its target is.
539
+ ...(taskIds.includes("unit-browser")
540
+ ? {
541
+ "flawless:unit": opts.colocate
542
+ ? `vitest run --config ${outRel}/${VITEST_CONFIG}`
543
+ : `vitest run --root ${outRel}`,
544
+ }
545
+ : {}),
546
+ // browser/ is rendered only when perf-harness is also in the run —
547
+ // the browser specs drive its host (unitAndBrowser.ts sets
548
+ // browserSpecs from exactly this condition). Gated on unit-browser
549
+ // alone, `--nextjs --tasks unit-browser` declared this script for a
550
+ // config that was never written, and --run-tests then invoked it and
551
+ // reported `failed suites: browser` for a correct run.
552
+ ...(taskIds.includes("unit-browser") && taskIds.includes("perf-harness")
553
+ ? {
554
+ "flawless:browser": `playwright test --config ${outRel}/browser/playwright.config.ts`,
555
+ }
556
+ : {}),
557
+ // --project=chromium is a no-op against the emitted config's single
558
+ // project — kept because it says what this script IS (the headless
559
+ // perf plane) and keeps working on configs where the user added
560
+ // projects of their own. The config once shipped a headed second
561
+ // project, which turned every bare \`playwright test\` into a
562
+ // double pass with real windows; that project is gone.
563
+ ...(taskIds.includes("perf-harness")
564
+ ? {
565
+ "flawless:perf": `playwright test --config ${outRel}/playwright.config.ts --project=chromium ${outRel}/profile`,
566
+ }
567
+ : {}),
568
+ ...(taskIds.includes("e2e")
569
+ ? {
570
+ "flawless:e2e": `playwright test --config ${outRel}/e2e/playwright.config.ts`,
571
+ }
572
+ : {}),
573
+ ...(taskIds.includes("load")
574
+ ? {
575
+ "flawless:load": `crust -c 'test-pipes --target ${outRel}/load/routes.pipes --timeout 60000'`,
576
+ }
577
+ : {}),
578
+ // --report emits scripts/report.mjs into the suite, but in Next.js
579
+ // mode there is no suite package.json to declare a `report` script,
580
+ // so the file sat there with nothing anywhere able to invoke it while
581
+ // its own banner said "Re-run with bun run report". Same gate as
582
+ // pkgJson's standalone `report` key, and same pairing with the task
583
+ // that writes the file.
584
+ ...(taskIds.includes("perf-harness") && opts.report === true
585
+ ? { [NEXT_SCRIPT.report]: `node ${outRel}/scripts/report.mjs` }
586
+ : {}),
587
+ },
588
+ {
589
+ // Both spellings are the tool's own — which one a project holds
590
+ // depends only on whether --colocate was passed LAST run, and the
591
+ // stale one silently skips colocated specs under --run-tests.
592
+ "flawless:unit": [
593
+ `vitest run --config ${outRel}/${VITEST_CONFIG}`,
594
+ `vitest run --root ${outRel}`,
595
+ ],
596
+ },
597
+ );
598
+ log(
599
+ `nextjs: merged dev deps + flawless:* scripts into ${path.join(nextCtx.root, "package.json")}`,
600
+ );
601
+ keptScripts = scriptMerge.keptScripts;
602
+ if (keptScripts.length > 0) {
603
+ log(
604
+ `nextjs: kept ${keptScripts.length} script(s) with commands this tool does not ` +
605
+ `recognize: ${keptScripts.join(", ")} — delete a key from package.json to take ` +
606
+ `the tool's current command`,
607
+ );
608
+ }
609
+ // Colocated specs sit in src/ next to their components, where an
610
+ // unscoped test runner will collect them and run them under the wrong
611
+ // runner. Silently creating that collision is not acceptable — it turns
612
+ // a green project suite red for a reason that looks nothing like the
613
+ // cause.
614
+ for (const collision of detectTestScriptCollisions(nextCtx.root, ctx.colocate ?? false)) {
615
+ warn(`warning: ${collision}`);
616
+ }
617
+ // outRel, not a literal "flawless": every script twelve lines above is
618
+ // already written relative to the real out dir, and these were not. Under
619
+ // `--out qa` the run wrote qa/ and ignored flawless/, so the heaps (566MB
620
+ // of them), the Playwright output and — worst — qa/e2e/.auth.json were
621
+ // left untracked and unignored. That file is where global-setup.ts tells
622
+ // you to persist a logged-in storageState, i.e. session cookies staged
623
+ // for commit. `*.flawless.json` stays literal: it is the inline-summary
624
+ // sidecar pattern, written beside the analysed sources, not under outDir.
625
+ const added = updateGitignore(nextCtx.root, ignoreEntries(outRel));
626
+ if (added.length > 0) {
627
+ log(`nextjs: added ${added.length} entry(ies) to .gitignore (${added.join(", ")})`);
628
+ }
629
+ for (const t of ensureFormatterIgnores(nextCtx.root, outRel)) {
630
+ log(
631
+ `nextjs: added ${t.entry} to ${t.file} — the suite is tool-owned, not project lint input`,
632
+ );
633
+ }
634
+ } catch (err) {
635
+ anyFailed = true;
636
+ const message = `nextjs emit failed: ${(err as Error).message}`;
637
+ runErrors.push(message);
638
+ warn(message);
639
+ }
640
+ }
641
+
642
+ // Expo mode merges the native test stack into the app, the way Next.js mode
643
+ // merges the DOM one. The version pairing is the whole point and it is not
644
+ // cosmetic: a bare `jest` resolves to 30 while jest-expo 54's tooling is all
645
+ // ^29.2.1, and the mismatch surfaces as
646
+ // "TypeError: this._moduleMocker.clearMocksOnScope is not a function"
647
+ // from inside jest-runtime — which names nothing a user could act on. Found by
648
+ // running the emitted suite, not by reading the manifest.
649
+ if (expoCtx) {
650
+ try {
651
+ const outRel = outDirRel(outDir, expoCtx.root);
652
+ const merge = await mergeIntoNextPackageJson(
653
+ expoCtx.root,
654
+ {},
655
+ {
656
+ // Pinned to the project's OWN SDK major. jest-expo@54 pairs with Expo
657
+ // SDK 54; a fixed range breaks every other adopter, and a guessed one
658
+ // fails deep inside expo-modules-core.
659
+ ...(expoCtx.sdkMajor === undefined ? {} : { "jest-expo": `~${expoCtx.sdkMajor}.0.0` }),
660
+ // ^29, matching jest-expo's own babel-jest/@jest/globals/jest-snapshot.
661
+ jest: "^29.7.0",
662
+ // 13, not 14: expo-router 6's testing-library is built against
663
+ // react-test-renderer, which RNTL 14 replaced with `test-renderer`.
664
+ "@testing-library/react-native": "^13.3.3",
665
+ "react-test-renderer": "19.1.0",
666
+ // The suite does not type-check without it. jest ships no types of its
667
+ // own, so an adopting app running `tsc --noEmit` over the emitted
668
+ // specs reports TS2593 on every describe/test and TS2304 on every
669
+ // expect — 2366 errors across the 32 specs of the dogfood app, none of
670
+ // them the adopter's fault. Majored to the jest pin above: @types/jest
671
+ // 30 against jest 29 is a mismatch nobody asked for.
672
+ "@types/jest": "^29.5.14",
673
+ // The e2e specs' runner. Same pin as the Next.js merge — an Expo app
674
+ // has no Playwright of its own (the dogfood app's lived in a sibling
675
+ // workspace the mobile package cannot resolve from).
676
+ ...(taskIds.includes("e2e") ? { "@playwright/test": "^1.49.0" } : {}),
677
+ },
678
+ {
679
+ ...(taskIds.includes("unit-browser")
680
+ ? { [EXPO_SCRIPT.unit]: `jest --config ${underOutDir(outRel, JEST_CONFIG)}` }
681
+ : {}),
682
+ ...(taskIds.includes("e2e")
683
+ ? {
684
+ [EXPO_SCRIPT.e2e]: `playwright test --config ${underOutDir(outRel, "e2e/playwright.config.ts")}`,
685
+ }
686
+ : {}),
687
+ // crust is a CLI, not an npm dep — same as the Next.js load script,
688
+ // which is why no @-dep rides alongside this one.
689
+ ...(taskIds.includes("load")
690
+ ? {
691
+ [EXPO_SCRIPT.load]: `crust -c 'test-pipes --target ${underOutDir(outRel, "load/routes.pipes")} --timeout 60000'`,
692
+ }
693
+ : {}),
694
+ },
695
+ );
696
+ keptScripts = merge.keptScripts;
697
+ log(`expo: merged the native test stack into ${path.join(expoCtx.root, "package.json")}`);
698
+ if (keptScripts.length > 0) {
699
+ log(
700
+ `expo: kept ${keptScripts.length} script(s) with commands this tool does not ` +
701
+ `recognize: ${keptScripts.join(", ")} — delete a key from package.json to take ` +
702
+ `the tool's current command`,
703
+ );
704
+ }
705
+ for (const collision of detectTestScriptCollisions(expoCtx.root, true)) {
706
+ warn(`warning: ${collision}`);
707
+ }
708
+ const added = updateGitignore(expoCtx.root, ignoreEntries(outRel));
709
+ if (added.length > 0) {
710
+ log(`expo: added ${added.length} entry(ies) to .gitignore (${added.join(", ")})`);
711
+ }
712
+ for (const t of ensureFormatterIgnores(expoCtx.root, outRel)) {
713
+ log(
714
+ `expo: added ${t.entry} to ${t.file} — the suite is tool-owned, not project lint input`,
715
+ );
716
+ }
717
+ } catch (err) {
718
+ anyFailed = true;
719
+ const message = `expo emit failed: ${(err as Error).message}`;
720
+ runErrors.push(message);
721
+ warn(message);
722
+ }
723
+ }
724
+
725
+ // Outside Next.js mode this used to run at all: updateGitignore was reached
726
+ // only from the branch above, so a plain `flawless-react "src/**/*.tsx"` run
727
+ // ignored NOTHING it generated — not reports/, not heaps/, not
728
+ // playwright-report/, and not e2e/.auth.json, which the emitted global-setup
729
+ // itself describes as session cookies staged for commit. One run in a fresh
730
+ // repo left 28 generated files untracked and unignored.
731
+ //
732
+ // The design call this needed: WHERE does a non-Next .gitignore belong? At the
733
+ // git root — that is where git reads a repo-wide ignore and where the user's
734
+ // existing one already is. Writing beside the out dir instead would create a
735
+ // second, easily-missed ignore file inside generated output. Failing that (a
736
+ // package that is not a repo) the nearest package.json, and failing that the
737
+ // out dir's parent, so something is always ignored rather than nothing.
738
+ if (!nextCtx && !expoCtx) {
739
+ const ignoreRoot = ignoreRootFor(outDir);
740
+ const added = updateGitignore(ignoreRoot, ignoreEntries(outDirRel(outDir, ignoreRoot)));
741
+ if (added.length > 0) {
742
+ log(
743
+ `added ${added.length} entry(ies) to ${path.join(ignoreRoot, ".gitignore")} ` +
744
+ `(${added.join(", ")})`,
745
+ );
746
+ }
747
+ for (const t of ensureFormatterIgnores(ignoreRoot, outDirRel(outDir, ignoreRoot))) {
748
+ log(`added ${t.entry} to ${path.join(ignoreRoot, t.file)} — tool-owned suite`);
749
+ }
750
+ }
751
+
752
+ if (ctx.inlineSummary) {
753
+ await writeInlineSummaries(trees, results, outDir);
754
+ }
755
+ await writeSummaryJson(outDir, results);
756
+ // A suite whose specs cannot even be collected should say so at generation
757
+ // time: the vitest log for that run is 401 lines of "failed (0 test)" with
758
+ // the reason buried after them (see analyse/bootability.ts). Only when this
759
+ // run writes specs — a `perf-static`-only run collects nothing, and telling
760
+ // it that an import would fail at collect would be a claim about a run it
761
+ // did not make.
762
+ const unresolvable = taskIds.includes("unit-browser")
763
+ ? findUnresolvableImports(
764
+ trees,
765
+ // The suite's own node_modules (the test stack lives there) and the
766
+ // project root; each importing file's own directory is added inside.
767
+ nextCtx ? [outDir, nextCtx.root] : [outDir],
768
+ )
769
+ : [];
770
+ if (unresolvable.length > 0) {
771
+ const shown = unresolvable
772
+ .slice(0, 3)
773
+ .map((b) => b.specifier)
774
+ .join(", ");
775
+ warn(
776
+ `${unresolvable.length} imported package(s) are not installed where the suite resolves them ` +
777
+ `(${shown}${unresolvable.length > 3 ? ", …" : ""}) — specs importing those fail at ` +
778
+ `collect, before any test runs. REPORT.md lists them.`,
779
+ );
780
+ }
781
+ const reportPath = await writeReportMarkdown({
782
+ trees,
783
+ results,
784
+ outDir,
785
+ projectRoot: nextCtx?.root,
786
+ unparseable,
787
+ unresolvable,
788
+ keptScripts,
789
+ allTaskIds: TASKS.map((t) => t.id),
790
+ // Only in Next.js mode, where there is a project root that both the command
791
+ // and the report's file paths are relative to, and which is where the user
792
+ // ran the command from. Standalone, the report lands in outDir while the
793
+ // pattern is relative to the caller's cwd — two different directories, so a
794
+ // line claiming to be runnable "from the project root" would not be.
795
+ // The RESOLVED pattern, not opts.pattern: Next.js mode auto-detects the
796
+ // entry glob, leaving opts.pattern undefined despite its type — which
797
+ // crashed the report line AFTER every task had already run.
798
+ command: nextCtx ? reproCommand({ ...opts, pattern }, nextCtx.root, taskIds) : undefined,
799
+ });
800
+
801
+ // Format everything we wrote with the host project's own formatter so
802
+ // generation never churns the target repo's lint/format state.
803
+ const styleRoot = nextCtx?.root ?? outDir;
804
+ const writtenFiles = [
805
+ reportPath,
806
+ ...results.flatMap((r) => r.outputs),
807
+ // swagger.json + mocks are rewritten on every run; if they are not written
808
+ // in the project's own style, its formatter (or a pre-commit hook) will
809
+ // reformat them and the next run will churn the diff straight back.
810
+ ...nextjsWritten,
811
+ ...(providerOverride && isInside(outDir, providerOverride) ? [providerOverride] : []),
812
+ ];
813
+ try {
814
+ const adopted = await adoptProjectStyle(styleRoot, writtenFiles);
815
+ if (adopted.formatter !== "none") {
816
+ log(
817
+ `style: formatted ${adopted.formatted} generated file(s) with the project's ${adopted.formatter}`,
818
+ );
819
+ }
820
+ if (adopted.warning) warn(`style: ${adopted.warning}`);
821
+ } catch (err) {
822
+ warn(`style adoption failed (non-fatal): ${(err as Error).message}`);
823
+ }
824
+ // Second phase of the colocated-spec stamps: the writer stamped the raw
825
+ // render, and the style pass above may just have reformatted those bytes —
826
+ // leaving stamps that would classify every untouched spec as hand-edited on
827
+ // the next --force. Restamp AFTER (and regardless of) style adoption; when
828
+ // the formatter didn't run or didn't change a file this is a no-op, and
829
+ // restampGenerated ignores everything that doesn't open with a stamp line.
830
+ try {
831
+ await restampGenerated(writtenFiles);
832
+ } catch (err) {
833
+ warn(`stamp refresh failed (non-fatal): ${(err as Error).message}`);
834
+ }
835
+
836
+ // The tasks that generate a suite something can run. Named once, so the
837
+ // message below cannot drift from the predicate that produced it.
838
+ const RUNNABLE_TASK_IDS: readonly TaskId[] = ["perf-harness", "unit-browser", "e2e"];
839
+ const runnableSelected = selected.some((t) => RUNNABLE_TASK_IDS.includes(t.id));
840
+
841
+ // --changed narrows the RUN, never the generation. The profiling host is a
842
+ // single bundle of every component, so regenerating only part of it would
843
+ // leave a host that no longer matches its specs — and generation is not where
844
+ // the time goes anyway: analysing 99 files takes half a second, while running
845
+ // every spec they produce took seventeen minutes.
846
+ // A suite failure must reach the exit code: --ci --run-tests previously
847
+ // discarded both runners' outcomes and exited 0 on red suites.
848
+ let suitesGreen = true;
849
+ // Recorded on CoreResult so a caller (or a test) can tell the affected run
850
+ // actually FIRED — exit 0 alone is equally consistent with the branch never
851
+ // executing, which is exactly how FR-017 hid.
852
+ let testRun: CoreResult["testRun"];
853
+ // FR-044. --changed is checked first, so passing both flags silently runs the
854
+ // NARROW set and drops the full suite the caller also asked for. Say so: the
855
+ // outcome is defensible, choosing it in silence is not.
856
+ if (opts.changed !== undefined && opts.runTests === true) {
857
+ warn(
858
+ "--changed and --run-tests were both given: running only the affected specs. " +
859
+ "Drop --changed to run the full generated suites.",
860
+ );
861
+ }
862
+ if (!anyFailed && opts.changed !== undefined && runnableSelected) {
863
+ const projectRoot = nextCtx?.root ?? expoCtx?.root;
864
+ const repoRoot = (await findRepoRoot(projectRoot ?? outDir)) ?? projectRoot ?? outDir;
865
+ const files = await changedFiles(repoRoot, opts.changed === true ? "HEAD" : opts.changed);
866
+ const affected = affectedSpecs(
867
+ trees,
868
+ files,
869
+ nextCtx ? { router: nextCtx.router, root: nextCtx.root } : undefined,
870
+ expoCtx?.routerRoot !== undefined ? { routerRoot: expoCtx.routerRoot } : undefined,
871
+ );
872
+ log(
873
+ `changed: ${affected.changedFiles.length} source file(s) → ${affected.components.length} component spec(s), ${affected.routes.length} route(s)` +
874
+ (affected.withoutSpecs.length > 0
875
+ ? ` (${affected.withoutSpecs.length} unexported component(s) covered through their parent)`
876
+ : ""),
877
+ );
878
+ const outcome = await runAffectedTests(
879
+ outDir,
880
+ selected.map((t) => t.id),
881
+ affected,
882
+ // The project whose package.json carries the flawless:* scripts — the
883
+ // Expo root serves the same role the Next root does.
884
+ nextCtx?.root ?? expoCtx?.root,
885
+ // log:false means stdout belongs to the caller (--json's blob, or a
886
+ // programmatic consumer) — the runner and its children use stderr.
887
+ //
888
+ // This argument is NOT opts.quiet, and must never become it. The runner's
889
+ // `quiet` has the OPPOSITE polarity to CoreOptions.quiet: there it means
890
+ // "route everything to stderr", here it would mean "say nothing at all".
891
+ // Wiring opts.quiet into this slot inverts the --json stdout-purity
892
+ // guarantee (a quiet:false caller would get suite output on stdout, in
893
+ // front of the blob), and tests/runTests.test.ts drives the runner
894
+ // directly, so nothing would catch it. Same word, one file apart.
895
+ opts.log !== true,
896
+ );
897
+ suitesGreen = outcome.green;
898
+ // ran === 0 FIRST, always. A run arm of this union is a promise that suites
899
+ // executed, so "green because nothing ran" must never wear one (FR-042).
900
+ testRun =
901
+ outcome.ran === 0
902
+ ? { mode: "none", reason: outcome.reason ?? "changed: no specs to run" }
903
+ : { mode: "affected", green: outcome.green };
904
+ } else if (!anyFailed && opts.runTests === true && runnableSelected) {
905
+ const outcome = await runGeneratedTests(
906
+ outDir,
907
+ selected.map((t) => t.id),
908
+ // Same role either way: the project whose package.json holds flawless:*.
909
+ nextCtx?.root ?? expoCtx?.root,
910
+ opts.log !== true,
911
+ );
912
+ suitesGreen = outcome.green;
913
+ testRun =
914
+ outcome.ran === 0
915
+ ? { mode: "none", reason: outcome.reason ?? "no generated suite matched the selection" }
916
+ : { mode: "generated", green: outcome.green };
917
+ } else if (!anyFailed && (opts.runTests === true || opts.changed !== undefined)) {
918
+ // B-14: a run WAS asked for and cannot happen, because nothing selected
919
+ // generates a suite. Previously this fell off the end of the chain with no
920
+ // else — exit 0, empty stderr, and a blob indistinguishable from a green
921
+ // suite run. The reason names the ids that would have run something: told
922
+ // only that nothing ran, a caller has to go and read the source to learn
923
+ // what to select instead.
924
+ //
925
+ // The !anyFailed guard is deliberate and load-bearing. A run where a task
926
+ // failed exits 1 and its TaskResult says which — that run is already
927
+ // telling the truth, and marking it "none" would relabel a real failure as
928
+ // a no-op.
929
+ const asked = opts.runTests === true ? "--run-tests" : "--changed";
930
+ const selectedIds = selected.map((t) => t.id).join(", ");
931
+ // FR-045. "generates no suite" was false for `load`, which generates pipes
932
+ // AND declares the flawless:load script to run them in the same breath —
933
+ // --run-tests simply does not execute them. Saying a generated artefact is
934
+ // not one is exactly the kind of claim this repo's rules forbid.
935
+ const loadNote = selected.some((t) => t.id === "load")
936
+ ? " load pipes are a suite, but not one --run-tests executes — run them with `bun run flawless:load`."
937
+ : "";
938
+ const reason =
939
+ `${asked} ran no suite: none of the selected task(s) (${selectedIds}) generates one ` +
940
+ `that ${asked} executes.${loadNote} Select at least one of ${RUNNABLE_TASK_IDS.join(", ")}.`;
941
+ warn(reason);
942
+ testRun = { mode: "none", reason };
943
+ }
944
+
945
+ return {
946
+ keptScripts,
947
+ exitCode: anyFailed || !suitesGreen ? 1 : 0,
948
+ outDir,
949
+ results,
950
+ trees,
951
+ nextjs: nextCtx,
952
+ provider: providerOverride,
953
+ testRun,
954
+ errors: runErrors,
955
+ };
956
+ }
957
+
958
+ /**
959
+ * Test scripts that would collect colocated `*.flawless.test.tsx` files and run
960
+ * them under a runner they were not written for. Bun's and Jest's default globs
961
+ * both match `*.test.tsx` anywhere, so an unscoped script silently absorbs
962
+ * every generated spec.
963
+ */
964
+ export function detectTestScriptCollisions(nextRoot: string, colocate: boolean): string[] {
965
+ if (!colocate) return [];
966
+ const pkgPath = path.join(nextRoot, "package.json");
967
+ if (!existsSync(pkgPath)) return [];
968
+ let scripts: Record<string, string> = {};
969
+ try {
970
+ scripts =
971
+ (JSON.parse(readFileSync(pkgPath, "utf8")) as { scripts?: Record<string, string> }).scripts ??
972
+ {};
973
+ } catch {
974
+ return [];
975
+ }
976
+
977
+ const out: string[] = [];
978
+ for (const [name, cmd] of Object.entries(scripts)) {
979
+ if (name.startsWith("flawless:")) continue;
980
+ const trimmed = cmd.trim();
981
+ // A bare runner with no path/pattern argument globs the whole project.
982
+ if (selectsAllTests(trimmed)) {
983
+ out.push(
984
+ `"${name}": "${trimmed}" names no path, so it will also collect the ` +
985
+ `*.flawless.test.tsx specs this run emits and execute them under the wrong ` +
986
+ `runner — which fails on a defect that is not theirs. Scope it ` +
987
+ `(e.g. "bun test tests"), or point it at a config whose testMatch already does.`,
988
+ );
989
+ }
990
+ }
991
+ return out;
992
+ }
993
+
994
+ /**
995
+ * Flags whose argument is NOT a test-selection filter.
996
+ *
997
+ * A runner invoked with only these still globs the whole project, so it will
998
+ * collect the specs this tool emits and run them under the wrong runner.
999
+ */
1000
+ const VALUE_FLAGS = new Set([
1001
+ "--preload",
1002
+ "--config",
1003
+ "-c",
1004
+ "--root",
1005
+ "--reporter",
1006
+ "--timeout",
1007
+ "--env-file",
1008
+ "--setup-files",
1009
+ "--coverage-reporter",
1010
+ "--max-workers",
1011
+ "-w",
1012
+ // vitest's workspace selector: it chooses WHICH project runs, not which
1013
+ // tests — `vitest --project unit` still globs that project's whole suite,
1014
+ // so its argument must not be mistaken for a path filter.
1015
+ "--project",
1016
+ ]);
1017
+
1018
+ /**
1019
+ * Flags that take NO argument.
1020
+ *
1021
+ * Kept separate from VALUE_FLAGS because conflating them was wrong in the
1022
+ * expensive direction: treating `--coverage` as value-taking made
1023
+ * `jest --coverage tests/` consume "tests/" as the flag's argument, so a
1024
+ * correctly-scoped script was reported as a collision.
1025
+ */
1026
+ const BOOLEAN_FLAGS = new Set([
1027
+ "--coverage",
1028
+ "--bail",
1029
+ "--silent",
1030
+ "--watch",
1031
+ "--watchAll",
1032
+ "--ci",
1033
+ "--verbose",
1034
+ "--run",
1035
+ ]);
1036
+
1037
+ /**
1038
+ * Whether a test command selects EVERYTHING, rather than a scoped path.
1039
+ *
1040
+ * The old test was a four-spelling regex — `bun test`, `jest`, `vitest run`,
1041
+ * `vitest` — which the dogfood app walked straight past: its script is
1042
+ * `bun test --preload ./tests/setup.ts`, a bare runner with a flag, no path, and
1043
+ * no match. Bun's default discovery then sweeps every emitted spec into the
1044
+ * project suite, where it fails on a runner it was not written for.
1045
+ *
1046
+ * `--config` is the interesting exception: a runner given an explicit config is
1047
+ * scoped BY that config's own testMatch, so `jest --config flawless/jest.config.cjs`
1048
+ * is not a collision even though it names no path.
1049
+ */
1050
+ export function selectsAllTests(cmd: string): boolean {
1051
+ const tokens = cmd.trim().split(/\s+/);
1052
+ const head = tokens[0] ?? "";
1053
+ if (!/^(bun|jest|vitest|bunx|npx)$/.test(head)) return false;
1054
+
1055
+ // Where the runner's ARGUMENTS begin.
1056
+ //
1057
+ // This was `bun test ? 2 : 1`, which counted the runner's own name as a
1058
+ // positional path filter for every `npx`/`bunx` form — so `npx jest` and
1059
+ // `bunx vitest` were reported as scoped when they glob the whole project,
1060
+ // which is precisely the collision this function exists to catch. `npx` and
1061
+ // `bunx` are in the accepted-runner regex above, so covering them was always
1062
+ // the intent.
1063
+ let i: number;
1064
+ if (head === "bun") {
1065
+ if (tokens[1] !== "test") return false;
1066
+ i = 2;
1067
+ } else if (head === "npx" || head === "bunx") {
1068
+ // `npx jest`, `bunx vitest run`, and `bunx bun test` — skip the launcher and
1069
+ // whatever runner it names.
1070
+ if (!/^(jest|vitest|bun)$/.test(tokens[1] ?? "")) return false;
1071
+ i = tokens[1] === "bun" ? (tokens[2] === "test" ? 3 : -1) : 2;
1072
+ if (i === -1) return false;
1073
+ } else {
1074
+ i = 1;
1075
+ }
1076
+ let positionals = 0;
1077
+ let configScoped = false;
1078
+ while (i < tokens.length) {
1079
+ const t = tokens[i] as string;
1080
+ if (t === "run" && positionals === 0) {
1081
+ i++;
1082
+ continue;
1083
+ }
1084
+ if (t.startsWith("-")) {
1085
+ const [flag, inline] = t.includes("=") ? t.split("=", 2) : [t, undefined];
1086
+ if (flag === "--config" || flag === "-c") configScoped = true;
1087
+ // A flag that takes a SEPARATE argument consumes it, so the argument is not
1088
+ // mistaken for a path filter. Boolean flags must not, or the path after one
1089
+ // disappears and a scoped script reads as a collision.
1090
+ if (
1091
+ inline === undefined &&
1092
+ VALUE_FLAGS.has(flag as string) &&
1093
+ !BOOLEAN_FLAGS.has(flag as string)
1094
+ ) {
1095
+ i++;
1096
+ }
1097
+ i++;
1098
+ continue;
1099
+ }
1100
+ positionals++;
1101
+ i++;
1102
+ }
1103
+ return positionals === 0 && !configScoped;
1104
+ }
1105
+
1106
+ /**
1107
+ * Refuse an out dir outside the project root, in a mode that writes INTO that project.
1108
+ *
1109
+ * `--nextjs`/`--expo` do more than choose a directory: they merge scripts into the
1110
+ * project's package.json and add entries to its .gitignore, both computed relative
1111
+ * to the project. With the out dir outside it, those come out as
1112
+ * `../../../../tmp/…` — a gitignore pattern git ignores, and a `flawless:unit`
1113
+ * script naming a path that exists on one machine. Both were observed: a run with
1114
+ * `--out` in a tmpdir left
1115
+ *
1116
+ * "flawless:unit": "jest --config ../../../../../../../tmp/mockfix2/out/jest.config.cjs"
1117
+ *
1118
+ * in the app's package.json, and ten `../../../../tmp/…` lines in its .gitignore.
1119
+ *
1120
+ * A UsageError rather than a warning: the run would otherwise write those into a
1121
+ * repo the user then commits, and every emitted path in the suite has the same
1122
+ * problem. The escape is to leave --out off, which defaults to <root>/flawless.
1123
+ */
1124
+ function assertOutDirInsideProject(outDir: string, root: string, flag: string): void {
1125
+ if (isInside(root, outDir) || path.resolve(outDir) === path.resolve(root)) return;
1126
+ throw new UsageError(
1127
+ `--out ${outDir} is outside the ${flag} project at ${root}. ` +
1128
+ `In ${flag} mode the run writes into that project — package.json scripts and ` +
1129
+ `.gitignore entries are both relative to it — so an out dir outside it produces ` +
1130
+ `paths that escape the repository and name this machine. ` +
1131
+ `Drop --out to use ${path.join(root, "flawless")}, or point it inside the project.`,
1132
+ );
1133
+ }
1134
+
1135
+ /** True when `file` lives under `dir` (not merely shares its path prefix). */
1136
+ function isInside(dir: string, file: string): boolean {
1137
+ const rel = path.relative(dir, file);
1138
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
1139
+ }
1140
+
1141
+ function mergeRoutes(nextCtx: NextjsContext, routes: NextjsApiRoute[]): void {
1142
+ const byPath = new Map(routes.map((r) => [r.path, r] as const));
1143
+ for (const ar of nextCtx.apiRoutes) {
1144
+ const match = byPath.get(ar.path);
1145
+ if (match) {
1146
+ ar.responseBodies = match.responseBodies;
1147
+ for (const m of match.methods) {
1148
+ if (!ar.methods.includes(m)) ar.methods.push(m);
1149
+ }
1150
+ }
1151
+ }
1152
+ for (const r of routes) {
1153
+ if (!nextCtx.apiRoutes.some((ar) => ar.path === r.path)) nextCtx.apiRoutes.push(r);
1154
+ }
1155
+ }
1156
+
1157
+ /**
1158
+ * Never analysed by a glob. The first entry is the important one: without it a
1159
+ * second `src/**\/*.tsx` run analyses the specs the first run generated, and
1160
+ * writes specs for its own specs.
1161
+ */
1162
+ export const DEFAULT_EXCLUDES: readonly string[] = [
1163
+ "**/*.flawless.test.*",
1164
+ "**/*.test.*",
1165
+ "**/*.spec.*",
1166
+ "**/*.stories.*",
1167
+ "**/*.d.ts",
1168
+ "**/node_modules/**",
1169
+ "**/.next/**",
1170
+ "**/dist/**",
1171
+ "**/build/**",
1172
+ "**/coverage/**",
1173
+ "**/flawless/**",
1174
+ ];
1175
+
1176
+ export interface ExpandGlobOptions {
1177
+ /** Appended to the defaults unless `useDefaultExcludes` is false. */
1178
+ exclude?: readonly string[];
1179
+ useDefaultExcludes?: boolean;
1180
+ }
1181
+
1182
+ export async function expandGlob(pattern: string, opts: ExpandGlobOptions = {}): Promise<string[]> {
1183
+ const abs = path.resolve(pattern);
1184
+ // An explicit path wins over every exclusion — otherwise you could never
1185
+ // deliberately point the tool at a file the defaults would skip. A directory
1186
+ // is a usage mistake, not an entry: without this check it fell through to
1187
+ // "Directories cannot be read like files" + "no analysable components found",
1188
+ // neither of which names the fix. "" resolves to cwd, so the interactive
1189
+ // prompt's empty submit landed here too.
1190
+ if (existsSync(abs)) {
1191
+ if (statSync(abs).isDirectory()) {
1192
+ // Shell tab-completion appends a trailing slash ("src/"); keep it out of
1193
+ // the suggested glob or the fix reads "src//**/*.tsx".
1194
+ const shown = pattern.replace(/[\\/]+$/, "");
1195
+ throw new UsageError(
1196
+ `${shown === "" ? abs : shown} is a directory — pass a .tsx file or a glob like "${shown === "" ? "src" : shown}/**/*.tsx"`,
1197
+ );
1198
+ }
1199
+ return [abs];
1200
+ }
1201
+ const hasGlobChar = /[*?[\]{}]/.test(pattern);
1202
+ if (!hasGlobChar) {
1203
+ return [];
1204
+ }
1205
+
1206
+ const excludes = [
1207
+ ...(opts.useDefaultExcludes === false ? [] : DEFAULT_EXCLUDES),
1208
+ ...(opts.exclude ?? []),
1209
+ ].map((p) => new Bun.Glob(p));
1210
+ const cwd = process.cwd();
1211
+ const isExcluded = (file: string): boolean => {
1212
+ const rel = path.relative(cwd, file);
1213
+ // Match both forms: users write "**/*.test.*" and "src/**/*.test.tsx".
1214
+ return excludes.some((g) => g.match(file) || g.match(rel));
1215
+ };
1216
+
1217
+ const glob = new Bun.Glob(pattern);
1218
+ const out: string[] = [];
1219
+ try {
1220
+ for await (const f of glob.scan({ cwd, absolute: true })) {
1221
+ if (!isExcluded(f)) out.push(f);
1222
+ }
1223
+ } catch {
1224
+ // A glob rooted at a directory that doesn't exist throws ENOENT rather
1225
+ // than yielding nothing; "no files matched" is the useful message.
1226
+ return [];
1227
+ }
1228
+ return out.sort();
1229
+ }