@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,397 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { underOutDir } from "../emitPaths.ts";
4
+ import { stringifyLike } from "../scaffold/adoptStyle.ts";
5
+ import {
6
+ type NextjsApiRoute,
7
+ type NextjsContext,
8
+ type NextjsRouter,
9
+ UsageError,
10
+ } from "../types.ts";
11
+ import { walkApiRoutes } from "./apiRoutes.ts";
12
+
13
+ export async function detectNextjs(root: string): Promise<NextjsContext> {
14
+ const abs = path.resolve(root);
15
+ if (!existsSync(abs)) {
16
+ throw new UsageError(`nextjs: directory not found: ${abs}`);
17
+ }
18
+
19
+ if (!looksLikeNext(abs)) {
20
+ throw new UsageError(
21
+ `nextjs: ${abs} does not look like a Next.js project (no next.config or "next" in package.json).`,
22
+ );
23
+ }
24
+
25
+ const appDir = path.join(abs, "app");
26
+ const srcAppDir = path.join(abs, "src", "app");
27
+ const pagesDir = path.join(abs, "pages");
28
+ const srcPagesDir = path.join(abs, "src", "pages");
29
+
30
+ let router: NextjsRouter;
31
+ let routerRoot: string;
32
+ if (isDir(appDir)) {
33
+ router = "app";
34
+ routerRoot = appDir;
35
+ } else if (isDir(srcAppDir)) {
36
+ router = "app";
37
+ routerRoot = srcAppDir;
38
+ } else if (isDir(pagesDir)) {
39
+ router = "pages";
40
+ routerRoot = pagesDir;
41
+ } else if (isDir(srcPagesDir)) {
42
+ router = "pages";
43
+ routerRoot = srcPagesDir;
44
+ } else {
45
+ throw new UsageError(`nextjs: no app/ or pages/ directory under ${abs}`);
46
+ }
47
+
48
+ const appResolver = await detectAppResolver(router, routerRoot);
49
+ const apiRoutes: NextjsApiRoute[] = await walkApiRoutes(router, routerRoot);
50
+
51
+ return {
52
+ root: abs,
53
+ router,
54
+ appResolver,
55
+ apiRoutes,
56
+ };
57
+ }
58
+
59
+ function looksLikeNext(dir: string): boolean {
60
+ for (const f of ["next.config.ts", "next.config.js", "next.config.mjs", "next.config.cjs"]) {
61
+ if (existsSync(path.join(dir, f))) return true;
62
+ }
63
+ const pkgPath = path.join(dir, "package.json");
64
+ if (!existsSync(pkgPath)) return false;
65
+ try {
66
+ const fs = require("node:fs") as typeof import("node:fs");
67
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as {
68
+ dependencies?: Record<string, string>;
69
+ devDependencies?: Record<string, string>;
70
+ };
71
+ return Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next);
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ function isDir(p: string): boolean {
78
+ try {
79
+ return statSync(p).isDirectory();
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ async function detectAppResolver(
86
+ router: NextjsRouter,
87
+ routerRoot: string,
88
+ ): Promise<string | undefined> {
89
+ if (router === "app") {
90
+ for (const f of ["layout.tsx", "layout.jsx", "layout.ts", "layout.js"]) {
91
+ const candidate = path.join(routerRoot, f);
92
+ if (existsSync(candidate)) return candidate;
93
+ }
94
+ return undefined;
95
+ }
96
+ // Pages router: pages/_app.tsx
97
+ for (const f of ["_app.tsx", "_app.jsx", "_app.ts", "_app.js"]) {
98
+ const candidate = path.join(routerRoot, f);
99
+ if (existsSync(candidate)) return candidate;
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ /**
105
+ * The entries a run must add, given the out dir as the ignore root sees it.
106
+ *
107
+ * Shared rather than written per call site: the Next and non-Next paths ignoring
108
+ * different sets is the bug this list exists to prevent, one level up.
109
+ *
110
+ * `underOutDir`, not a literal "flawless": under `--out qa` the run writes qa/
111
+ * and an earlier version ignored flawless/, leaving 566MB of heaps, the
112
+ * Playwright output and — worst — qa/e2e/.auth.json untracked and unignored.
113
+ * That file is where global-setup.ts tells you to persist a logged-in
114
+ * storageState, i.e. session cookies staged for commit.
115
+ *
116
+ * `*.flawless.json` stays literal: it is the inline-summary sidecar pattern,
117
+ * written beside the analysed sources rather than under the out dir.
118
+ */
119
+ export function ignoreEntries(outRel: string): string[] {
120
+ // The default out dir is `.flawless`, and `.*` is a common first line in a
121
+ // real .gitignore — saleor-dashboard's is, and its 1494 generated specs came
122
+ // out invisible to `git status` with nothing said about it. So negate the one
123
+ // directory a run owns. It leads the appended block and matches only the
124
+ // directory itself (trailing slash, anchored), which is what lets every entry
125
+ // below still ignore its own byproduct: git's last match wins, and none of
126
+ // them were ever competing for the directory. Skipping this silently is the
127
+ // bug — a suite nobody can stage is not a deliverable.
128
+ const base = outRel === "." ? "" : (outRel.split("/").pop() ?? "");
129
+ return [
130
+ ...(base.startsWith(".") ? [`!${outRel}/`] : []),
131
+ underOutDir(outRel, "reports/"),
132
+ underOutDir(outRel, "heaps/"),
133
+ underOutDir(outRel, "playwright-report/"),
134
+ underOutDir(outRel, "node_modules/"),
135
+ underOutDir(outRel, "test-results/"),
136
+ underOutDir(outRel, "browser/test-results/"),
137
+ underOutDir(outRel, "e2e/test-results/"),
138
+ underOutDir(outRel, "e2e/.auth.json"),
139
+ underOutDir(outRel, "load/last-*.json"),
140
+ "*.flawless.json",
141
+ ];
142
+ }
143
+
144
+ /**
145
+ * Where a non-Next run's .gitignore belongs.
146
+ *
147
+ * The git root first: that is where git reads a repo-wide ignore and where the
148
+ * user's existing one already is. Then the nearest package.json, for a package
149
+ * that is not a repository. Then the out dir's parent, so a run always ignores
150
+ * something rather than nothing.
151
+ */
152
+ export function ignoreRootFor(outDir: string): string {
153
+ let dir = path.resolve(outDir);
154
+ const start = dir;
155
+ for (;;) {
156
+ if (existsSync(path.join(dir, ".git"))) return dir;
157
+ const parent = path.dirname(dir);
158
+ if (parent === dir) break;
159
+ dir = parent;
160
+ }
161
+ dir = start;
162
+ for (;;) {
163
+ if (existsSync(path.join(dir, "package.json"))) return dir;
164
+ const parent = path.dirname(dir);
165
+ if (parent === dir) break;
166
+ dir = parent;
167
+ }
168
+ return path.dirname(start);
169
+ }
170
+
171
+ export function updateGitignore(nextRoot: string, entries: string[]): string[] {
172
+ const fs = require("node:fs") as typeof import("node:fs");
173
+ const gipath = path.join(nextRoot, ".gitignore");
174
+ let current = "";
175
+ if (existsSync(gipath)) {
176
+ try {
177
+ current = fs.readFileSync(gipath, "utf8");
178
+ } catch {
179
+ current = "";
180
+ }
181
+ }
182
+ const have = new Set(
183
+ current
184
+ .split(/\r?\n/)
185
+ .map((l) => l.trim())
186
+ .filter(Boolean),
187
+ );
188
+ const added: string[] = [];
189
+ for (const e of entries) {
190
+ if (!have.has(e)) added.push(e);
191
+ }
192
+ if (added.length === 0) return [];
193
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
194
+ const block = `${sep}\n# flawless-react — generated reports + sidecars\n${added.join("\n")}\n`;
195
+ fs.writeFileSync(gipath, current + block);
196
+ return added;
197
+ }
198
+
199
+ const PRETTIER_CONFIG_NAMES = [
200
+ ".prettierrc",
201
+ ".prettierrc.json",
202
+ ".prettierrc.js",
203
+ ".prettierrc.cjs",
204
+ ".prettierrc.yaml",
205
+ ".prettierrc.yml",
206
+ "prettier.config.js",
207
+ "prettier.config.cjs",
208
+ ];
209
+ // The ES-LINT-RC family only. eslint.config.* is the flat config, and a flat
210
+ // config does not read .eslintignore at all — writing the file there would be
211
+ // dead weight that outlives the migration to make it seem supported.
212
+ const ESLINTRC_CONFIG_NAMES = [
213
+ ".eslintrc",
214
+ ".eslintrc.js",
215
+ ".eslintrc.cjs",
216
+ ".eslintrc.json",
217
+ ".eslintrc.json5",
218
+ ".eslintrc.yaml",
219
+ ".eslintrc.yml",
220
+ ];
221
+
222
+ /**
223
+ * Make the repo's own formatter and linter skip the suite directory.
224
+ *
225
+ * The tool formats its output with the project's prettier before finishing, but
226
+ * "failures are warnings, never task failures" only covers the tool's own run.
227
+ * The project's `prettier --check .` is not so forgiving, and dogfood proved the
228
+ * gap real: prettier 3.0.3 cannot parse `import ... with { type: "json" }` —
229
+ * import-attribute support landed in 3.1 — so the whole profile plane flagged
230
+ * as unparseable in that repo's CI, over files the tool owns and rewrites.
231
+ *
232
+ * Ignore FILES are the append-safe, schema-free surface: prettier reads
233
+ * `.prettierignore`, eslintrc reads `.eslintignore`, and both are inert until a
234
+ * repo actually uses the tool — so the file is written only where the tool is
235
+ * configured. Biome gets no entry: it has no ignore file, and its 2.x
236
+ * `files.includes` marker is version-gated JSONC that belongs in a documented
237
+ * one-line setup step, not rewritten into a user's config.
238
+ */
239
+ export function ensureFormatterIgnores(
240
+ root: string,
241
+ outRel: string,
242
+ ): { file: string; entry: string }[] {
243
+ const fs = require("node:fs") as typeof import("node:fs");
244
+ const pkg = ((): Record<string, unknown> => {
245
+ try {
246
+ return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
247
+ } catch {
248
+ return {};
249
+ }
250
+ })();
251
+ const usesPrettier =
252
+ PRETTIER_CONFIG_NAMES.some((n) => existsSync(path.join(root, n))) || pkg.prettier !== undefined;
253
+ const usesEslintrc =
254
+ ESLINTRC_CONFIG_NAMES.some((n) => existsSync(path.join(root, n))) ||
255
+ pkg.eslintConfig !== undefined;
256
+ const entry = `${outRel}/`;
257
+ const touched: { file: string; entry: string }[] = [];
258
+ for (const [file, inUse] of [
259
+ [".prettierignore", usesPrettier],
260
+ [".eslintignore", usesEslintrc],
261
+ ] as const) {
262
+ if (!inUse) continue;
263
+ const abs = path.join(root, file);
264
+ let current = "";
265
+ try {
266
+ if (existsSync(abs)) current = fs.readFileSync(abs, "utf8");
267
+ } catch {
268
+ current = "";
269
+ }
270
+ const have = new Set(
271
+ current
272
+ .split(/\r?\n/)
273
+ .map((l) => l.trim())
274
+ .filter(Boolean),
275
+ );
276
+ if (have.has(entry) || have.has(outRel)) continue;
277
+ const sep = current === "" || current.endsWith("\n") ? "" : "\n";
278
+ fs.writeFileSync(abs, `${current}${sep}\n# flawless-react — tool-owned suite\n${entry}\n`);
279
+ touched.push({ file, entry });
280
+ }
281
+ return touched;
282
+ }
283
+
284
+ export function detectComponentsGlob(nextRoot: string): string | undefined {
285
+ for (const rel of ["components", "src/components", "app/components"]) {
286
+ if (isDir(path.join(nextRoot, rel))) return path.join(nextRoot, rel, "**/*.tsx");
287
+ }
288
+ return undefined;
289
+ }
290
+
291
+ /**
292
+ * Add the entries the suite needs, without touching a version already pinned.
293
+ *
294
+ * These were spread last, so the tool's own pins overwrote the project's: a repo
295
+ * on `vitest ^3.x` came back on `^2.1.0`, and the next `bun install` acted on it.
296
+ * Nothing said so, and README calls generation "an additive diff". The
297
+ * standalone path has always done it this way (scaffold-tests/index.ts) — this
298
+ * only brings the Next.js path in line.
299
+ */
300
+ function addMissing(
301
+ existing: Record<string, string> | undefined,
302
+ incoming: Record<string, string>,
303
+ ): Record<string, string> {
304
+ const out = { ...(existing ?? {}) };
305
+ for (const [name, range] of Object.entries(incoming)) {
306
+ if (!(name in out)) out[name] = range;
307
+ }
308
+ return out;
309
+ }
310
+
311
+ export async function mergeIntoNextPackageJson(
312
+ nextRoot: string,
313
+ deps: Record<string, string>,
314
+ devDeps: Record<string, string>,
315
+ scripts: Record<string, string>,
316
+ /**
317
+ * Per key: OTHER command spellings this tool itself emits or once emitted
318
+ * (the --root vs --config forms of flawless:unit, say). An existing value
319
+ * on this list is tool-owned and upgrades; only a value the tool does not
320
+ * recognize is someone's work and kept.
321
+ */
322
+ toolSpellings: Record<string, readonly string[]> = {},
323
+ ): Promise<{ keptScripts: string[] }> {
324
+ const pkgPath = path.join(nextRoot, "package.json");
325
+ const fs = require("node:fs") as typeof import("node:fs");
326
+ if (!existsSync(pkgPath)) {
327
+ throw new UsageError(`nextjs: package.json not found at ${pkgPath}`);
328
+ }
329
+ type Pkg = {
330
+ dependencies?: Record<string, string>;
331
+ devDependencies?: Record<string, string>;
332
+ scripts?: Record<string, string>;
333
+ };
334
+ const source = fs.readFileSync(pkgPath, "utf8");
335
+ const pkg = JSON.parse(source) as Pkg;
336
+ // Only touch sections that receive entries (or already exist) so the merge
337
+ // never invents empty keys, and preserve the file's own indentation — a
338
+ // tabs-indented package.json must not come back 2-space-reindented.
339
+ if (Object.keys(deps).length > 0 || pkg.dependencies) {
340
+ pkg.dependencies = addMissing(pkg.dependencies, deps);
341
+ }
342
+ if (Object.keys(devDeps).length > 0 || pkg.devDependencies) {
343
+ pkg.devDependencies = addMissing(pkg.devDependencies, devDeps);
344
+ }
345
+ // Scripts used to be the one place the tool spread last and "won its own
346
+ // namespace" — so a command improvement reached older projects. Dogfood
347
+ // round 5 showed the cost: an app's hand-customized flawless:load
348
+ // (`--env-file …`) was clobbered by a refresh and the load run failed at
349
+ // setup (FR-034). A differing existing value is the user's work now, kept
350
+ // and REPORTED — the caller logs the kept keys with the same escape every
351
+ // kept file gets: delete the key to take the tool's current command.
352
+ const keptScripts: string[] = [];
353
+ if (Object.keys(scripts).length > 0 || pkg.scripts) {
354
+ const merged = { ...(pkg.scripts ?? {}) };
355
+ for (const [name, cmd] of Object.entries(scripts)) {
356
+ const existing = merged[name];
357
+ if (
358
+ existing !== undefined &&
359
+ existing !== cmd &&
360
+ !(toolSpellings[name] ?? []).includes(existing)
361
+ ) {
362
+ keptScripts.push(name);
363
+ continue;
364
+ }
365
+ merged[name] = cmd;
366
+ }
367
+ pkg.scripts = merged;
368
+ }
369
+ // pnpm ≥10 treats an UNRECORDED build-script decision as an error: with
370
+ // verifyDepsBeforeRun (the default in 11), `pnpm run dev` install-checks
371
+ // first, and ERR_PNPM_IGNORED_BUILDS over msw/@playwright/test — packages
372
+ // THIS merge introduced — killed the app's own dev server before Next
373
+ // could boot. Writing the ignore down IS the decision: msw's postinstall
374
+ // copies a service worker into a public dir and playwright's downloads
375
+ // browsers; neither is needed to run the app, and neither may block it.
376
+ // A dependency the app already approved (onlyBuiltDependencies) stays
377
+ // approved — that is the app's call, not ours.
378
+ if (existsSync(path.join(nextRoot, "pnpm-lock.yaml"))) {
379
+ const BUILD_SCRIPT_DEPS = ["@playwright/test", "msw"];
380
+ const added = Object.keys(devDeps);
381
+ const p = pkg as Pkg & {
382
+ pnpm?: { ignoredBuiltDependencies?: string[]; onlyBuiltDependencies?: string[] };
383
+ };
384
+ const approved = new Set(p.pnpm?.onlyBuiltDependencies ?? []);
385
+ const ignored = [...(p.pnpm?.ignoredBuiltDependencies ?? [])];
386
+ let changed = false;
387
+ for (const d of BUILD_SCRIPT_DEPS) {
388
+ if (added.includes(d) && !approved.has(d) && !ignored.includes(d)) {
389
+ ignored.push(d);
390
+ changed = true;
391
+ }
392
+ }
393
+ if (changed) p.pnpm = { ...p.pnpm, ignoredBuiltDependencies: ignored.sort() };
394
+ }
395
+ fs.writeFileSync(pkgPath, stringifyLike(source, pkg));
396
+ return { keptScripts };
397
+ }
@@ -0,0 +1,106 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { NextjsContext } from "../types.ts";
5
+ import { renderBrowserSetup, renderMswHandlers, renderNodeSetup } from "./mswHandlers.ts";
6
+ import { buildSwagger } from "./swagger.ts";
7
+
8
+ export interface EmitNextjsResult {
9
+ written: string[];
10
+ swaggerPath: string;
11
+ handlersPath: string;
12
+ }
13
+
14
+ export interface EmitNextjsOptions {
15
+ /** When present, written verbatim to swagger.json instead of buildSwagger(routes). */
16
+ liveSpec?: unknown;
17
+ /**
18
+ * Refresh the generated files here that hold nothing of the user's.
19
+ *
20
+ * handlers.ts stays protected either way — its bodies are yours to fill in.
21
+ * browser.ts, node.ts and the README are boilerplate, and freezing them meant
22
+ * an existing suite could never receive a later version's fix.
23
+ */
24
+ force?: boolean;
25
+ }
26
+
27
+ export async function emitNextjsMocks(
28
+ ctx: NextjsContext,
29
+ outDir: string,
30
+ opts: EmitNextjsOptions = {},
31
+ ): Promise<EmitNextjsResult> {
32
+ const mocksDir = path.join(outDir, "mocks");
33
+ await mkdir(mocksDir, { recursive: true });
34
+
35
+ const swaggerPath = path.join(outDir, "swagger.json");
36
+ const handlersPath = path.join(mocksDir, "handlers.ts");
37
+ const browserPath = path.join(mocksDir, "browser.ts");
38
+ const nodePath = path.join(mocksDir, "node.ts");
39
+ const readmePath = path.join(mocksDir, "README.md");
40
+
41
+ const written: string[] = [];
42
+
43
+ const swagger = opts.liveSpec ?? buildSwagger(ctx.apiRoutes, path.basename(ctx.root));
44
+ await Bun.write(swaggerPath, `${JSON.stringify(swagger, null, 2)}\n`);
45
+ written.push(swaggerPath);
46
+
47
+ if (!existsSync(handlersPath)) {
48
+ // Don't clobber user edits — handlers are a starting point.
49
+ await Bun.write(handlersPath, renderMswHandlers(ctx.apiRoutes));
50
+ written.push(handlersPath);
51
+ }
52
+
53
+ if (!existsSync(browserPath) || opts.force) {
54
+ await Bun.write(browserPath, renderBrowserSetup());
55
+ written.push(browserPath);
56
+ }
57
+
58
+ if (!existsSync(nodePath) || opts.force) {
59
+ await Bun.write(nodePath, renderNodeSetup());
60
+ written.push(nodePath);
61
+ }
62
+
63
+ if (!existsSync(readmePath) || opts.force) {
64
+ await Bun.write(readmePath, renderMocksReadme(ctx));
65
+ written.push(readmePath);
66
+ }
67
+
68
+ return { written, swaggerPath, handlersPath };
69
+ }
70
+
71
+ function renderMocksReadme(ctx: NextjsContext): string {
72
+ return `# MSW mocks
73
+
74
+ Derived from your Next.js \`${ctx.router}\` router's \`api/\` directory at scaffold time.
75
+
76
+ - \`handlers.ts\` — one MSW handler per HTTP method × API route. **Edit freely** — once this file exists, flawless-react won't overwrite it.
77
+ - \`browser.ts\` — \`setupWorker(...handlers)\` for the Bun host bundle (catches client-side \`fetch\`).
78
+ - \`node.ts\` — \`setupServer(...handlers)\` for Vitest (jsdom) and any Node-side fetch (RSC, server actions).
79
+ - \`../swagger.json\` — human-readable OpenAPI doc derived from your routes. Regenerated every run.
80
+
81
+ ## How the layers wire up
82
+
83
+ 1. **Vitest unit tests** — \`setupServer\` from \`node.ts\` runs in \`flawless/setup.ts\`; any \`fetch\` called from a component renders against your mock responses.
84
+ 2. **Playwright browser specs + perf harness** — \`host/main.tsx\` calls \`startMockWorker()\` from \`browser.ts\` before mounting; the service worker intercepts \`fetch\` from the page.
85
+ 3. **Next.js SSR / server components / server actions** — when you run against a real Next dev server, configure your Next instrumentation to import \`./node.ts\` to intercept server-side fetches too.
86
+
87
+ ## Adding realistic data
88
+
89
+ Replace \`HttpResponse.json({})\` in a handler with a real fixture:
90
+
91
+ \`\`\`ts
92
+ http.get("/api/users", () =>
93
+ HttpResponse.json([{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }])
94
+ ),
95
+ \`\`\`
96
+
97
+ For conditional responses, inspect the request:
98
+
99
+ \`\`\`ts
100
+ http.get("/api/users/:id", ({ params }) => {
101
+ if (params.id === "999") return new HttpResponse(null, { status: 404 });
102
+ return HttpResponse.json({ id: Number(params.id), name: "Alice" });
103
+ }),
104
+ \`\`\`
105
+ `;
106
+ }
@@ -0,0 +1,79 @@
1
+ import { extractRoutes } from "../scaffold/openapi.ts";
2
+ import type { NextjsApiRoute } from "../types.ts";
3
+
4
+ const DEFAULT_URL = "http://localhost:3000/api/docs";
5
+ const FETCH_TIMEOUT_MS = 1500;
6
+
7
+ export interface LiveSpecResult {
8
+ ok: boolean;
9
+ url: string;
10
+ routes: NextjsApiRoute[] | undefined;
11
+ rawSpec: unknown;
12
+ error?: string;
13
+ }
14
+
15
+ export async function fetchLiveSwagger(explicitUrl: string | undefined): Promise<LiveSpecResult> {
16
+ const url = explicitUrl ?? DEFAULT_URL;
17
+ try {
18
+ const ctl = new AbortController();
19
+ const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
20
+ let res: Response;
21
+ try {
22
+ res = await fetch(url, { signal: ctl.signal, headers: { accept: "application/json" } });
23
+ } finally {
24
+ clearTimeout(timer);
25
+ }
26
+ if (!res.ok) {
27
+ return { ok: false, url, routes: undefined, rawSpec: undefined, error: `HTTP ${res.status}` };
28
+ }
29
+ const ct = res.headers.get("content-type") ?? "";
30
+ if (!ct.includes("application/json")) {
31
+ return {
32
+ ok: false,
33
+ url,
34
+ routes: undefined,
35
+ rawSpec: undefined,
36
+ error: `unexpected content-type: ${ct}`,
37
+ };
38
+ }
39
+ const spec = (await res.json()) as Parameters<typeof extractRoutes>[0];
40
+ return { ok: true, url, routes: parseSpecRoutes(spec), rawSpec: spec };
41
+ } catch (err) {
42
+ return { ok: false, url, routes: undefined, rawSpec: undefined, error: (err as Error).message };
43
+ }
44
+ }
45
+
46
+ /** Group an OpenAPI spec's operations into per-path NextjsApiRoutes with response bodies. */
47
+ export function parseSpecRoutes(spec: Parameters<typeof extractRoutes>[0]): NextjsApiRoute[] {
48
+ const mockRoutes = extractRoutes(spec);
49
+ const grouped = new Map<string, NextjsApiRoute>();
50
+ for (const r of mockRoutes) {
51
+ const key = r.path;
52
+ const existing = grouped.get(key);
53
+ if (existing) {
54
+ if (!existing.methods.includes(r.method)) existing.methods.push(r.method);
55
+ existing.responseBodies ??= {};
56
+ existing.responseBodies[r.method] = r.body;
57
+ } else {
58
+ grouped.set(key, {
59
+ path: key,
60
+ methods: [r.method],
61
+ file: "",
62
+ responseBodies: { [r.method]: r.body },
63
+ });
64
+ }
65
+ }
66
+ return [...grouped.values()];
67
+ }
68
+
69
+ /** Load a local OpenAPI JSON file and group it like the live-spec path does. */
70
+ export async function loadLocalSpec(
71
+ specPath: string,
72
+ ): Promise<{ routes: NextjsApiRoute[]; rawSpec: unknown } | undefined> {
73
+ try {
74
+ const raw = (await Bun.file(specPath).json()) as Parameters<typeof extractRoutes>[0];
75
+ return { routes: parseSpecRoutes(raw), rawSpec: raw };
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
@@ -0,0 +1,55 @@
1
+ import type { NextjsApiRoute } from "../types.ts";
2
+
3
+ export function renderMswHandlers(routes: NextjsApiRoute[]): string {
4
+ const lines: string[] = [];
5
+ lines.push(`// MSW request handlers derived from your Next.js API routes.`);
6
+ lines.push(
7
+ `// Bodies come from the live OpenAPI spec when available (examples > schema defaults);`,
8
+ );
9
+ lines.push(
10
+ `// otherwise an empty object. Edit freely — flawless-react will not overwrite this file.`,
11
+ );
12
+ lines.push(``);
13
+ lines.push(`import { http, HttpResponse } from "msw";`);
14
+ lines.push(``);
15
+ lines.push(`export const handlers = [`);
16
+ for (const r of routes) {
17
+ const mswPath = toMswPath(r.path);
18
+ for (const m of r.methods) {
19
+ const verb = m.toLowerCase();
20
+ const body = r.responseBodies?.[m.toUpperCase()] ?? r.responseBodies?.[m] ?? {};
21
+ const bodyLiteral = JSON.stringify(body);
22
+ lines.push(
23
+ ` http.${verb}(${JSON.stringify(mswPath)}, () => HttpResponse.json(${bodyLiteral})),`,
24
+ );
25
+ }
26
+ }
27
+ lines.push(`];`);
28
+ lines.push(``);
29
+ return lines.join("\n");
30
+ }
31
+
32
+ function toMswPath(p: string): string {
33
+ // OpenAPI-style {id} → MSW :id.
34
+ return p.replace(/\{([^}]+)\}/g, ":$1");
35
+ }
36
+
37
+ export function renderBrowserSetup(): string {
38
+ return `import { setupWorker } from "msw/browser";
39
+ import { handlers } from "./handlers";
40
+
41
+ export const worker = setupWorker(...handlers);
42
+
43
+ export async function startMockWorker(): Promise<void> {
44
+ await worker.start({ onUnhandledRequest: "bypass", quiet: true });
45
+ }
46
+ `;
47
+ }
48
+
49
+ export function renderNodeSetup(): string {
50
+ return `import { setupServer } from "msw/node";
51
+ import { handlers } from "./handlers";
52
+
53
+ export const server = setupServer(...handlers);
54
+ `;
55
+ }