@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,463 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { appReactPin, mergePackageJson, repairReactPin } from "../packageJson.ts";
5
+ import { PNPM_SUITE_SETTINGS, tsConfig } from "../scaffold/templates.ts";
6
+ import { SUITE_COMMAND, SUITE_SCRIPT, VITEST_CONFIG } from "../suiteScripts.ts";
7
+ import { classifyStamped, stamp } from "./stamp.ts";
8
+ import { renderUnitBrowser, type UnitBrowserRenderOptions } from "./templates.ts";
9
+
10
+ /**
11
+ * Everything the unit-browser scaffolder takes.
12
+ *
13
+ * It EXTENDS the renderer's options instead of re-declaring a subset, because
14
+ * the two types describe ONE object: `scaffoldUnitBrowser` hands this same
15
+ * options value to `renderUnitBrowser` unchanged. A hand-copied subset is a
16
+ * silently shrinking contract — `unitAndBrowser.ts` builds the options in a
17
+ * variable and sets `multiRootAliases`, `appEnvRoot` and `todos` on it, and
18
+ * TypeScript accepts that even while this type does not name them, because
19
+ * excess-property checks run on object literals and not on variables. That is
20
+ * how the emitted manifest lost its `reactPinDir` anchor: the field was live at
21
+ * runtime, read by the config, and absent from the type the caller supposedly
22
+ * satisfied.
23
+ *
24
+ * What remains here is only what the scaffolder itself has an opinion about.
25
+ * Every other field — providers, aliases, the React pin and its anchor,
26
+ * colocation, the todo counter, the app's env root — is documented once, on
27
+ * UnitBrowserRenderOptions.
28
+ */
29
+ export interface ScaffoldUnitBrowserOptions extends UnitBrowserRenderOptions {
30
+ skipInnerPackageJson?: boolean;
31
+ /**
32
+ * The run is a Next.js suite living under the app. The manifest is emitted
33
+ * there too (a self-contained install needs it), but WITHOUT its "type"
34
+ * field: the playwright configs around it are written assuming the app's
35
+ * CJS transpile default (the nextjs e2e config anchors on __dirname), and
36
+ * a package.json placed above them decides how Playwright loads them.
37
+ */
38
+ nextjs?: boolean;
39
+ /** Overwrite existing colocated specs (in-outDir files are always rewritten). */
40
+ force?: boolean;
41
+ }
42
+
43
+ export interface ScaffoldUnitBrowserResult {
44
+ written: string[];
45
+ /** Colocated spec targets left untouched because they already exist (no --force). */
46
+ skipped: string[];
47
+ /** Colocated specs --force kept because their stamp no longer matches the body. */
48
+ keptEdited: string[];
49
+ /** Colocated specs --force kept because they carry no stamp at all (pre-stamp). */
50
+ keptLegacy: string[];
51
+ /** Browser specs removed: their component left the mountable set and the file was untouched tool output. */
52
+ staleRemoved: string[];
53
+ /** Browser specs whose component left the mountable set, kept because a human may have worked in them. */
54
+ staleKept: StaleBrowserSpec[];
55
+ /** Unit specs the runner will pick up with no component behind them. Named, never removed — see findOrphanUnitSpecs. */
56
+ orphanUnitSpecs: string[];
57
+ }
58
+
59
+ export async function scaffoldUnitBrowser(
60
+ opts: ScaffoldUnitBrowserOptions,
61
+ ): Promise<ScaffoldUnitBrowserResult> {
62
+ const outDir = path.resolve(opts.outDir);
63
+ await mkdir(outDir, { recursive: true });
64
+
65
+ const files = renderUnitBrowser(opts);
66
+ const written: string[] = [];
67
+ const skipped: string[] = [];
68
+ const keptEdited: string[] = [];
69
+ const keptLegacy: string[] = [];
70
+ for (const f of files) {
71
+ const abs = f.absPath ?? path.join(outDir, f.relPath);
72
+ if (f.skipIfExists && existsSync(abs)) {
73
+ if (!opts.force) {
74
+ skipped.push(abs);
75
+ continue;
76
+ }
77
+ // --force refreshes what is still exactly ours and keeps everything a
78
+ // human may have worked on. "Ours" is what the stamp proves; a file
79
+ // from before stamping existed can't prove it either way, so it is
80
+ // kept too and reported separately (delete it to regenerate).
81
+ const verdict = classifyStamped(await readFile(abs, "utf8"));
82
+ if (verdict === "edited") {
83
+ keptEdited.push(abs);
84
+ continue;
85
+ }
86
+ if (verdict === "stampless") {
87
+ keptLegacy.push(abs);
88
+ continue;
89
+ }
90
+ }
91
+ await mkdir(path.dirname(abs), { recursive: true });
92
+ // Only colocated specs (the skipIfExists set) are stamped: the in-outDir
93
+ // files are identified by being rewritten every run, not by provenance.
94
+ // This stamp hashes the raw render; core.ts restamps after the host
95
+ // project's formatter has had its turn.
96
+ await Bun.write(abs, f.skipIfExists ? stamp(f.content) : f.content);
97
+ written.push(abs);
98
+ }
99
+
100
+ // The config used to be emitted as vitest.config.ts and is now .mts, so a
101
+ // suite generated before this leaves the old one sitting beside the new one.
102
+ // Two configs in one directory is worse than either: vitest picks by its own
103
+ // precedence, so the file a reader edits and the file that runs can differ
104
+ // silently. This name has only ever been ours, and it has never been
105
+ // writeOnce, so removing the stale one is safe.
106
+ // Compared as paths rather than against the literal, so that if VITEST_CONFIG
107
+ // ever goes back to ".ts" this deletes nothing instead of deleting the file
108
+ // the loop above just wrote.
109
+ const stale = path.join(outDir, "vitest.config.ts");
110
+ if (stale !== path.join(outDir, VITEST_CONFIG) && existsSync(stale)) {
111
+ await rm(stale);
112
+ written.push(stale);
113
+ }
114
+
115
+ // Browser specs are rewritten every run ONLY while their component stays
116
+ // host-mountable. When it leaves the mountable set (a new guard, a render
117
+ // mode change, a deleted file) the spec is simply no longer rendered — and
118
+ // without this sweep it sits in browser/ forever, failing at mount with
119
+ // "unknown component": a suite red by the tool's own doing. Provenance
120
+ // decides, exactly as for the colocated specs: an untouched stamp is pure
121
+ // tool output (removed and reported); a mismatched stamp is hand work
122
+ // (kept, reported); no stamp at all is unprovable (kept, reported).
123
+ const staleRemoved: string[] = [];
124
+ const staleKept: StaleBrowserSpec[] = [];
125
+ if (opts.browserSpecs !== false) {
126
+ const rendered = new Set(
127
+ files
128
+ .filter((f) => f.relPath.startsWith("browser/") && f.relPath.endsWith(".spec.ts"))
129
+ .map((f) => f.relPath),
130
+ );
131
+ const browserDir = path.join(outDir, "browser");
132
+ if (existsSync(browserDir)) {
133
+ for (const entry of (await readdir(browserDir)).sort()) {
134
+ if (!entry.endsWith(".spec.ts")) continue;
135
+ if (rendered.has(`browser/${entry}`)) continue;
136
+ const abs = path.join(browserDir, entry);
137
+ const verdict = classifyStamped(await readFile(abs, "utf8"));
138
+ if (verdict === "untouched") {
139
+ await rm(abs);
140
+ staleRemoved.push(abs);
141
+ } else {
142
+ staleKept.push({ abs, verdict });
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ // Unit specs the suite will still run with no component behind them. Naming
149
+ // them, never deleting them — see findOrphanUnitSpecs for why.
150
+ const orphanUnitSpecs = await findOrphanUnitSpecs(
151
+ outDir,
152
+ new Set(files.filter((f) => f.relPath.startsWith("unit/")).map((f) => f.relPath)),
153
+ );
154
+
155
+ if (!opts.skipInnerPackageJson) {
156
+ await writeSuitePackageJson(
157
+ outDir,
158
+ opts.browserSpecs !== false,
159
+ Boolean(opts.mswSetup),
160
+ Boolean(opts.nextjs),
161
+ opts.reactPinDir,
162
+ );
163
+ written.push(path.join(outDir, "package.json"));
164
+ if (await ensurePnpmSuiteSettings(path.join(outDir, "pnpm-workspace.yaml"))) {
165
+ written.push(path.join(outDir, "pnpm-workspace.yaml"));
166
+ }
167
+ // The suite type-checks in isolation (docs promise); emitted once, then
168
+ // the user's — the same write-once promise as the yaml above.
169
+ const tsPath = path.join(outDir, "tsconfig.json");
170
+ if (!existsSync(tsPath)) {
171
+ await writeFile(tsPath, tsConfig());
172
+ written.push(tsPath);
173
+ }
174
+ }
175
+ return { written, skipped, keptEdited, keptLegacy, staleRemoved, staleKept, orphanUnitSpecs };
176
+ }
177
+
178
+ /**
179
+ * Unit specs the suite will still run, with no component behind them.
180
+ *
181
+ * Report-only, and the reason is provenance. In-outDir specs are identified by
182
+ * being rewritten every run and therefore carry NO stamp (see the write loop),
183
+ * which makes a hand-hardened spec byte-identical to an orphan one. The tool can
184
+ * name those files; it cannot prove any of them is safe to delete — and
185
+ * deleting a suite spec because an analysis stopped pointing at it is how
186
+ * coverage disappears quietly.
187
+ *
188
+ * Naming is still worth having, because an orphan is not coverage: it is a red
189
+ * import at the start of every run. Components get orphaned by refactors, by
190
+ * renames, and now by the React Native rule (nativeDialect.ts), which can strand
191
+ * a whole directory of specs at once.
192
+ *
193
+ * Recursive to match the glob the suite emits over its own `unit/` directory, and keyed
194
+ * on the writer's own relPath (`unit/<spec>.test.tsx`), so what is reported is exactly
195
+ * what the runner will pick up.
196
+ */
197
+ export async function findOrphanUnitSpecs(
198
+ outDir: string,
199
+ rendered: Set<string>,
200
+ ): Promise<string[]> {
201
+ const dir = path.join(outDir, "unit");
202
+ if (!existsSync(dir)) return [];
203
+ const orphans: string[] = [];
204
+ const walk = async (at: string, rel: string): Promise<void> => {
205
+ for (const entry of await readdir(at, { withFileTypes: true })) {
206
+ const childRel = `${rel}/${entry.name}`;
207
+ if (entry.isDirectory()) {
208
+ await walk(path.join(at, entry.name), childRel);
209
+ continue;
210
+ }
211
+ if (!/\.test\.(ts|tsx)$/.test(entry.name)) continue;
212
+ if (rendered.has(childRel)) continue;
213
+ orphans.push(path.join(at, entry.name));
214
+ }
215
+ };
216
+ await walk(dir, "unit");
217
+ return orphans.sort();
218
+ }
219
+
220
+ /** A stale browser spec the writer refused to remove, and why. */
221
+ export interface StaleBrowserSpec {
222
+ abs: string;
223
+ /** "edited" = stamp present, body differs (hand work); "stampless" = no provenance at all. */
224
+ verdict: "edited" | "stampless";
225
+ }
226
+
227
+ /**
228
+ * What unit-browser contributes to the suite's package.json.
229
+ *
230
+ * Exported so tests/core.test.ts can compare it against perf-harness's without
231
+ * going through disk — the only place two emitters' declarations can be seen
232
+ * side by side, and so the only place a disagreement between them is visible.
233
+ *
234
+ * `test:perf` is deliberately absent. It names perf-harness's config and
235
+ * directory, and `browserSpecs` is set from exactly `selectedTasks.includes
236
+ * ("perf-harness")` (unitAndBrowser.ts), so this emitter could only ever declare
237
+ * it in the runs where perf-harness declares it too. A second, differently
238
+ * spelled copy of a key that is never needed alone is how the two files came to
239
+ * disagree in the first place.
240
+ */
241
+ export function suitePackageJson(
242
+ outDir: string,
243
+ withBrowser: boolean,
244
+ msw = false,
245
+ nextjs = false,
246
+ reactPinDir?: string,
247
+ ): string {
248
+ // The suite's react IS the copy every plane renders with (aliases + host
249
+ // resolution point at the suite's node_modules), so it must be the
250
+ // version the app's component code was written against — and it must be the
251
+ // SAME copy the config's react aliases anchor on, or the two derivations
252
+ // disagree and one plane ends up with two Reacts. See detectAppReact /
253
+ // appReactPin.
254
+ const pin = appReactPin(outDir, reactPinDir);
255
+ const typesPin = pin ? `^${pin.react.split(".")[0]}.0.0` : "^18.3.0";
256
+ // RTL majors split at React 18: 13+ peer-require react ^18 and their
257
+ // dist/pure.js hard-requires the react-dom 18 client entry (a canary
258
+ // proved 14.3.1 fails at collection under React 17). 12.1.5 is the last
259
+ // major whose peers carry <18; its bundled dom-v8 covers every API the
260
+ // spec templates emit.
261
+ const legacyRtl = pin && Number(pin.react.split(".")[0]) <= 17;
262
+ const rtlPin = legacyRtl ? "^12.1.5" : "^16.0.0";
263
+ return `${JSON.stringify(
264
+ {
265
+ name: `${path.basename(outDir).replace(/[^a-zA-Z0-9_-]+/g, "-")}-suite`,
266
+ version: "0.0.0",
267
+ private: true,
268
+ // A standalone suite owns its loader: "module", with configs and
269
+ // sources written for ESM. A nextjs suite's manifest must NOT say it:
270
+ // the file sits above playwright configs emitted against the app's
271
+ // CJS transpile default (the nextjs e2e config anchors on __dirname),
272
+ // and the nearest package.json picks Playwright's loader — inheriting
273
+ // "module" here crashed those configs at import with "__dirname is not
274
+ // defined in ES module scope".
275
+ ...(nextjs ? {} : { type: "module" }),
276
+ scripts: {
277
+ [SUITE_SCRIPT.unit]: SUITE_COMMAND.unit,
278
+ // browser/ is only rendered when browserSpecs is on. Declared
279
+ // unconditionally, `--tasks unit-browser` wrote a script pointing at a
280
+ // config that run never created, and --run-tests then invoked it and
281
+ // reported a correct suite as failing.
282
+ ...(withBrowser ? { [SUITE_SCRIPT.browser]: SUITE_COMMAND.browser } : {}),
283
+ },
284
+ dependencies: {
285
+ react: pin?.react ?? "^18.3.0",
286
+ "react-dom": pin?.reactDom ?? "^18.3.0",
287
+ // The perf host's main.tsx imports react-scan; withBrowser IS
288
+ // "perf-harness ran", so the host is in this suite. Same pin as
289
+ // perf-harness's own emitter — divergent pins meant the file's
290
+ // bytes depended on which task ran last.
291
+ ...(withBrowser ? { "react-scan": "^0.4.3" } : {}),
292
+ // mswSetup IS "setup.ts wires the MSW server", and every nextjs
293
+ // suite fetches through it — a self-contained install cannot
294
+ // assume the APP ships msw (chanhdai happened to; that is luck,
295
+ // not a contract).
296
+ ...(msw ? { msw: "^2.7.0" } : {}),
297
+ },
298
+ devDependencies: {
299
+ "@playwright/test": "^1.49.0",
300
+ // next's trace tracer require()s this OPTIONAL peer at module load.
301
+ // webpack tolerates the miss; the perf host's bun transpiler hard-
302
+ // errors on it. Ship the real package — without a registered
303
+ // provider it is a faithful no-op, identical to production behavior.
304
+ "@opentelemetry/api": "^1.9.0",
305
+ "@types/node": "^22.0.0",
306
+ "@types/react": typesPin,
307
+ "@types/react-dom": typesPin,
308
+ typescript: "^5.6.0",
309
+ vitest: "^2.1.0",
310
+ "@vitest/ui": "^2.1.0",
311
+ jsdom: "^25.0.0",
312
+ "@testing-library/react": rtlPin,
313
+ // user-event 14 peers @testing-library/dom >=7.21.4. Modern RTL
314
+ // ships dom 10 as a dependency; RTL 12's dom 8 is only reachable
315
+ // transitively, and pnpm's strict linking does not expose it to a
316
+ // sibling — declare it so the peer resolves deterministically.
317
+ ...(legacyRtl ? { "@testing-library/dom": "^8.20.1" } : {}),
318
+ "@testing-library/user-event": "^14.5.0",
319
+ "@testing-library/jest-dom": "^6.5.0",
320
+ },
321
+ },
322
+ null,
323
+ 2,
324
+ )}\n`;
325
+ }
326
+
327
+ /**
328
+ * Through the shared merge, like perf-harness — the version pins above are
329
+ * defaults an existing file overrides, not values that overwrite it.
330
+ */
331
+ async function writeSuitePackageJson(
332
+ outDir: string,
333
+ withBrowser: boolean,
334
+ msw: boolean,
335
+ nextjs = false,
336
+ reactPinDir?: string,
337
+ ): Promise<void> {
338
+ const pkgPath = path.join(outDir, "package.json");
339
+ const generated = suitePackageJson(outDir, withBrowser, msw, nextjs, reactPinDir);
340
+ if (!existsSync(pkgPath)) {
341
+ await writeFile(pkgPath, generated);
342
+ return;
343
+ }
344
+ // Same anchor as the emitted config's react aliases (see appReactPin).
345
+ const pin = appReactPin(outDir, reactPinDir);
346
+ const merged = repairRtlPin(
347
+ repairReactPin(mergePackageJson(await readFile(pkgPath, "utf8"), generated), pin),
348
+ pin,
349
+ );
350
+ // The merge lets an existing file win on every key — except in nextjs
351
+ // mode, where the loader switch is the TOOL's key (see suitePackageJson):
352
+ // a manifest that already says "module" would pin ESM onto the playwright
353
+ // configs around it and kill their __dirname at import. Everything else
354
+ // in the file stays the user's.
355
+ if (nextjs && /"type":/.test(merged)) {
356
+ const parsed = JSON.parse(merged) as Record<string, unknown>;
357
+ delete parsed.type;
358
+ await writeFile(pkgPath, `${JSON.stringify(parsed, null, 2)}\n`);
359
+ return;
360
+ }
361
+ await writeFile(pkgPath, merged);
362
+ }
363
+
364
+ /**
365
+ * The merge lets an existing manifest out-vote every pin — but a pin that
366
+ * peer-requires react ^18 (RTL 13+) inside a React-17 suite cannot even be
367
+ * collected: dist/pure.js requires the react-dom 18 client entry that 17
368
+ * does not ship. When the surviving range is one this tool itself emitted
369
+ * before that was known (a canary proved ^14.0.0 fails at collection), the
370
+ * merge yields that one key back. Ranges the tool never offered, or that
371
+ * merely degrade rather than fail outright (RTL 12 on react 18 renders in
372
+ * legacy mode with a warning), are left alone.
373
+ */
374
+ export function repairRtlPin(
375
+ merged: string,
376
+ pin: { react: string; reactDom: string } | undefined,
377
+ ): string {
378
+ const appMajor = pin ? Number(pin.react.split(".")[0]) : undefined;
379
+ if (appMajor === undefined || !(appMajor <= 17)) return merged;
380
+ let parsed: Record<string, unknown>;
381
+ try {
382
+ parsed = JSON.parse(merged) as Record<string, unknown>;
383
+ } catch {
384
+ return merged;
385
+ }
386
+ const dev = parsed.devDependencies as Record<string, string> | undefined;
387
+ const range = dev?.["@testing-library/react"];
388
+ const rtlMajor = range ? /^\D*(\d+)/.exec(range)?.[1] : undefined;
389
+ if (!dev || rtlMajor === undefined || Number(rtlMajor) < 13) return merged;
390
+ dev["@testing-library/react"] = "^12.1.5";
391
+ if (!dev["@testing-library/dom"]) dev["@testing-library/dom"] = "^8.20.1";
392
+ return `${JSON.stringify(parsed, null, 2)}\n`;
393
+ }
394
+
395
+ /**
396
+ * Build scripts a suite install must run (pnpm 10+ refuses them by default
397
+ * and EXITS 1 doing so). esbuild's postinstall IS its binary — without it
398
+ * vitest dies at startup; msw's copies its service worker.
399
+ */
400
+ const SUITE_BUILD_ALLOWLIST = ["esbuild", "msw"];
401
+
402
+ /**
403
+ * The suite's pnpm-workspace.yaml: write-once in spirit, self-healing in
404
+ * fact. Every dogfood suite install died on ERR_PNPM_IGNORED_BUILDS because
405
+ * the allowlist had not grown with the suite's dependencies; a file that is
406
+ * merely re-written-when-absent repeats that on existing suites. A missing
407
+ * file is written whole; an existing one gains only the entries it lacks —
408
+ * user keys are untouched, nothing is ever duplicated, and a key the user
409
+ * rewrote inline (flow style, not a block) is theirs to keep, so the pass
410
+ * hands off entirely.
411
+ */
412
+ export async function ensurePnpmSuiteSettings(wsPath: string): Promise<boolean> {
413
+ if (!existsSync(wsPath)) {
414
+ await writeFile(wsPath, PNPM_SUITE_SETTINGS);
415
+ return true;
416
+ }
417
+ const body = await readFile(wsPath, "utf8");
418
+ const inList = (name: string) => new RegExp(`^[ \\t]*-[ \\t]+${name}[ \\t]*$`, "m").test(body);
419
+ const flagged = (name: string) =>
420
+ new RegExp(`^[ \\t]*${name}:[ \\t]*true[ \\t]*$`, "m").test(body);
421
+ const items = SUITE_BUILD_ALLOWLIST.filter((n) => !inList(n)).map((n) => ` - ${n}`);
422
+ const flags = SUITE_BUILD_ALLOWLIST.filter((n) => !flagged(n)).map((n) => ` ${n}: true`);
423
+ if (items.length === 0 && flags.length === 0) return false;
424
+ let changed = false;
425
+ let next = body;
426
+ if (items.length > 0) {
427
+ const merged = appendToYamlBlock(next, "onlyBuiltDependencies:", items);
428
+ if (merged !== null) {
429
+ next = merged;
430
+ changed = true;
431
+ }
432
+ }
433
+ if (flags.length > 0) {
434
+ const merged = appendToYamlBlock(next, "allowBuilds:", flags);
435
+ if (merged !== null) {
436
+ next = merged;
437
+ changed = true;
438
+ }
439
+ }
440
+ if (!changed) return false;
441
+ await writeFile(wsPath, next);
442
+ return true;
443
+ }
444
+
445
+ /**
446
+ * Insert lines at the end of a top-level YAML block (creating it at EOF when
447
+ * absent). Null means hands off: the key carries an inline value the user
448
+ * wrote — duplicating the key would make the file invalid YAML.
449
+ */
450
+ function appendToYamlBlock(body: string, header: string, lines: string[]): string | null {
451
+ const all = body.split("\n");
452
+ const at = all.findIndex((l) => l.trimStart().startsWith(header));
453
+ if (at === -1) {
454
+ const base = body.endsWith("\n") ? body : `${body}\n`;
455
+ return `${base}${header}\n${lines.join("\n")}\n`;
456
+ }
457
+ const atLine = all[at] ?? "";
458
+ if (atLine.slice(atLine.indexOf(header) + header.length).trim() !== "") return null;
459
+ let end = at + 1;
460
+ while (end < all.length && /^[ \t]+\S/.test(all[end] ?? "")) end++;
461
+ all.splice(end, 0, ...lines);
462
+ return all.join("\n");
463
+ }
@@ -0,0 +1,208 @@
1
+ import type { MockField, MockShape, PropsShape, PropValue } from "../types.ts";
2
+
3
+ /**
4
+ * Resolves an `equalsDep` note — the field of another dependency this one is
5
+ * compared against — to the literal text that dep's own fixture holds there.
6
+ * Undefined when the name is not a dep, or the field has no readable value.
7
+ */
8
+ export type DepFieldResolver = (dep: string, field: string) => string | undefined;
9
+
10
+ /**
11
+ * Renders an inferred MockShape as JavaScript source text for a generated
12
+ * spec. Every branch answers the same question: which literal keeps the
13
+ * component rendering when the inference was only half right?
14
+ */
15
+ export function mockLiteral(
16
+ shape: MockShape,
17
+ seed: string,
18
+ props?: PropsShape,
19
+ depValues?: DepFieldResolver,
20
+ fill?: unknown,
21
+ ): string {
22
+ switch (shape.kind) {
23
+ case "array":
24
+ // Exactly one element. An empty array would let a spec pass without ever
25
+ // rendering the row markup it claims to check.
26
+ return shape.element
27
+ ? `[${mockLiteral(shape.element, seed, props, depValues, Array.isArray(fill) ? fill[0] : undefined)}]`
28
+ : "[]";
29
+ case "object":
30
+ return objectLiteral(shape.fields, props, depValues, plainObject(fill));
31
+ case "number":
32
+ return "1";
33
+ case "date-string":
34
+ return quote("2024-01-01T00:00:00.000Z");
35
+ case "string":
36
+ return quote(`flawless-${seed}`);
37
+ case "truthy-object":
38
+ // The only evidence was `if (x)` / `!x`: any non-null object satisfies
39
+ // that, and nothing in the component reads a field off it.
40
+ return "{}";
41
+ case "set":
42
+ // The field's only use was `.has(x)` / `.add(x)` — a live empty Set
43
+ // answers every one of them; an object with a fake `has` property dies
44
+ // on the first call.
45
+ return "new Set()";
46
+ case "map":
47
+ return "new Map()";
48
+ case "date":
49
+ // Only use was Date methods (`.getTime()`, `.toISOString()`). A string
50
+ // has none of them; this has all of them.
51
+ return `new Date("2024-01-01T00:00:00.000Z")`;
52
+ case "unknown":
53
+ // NEVER `undefined`. `undefined` throws on *any* member access, so a
54
+ // single `dep.anything` anywhere in the component fails the generated
55
+ // spec. `{}` only gives way when the component calls an array method on
56
+ // it, which is strictly rarer. This is the fix for the "items.length
57
+ // crashes the generated test" failure mode.
58
+ return "{}";
59
+ }
60
+ }
61
+
62
+ function objectLiteral(
63
+ fields: MockField[],
64
+ props?: PropsShape,
65
+ depValues?: DepFieldResolver,
66
+ fill?: Record<string, unknown>,
67
+ ): string {
68
+ if (fields.length === 0) return "{}";
69
+ return `{ ${fields.map((f) => `${keyText(f.name)}: ${fieldLiteral(f, props, depValues, fill?.[f.name])}`).join(", ")} }`;
70
+ }
71
+
72
+ function fieldLiteral(
73
+ field: MockField,
74
+ props?: PropsShape,
75
+ depValues?: DepFieldResolver,
76
+ fill?: unknown,
77
+ ): string {
78
+ // A literal the component compares this field against wins over any
79
+ // synthesised value: `m.status === "active"` against `status:
80
+ // "flawless-status"` is a row the page's own lookup rejects, and twenty-two
81
+ // of the dogfood app's page specs rendered nothing because of it.
82
+ if (field.equals !== undefined) return JSON.stringify(field.equals);
83
+ // The same, where the comparison is against one of the component's own props:
84
+ // `m.building_id === id`, with `id` the route param. Read out of the fixture
85
+ // the spec's DEFAULTS is built from, so the two agree by construction rather
86
+ // than by both spelling "flawless-id".
87
+ if (field.equalsProp && props) {
88
+ const v = atPath(props, field.equalsProp);
89
+ if (v !== undefined) return JSON.stringify(v);
90
+ }
91
+ // And against a field of ANOTHER dep: `m.user_id === user.id`. Resolved to
92
+ // the literal the other dep's fixture actually holds, for the same reason —
93
+ // two independently synthesised spellings never match, and the lookup that
94
+ // compares them guard-exits the page.
95
+ if (field.equalsDep && depValues) {
96
+ const v = depValues(field.equalsDep.var, field.equalsDep.field);
97
+ if (v !== undefined) return v;
98
+ }
99
+ // A value the dep's own return ANNOTATION carries beats a synthesised
100
+ // placeholder, and only a placeholder: usage evidence above already won, and
101
+ // a field whose annotation resolved to another "flawless-" seed gains
102
+ // nothing from the swap (the name-seeded synthesis reads better in a
103
+ // failure). `job.status` fed into a lookup table says nothing about legal
104
+ // statuses; `Promise<Job>`'s z.enum names one, and the child that indexes
105
+ // `ALLOWED_TRANSITIONS[status]` crashes on anything else.
106
+ if (fill !== undefined) {
107
+ const kind = field.shape.kind;
108
+ if (
109
+ (kind === "string" || kind === "unknown") &&
110
+ typeof fill === "string" &&
111
+ !fill.startsWith("flawless-")
112
+ ) {
113
+ return JSON.stringify(fill);
114
+ }
115
+ if (kind === "number" && typeof fill === "number") return JSON.stringify(fill);
116
+ }
117
+ // A field with no evidence is a leaf, and a string survives the most uses: it
118
+ // renders as text, it is truthy, and an unexpected property read off it is
119
+ // `undefined` rather than a throw. The bare-`{}` rule above is for a whole
120
+ // dependency, where the component is far more likely to be reading fields.
121
+ if (field.shape.kind === "unknown") return quote(`flawless-${field.name}`);
122
+ return mockLiteral(field.shape, field.name, props, depValues, fill);
123
+ }
124
+
125
+ /**
126
+ * The literal ONE field of an object-shaped fixture resolves to — what an
127
+ * `equalsDep` reference reads. Deliberately resolved WITHOUT a dep resolver of
128
+ * its own: one hop is a value two fixtures can agree on, a chain is a graph
129
+ * this has no business walking.
130
+ */
131
+ export function fieldValueLiteral(field: MockField, props?: PropsShape): string {
132
+ return fieldLiteral(field, props);
133
+ }
134
+
135
+ /**
136
+ * The VALUE mirror of mockLiteral — the same fixture, as a PropValue instead
137
+ * of source text, so the predicate certifier can evaluate a page's conditions
138
+ * against the values the spec will actually mock with. Kept beside
139
+ * mockLiteral on purpose: if the two ever disagree, the certifier claims a
140
+ * side the mount does not take. `equalsDep` notes resolve to their synthesised
141
+ * default here (the emit-side resolver is one hop of emit-only knowledge);
142
+ * for evaluating a CONDITION that is the right call — both fixtures hold the
143
+ * SAME value there, whichever it is.
144
+ */
145
+ export function mockValue(shape: MockShape, seed: string, props?: PropsShape): PropValue {
146
+ switch (shape.kind) {
147
+ case "array":
148
+ return shape.element ? [mockValue(shape.element, seed, props)] : [];
149
+ case "object": {
150
+ const out: Record<string, PropValue> = {};
151
+ for (const f of shape.fields) out[f.name] = fieldValue(f, props);
152
+ return out;
153
+ }
154
+ case "number":
155
+ return 1;
156
+ case "date-string":
157
+ return "2024-01-01T00:00:00.000Z";
158
+ case "string":
159
+ return `flawless-${seed}`;
160
+ case "truthy-object":
161
+ return {};
162
+ case "set":
163
+ case "map":
164
+ case "date":
165
+ // The SOURCE literal (mockLiteral) is a live Set/Map/Date. The value
166
+ // mirror only feeds condition evaluation, which asks truthiness — and
167
+ // all three are truthy objects. JSON cannot carry the real thing.
168
+ return {};
169
+ case "unknown":
170
+ return {};
171
+ }
172
+ }
173
+
174
+ function fieldValue(field: MockField, props?: PropsShape): PropValue {
175
+ if (field.equals !== undefined) return field.equals;
176
+ if (field.equalsProp && props) {
177
+ const v = atPath(props, field.equalsProp);
178
+ if (v !== undefined) return v;
179
+ }
180
+ if (field.shape.kind === "unknown") return `flawless-${field.name}`;
181
+ return mockValue(field.shape, field.name, props);
182
+ }
183
+
184
+ function plainObject(v: unknown): Record<string, unknown> | undefined {
185
+ return v !== null && typeof v === "object" && !Array.isArray(v)
186
+ ? (v as Record<string, unknown>)
187
+ : undefined;
188
+ }
189
+
190
+ /** A prop path into the fixture; undefined at the first hop that is not there. */
191
+ function atPath(props: PropsShape, path: string[]): PropValue | undefined {
192
+ let cur: PropValue | undefined = props as unknown as PropValue;
193
+ for (const key of path) {
194
+ if (!cur || typeof cur !== "object" || Array.isArray(cur)) return undefined;
195
+ cur = (cur as Record<string, PropValue>)[key];
196
+ }
197
+ return cur;
198
+ }
199
+
200
+ const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
201
+
202
+ function keyText(name: string): string {
203
+ return BARE_KEY.test(name) ? name : quote(name);
204
+ }
205
+
206
+ function quote(value: string): string {
207
+ return JSON.stringify(value);
208
+ }