@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,301 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdtemp, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import type { Finding, TaskId } from "../types.ts";
7
+
8
+ interface BiomeDiagnostic {
9
+ severity?: string;
10
+ message?: string;
11
+ category?: string;
12
+ location?: { path?: string; start?: { line?: number; column?: number } };
13
+ }
14
+
15
+ interface BiomeResult {
16
+ diagnostics?: BiomeDiagnostic[];
17
+ }
18
+
19
+ export interface RunBiomeOptions {
20
+ files: string[];
21
+ source: TaskId;
22
+ /** Biome flat rule config under linter.rules. */
23
+ rules: unknown;
24
+ /** Optional rule-category prefix filter, e.g. "lint/a11y". When set, findings
25
+ * outside this prefix are dropped (so an a11y task doesn't surface
26
+ * correctness rules its config didn't enable). */
27
+ categoryPrefix?: string;
28
+ }
29
+
30
+ /** Where the biome executable might be, in order of preference.
31
+ *
32
+ * This used to check exactly one path — `findProjectRoot()/node_modules/.bin/biome` — which
33
+ * is correct for a source install (biome is a dependency, so it sits in our own
34
+ * node_modules) and wrong everywhere else.
35
+ *
36
+ * It matters because flawless-react is also shipped as a `bun build --compile` binary. In a
37
+ * compiled binary `import.meta.url` points into Bun's virtual filesystem, so findProjectRoot()
38
+ * walks six levels of a path that does not exist on disk and falls back to `process.cwd()` —
39
+ * the user's project, which almost never has biome. The result was that the two biome-backed
40
+ * tasks (a11y, react-rules) hard-failed in the binary while the other three worked.
41
+ *
42
+ * Checking a few plausible locations makes the binary useful whenever biome is reachable at
43
+ * all, and keeps the source install behaving exactly as before.
44
+ *
45
+ * The walk UP from the analysed project is not decoration. A monorepo hoists its dev
46
+ * dependencies to the workspace root, so `apps/web/node_modules/.bin/biome` does not exist
47
+ * while `<root>/node_modules/.bin/biome` does — and a monorepo app is the shape this tool is
48
+ * most often pointed at. Checking only the scanned package left a11y and react-rules dead in
49
+ * the compiled binary on exactly that layout, including on this project's own dogfood app,
50
+ * where every previous run had been made with the SOURCE install and therefore resolved
51
+ * biome from flawless-react's own node_modules instead. Two of seven tasks were failing in
52
+ * the shipped artifact and passing in the one used to test it. */
53
+ export function biomeCandidates(from: string = process.cwd()): string[] {
54
+ return [
55
+ // 1. Our own node_modules. This is the source-install case and stays first, so a
56
+ // source install always uses the biome version pinned in our package.json.
57
+ path.join(findProjectRoot(), "node_modules", ".bin", "biome"),
58
+ // 2. The project being analysed, then every ancestor — node's own resolution order,
59
+ // which is what makes a hoisted workspace root reachable.
60
+ ...nodeModulesBinCandidates(from, "biome"),
61
+ ];
62
+ }
63
+
64
+ /** The biome major whose linter config `runBiome` writes and whose JSON reporter
65
+ * it parses. Not a preference — the config below is spelled for it. */
66
+ const CONFIG_BIOME_MAJOR = "2";
67
+
68
+ /** The resolver's answer: a usable biome, or the one it refused, or nothing. */
69
+ export type BiomeBin = { bin: string } | { tooOld: { bin: string; version: string } };
70
+
71
+ /**
72
+ * The first biome that can read the config these sweeps write.
73
+ *
74
+ * The ladder walks up from the ANALYSED project, and a compiled binary resolves
75
+ * there (its own pinned biome lives inside the artifact, not on disk), so what
76
+ * it finds is whatever the user's repo happens to depend on. On
77
+ * code-forge-io/react-router-devtools that is a hoisted biome 1.9.4 — and the
78
+ * sweep died there on `The "paths[0]" property must be of type string, got
79
+ * object`: biome 1.x reports `location.path` as an object (`{ file: <relative> }`)
80
+ * and `message` as an array of console-markup runs, while the parser reads both
81
+ * as strings. a11y and react-rules reported `files:0 findings:0` on that shape —
82
+ * not clean, dead, and only in the shipped artifact, because a source install
83
+ * answers with our own pinned 2.x first and never sees the repo's biome.
84
+ *
85
+ * So ask each candidate its version and keep walking. A version that cannot be
86
+ * read (a stub, a wrapper, a build whose `--version` output we do not
87
+ * recognise) is accepted as before: only a biome PROVEN to be another major is
88
+ * refused, and the refusal names it. Walking past the refusal is what lets a
89
+ * 2.x further up the tree win over a 1.x nearer the scanned package.
90
+ */
91
+ export function pickBiome(candidates: string[]): BiomeBin | null {
92
+ let tooOld: { bin: string; version: string } | undefined;
93
+ for (const bin of candidates) {
94
+ if (!existsSync(bin)) continue;
95
+ const version = biomeVersion(bin);
96
+ if (version && version.split(".")[0] !== CONFIG_BIOME_MAJOR) {
97
+ tooOld ??= { bin, version };
98
+ continue;
99
+ }
100
+ return { bin };
101
+ }
102
+ return tooOld ? { tooOld } : null;
103
+ }
104
+
105
+ /** Every place a biome might be, resolution order: ours, the project's, its
106
+ * ancestors, then PATH. */
107
+ export function biomeLadder(from: string = process.cwd()): string[] {
108
+ const list = biomeCandidates(from);
109
+ try {
110
+ const onPath = Bun.which("biome");
111
+ if (onPath && !list.includes(onPath)) list.push(onPath);
112
+ } catch {
113
+ // Bun.which is unavailable under plain Node; the candidates above are the answer there.
114
+ }
115
+ return list;
116
+ }
117
+
118
+ /** What a refused biome means to the person running the sweep. Exported so the
119
+ * wording — the version, the path, and the fix — is pinned by a test. */
120
+ export function biomeUnusableMessage(found: { bin: string; version: string }): string {
121
+ return (
122
+ `biome ${found.version} at ${found.bin} is not the biome major these sweeps are written ` +
123
+ `for: they write a biome ${CONFIG_BIOME_MAJOR}.x linter config and parse its JSON ` +
124
+ `reporter, and biome ${found.version} spells both differently — its output cannot be ` +
125
+ "read as this task's findings. This is a hard failure, not a clean bill of health.\n" +
126
+ ` Fix: bun add -d @biomejs/biome@^${CONFIG_BIOME_MAJOR} in the project being scanned, ` +
127
+ `or put a matching biome on PATH. A source or npm install of flawless-react uses its ` +
128
+ `own pinned biome ${CONFIG_BIOME_MAJOR}.x and is unaffected.`
129
+ );
130
+ }
131
+
132
+ /** `Version: 2.5.1` → "2.5.1"; null when the binary will not say, or says
133
+ * something we cannot read as a version. */
134
+ function biomeVersion(bin: string): string | null {
135
+ try {
136
+ const out = Bun.spawnSync({ cmd: [bin, "--version"], stdout: "pipe", stderr: "pipe" });
137
+ return /(\d+\.\d+\.\d+)/.exec(out.stdout?.toString() ?? "")?.[1] ?? null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ function resolveBiomeBin(): BiomeBin | null {
144
+ return pickBiome(biomeLadder());
145
+ }
146
+
147
+ /** `<dir>/node_modules/.bin/<bin>` for dir and every ancestor, nearest first. */
148
+ export function nodeModulesBinCandidates(from: string, bin: string): string[] {
149
+ const out: string[] = [];
150
+ let dir = path.resolve(from);
151
+ for (;;) {
152
+ out.push(path.join(dir, "node_modules", ".bin", bin));
153
+ const parent = path.dirname(dir);
154
+ if (parent === dir) return out;
155
+ dir = parent;
156
+ }
157
+ }
158
+
159
+ export async function runBiome(opts: RunBiomeOptions): Promise<Finding[]> {
160
+ if (opts.files.length === 0) return [];
161
+ const found = resolveBiomeBin();
162
+ if (!found) {
163
+ throw new Error(
164
+ "biome executable not found. Looked in this package's node_modules, in " +
165
+ `${path.join(process.cwd(), "node_modules", ".bin", "biome")} and every ancestor ` +
166
+ "(so a hoisted workspace root counts), and on PATH.\n" +
167
+ " Source install: bun install\n" +
168
+ " Compiled binary: install biome in the project you are scanning " +
169
+ "(bun add -d @biomejs/biome) or put it on PATH.",
170
+ );
171
+ }
172
+ if ("tooOld" in found) throw new Error(biomeUnusableMessage(found.tooOld));
173
+ const biomeBin = found.bin;
174
+
175
+ const tmp = await mkdtemp(path.join(tmpdir(), "fhr-biome-"));
176
+ const configPath = path.join(tmp, "biome.json");
177
+ await writeFile(
178
+ configPath,
179
+ JSON.stringify(
180
+ {
181
+ $schema: "https://biomejs.dev/schemas/2.3.13/schema.json",
182
+ linter: { enabled: true, rules: opts.rules },
183
+ },
184
+ null,
185
+ 2,
186
+ ),
187
+ );
188
+
189
+ const proc = Bun.spawn(
190
+ [biomeBin, "lint", "--config-path", tmp, "--reporter=json", ...opts.files],
191
+ { stdout: "pipe", stderr: "pipe" },
192
+ );
193
+ const stdout = await new Response(proc.stdout).text();
194
+ const stderr = await new Response(proc.stderr).text();
195
+ await proc.exited;
196
+
197
+ const jsonStart = stdout.indexOf("{");
198
+ if (jsonStart < 0) {
199
+ throw new Error(`biome produced no JSON output. stderr:\n${stderr.slice(0, 2000)}`);
200
+ }
201
+ const jsonEnd = findMatchingBraceEnd(stdout, jsonStart);
202
+ if (jsonEnd < 0) throw new Error("biome JSON output appears truncated");
203
+ const parsed = JSON.parse(stdout.slice(jsonStart, jsonEnd)) as BiomeResult;
204
+
205
+ const wantedFiles = new Set(opts.files.map((f) => path.resolve(f)));
206
+ const findings: Finding[] = [];
207
+ for (const d of parsed.diagnostics ?? []) {
208
+ const p = d.location?.path;
209
+ if (!p) continue;
210
+ if (!wantedFiles.has(path.resolve(p))) continue;
211
+ // A file biome could not PARSE produces `parse` diagnostics, which a
212
+ // category filter ("lint/a11y") dropped on the floor — so an unreadable
213
+ // file was reported as clean by every sweep. A file the analyser could
214
+ // not read is never a pass; the diagnostic rides through the filter.
215
+ const isParseFailure = d.category === "parse" || d.category?.startsWith("parse/");
216
+ if (!isParseFailure && opts.categoryPrefix && !d.category?.startsWith(opts.categoryPrefix)) {
217
+ continue;
218
+ }
219
+ // The sweep runs against a scratch config with only its own rules, so a
220
+ // suppression aimed at the project's full config looks "unused" here.
221
+ // Reporting that would punish the user for answering another task's
222
+ // finding — suppression hygiene belongs to the project's own biome run.
223
+ if (d.category?.startsWith("suppressions/")) continue;
224
+ findings.push({
225
+ rule: d.category ?? "biome",
226
+ severity: d.severity === "error" ? "error" : "warn",
227
+ file: path.resolve(p),
228
+ line: d.location?.start?.line ?? 1,
229
+ column: d.location?.start?.column ?? 1,
230
+ message: d.message ?? "",
231
+ source: opts.source,
232
+ });
233
+ }
234
+ return stableFindingOrder(findings);
235
+ }
236
+
237
+ /**
238
+ * Biome emits its JSON diagnostics in worker-completion order, not source
239
+ * order: five identical `biome lint` runs over the same 120 files returned
240
+ * the same 365 diagnostics in five different orders (same-file and
241
+ * cross-file swaps both observed). Every report this feeds — the aggregate
242
+ * `findingsByFile` object and each per-component report — is written to disk
243
+ * and must be byte-identical across runs or a second regen churns the suite
244
+ * on pure ordering. Normalise here, at the one choke point both biome-backed
245
+ * tasks pass through, so every downstream writer inherits the determinism.
246
+ * (file, line, column, rule, severity, message, source) is a total order for
247
+ * JSON purposes: two diagnostics that tie on every key are identical objects,
248
+ * so their mutual order cannot change a byte of output.
249
+ */
250
+ export function stableFindingOrder(findings: Finding[]): Finding[] {
251
+ return [...findings].sort((a, b) => {
252
+ if (a.file !== b.file) return a.file < b.file ? -1 : 1;
253
+ if (a.line !== b.line) return a.line - b.line;
254
+ if (a.column !== b.column) return a.column - b.column;
255
+ if (a.rule !== b.rule) return a.rule < b.rule ? -1 : 1;
256
+ if (a.severity !== b.severity) return a.severity < b.severity ? -1 : 1;
257
+ if (a.message !== b.message) return a.message < b.message ? -1 : 1;
258
+ if (a.source !== b.source) return a.source < b.source ? -1 : 1;
259
+ return 0;
260
+ });
261
+ }
262
+
263
+ function findMatchingBraceEnd(s: string, openIdx: number): number {
264
+ let depth = 0;
265
+ let inStr = false;
266
+ let escaped = false;
267
+ for (let i = openIdx; i < s.length; i++) {
268
+ const ch = s[i];
269
+ if (escaped) {
270
+ escaped = false;
271
+ continue;
272
+ }
273
+ if (ch === "\\") {
274
+ escaped = true;
275
+ continue;
276
+ }
277
+ if (ch === '"') {
278
+ inStr = !inStr;
279
+ continue;
280
+ }
281
+ if (inStr) continue;
282
+ if (ch === "{") depth++;
283
+ else if (ch === "}") {
284
+ depth--;
285
+ if (depth === 0) return i + 1;
286
+ }
287
+ }
288
+ return -1;
289
+ }
290
+
291
+ function findProjectRoot(): string {
292
+ const here = fileURLToPath(import.meta.url);
293
+ let dir = path.dirname(here);
294
+ for (let i = 0; i < 6; i++) {
295
+ if (existsSync(path.join(dir, "package.json"))) return dir;
296
+ const parent = path.dirname(dir);
297
+ if (parent === dir) break;
298
+ dir = parent;
299
+ }
300
+ return process.cwd();
301
+ }
@@ -0,0 +1,166 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import type { ComponentTree, Finding, RunContext, TaskId, TaskResult } from "../types.ts";
4
+ import { runBiome } from "./biomeRunner.ts";
5
+
6
+ export interface BiomeSweepOptions {
7
+ id: TaskId;
8
+ ctx: RunContext;
9
+ /** Biome flat rule config under linter.rules. */
10
+ rules: unknown;
11
+ /** Forwarded to runBiome; a11y filters to "lint/a11y", react-rules does not filter. */
12
+ categoryPrefix?: string;
13
+ /** Basename of the single aggregate report, e.g. "a11y.json". */
14
+ aggregateFile: string;
15
+ /** Free-text note copied into every report this task writes. */
16
+ note?: string;
17
+ }
18
+
19
+ /** Every source file reachable from a single tree, absolute and deduped, entry first. */
20
+ export function treeFilePaths(tree: ComponentTree): string[] {
21
+ const out: string[] = [];
22
+ collectInto(tree, out, new Set<string>(), new Set<ComponentTree>());
23
+ return out;
24
+ }
25
+
26
+ /** Every source file reachable from *all* trees, absolute and deduped.
27
+ *
28
+ * Deduping across trees (rather than per tree) is the whole point: in a whole-app sweep
29
+ * the same leaf component is imported by dozens of roots, so a per-tree file list lints
30
+ * shared files once per importer. Paths are resolved before comparison so two trees that
31
+ * reached the same file by different spellings collapse to one entry. */
32
+ export function dedupeTreeFiles(trees: ComponentTree[]): string[] {
33
+ const out: string[] = [];
34
+ const seen = new Set<string>();
35
+ const visited = new Set<ComponentTree>();
36
+ for (const tree of trees) collectInto(tree, out, seen, visited);
37
+ return out;
38
+ }
39
+
40
+ /** Findings keyed by absolute file path, in first-seen order. */
41
+ export function groupFindingsByFile(findings: Finding[]): Map<string, Finding[]> {
42
+ const byFile = new Map<string, Finding[]>();
43
+ for (const f of findings) {
44
+ const key = path.resolve(f.file);
45
+ const list = byFile.get(key);
46
+ if (list) list.push(f);
47
+ else byFile.set(key, [f]);
48
+ }
49
+ return byFile;
50
+ }
51
+
52
+ export interface ComponentFindings {
53
+ tree: ComponentTree;
54
+ findings: Finding[];
55
+ }
56
+
57
+ /** Trees that own at least one finding, with the findings from their whole subtree.
58
+ *
59
+ * Attribution is by file, not by component, because a file's diagnostics belong to every
60
+ * component analysed out of it — two roots declared in one file both want to see them.
61
+ * Trees with nothing to report are dropped here rather than at the write site, so the
62
+ * "no findings, no report" rule is testable without running biome. */
63
+ export function findingsByComponent(
64
+ trees: ComponentTree[],
65
+ byFile: Map<string, Finding[]>,
66
+ ): ComponentFindings[] {
67
+ const out: ComponentFindings[] = [];
68
+ for (const tree of trees) {
69
+ const findings: Finding[] = [];
70
+ for (const file of treeFilePaths(tree)) {
71
+ const list = byFile.get(file);
72
+ if (list) findings.push(...list);
73
+ }
74
+ if (findings.length > 0) out.push({ tree, findings });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Lint every file behind every tree in one biome process, then fan the findings back out
80
+ * to the components that own each file. */
81
+ export async function runBiomeSweep(opts: BiomeSweepOptions): Promise<TaskResult> {
82
+ const { ctx, id } = opts;
83
+ const reportsDir = path.join(ctx.outDir, "reports");
84
+ await mkdir(reportsDir, { recursive: true });
85
+ const files = dedupeTreeFiles(ctx.trees);
86
+
87
+ let findings: Finding[];
88
+ try {
89
+ // One process for the entire sweep. This used to be a per-tree loop, which on a
90
+ // whole-app run (~175 trees) spawned ~175 biome processes per task. Passing the file
91
+ // set as argv is safe at that size: ~100 absolute paths is a few KB, three orders of
92
+ // magnitude under ARG_MAX, so do not "optimise" this back into batches.
93
+ findings = await runBiome({
94
+ files,
95
+ source: id,
96
+ rules: opts.rules,
97
+ categoryPrefix: opts.categoryPrefix,
98
+ });
99
+ } catch (err) {
100
+ return {
101
+ id,
102
+ ok: false,
103
+ outputs: [],
104
+ findings: [],
105
+ summary: `${id} scan failed`,
106
+ error: (err as Error).message,
107
+ };
108
+ }
109
+
110
+ const byFile = groupFindingsByFile(findings);
111
+ const written: string[] = [];
112
+
113
+ const aggregatePath = path.join(reportsDir, opts.aggregateFile);
114
+ const aggregate = {
115
+ task: id,
116
+ ...(opts.note ? { note: opts.note } : {}),
117
+ filesScanned: files.length,
118
+ findingCount: findings.length,
119
+ findingsByFile: Object.fromEntries(byFile),
120
+ };
121
+ await Bun.write(aggregatePath, `${JSON.stringify(aggregate, null, 2)}\n`);
122
+ written.push(aggregatePath);
123
+
124
+ // Per-component reports only for components that actually have something to report.
125
+ // A whole-app sweep covers ~175 components; a near-empty JSON for each of them buries
126
+ // the handful that matter, and the aggregate above already answers "was it scanned?".
127
+ for (const owner of findingsByComponent(ctx.trees, byFile)) {
128
+ const out = path.join(reportsDir, `${owner.tree.entry.componentName}.${id}.json`);
129
+ const report = {
130
+ component: owner.tree.entry.componentName,
131
+ file: owner.tree.entry.filePath,
132
+ ...(opts.note ? { note: opts.note } : {}),
133
+ findings: owner.findings,
134
+ };
135
+ await Bun.write(out, `${JSON.stringify(report, null, 2)}\n`);
136
+ written.push(out);
137
+ }
138
+
139
+ return {
140
+ id,
141
+ ok: true,
142
+ outputs: written,
143
+ findings,
144
+ summary: `${id}: ${findings.length} finding(s) across ${files.length} file(s)`,
145
+ };
146
+ }
147
+
148
+ function collectInto(
149
+ tree: ComponentTree,
150
+ out: string[],
151
+ seen: Set<string>,
152
+ visited: Set<ComponentTree>,
153
+ ): void {
154
+ // Two guards, because they answer different questions. `visited` stops runaway recursion
155
+ // if a caller ever hands us a tree whose children loop back; `seen` dedupes paths without
156
+ // pruning the walk, since a file already reached through one root can still be the only
157
+ // route to children another root has not seen yet.
158
+ if (visited.has(tree)) return;
159
+ visited.add(tree);
160
+ const file = path.resolve(tree.entry.filePath);
161
+ if (!seen.has(file)) {
162
+ seen.add(file);
163
+ out.push(file);
164
+ }
165
+ for (const child of tree.children) collectInto(child, out, seen, visited);
166
+ }