@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.
- package/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { AffectedSpecs } from "../changed.ts";
|
|
4
|
+
import { grepFor, grepForRoutes } from "../changed.ts";
|
|
5
|
+
import { VITEST_CONFIG } from "../suiteScripts.ts";
|
|
6
|
+
import type { TaskId } from "../types.ts";
|
|
7
|
+
|
|
8
|
+
/** One suite invocation the runner will spawn, in order. */
|
|
9
|
+
export interface PlannedRun {
|
|
10
|
+
id: string;
|
|
11
|
+
cmd: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The command plan for a full generated-suite run — a PURE function (FR-019:
|
|
16
|
+
* the two shipped bugs in this module both lived in these branches, guarded
|
|
17
|
+
* only by comments; tests/runPlan.test.ts pins the matrix now).
|
|
18
|
+
*
|
|
19
|
+
* Standalone suites carry test:* scripts in their own package.json; Next.js
|
|
20
|
+
* mode runs the target project's flawless:* scripts. The browser suite needs
|
|
21
|
+
* BOTH unit-browser and perf-harness: its specs mount through perf-harness's
|
|
22
|
+
* host, so the script exists only when both were generated — asking for it
|
|
23
|
+
* otherwise invoked a script that had never been written. Standalone suites
|
|
24
|
+
* have no e2e script at all.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* What a suite-run phase actually DID.
|
|
28
|
+
*
|
|
29
|
+
* `ran` is the number of suites that were spawned, and it exists because
|
|
30
|
+
* `green` alone cannot tell "every suite passed" from "no suite ran" — both
|
|
31
|
+
* were `true`. Both runners returned that bare boolean and both said so out
|
|
32
|
+
* loud ("all generated/affected suites passed") over an EMPTY plan, which is
|
|
33
|
+
* the false pass this project exists to kill: FR-042 reached it through plain
|
|
34
|
+
* `--changed --tasks e2e`, FR-043 through `--run-tests` with an `--out` dir
|
|
35
|
+
* that happened to hold a package.json.
|
|
36
|
+
*
|
|
37
|
+
* The rule at every call site: check `ran === 0` BEFORE reading `green`.
|
|
38
|
+
*/
|
|
39
|
+
export interface SuiteRunOutcome {
|
|
40
|
+
/** Suites actually spawned. Zero means nothing ran — never report green. */
|
|
41
|
+
ran: number;
|
|
42
|
+
/** No spawned suite failed. Meaningless unless `ran > 0`. */
|
|
43
|
+
green: boolean;
|
|
44
|
+
/** Why nothing ran. Set exactly when `ran === 0`, for the caller to relay. */
|
|
45
|
+
reason?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function planGeneratedRuns(selectedTasks: TaskId[], standalone: boolean): PlannedRun[] {
|
|
49
|
+
const prefix = standalone ? "test" : "flawless";
|
|
50
|
+
const script = (name: string): string[] => ["bun", "run", `${prefix}:${name}`];
|
|
51
|
+
const runs: PlannedRun[] = [];
|
|
52
|
+
if (selectedTasks.includes("unit-browser")) runs.push({ id: "unit", cmd: script("unit") });
|
|
53
|
+
if (selectedTasks.includes("perf-harness")) runs.push({ id: "perf", cmd: script("perf") });
|
|
54
|
+
if (selectedTasks.includes("unit-browser") && selectedTasks.includes("perf-harness")) {
|
|
55
|
+
runs.push({ id: "browser", cmd: script("browser") });
|
|
56
|
+
}
|
|
57
|
+
if (!standalone && selectedTasks.includes("e2e")) {
|
|
58
|
+
runs.push({ id: "e2e", cmd: script("e2e") });
|
|
59
|
+
}
|
|
60
|
+
return runs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Run the generated suites. Standalone mode (no nextRoot): the suite is its
|
|
65
|
+
* own package under outDir with test:* scripts. Next.js mode: the target
|
|
66
|
+
* project's package.json carries flawless:* scripts and owns node_modules,
|
|
67
|
+
* so everything runs with cwd = nextRoot.
|
|
68
|
+
*/
|
|
69
|
+
export async function runGeneratedTests(
|
|
70
|
+
outDir: string,
|
|
71
|
+
selectedTasks: TaskId[],
|
|
72
|
+
nextRoot?: string,
|
|
73
|
+
quiet = false,
|
|
74
|
+
): Promise<SuiteRunOutcome> {
|
|
75
|
+
// Under --json/--ci stdout is contractually one JSON blob; in quiet mode
|
|
76
|
+
// everything the runner says — and the child suites' own stdout — goes to
|
|
77
|
+
// stderr so the blob stays parseable on exactly the red runs it exists for.
|
|
78
|
+
const say = sayInto(quiet);
|
|
79
|
+
const standalone = existsSync(path.join(path.resolve(outDir), "package.json"));
|
|
80
|
+
const cwd = standalone ? path.resolve(outDir) : (nextRoot ?? path.resolve(outDir));
|
|
81
|
+
|
|
82
|
+
if (!existsSync(path.join(cwd, "node_modules"))) {
|
|
83
|
+
say(`\n› bun install in ${cwd}\n`);
|
|
84
|
+
await spawn("bun", ["install"], cwd, quiet);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const wantsPlaywright =
|
|
88
|
+
selectedTasks.includes("perf-harness") ||
|
|
89
|
+
selectedTasks.includes("unit-browser") ||
|
|
90
|
+
selectedTasks.includes("e2e");
|
|
91
|
+
if (wantsPlaywright) {
|
|
92
|
+
await ensureChromium(cwd, quiet);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const scripts = planGeneratedRuns(selectedTasks, standalone);
|
|
96
|
+
|
|
97
|
+
// FR-043. Checked BEFORE anything is spawned, because the loop below cannot
|
|
98
|
+
// distinguish an empty plan from a plan that all passed — it reports both as
|
|
99
|
+
// "all generated suites passed".
|
|
100
|
+
//
|
|
101
|
+
// The previous comment here claimed this was unreachable through runCore.
|
|
102
|
+
// That was WRONG, and the correctness lens refuted it with a repro:
|
|
103
|
+
// `standalone` is `existsSync(outDir/package.json)`, NOT "outside Next.js
|
|
104
|
+
// mode". Point --out at any directory holding a package.json — a leftover
|
|
105
|
+
// standalone suite, a package root — and `standalone` is true inside Next.js
|
|
106
|
+
// mode, where planGeneratedRuns(["e2e"], true) plans nothing at all.
|
|
107
|
+
if (scripts.length === 0) {
|
|
108
|
+
const reason = `no generated suite matched the selected task(s): ${selectedTasks.join(", ")}`;
|
|
109
|
+
say(`\n${reason}\n`);
|
|
110
|
+
return { ran: 0, green: true, reason };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The vitest plane is the one that pays for a second physical React (see
|
|
114
|
+
// healReactIdentity). Repair after any install has rewritten the tree.
|
|
115
|
+
if (standalone && scripts.some((s) => s.id === "unit")) {
|
|
116
|
+
const healed = healReactIdentity(path.resolve(outDir));
|
|
117
|
+
if (healed.length > 0) say(`\n› healed dual-React copies: ${healed.join(", ")}\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const failed: string[] = [];
|
|
121
|
+
for (const s of scripts) {
|
|
122
|
+
say(`\n› ${s.cmd.join(" ")}\n`);
|
|
123
|
+
const ok = await spawn(s.cmd[0] as string, s.cmd.slice(1), cwd, quiet);
|
|
124
|
+
if (!ok) failed.push(s.id);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (failed.length > 0) {
|
|
128
|
+
say(`\nfailed suites: ${failed.join(", ")}\n`);
|
|
129
|
+
return { ran: scripts.length, green: false };
|
|
130
|
+
}
|
|
131
|
+
say("\nall generated suites passed\n");
|
|
132
|
+
return { ran: scripts.length, green: true };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sayInto(quiet: boolean): (s: string) => void {
|
|
136
|
+
return (s) => void (quiet ? process.stderr : process.stdout).write(s);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Collapse the suite's separate physical copies of react/react-dom onto the
|
|
141
|
+
* app's copy, in place, right before a vitest run.
|
|
142
|
+
*
|
|
143
|
+
* Every request-level pin (vitest aliases, deps.inline, the app-version
|
|
144
|
+
* dependency) has been tried, and pnpm's layout defeats them all: an
|
|
145
|
+
* internalised package's own require("react") resolves by plain node rules
|
|
146
|
+
* from its realpath under the SUITE's .pnpm root, so the renderer and the
|
|
147
|
+
* app's context libraries land on two physical copies — same version,
|
|
148
|
+
* separate hook dispatchers ("Cannot read properties of null (reading
|
|
149
|
+
* 'useContext')"). Only module IDENTITY fixes it, and identity is
|
|
150
|
+
* filesystem-level: node's require cache is keyed by realpath, so the suite
|
|
151
|
+
* tree must not CONTAIN a second react. The top-level entries and the
|
|
152
|
+
* @testing-library peer links under .pnpm are replaced with relative
|
|
153
|
+
* symlinks to the app's copy (absolute links would break a moved checkout).
|
|
154
|
+
*
|
|
155
|
+
* Best-effort by design: every failure is swallowed, a re-run after any
|
|
156
|
+
* `install` (which rewrites the links) heals again, and suites whose app
|
|
157
|
+
* has no installed react are left exactly as they were.
|
|
158
|
+
*/
|
|
159
|
+
export function healReactIdentity(suiteDir: string): string[] {
|
|
160
|
+
const suiteNm = path.join(suiteDir, "node_modules");
|
|
161
|
+
if (!existsSync(suiteNm)) return [];
|
|
162
|
+
let appNm: string | undefined;
|
|
163
|
+
let dir = path.dirname(suiteDir);
|
|
164
|
+
for (let hop = 0; hop < 8 && !appNm; hop++) {
|
|
165
|
+
const nm = path.join(dir, "node_modules");
|
|
166
|
+
if (existsSync(path.join(nm, "react"))) appNm = nm;
|
|
167
|
+
else {
|
|
168
|
+
const up = path.dirname(dir);
|
|
169
|
+
if (up === dir) break;
|
|
170
|
+
dir = up;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!appNm) return [];
|
|
174
|
+
const app = appNm;
|
|
175
|
+
const healed: string[] = [];
|
|
176
|
+
const versionOf = (dir: string): string | undefined => {
|
|
177
|
+
try {
|
|
178
|
+
return (
|
|
179
|
+
JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8")) as { version?: string }
|
|
180
|
+
).version;
|
|
181
|
+
} catch {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const repair = (entry: string, name: string): void => {
|
|
186
|
+
const appCopy = path.join(app, name);
|
|
187
|
+
try {
|
|
188
|
+
// Only copies the suite ACTUALLY carries are replaced; a missing entry
|
|
189
|
+
// already resolves outward toward the app, and inventing links in a
|
|
190
|
+
// package-manager-managed tree invites that manager's corrections.
|
|
191
|
+
if (!existsSync(entry) || realpathSync(entry) === realpathSync(appCopy)) return;
|
|
192
|
+
// Never collapse copies that disagree on version: react-dom@N is only
|
|
193
|
+
// a renderer for react@N, and the suite's pair is internally
|
|
194
|
+
// consistent. Linking across versions turns a broken-but-rendering
|
|
195
|
+
// dual-React suite into a crashed renderer.
|
|
196
|
+
const from = versionOf(entry);
|
|
197
|
+
const to = versionOf(appCopy);
|
|
198
|
+
if (from && to && from !== to) return;
|
|
199
|
+
rmSync(entry, { recursive: true, force: true });
|
|
200
|
+
symlinkSync(path.relative(path.dirname(entry), appCopy), entry);
|
|
201
|
+
healed.push(path.relative(suiteDir, entry));
|
|
202
|
+
} catch {
|
|
203
|
+
/* permission or a busy tree: the suite runs on what it has */
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
for (const name of ["react", "react-dom"]) {
|
|
207
|
+
repair(path.join(suiteNm, name), name);
|
|
208
|
+
// pnpm's peer links are where the split actually bites: RTL renders
|
|
209
|
+
// through ITS react-dom peer, so replacing only the top level left the
|
|
210
|
+
// dual instance exactly where it was.
|
|
211
|
+
let pnpmDirs: string[];
|
|
212
|
+
try {
|
|
213
|
+
pnpmDirs = readdirSync(path.join(suiteNm, ".pnpm"));
|
|
214
|
+
} catch {
|
|
215
|
+
pnpmDirs = [];
|
|
216
|
+
}
|
|
217
|
+
for (const d of pnpmDirs) {
|
|
218
|
+
if (!d.startsWith("@testing-library")) continue;
|
|
219
|
+
const peer = path.join(suiteNm, ".pnpm", d, "node_modules", name);
|
|
220
|
+
if (existsSync(peer)) repair(peer, name);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return healed;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Best-effort chromium install so Playwright suites run hands-free. No
|
|
228
|
+
* --with-deps (it sudo-prompts on Linux and would hang unattended runs);
|
|
229
|
+
* bounded by a kill-timer; opt out with FLAWLESS_SKIP_BROWSER_INSTALL=1.
|
|
230
|
+
*/
|
|
231
|
+
const INSTALL_TIMEOUT_MS = 300_000;
|
|
232
|
+
|
|
233
|
+
async function ensureChromium(cwd: string, quiet = false): Promise<void> {
|
|
234
|
+
if (process.env.FLAWLESS_SKIP_BROWSER_INSTALL === "1") return;
|
|
235
|
+
sayInto(quiet)(
|
|
236
|
+
"\n› bunx playwright install chromium (set FLAWLESS_SKIP_BROWSER_INSTALL=1 to skip)\n",
|
|
237
|
+
);
|
|
238
|
+
const proc = Bun.spawn(["bunx", "playwright", "install", "chromium"], {
|
|
239
|
+
cwd,
|
|
240
|
+
// fd 2: in quiet mode even the child's stdout belongs on stderr.
|
|
241
|
+
stdout: quiet ? 2 : "inherit",
|
|
242
|
+
stderr: "inherit",
|
|
243
|
+
});
|
|
244
|
+
const timer = setTimeout(() => proc.kill(), INSTALL_TIMEOUT_MS);
|
|
245
|
+
const code = await proc.exited;
|
|
246
|
+
clearTimeout(timer);
|
|
247
|
+
if (code !== 0) {
|
|
248
|
+
process.stderr.write(
|
|
249
|
+
"playwright browser install failed — if suites can't find a browser, run: bunx playwright install chromium --with-deps\n",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function spawn(cmd: string, args: string[], cwd: string, quiet = false): Promise<boolean> {
|
|
255
|
+
const proc = Bun.spawn([cmd, ...args], {
|
|
256
|
+
cwd,
|
|
257
|
+
// fd 2: in quiet mode the child suite's stdout must not contaminate the
|
|
258
|
+
// parent's stdout (the --json blob is the only thing allowed there).
|
|
259
|
+
stdout: quiet ? 2 : "inherit",
|
|
260
|
+
stderr: "inherit",
|
|
261
|
+
});
|
|
262
|
+
const code = await proc.exited;
|
|
263
|
+
return code === 0;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Run only the suites that cover the changed components.
|
|
268
|
+
*
|
|
269
|
+
* Two different narrowing mechanisms, because the two runners offer different
|
|
270
|
+
* and better ones:
|
|
271
|
+
*
|
|
272
|
+
* - **vitest `--related`** walks the module graph, so it finds every spec that
|
|
273
|
+
* imports a changed file without the tool having to reproduce its own spec
|
|
274
|
+
* naming rules. It is also transitively correct for free.
|
|
275
|
+
* - **Playwright `--grep`** anchored on `\b(Name): `, because every emitted
|
|
276
|
+
* title starts that way. Anchoring matters: a bare "Card" would also select
|
|
277
|
+
* CardHeader, CardFooter and CardTitle.
|
|
278
|
+
*
|
|
279
|
+
* e2e is selected by whether a page's tree RENDERS a changed component — the
|
|
280
|
+
* relationship a path filter cannot see — and narrowed the same way, by
|
|
281
|
+
* grepping for the route templates its titles embed.
|
|
282
|
+
*/
|
|
283
|
+
/**
|
|
284
|
+
* The command plan for an affected-only run — pure, same discipline as
|
|
285
|
+
* planGeneratedRuns. Unit narrowing keys on changed FILES (vitest `related`
|
|
286
|
+
* walks the module graph from them), so it fires even when components+routes
|
|
287
|
+
* are empty — the case of a change confined to an unexported component, which
|
|
288
|
+
* has no spec name of its own but whose parent's spec DOES import it. An
|
|
289
|
+
* affected set that matches no branch plans nothing; runAffectedTests then
|
|
290
|
+
* reports why (nothing affected, or affected-but-unnarrowable).
|
|
291
|
+
*/
|
|
292
|
+
export function planAffectedRuns(
|
|
293
|
+
outDir: string,
|
|
294
|
+
selectedTasks: TaskId[],
|
|
295
|
+
affected: AffectedSpecs,
|
|
296
|
+
standalone: boolean,
|
|
297
|
+
): PlannedRun[] {
|
|
298
|
+
// No blanket empty-set early return: the unit branch below keys on
|
|
299
|
+
// affected.changedFiles, not components/routes, so vitest `related` must
|
|
300
|
+
// still fire for a change confined to an UNEXPORTED component (no spec name
|
|
301
|
+
// of its own, so components is empty) — related walks the module graph from
|
|
302
|
+
// the file and reaches the parent's spec. An all-empty affected set still
|
|
303
|
+
// matches no branch and returns [] naturally; runAffectedTests then reports
|
|
304
|
+
// why. The old early return made --changed report "nothing affected" and
|
|
305
|
+
// exit 0 green for exactly that change.
|
|
306
|
+
const runs: PlannedRun[] = [];
|
|
307
|
+
const prefix = standalone ? "test" : "flawless";
|
|
308
|
+
|
|
309
|
+
if (selectedTasks.includes("unit-browser") && affected.changedFiles.length > 0) {
|
|
310
|
+
// `related` is a vitest SUBCOMMAND, not a flag, so it cannot be appended to
|
|
311
|
+
// the generated `vitest run` script — vitest exits with "Unknown option
|
|
312
|
+
// --related". Invoking vitest directly with the emitted config is the only
|
|
313
|
+
// way to reach it, and it is worth reaching: it walks the module graph, so
|
|
314
|
+
// it finds every spec that transitively imports a changed file without this
|
|
315
|
+
// tool having to reproduce its own spec-naming rules.
|
|
316
|
+
const configPath = path.join(path.resolve(outDir), VITEST_CONFIG);
|
|
317
|
+
runs.push({
|
|
318
|
+
id: "unit",
|
|
319
|
+
cmd: ["bunx", "vitest", "related", ...affected.changedFiles, "--run", "--config", configPath],
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (affected.components.length > 0) {
|
|
323
|
+
const grep = grepFor(affected.components);
|
|
324
|
+
if (selectedTasks.includes("perf-harness")) {
|
|
325
|
+
runs.push({ id: "perf", cmd: ["bun", "run", `${prefix}:perf`, "--", "--grep", grep] });
|
|
326
|
+
}
|
|
327
|
+
if (selectedTasks.includes("unit-browser") && selectedTasks.includes("perf-harness")) {
|
|
328
|
+
runs.push({ id: "browser", cmd: ["bun", "run", `${prefix}:browser`, "--", "--grep", grep] });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (selectedTasks.includes("e2e") && affected.routes.length > 0) {
|
|
332
|
+
// Same narrowing as perf/browser: every emitted e2e title embeds its route
|
|
333
|
+
// template ("GET /things/[id] renders …"), so the affected routes select
|
|
334
|
+
// their specs by grep — the whole suite must not run for one route.
|
|
335
|
+
runs.push({
|
|
336
|
+
id: "e2e",
|
|
337
|
+
cmd: ["bun", "run", `${prefix}:e2e`, "--", "--grep", grepForRoutes(affected.routes)],
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return runs;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function runAffectedTests(
|
|
344
|
+
outDir: string,
|
|
345
|
+
selectedTasks: TaskId[],
|
|
346
|
+
affected: AffectedSpecs,
|
|
347
|
+
nextRoot?: string,
|
|
348
|
+
quiet = false,
|
|
349
|
+
): Promise<SuiteRunOutcome> {
|
|
350
|
+
const say = sayInto(quiet);
|
|
351
|
+
const standalone = existsSync(path.join(path.resolve(outDir), "package.json"));
|
|
352
|
+
const cwd = standalone ? path.resolve(outDir) : (nextRoot ?? path.resolve(outDir));
|
|
353
|
+
|
|
354
|
+
const runs = planAffectedRuns(outDir, selectedTasks, affected, standalone);
|
|
355
|
+
// FR-042. The old guard demanded runs.length === 0 AND both affected sets
|
|
356
|
+
// empty, so an empty plan WITH affected work fell through the loop below and
|
|
357
|
+
// announced "all affected suites passed" having spawned nothing. Two plain
|
|
358
|
+
// routes reached it: --changed --tasks e2e when the edited component sits on
|
|
359
|
+
// no page route, and --changed --tasks perf-harness when the only affected
|
|
360
|
+
// work is a route. The plan being empty is the whole condition — what made
|
|
361
|
+
// it empty only decides which sentence to say.
|
|
362
|
+
if (runs.length === 0) {
|
|
363
|
+
// withoutSpecs in the "nothing" test is load-bearing: a change confined to
|
|
364
|
+
// unexported components is NOT "nothing affected" — the file changed and
|
|
365
|
+
// is covered through a parent's spec. It is simply not reachable by a
|
|
366
|
+
// task set that narrows by spec name.
|
|
367
|
+
const nothingAffected =
|
|
368
|
+
affected.components.length === 0 &&
|
|
369
|
+
affected.routes.length === 0 &&
|
|
370
|
+
affected.withoutSpecs.length === 0;
|
|
371
|
+
let reason: string;
|
|
372
|
+
if (nothingAffected) {
|
|
373
|
+
reason = "changed: nothing affected — no specs to run";
|
|
374
|
+
} else if (affected.components.length === 0 && affected.routes.length === 0) {
|
|
375
|
+
reason = `changed: ${affected.withoutSpecs.length} unexported component(s) changed (covered through a parent's spec), but the selected task(s) (${selectedTasks.join(", ")}) narrow by spec name and cannot reach them — add unit-browser to run vitest \`related\``;
|
|
376
|
+
} else {
|
|
377
|
+
reason = `changed: ${affected.components.length} affected component(s) and ${affected.routes.length} affected route(s), but the selected task(s) (${selectedTasks.join(", ")}) plan no run for them`;
|
|
378
|
+
}
|
|
379
|
+
say(`\n${reason}\n`);
|
|
380
|
+
return { ran: 0, green: true, reason };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Same repair as the full run: vitest is the plane that pays for two
|
|
384
|
+
// physical Reacts, and --changed runs it just as hard.
|
|
385
|
+
if (standalone && runs.some((r) => r.id === "unit")) {
|
|
386
|
+
const healed = healReactIdentity(path.resolve(outDir));
|
|
387
|
+
if (healed.length > 0) say(`\n› healed dual-React copies: ${healed.join(", ")}\n`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const failed: string[] = [];
|
|
391
|
+
for (const r of runs) {
|
|
392
|
+
say(`\n› ${r.cmd.join(" ")}\n`);
|
|
393
|
+
const ok = await spawn(r.cmd[0] as string, r.cmd.slice(1), cwd, quiet);
|
|
394
|
+
if (!ok) failed.push(r.id);
|
|
395
|
+
}
|
|
396
|
+
if (failed.length > 0) {
|
|
397
|
+
say(`\nfailed suites: ${failed.join(", ")}\n`);
|
|
398
|
+
return { ran: runs.length, green: false };
|
|
399
|
+
}
|
|
400
|
+
say("\nall affected suites passed\n");
|
|
401
|
+
return { ran: runs.length, green: true };
|
|
402
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Make generated files conform to the HOST project's formatter so flawless
|
|
7
|
+
* output never churns a target repo's lint/format state. Detection walks up
|
|
8
|
+
* from the project root: biome config → run the project's own biome;
|
|
9
|
+
* prettier config → run prettier; neither → leave files untouched.
|
|
10
|
+
* Failures are warnings, never task failures.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface AdoptStyleResult {
|
|
14
|
+
formatter: "biome" | "prettier" | "none";
|
|
15
|
+
formatted: number;
|
|
16
|
+
warning?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// .html is deliberately excluded: biome's HTML formatting is not stable enough
|
|
20
|
+
// to churn the emitted host/index.html.
|
|
21
|
+
const FORMATTABLE = /\.(tsx?|jsx?|mts|cts|mjs|cjs|jsonc?|css|md)$/;
|
|
22
|
+
|
|
23
|
+
export async function adoptProjectStyle(
|
|
24
|
+
projectRoot: string,
|
|
25
|
+
writtenFiles: string[],
|
|
26
|
+
): Promise<AdoptStyleResult> {
|
|
27
|
+
const files = writtenFiles.filter((f) => FORMATTABLE.test(f) && existsSync(f));
|
|
28
|
+
if (files.length === 0) return { formatter: "none", formatted: 0 };
|
|
29
|
+
|
|
30
|
+
const biomeDir = findUp(projectRoot, ["biome.json", "biome.jsonc"]);
|
|
31
|
+
if (biomeDir) {
|
|
32
|
+
const bin = resolveLocalBin(biomeDir.dir, "biome") ?? Bun.which("biome");
|
|
33
|
+
if (bin) {
|
|
34
|
+
const proc = Bun.spawn([bin, "check", "--write", "--no-errors-on-unmatched", ...files], {
|
|
35
|
+
cwd: biomeDir.dir,
|
|
36
|
+
stdout: "pipe",
|
|
37
|
+
stderr: "pipe",
|
|
38
|
+
});
|
|
39
|
+
const code = await proc.exited;
|
|
40
|
+
if (code === 0 || code === 1) {
|
|
41
|
+
// biome exits 1 when unfixable diagnostics remain — files are still formatted.
|
|
42
|
+
return { formatter: "biome", formatted: files.length };
|
|
43
|
+
}
|
|
44
|
+
const stderr = await new Response(proc.stderr).text();
|
|
45
|
+
return {
|
|
46
|
+
formatter: "biome",
|
|
47
|
+
formatted: 0,
|
|
48
|
+
warning: `biome check --write exited ${code}: ${stderr.slice(0, 300)}`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { formatter: "biome", formatted: 0, warning: "biome config found but no biome binary" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const prettierDir = findUp(projectRoot, [
|
|
55
|
+
".prettierrc",
|
|
56
|
+
".prettierrc.json",
|
|
57
|
+
".prettierrc.js",
|
|
58
|
+
".prettierrc.cjs",
|
|
59
|
+
".prettierrc.yaml",
|
|
60
|
+
".prettierrc.yml",
|
|
61
|
+
"prettier.config.js",
|
|
62
|
+
"prettier.config.cjs",
|
|
63
|
+
]);
|
|
64
|
+
const hasPrettierConfig = prettierDir !== undefined || packageJsonHasPrettier(projectRoot);
|
|
65
|
+
if (hasPrettierConfig) {
|
|
66
|
+
const cwd = prettierDir?.dir ?? projectRoot;
|
|
67
|
+
const bin = resolveLocalBin(cwd, "prettier") ?? Bun.which("prettier");
|
|
68
|
+
const cmd = bin ? [bin] : ["bunx", "prettier"];
|
|
69
|
+
// Ignore files govern the repo's sweeps, not the tool's own conformance pass.
|
|
70
|
+
// prettier skips ignored files even when they are passed explicitly (proven on
|
|
71
|
+
// 3.0.3), so once a run appends `flawless/` to the repo's .prettierignore the
|
|
72
|
+
// format pass would silently stop formatting the suite it just wrote — and
|
|
73
|
+
// any pre-existing repo ignore covering an output directory already had that
|
|
74
|
+
// effect. An empty ignore path keeps the pass alive for exactly the files the
|
|
75
|
+
// tool passed; the repo's own ignore still owns the repo's own sweeps.
|
|
76
|
+
const ignoreOverride = path.join(os.tmpdir(), "flawless-empty.prettierignore");
|
|
77
|
+
writeFileSync(ignoreOverride, "");
|
|
78
|
+
const proc = Bun.spawn(
|
|
79
|
+
[...cmd, "--write", "--ignore-unknown", "--ignore-path", ignoreOverride, ...files],
|
|
80
|
+
{
|
|
81
|
+
cwd,
|
|
82
|
+
stdout: "pipe",
|
|
83
|
+
stderr: "pipe",
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
const code = await proc.exited;
|
|
87
|
+
if (code === 0) return { formatter: "prettier", formatted: files.length };
|
|
88
|
+
const stderr = await new Response(proc.stderr).text();
|
|
89
|
+
// Syntax the repo's pinned prettier predates - `with { type: "json" }`
|
|
90
|
+
// import attributes need prettier 3.1, and the tool's specs use them.
|
|
91
|
+
// Those files are still valid TS the app's own toolchain accepts; the
|
|
92
|
+
// honest degrade is to skip THOSE and format the rest, not to abandon
|
|
93
|
+
// the whole pass. (Repo CI never sees any of them: the ignore files
|
|
94
|
+
// say so.)
|
|
95
|
+
// Normalize both sides: prettier reports paths relative to its cwd,
|
|
96
|
+
// while the caller's list may be absolute.
|
|
97
|
+
const unparseable = new Set<string>();
|
|
98
|
+
for (const m of stderr.matchAll(/^\[error\] (\S+):/gm)) {
|
|
99
|
+
if (m[1] !== undefined) unparseable.add(path.resolve(cwd, m[1]));
|
|
100
|
+
}
|
|
101
|
+
if (/SyntaxError/.test(stderr) && unparseable.size > 0 && unparseable.size < files.length) {
|
|
102
|
+
const rest = files.filter((f) => !unparseable.has(path.resolve(cwd, f)));
|
|
103
|
+
if (rest.length > 0) {
|
|
104
|
+
const retry = Bun.spawn(
|
|
105
|
+
[...cmd, "--write", "--ignore-unknown", "--ignore-path", ignoreOverride, ...rest],
|
|
106
|
+
{ cwd, stdout: "pipe", stderr: "pipe" },
|
|
107
|
+
);
|
|
108
|
+
if ((await retry.exited) === 0) {
|
|
109
|
+
return {
|
|
110
|
+
formatter: "prettier",
|
|
111
|
+
formatted: rest.length,
|
|
112
|
+
warning: `prettier could not parse ${unparseable.size} generated file(s) - its version predates their syntax; the remaining ${rest.length} were formatted`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (/SyntaxError/.test(stderr)) {
|
|
118
|
+
return {
|
|
119
|
+
formatter: "prettier",
|
|
120
|
+
formatted: 0,
|
|
121
|
+
warning: `prettier could not parse the generated files (its version predates their syntax) - style pass skipped: ${stderr.slice(0, 160)}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
formatter: "prettier",
|
|
126
|
+
formatted: 0,
|
|
127
|
+
warning: `prettier --write exited ${code}: ${stderr.slice(0, 300)}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { formatter: "none", formatted: 0 };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Detect a JSON file's indentation so merges preserve it (tabs beat spaces). */
|
|
135
|
+
export function detectJsonIndent(source: string): string {
|
|
136
|
+
const m = source.match(/^(\t+|[ ]+)"/m);
|
|
137
|
+
if (!m?.[1]) return " ";
|
|
138
|
+
return m[1].startsWith("\t") ? "\t" : " ".repeat(m[1].length);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Stringify preserving the original file's indentation and final newline. */
|
|
142
|
+
export function stringifyLike(original: string, value: unknown): string {
|
|
143
|
+
const indent = detectJsonIndent(original);
|
|
144
|
+
const trailingNewline = original.endsWith("\n") ? "\n" : "";
|
|
145
|
+
return `${JSON.stringify(value, null, indent)}${trailingNewline}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function findUp(fromDir: string, names: string[]): { dir: string; file: string } | undefined {
|
|
149
|
+
let dir = path.resolve(fromDir);
|
|
150
|
+
for (;;) {
|
|
151
|
+
for (const name of names) {
|
|
152
|
+
const candidate = path.join(dir, name);
|
|
153
|
+
if (existsSync(candidate)) return { dir, file: candidate };
|
|
154
|
+
}
|
|
155
|
+
const parent = path.dirname(dir);
|
|
156
|
+
if (parent === dir) return undefined;
|
|
157
|
+
dir = parent;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveLocalBin(fromDir: string, name: string): string | undefined {
|
|
162
|
+
let dir = path.resolve(fromDir);
|
|
163
|
+
for (;;) {
|
|
164
|
+
const candidate = path.join(dir, "node_modules", ".bin", name);
|
|
165
|
+
if (existsSync(candidate)) return candidate;
|
|
166
|
+
const parent = path.dirname(dir);
|
|
167
|
+
if (parent === dir) return undefined;
|
|
168
|
+
dir = parent;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function packageJsonHasPrettier(projectRoot: string): boolean {
|
|
173
|
+
const found = findUp(projectRoot, ["package.json"]);
|
|
174
|
+
if (!found) return false;
|
|
175
|
+
try {
|
|
176
|
+
const pkg = JSON.parse(readFileSync(found.file, "utf8")) as Record<string, unknown>;
|
|
177
|
+
return pkg.prettier !== undefined;
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|