@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,32 @@
1
+ import type { NextjsApiRoute } from "../types.ts";
2
+
3
+ export function buildSwagger(routes: NextjsApiRoute[], projectName: string): unknown {
4
+ const paths: Record<string, Record<string, unknown>> = {};
5
+ for (const r of routes) {
6
+ const ops: Record<string, unknown> = paths[r.path] ?? {};
7
+ for (const m of r.methods) {
8
+ ops[m.toLowerCase()] = {
9
+ summary: `${m} ${r.path}`,
10
+ responses: {
11
+ "200": {
12
+ description: "OK",
13
+ content: {
14
+ "application/json": {
15
+ example: {},
16
+ },
17
+ },
18
+ },
19
+ },
20
+ };
21
+ }
22
+ paths[r.path] = ops;
23
+ }
24
+ return {
25
+ openapi: "3.0.0",
26
+ info: {
27
+ title: `${projectName} — derived from Next.js API routes`,
28
+ version: "0.0.0",
29
+ },
30
+ paths,
31
+ };
32
+ }
@@ -0,0 +1,414 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { stringifyLike } from "./scaffold/adoptStyle.ts";
4
+
5
+ interface Pkg {
6
+ scripts?: Record<string, string>;
7
+ dependencies?: Record<string, string>;
8
+ devDependencies?: Record<string, string>;
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ /**
13
+ * The app's own React pin, as a bare exact version.
14
+ *
15
+ * The suite installs its OWN react/react-dom (its harness deps live in the
16
+ * suite's tree), and every plane resolves react through that one copy: the
17
+ * vitest aliases point at the suite's node_modules, and the perf host's
18
+ * resolveFromSuite resolves from the suite directory. So the suite's copy IS
19
+ * the copy the app's component code runs against — and a suite pinned to 18
20
+ * under a React-19 app fails every component that touches a 19-only hook
21
+ * (`useOptimistic is not a function` across 100+ specs). Pin what the app
22
+ * declares instead; absent a detectable app pin, fall back to the default.
23
+ *
24
+ * Walks up from the suite dir past package.jsons that merely exist until one
25
+ * DEFINES react (monorepo roots often carry no react at all), resolving pnpm
26
+ * `catalog:` references through the package.json catalogs map or a sibling
27
+ * pnpm-workspace.yaml.
28
+ */
29
+ export function detectAppReact(fromDir: string): { react: string; reactDom: string } | undefined {
30
+ let dir = path.dirname(path.resolve(fromDir));
31
+ // Nothing on the walk declared BOTH halves, so the answer has to be read off
32
+ // what is there. Three one-sided readings qualify, and each is remembered
33
+ // with the hop it was seen at:
34
+ // 0. a full installed pair — the versions the graph actually shares;
35
+ // 1. one installed half alone — the copy the app's own imports land on;
36
+ // 2. a manifest declaring react alone — an app with no DOM renderer at all
37
+ // (ospfranco/sol: `"react": "19.1.4"`, no react-dom in the file, and no
38
+ // react-dom installed anywhere either — a Tauri app rendering elsewhere).
39
+ // Nothing in its tree renders to DOM, so the SUITE is what supplies a
40
+ // renderer, and the only version that cannot drift is the react it does
41
+ // pin. Missing this left sol's suite on the React-18 default under a
42
+ // React-19.1.4 app — the exact mismatch this whole file exists to stop.
43
+ // A library whose react is a peer dependency declares a disjunction —
44
+ // `"react": "^18.0.0 || ^19.0.0"`, lightswind-ui-library's whole declaration —
45
+ // and no single version answers it: only reading 0 or 1 can speak for it.
46
+ // Nearest wins, then the fullest reading at that hop, because an ancestor
47
+ // that carries react for its own tooling must not out-vote the app.
48
+ const partial: { react: string; reactDom: string; hop: number; rank: number }[] = [];
49
+ for (let hop = 0; hop < 8; hop++) {
50
+ const pkgPath = path.join(dir, "package.json");
51
+ const reactInstalled = installedVersionAt(dir, "react");
52
+ const reactDomInstalled = installedVersionAt(dir, "react-dom");
53
+ const installed =
54
+ reactInstalled && reactDomInstalled
55
+ ? { react: reactInstalled, reactDom: reactDomInstalled }
56
+ : undefined;
57
+ if (installed) partial.push({ ...installed, hop, rank: 0 });
58
+ else if (reactInstalled)
59
+ partial.push({ react: reactInstalled, reactDom: reactInstalled, hop, rank: 1 });
60
+ if (existsSync(pkgPath)) {
61
+ try {
62
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
63
+ dependencies?: Record<string, string>;
64
+ devDependencies?: Record<string, string>;
65
+ catalogs?: Record<string, Record<string, string>>;
66
+ pnpm?: { catalog?: Record<string, string> };
67
+ };
68
+ const all = { ...pkg.devDependencies, ...pkg.dependencies };
69
+ const react = resolveCatalog(all.react, "react", dir, pkg);
70
+ const reactDom = resolveCatalog(all["react-dom"], "react-dom", dir, pkg);
71
+ const v = bareVersion(react);
72
+ const dv = bareVersion(reactDom);
73
+ // The versions the graph will ACTUALLY share are the installed ones. A
74
+ // declared range is a floor: `^19.2.0` installs whatever is newest, so
75
+ // a suite pinned from the declaration and an app tree holding the copy
76
+ // it installed last month are two versions — and react-dom refuses to
77
+ // load when the pair it sees disagrees ("Incompatible React versions",
78
+ // every spec file dead at collect: 91 of 93 on bradgarropy.com, whose
79
+ // app declares ^19.2.0 and has 19.2.8 installed). Prefer the installed
80
+ // pair, both or neither, so the two never drift from each other.
81
+ if (v && dv) return installed ?? { react: v, reactDom: dv };
82
+ if (v && !dv) partial.push({ react: v, reactDom: v, hop, rank: 2 });
83
+ } catch {
84
+ /* unparseable: keep walking */
85
+ }
86
+ }
87
+ const up = path.dirname(dir);
88
+ if (up === dir) break;
89
+ dir = up;
90
+ }
91
+ partial.sort((a, b) => a.hop - b.hop || a.rank - b.rank);
92
+ const answer = partial[0];
93
+ return answer ? { react: answer.react, reactDom: answer.reactDom } : undefined;
94
+ }
95
+
96
+ /**
97
+ * The exact version physically installed under a package root, reading through
98
+ * a pnpm symlink and returning undefined when that tree carries no copy.
99
+ */
100
+ function installedVersionAt(dir: string, name: string): string | undefined {
101
+ try {
102
+ const raw = JSON.parse(
103
+ readFileSync(path.join(dir, "node_modules", name, "package.json"), "utf8"),
104
+ ) as { name?: string; version?: string };
105
+ // Not the package the path points at (a stray link): treat it as absent.
106
+ return raw.name === name && typeof raw.version === "string" ? raw.version : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /** A package's version as installed directly in `nmDir` (a node_modules dir). */
113
+ function versionInNodeModules(nmDir: string, name: string): string | undefined {
114
+ try {
115
+ const raw = JSON.parse(readFileSync(path.join(nmDir, name, "package.json"), "utf8")) as {
116
+ name?: string;
117
+ version?: string;
118
+ };
119
+ return raw.name === name && typeof raw.version === "string" ? raw.version : undefined;
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The React the suite's own install has to match.
127
+ *
128
+ * Two derivations had to be made one. `detectAppReact` walks UP from the suite
129
+ * and reads the pair the app declares and installs; the emitted vitest config
130
+ * anchors its `react` aliases on the copy the SOURCES resolve —
131
+ * `nearestReactNodeModules` over the source roots, which walks DOWN. In a
132
+ * workspace the two can answer different questions:
133
+ * code-forge-io/react-router-devtools has no react at its root at all, so the
134
+ * pin fell through to the `^18.3.0` default while the alias pointed at the
135
+ * `docs` package's linked React 19.2.7. Whatever the manifest names, the app's
136
+ * copy wins at runtime, and React 18's `react-dom/test-utils` reading React
137
+ * 19's renamed internals kills every spec at collect — `Cannot read properties
138
+ * of undefined (reading 'ReactCurrentOwner')`, 180 files contributing zero
139
+ * tests. The suite's install, meanwhile, is the copy RTL and the harness load.
140
+ *
141
+ * So take the pin from the copy the aliases anchor on. The guard is what keeps
142
+ * this surgical: an app with no react anywhere in its tree anchors on the
143
+ * SUITE's own node_modules, and there the anchor is only the install about to
144
+ * happen — `detectAppReact` stays the answer, so an app that never had an
145
+ * app-tree React behaves exactly as it did before.
146
+ */
147
+ export function appReactPin(
148
+ fromDir: string,
149
+ anchorNmDir?: string,
150
+ ): { react: string; reactDom: string } | undefined {
151
+ if (anchorNmDir) {
152
+ const nm = path.resolve(anchorNmDir);
153
+ const suite = path.resolve(fromDir);
154
+ // The anchor is the APP's copy only while it sits outside the suite. When
155
+ // it is the suite's own node_modules (or below it) there is no second
156
+ // copy to agree with — that tree is this install, about to be written —
157
+ // so detectAppReact stays the answer.
158
+ if (nm !== suite && !nm.startsWith(`${suite}${path.sep}`)) {
159
+ const react = versionInNodeModules(nm, "react");
160
+ // A copy carrying react alone still answers the question the alias asks:
161
+ // it is the copy every leg of the graph will load, so it is the pair.
162
+ if (react) return { react, reactDom: versionInNodeModules(nm, "react-dom") ?? react };
163
+ }
164
+ }
165
+ return detectAppReact(fromDir);
166
+ }
167
+
168
+ /**
169
+ * The app's React MAJOR (17, 18, 19 …) — the switch that picks the test
170
+ * stack. React 17 has no `react-dom/client`, no `createRoot`, and no
171
+ * streaming server renderer, and @testing-library/react 15+ dropped it from
172
+ * its peers; the emitters that touch those APIs branch on this number.
173
+ * Undefined when no app pin was detected — callers treat that as the
174
+ * default major their manifests carry.
175
+ */
176
+ export function appReactMajor(fromDir: string): number | undefined {
177
+ // Per-component spec dispatch consults this; cache the scan so generation
178
+ // does not re-walk package.jsons per component.
179
+ const key = path.resolve(fromDir);
180
+ if (MAJOR_MEMO.has(key)) return MAJOR_MEMO.get(key);
181
+ const pin = detectAppReact(fromDir);
182
+ const major = pin ? Number(pin.react.split(".")[0]) : undefined;
183
+ const out = pin && Number.isFinite(major) ? major : undefined;
184
+ MAJOR_MEMO.set(key, out);
185
+ return out;
186
+ }
187
+ const MAJOR_MEMO = new Map<string, number | undefined>();
188
+
189
+ /**
190
+ * The nearest ancestor of `fromDir` that holds a package.json — the package
191
+ * an analysed source belongs to, and therefore where the app's .env files
192
+ * live (the app reads them from its own package directory).
193
+ */
194
+ export function appPackageRoot(fromDir: string): string | undefined {
195
+ let dir = path.dirname(path.resolve(fromDir));
196
+ for (let hop = 0; hop < 8; hop++) {
197
+ if (existsSync(path.join(dir, "package.json"))) return dir;
198
+ const up = path.dirname(dir);
199
+ if (up === dir) break;
200
+ dir = up;
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ function resolveCatalog(
206
+ range: string | undefined,
207
+ key: "react" | "react-dom",
208
+ dir: string,
209
+ pkg: {
210
+ catalogs?: Record<string, Record<string, string>>;
211
+ pnpm?: { catalog?: Record<string, string> };
212
+ },
213
+ ): string | undefined {
214
+ if (!range) return undefined;
215
+ if (!range.startsWith("catalog:")) return range;
216
+ const named = range.slice("catalog:".length) || "default";
217
+ if (named === "default") {
218
+ return (
219
+ pkg.catalogs?.default?.[key] ?? pkg.pnpm?.catalog?.[key] ?? catalogFromYaml(dir, null, key)
220
+ );
221
+ }
222
+ return pkg.catalogs?.[named]?.[key] ?? catalogFromYaml(dir, named, key);
223
+ }
224
+
225
+ // `catalog:` / `catalogs:` maps in a pnpm-workspace.yaml, without a YAML
226
+ // parser: ` react: x` under a top-level catalog:, or one more indent under
227
+ // catalogs:<name>: . A file with only the default catalog is unambiguous.
228
+ function catalogFromYaml(
229
+ dir: string,
230
+ catalog: string | null,
231
+ key: "react" | "react-dom",
232
+ ): string | undefined {
233
+ let yaml: string;
234
+ try {
235
+ yaml = readFileSync(path.join(dir, "pnpm-workspace.yaml"), "utf8");
236
+ } catch {
237
+ return undefined;
238
+ }
239
+ const escaped = key.replace("-", "\\-");
240
+ let section: "root" | "catalog" | "catalogs" | "other" = "other";
241
+ let named = "";
242
+ for (const line of yaml.split("\n")) {
243
+ if (/^[^\s#]/.test(line)) {
244
+ if (/^catalog:\s*$/.test(line)) section = "catalog";
245
+ else if (/^catalogs:\s*$/.test(line)) section = "catalogs";
246
+ else section = "other";
247
+ continue;
248
+ }
249
+ if (section === "catalog") {
250
+ const m = line.match(new RegExp(`^\\s{2}${escaped}:\\s*['"]?([^'"\\s#]+)`));
251
+ const value = m?.[1];
252
+ if (value) return value;
253
+ } else if (section === "catalogs") {
254
+ const nm = line.match(/^(\s{2})(\S[^:]*):\s*$/);
255
+ const namedHeader = nm?.[2];
256
+ // Group 2 is only missing when the line did not match at all, so the
257
+ // else-if still means "a line inside the catalogs block".
258
+ if (namedHeader !== undefined) named = namedHeader;
259
+ else if (catalog && named === catalog) {
260
+ const m = line.match(new RegExp(`^\\s{4}${escaped}:\\s*['"]?([^'"\\s#]+)`));
261
+ const value = m?.[1];
262
+ if (value) return value;
263
+ }
264
+ }
265
+ }
266
+ return undefined;
267
+ }
268
+
269
+ function bareVersion(range: string | undefined): string | undefined {
270
+ const m = range?.match(/(\d+\.\d+\.\d+)/);
271
+ return m?.[1];
272
+ }
273
+
274
+ /**
275
+ * Fold a generated `package.json` into the one already on disk.
276
+ *
277
+ * `<outDir>/package.json` is the only file two tasks both write. unit-browser
278
+ * merged into whatever it found; perf-harness overwrote it. Task order is the
279
+ * order you type — the CLI documents that — so `--tasks unit-browser,perf-harness`
280
+ * ran the overwrite second and dropped `test:unit` and `test:browser` without a
281
+ * word, after which --run-tests invoked `test:unit`, found nothing, and reported
282
+ * a correct suite as failing.
283
+ *
284
+ * Both emitters now come through here, because two merges into one file that
285
+ * disagree about who owns a field are worse than either: the copy in
286
+ * scaffold-tests kept an existing `name`, the one in scaffold did not, and a
287
+ * renamed suite would have been renamed back depending on which task ran last —
288
+ * the same bug one level up from the one being fixed.
289
+ *
290
+ * The policy, matching mergeIntoNextPackageJson for the target app:
291
+ *
292
+ * - Generated scripts win their own keys, so an existing suite picks up a
293
+ * corrected command. The command that runs the generated suite is ours.
294
+ * - Existing dependency pins win: a version range is the project's decision and
295
+ * ours is only a default.
296
+ * - Everything else the file already carries wins — `name`, `version`, a script
297
+ * you added, a `packageManager` field — and is left exactly as it was.
298
+ *
299
+ * Indentation and the trailing newline are preserved, so a tabs-indented file
300
+ * does not come back reindented, and a second run over a merged file produces
301
+ * identical bytes.
302
+ */
303
+ export function mergePackageJson(existingSource: string, generated: string): string {
304
+ const fresh = JSON.parse(generated) as Pkg;
305
+ let existing: Pkg;
306
+ try {
307
+ existing = JSON.parse(existingSource) as Pkg;
308
+ } catch {
309
+ // An unparseable package.json is not a reason to abandon the suite, and the
310
+ // generated one is at least valid JSON.
311
+ return generated;
312
+ }
313
+
314
+ // fresh first, so a field the file already carries keeps its own value and
315
+ // only the ones it lacks are contributed.
316
+ const merged: Pkg = { ...fresh, ...existing };
317
+ merged.scripts = { ...(existing.scripts ?? {}), ...(fresh.scripts ?? {}) };
318
+ for (const field of ["dependencies", "devDependencies"] as const) {
319
+ const before = existing[field];
320
+ const add = fresh[field];
321
+ if (!before && !add) {
322
+ delete merged[field];
323
+ continue;
324
+ }
325
+ merged[field] = { ...(add ?? {}), ...(before ?? {}) };
326
+ }
327
+ // pnpm >=12 stopped reading package.json's `pnpm` settings block entirely
328
+ // and warns on every install (the allow-list lives in pnpm-workspace.yaml,
329
+ // which the suite emits). An older generation of this tool wrote
330
+ // pnpm.onlyBuiltDependencies into the suite's own package.json; the file is
331
+ // ours, so the dead key is stripped instead of warned about forever.
332
+ const pnpmBlock = (merged as { pnpm?: Record<string, unknown> }).pnpm;
333
+ if (pnpmBlock && "onlyBuiltDependencies" in pnpmBlock) {
334
+ delete pnpmBlock.onlyBuiltDependencies;
335
+ if (Object.keys(pnpmBlock).length === 0) delete merged.pnpm;
336
+ }
337
+ return stringifyLike(existingSource, merged);
338
+ }
339
+
340
+ /**
341
+ * Bring a suite manifest's React back to the app's React, exactly.
342
+ *
343
+ * The merge lets an existing pin win, which is right for a range the project
344
+ * chose and wrong for this key: the suite's react IS the copy the app's
345
+ * component code runs against, and the pair must be the SAME VERSION as the one
346
+ * the app tree carries. A caret is a floor, not a version — `^19.2.0` installs
347
+ * the newest 19.x — so a stale suite manifest pairs its own fresh install with
348
+ * the app's older one and react-dom's load-time guard kills the whole plane:
349
+ * `Cannot read properties of undefined (reading 'ReactCurrentOwner')` for a
350
+ * stale major (90 of 93 files on bradgarropy.com), `Incompatible React
351
+ * versions: react 19.2.8, react-dom 19.3.1` for one that merely drifted patch
352
+ * (91 of 93 there too; the same suite reads 44 passed once the pair matches).
353
+ *
354
+ * So any react/react-dom value that does not name the app's version exactly is
355
+ * rewritten to it, and only when the app actually declares a React. Ranges
356
+ * whose major cannot be read (`catalog:`, `latest`, `workspace:`) are left
357
+ * alone: an unreadable preference is not a disagreement, and guessing at one is
358
+ * how a repair becomes a break. `@types/*` follow the major of the runtime they
359
+ * describe, since a types release need not track a patch.
360
+ */
361
+ export function repairReactPin(
362
+ merged: string,
363
+ pin: { react: string; reactDom: string } | undefined,
364
+ ): string {
365
+ if (!pin) return merged;
366
+ let parsed: Record<string, unknown>;
367
+ try {
368
+ parsed = JSON.parse(merged) as Record<string, unknown>;
369
+ } catch {
370
+ return merged;
371
+ }
372
+ const majorOf = (range: string): string | undefined => /^\D*(\d+)/.exec(range)?.[1];
373
+ let changed = false;
374
+ for (const field of ["dependencies", "devDependencies"] as const) {
375
+ const block = parsed[field] as Record<string, string> | undefined;
376
+ if (!block) continue;
377
+ for (const [dep, want] of [
378
+ ["react", pin.react],
379
+ ["react-dom", pin.reactDom],
380
+ ["@types/react", pin.react],
381
+ ["@types/react-dom", pin.reactDom],
382
+ ] as const) {
383
+ const have = block[dep];
384
+ if (!have) continue;
385
+ // An unreadable major (`catalog:`, `latest`, `workspace:*`) is not a
386
+ // disagreement, it is an unreadable preference — leave it.
387
+ if (majorOf(have) === undefined) continue;
388
+ const types = dep.startsWith("@types/");
389
+ // The runtime must be the SAME version; types only need the same major.
390
+ if (types ? majorOf(have) === majorOf(want) : have === want) continue;
391
+ block[dep] = types ? `^${majorOf(want)}.0.0` : want;
392
+ changed = true;
393
+ }
394
+ }
395
+ return changed ? stringifyLike(merged, parsed) : merged;
396
+ }
397
+
398
+ /**
399
+ * Script keys two emitters declare with different commands.
400
+ *
401
+ * Not checkable at merge time: a value on disk that differs from the generated
402
+ * one is indistinguishable from an older version being upgraded, which is
403
+ * exactly the case the merge exists to allow. Divergence is only visible
404
+ * between two emitters' FRESH output in one run, which is where
405
+ * tests/core.test.ts checks it.
406
+ */
407
+ export function divergentScripts(a: Record<string, string>, b: Record<string, string>): string[] {
408
+ const out: string[] = [];
409
+ for (const [key, command] of Object.entries(a)) {
410
+ const other = b[key];
411
+ if (other !== undefined && other !== command) out.push(`${key}: "${command}" vs "${other}"`);
412
+ }
413
+ return out.sort();
414
+ }
@@ -0,0 +1,18 @@
1
+ import type { Platform, RunContext } from "./types.ts";
2
+
3
+ /**
4
+ * Which element vocabulary a run speaks.
5
+ *
6
+ * ONE derivation, in one place, for the same reason `TAG_TO_ROLE` is exported
7
+ * from hostProps.ts and `SUITE_SCRIPT` lives in suiteScripts.ts: a rule spelled
8
+ * twice is a rule that drifts. Every task and every emitter asks this rather
9
+ * than testing `ctx.expo !== undefined` itself, so widening the rule later —
10
+ * a React Native project that is not Expo, say — happens here and nowhere else.
11
+ *
12
+ * It is deliberately NOT a stored field on RunContext. Storing it would let a
13
+ * caller construct a context whose `platform` disagrees with its `expo`, and
14
+ * there is no legitimate run where those two differ.
15
+ */
16
+ export function platformOf(ctx: Pick<RunContext, "expo">): Platform {
17
+ return ctx.expo === undefined ? "web" : "native";
18
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { TaskId } from "./types.ts";
5
+
6
+ /**
7
+ * The subset of @clack/prompts the flows below touch. Exported so tests can
8
+ * drive the whole interactive path with a scripted fake (FR-024) — the flows
9
+ * accept an injected instance and fall back to the real dynamic import.
10
+ */
11
+ export interface ClackPrompts {
12
+ intro: (msg: string) => void;
13
+ outro: (msg: string) => void;
14
+ text: (opts: {
15
+ message: string;
16
+ placeholder?: string;
17
+ initialValue?: string;
18
+ }) => Promise<string | symbol>;
19
+ multiselect: (opts: {
20
+ message: string;
21
+ options: Array<{ value: string; label: string; hint?: string }>;
22
+ initialValues?: string[];
23
+ required?: boolean;
24
+ }) => Promise<string[] | symbol>;
25
+ confirm: (opts: { message: string; initialValue?: boolean }) => Promise<boolean | symbol>;
26
+ isCancel: (v: unknown) => boolean;
27
+ cancel: (msg: string) => void;
28
+ }
29
+
30
+ let cached: ClackPrompts | undefined;
31
+ async function clack(): Promise<ClackPrompts> {
32
+ if (cached) return cached;
33
+ cached = (await import("@clack/prompts")) as unknown as ClackPrompts;
34
+ return cached;
35
+ }
36
+
37
+ /**
38
+ * The same clack instance the generation prompts use, for any other interactive
39
+ * surface that wants the injection seam (FR-024) rather than a second copy of
40
+ * the dynamic import. `skills install`'s menu is the other caller.
41
+ */
42
+ export async function loadClack(): Promise<ClackPrompts> {
43
+ return clack();
44
+ }
45
+
46
+ export interface InteractivePromptResult {
47
+ pattern: string;
48
+ outDir: string;
49
+ tasks: TaskId[];
50
+ }
51
+
52
+ export interface InteractivePromptOptions {
53
+ taskChoices: Array<{ id: TaskId; label: string; description: string }>;
54
+ defaultTaskIds: TaskId[];
55
+ }
56
+
57
+ export async function promptInteractive(
58
+ opts: InteractivePromptOptions,
59
+ prompts?: ClackPrompts,
60
+ ): Promise<InteractivePromptResult | undefined> {
61
+ const p = prompts ?? (await clack());
62
+ p.intro("flawless-react");
63
+
64
+ const detectedPattern = await detectDefaultPattern();
65
+ const patternRaw = await p.text({
66
+ message: "Component path or glob (e.g. src/Dashboard.tsx or src/**/*.tsx)",
67
+ placeholder: detectedPattern ?? "src/**/*.tsx",
68
+ initialValue: detectedPattern ?? "",
69
+ });
70
+ if (p.isCancel(patternRaw)) {
71
+ p.cancel("aborted");
72
+ return undefined;
73
+ }
74
+ const pattern = String(patternRaw).trim();
75
+
76
+ const outRaw = await p.text({
77
+ message: "Output directory",
78
+ initialValue: "./.flawless",
79
+ });
80
+ if (p.isCancel(outRaw)) {
81
+ p.cancel("aborted");
82
+ return undefined;
83
+ }
84
+
85
+ const taskRaw = await p.multiselect({
86
+ message: "Pick the tasks to run (space to toggle, enter to confirm)",
87
+ options: opts.taskChoices.map((t) => ({
88
+ value: t.id,
89
+ label: t.label,
90
+ hint: t.description,
91
+ })),
92
+ initialValues: opts.defaultTaskIds,
93
+ required: true,
94
+ });
95
+ if (p.isCancel(taskRaw)) {
96
+ p.cancel("aborted");
97
+ return undefined;
98
+ }
99
+
100
+ return {
101
+ pattern,
102
+ outDir: String(outRaw).trim() || "./.flawless",
103
+ tasks: taskRaw as TaskId[],
104
+ };
105
+ }
106
+
107
+ export async function confirmRunTests(prompts?: ClackPrompts): Promise<boolean> {
108
+ const p = prompts ?? (await clack());
109
+ const v = await p.confirm({ message: "Run the generated tests now?", initialValue: false });
110
+ if (p.isCancel(v)) return false;
111
+ return Boolean(v);
112
+ }
113
+
114
+ export async function outro(msg: string): Promise<void> {
115
+ const p = await clack();
116
+ p.outro(msg);
117
+ }
118
+
119
+ async function detectDefaultPattern(): Promise<string | undefined> {
120
+ const candidates = ["src/App.tsx", "src/index.tsx", "src/main.tsx"];
121
+ for (const c of candidates) {
122
+ if (existsSync(c)) return c;
123
+ }
124
+ const srcDir = path.resolve("src");
125
+ if (existsSync(srcDir)) {
126
+ const entries = await readdir(srcDir, { withFileTypes: true });
127
+ const tsx = entries.filter((e) => e.isFile() && e.name.endsWith(".tsx"));
128
+ if (tsx.length === 1 && tsx[0]) return path.join("src", tsx[0].name);
129
+ if (tsx.length > 1) return "src/*.tsx";
130
+ }
131
+ return undefined;
132
+ }