@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,463 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { UnresolvableImport } from "../analyse/bootability.ts";
|
|
3
|
+
import { flattenTrees, hostEligibility } from "../scaffold-tests/registry.ts";
|
|
4
|
+
import type { ComponentTree, TaskId, TaskResult, TodoKind } from "../types.ts";
|
|
5
|
+
import { VERSION } from "../version.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A human-readable account of what the run covered and what it did not.
|
|
9
|
+
*
|
|
10
|
+
* The number that matters is not "175 components analysed" — that is a file
|
|
11
|
+
* count wearing a suit. It is whether every component the analyser found ended
|
|
12
|
+
* up somewhere you can point at: a spec, or a stated reason it has none. This
|
|
13
|
+
* file is what makes that checkable, and its component table is arranged so the
|
|
14
|
+
* totals have to add up.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately timestamp-free and fully sorted. REPORT.md is committed, so a
|
|
17
|
+
* clock in it would churn `git status` on every run and defeat the idempotence
|
|
18
|
+
* check that catches non-deterministic emission.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface UnparseableFile {
|
|
22
|
+
file: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReportInput {
|
|
27
|
+
trees: ComponentTree[];
|
|
28
|
+
results: TaskResult[];
|
|
29
|
+
outDir: string;
|
|
30
|
+
/** Repo root, so paths read as they do in an editor. */
|
|
31
|
+
projectRoot?: string;
|
|
32
|
+
/** Files the analyser could not read a component out of. */
|
|
33
|
+
unparseable?: UnparseableFile[];
|
|
34
|
+
/**
|
|
35
|
+
* Packages the analysed sources import that the suite cannot resolve, so the
|
|
36
|
+
* specs that import them will fail at collect (see analyse/bootability.ts).
|
|
37
|
+
* The section renders only when something is in here.
|
|
38
|
+
*/
|
|
39
|
+
unresolvable?: UnresolvableImport[];
|
|
40
|
+
/**
|
|
41
|
+
* A command that reproduces this run.
|
|
42
|
+
*
|
|
43
|
+
* Rebuilt from the run's options rather than captured from argv, so it is the
|
|
44
|
+
* same line for a programmatic caller as for the CLI and carries no absolute
|
|
45
|
+
* path — this file is committed, and a path under one machine's root churns
|
|
46
|
+
* `git status` on every other. See reproCommand in core.ts.
|
|
47
|
+
*/
|
|
48
|
+
command?: string;
|
|
49
|
+
/** Every task id the tool has, so a partial run can say that it was partial. */
|
|
50
|
+
allTaskIds?: readonly string[];
|
|
51
|
+
/**
|
|
52
|
+
* flawless:* scripts kept because their package.json value is not one the
|
|
53
|
+
* tool recognizes as its own. In here — the committed artifact — because
|
|
54
|
+
* the run's log line is invisible under --ci and programmatic use.
|
|
55
|
+
*/
|
|
56
|
+
keptScripts?: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderReportMarkdown(input: ReportInput): string {
|
|
60
|
+
const root = input.projectRoot ?? input.outDir;
|
|
61
|
+
const rel = (p: string): string => {
|
|
62
|
+
const r = path.relative(root, p);
|
|
63
|
+
if (!r.startsWith("..")) return r;
|
|
64
|
+
// Without --nextjs, root is outDir — but colocated specs live in the
|
|
65
|
+
// project tree BESIDE it, so they escape. Its parent is the tree both
|
|
66
|
+
// share; relative to that they stay readable and machine-independent.
|
|
67
|
+
const rp = path.relative(path.dirname(root), p);
|
|
68
|
+
if (!rp.startsWith("..")) return rp;
|
|
69
|
+
// This file is committed: an absolute path is forbidden because it names
|
|
70
|
+
// one machine's checkout and churns `git status` on every other. A path
|
|
71
|
+
// outside even the parent degrades to its basename instead.
|
|
72
|
+
return path.basename(p);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const flat = flattenTrees(input.trees);
|
|
76
|
+
const byMode = new Map<string, { hosted: number; excluded: number }>();
|
|
77
|
+
const excluded: Array<{ name: string; file: string; reason: string }> = [];
|
|
78
|
+
|
|
79
|
+
for (const c of flat) {
|
|
80
|
+
const mode = c.info.renderMode ?? (c.info.isAsync ? "rsc" : "client");
|
|
81
|
+
const row = byMode.get(mode) ?? { hosted: 0, excluded: 0 };
|
|
82
|
+
const verdict = hostEligibility(c.info);
|
|
83
|
+
if (verdict.eligible) row.hosted += 1;
|
|
84
|
+
else {
|
|
85
|
+
row.excluded += 1;
|
|
86
|
+
excluded.push({
|
|
87
|
+
name: c.info.componentName,
|
|
88
|
+
file: rel(c.info.filePath),
|
|
89
|
+
reason: verdict.reason ?? "not eligible",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
byMode.set(mode, row);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const unparseable = [...(input.unparseable ?? [])].sort((a, b) => (a.file < b.file ? -1 : 1));
|
|
96
|
+
const hostedTotal = [...byMode.values()].reduce((n, r) => n + r.hosted, 0);
|
|
97
|
+
const excludedTotal = [...byMode.values()].reduce((n, r) => n + r.excluded, 0);
|
|
98
|
+
|
|
99
|
+
const lines: string[] = [
|
|
100
|
+
"# flawless-react run report",
|
|
101
|
+
"",
|
|
102
|
+
`Generated by flawless-react ${VERSION}. This file is regenerated on every run;`,
|
|
103
|
+
"edit the specs, not this.",
|
|
104
|
+
"",
|
|
105
|
+
];
|
|
106
|
+
if (input.command) {
|
|
107
|
+
// Paths in it are relative to the same directory the file paths below are,
|
|
108
|
+
// so it only reproduces the run when run from there.
|
|
109
|
+
lines.push(
|
|
110
|
+
"Reproduce this run from the project root:",
|
|
111
|
+
"",
|
|
112
|
+
"```bash",
|
|
113
|
+
input.command,
|
|
114
|
+
"```",
|
|
115
|
+
"",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- components ----------------------------------------------------------
|
|
120
|
+
lines.push(
|
|
121
|
+
"## Components",
|
|
122
|
+
"",
|
|
123
|
+
`${flat.length} component(s) across ${new Set(flat.map((c) => c.info.filePath)).size} file(s).`,
|
|
124
|
+
"",
|
|
125
|
+
"| Render mode | Mountable in the browser host | Not mountable |",
|
|
126
|
+
"| --- | ---: | ---: |",
|
|
127
|
+
);
|
|
128
|
+
for (const mode of [...byMode.keys()].sort()) {
|
|
129
|
+
const row = byMode.get(mode) as { hosted: number; excluded: number };
|
|
130
|
+
lines.push(`| ${mode} | ${row.hosted} | ${row.excluded} |`);
|
|
131
|
+
}
|
|
132
|
+
lines.push(
|
|
133
|
+
`| **total** | **${hostedTotal}** | **${excludedTotal}** |`,
|
|
134
|
+
"",
|
|
135
|
+
`${hostedTotal} + ${excludedTotal} = ${hostedTotal + excludedTotal} — every component ` +
|
|
136
|
+
"the analyser found is in exactly one of those columns. " +
|
|
137
|
+
(unparseable.length > 0
|
|
138
|
+
? `A further ${unparseable.length} file(s) yielded no component at all; they are listed below.`
|
|
139
|
+
: "Every file it was pointed at yielded at least one component."),
|
|
140
|
+
"",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
if ((input.keptScripts ?? []).length > 0) {
|
|
144
|
+
lines.push(
|
|
145
|
+
`> Kept script(s): ${(input.keptScripts ?? []).join(", ")} — their package.json ` +
|
|
146
|
+
"commands are not ones this tool version writes, so they were left in place. " +
|
|
147
|
+
"Delete a key to take the tool's current command.",
|
|
148
|
+
"",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (unparseable.length > 0) {
|
|
153
|
+
lines.push("### Files no component could be read from", "");
|
|
154
|
+
for (const u of unparseable) {
|
|
155
|
+
// The reason usually repeats the absolute path the header already carries.
|
|
156
|
+
const reason = u.reason
|
|
157
|
+
.replace(u.file, "")
|
|
158
|
+
.replace(/\s+in\s*[.:]/, ":")
|
|
159
|
+
.trim();
|
|
160
|
+
lines.push(`- \`${rel(u.file)}\` — ${reason}`);
|
|
161
|
+
}
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- what each task produced ---------------------------------------------
|
|
166
|
+
// A `--tasks load` run used to rewrite this table with one row and no
|
|
167
|
+
// qualification, so the report read as "this project has one task" rather
|
|
168
|
+
// than "this run selected one". Two sessions reverted a truncated REPORT.md
|
|
169
|
+
// by hand before anyone noticed the file was the thing lying.
|
|
170
|
+
const ran = new Set<string>(input.results.map((r) => r.id));
|
|
171
|
+
const notRun = (input.allTaskIds ?? []).filter((id) => !ran.has(id));
|
|
172
|
+
lines.push("## Tasks", "");
|
|
173
|
+
if (notRun.length > 0) {
|
|
174
|
+
lines.push(
|
|
175
|
+
`This run selected ${input.results.length} of ${(input.allTaskIds ?? []).length} tasks.`,
|
|
176
|
+
`Not run, and therefore not described below: ${notRun.map((id) => `\`${id}\``).join(", ")}.`,
|
|
177
|
+
"",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
lines.push(
|
|
181
|
+
"| Task | Files written | Findings | Status | Summary |",
|
|
182
|
+
"| --- | ---: | ---: | --- | --- |",
|
|
183
|
+
);
|
|
184
|
+
for (const r of input.results) {
|
|
185
|
+
// The full component list in a task summary runs to thousands of characters.
|
|
186
|
+
const summary = r.summary.length > 120 ? `${r.summary.slice(0, 117)}…` : r.summary;
|
|
187
|
+
lines.push(
|
|
188
|
+
// An em dash, not an empty cell: only perf-harness reports a lifecycle
|
|
189
|
+
// status today, and an empty cell reads as a rendering bug.
|
|
190
|
+
`| \`${r.id}\` | ${r.outputs.length} | ${r.findings.length} | ${r.status ?? "—"} | ${summary.replace(/\|/g, "\\|")} |`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
lines.push("");
|
|
194
|
+
|
|
195
|
+
// --- what the React Compiler did for this run -----------------------------
|
|
196
|
+
// The Tasks table row says "12 finding(s)". Whether that 12 was once 148 is a
|
|
197
|
+
// fact about the compiler, and it is not in the table — so a reader of the
|
|
198
|
+
// committed file cannot tell a suppressed zero from an honest one. Same
|
|
199
|
+
// principle as the native-a11y denominator: a zero has to say what kind of
|
|
200
|
+
// zero it is. Rendered only for the two states that change what the number
|
|
201
|
+
// means or could have changed (see compilerNote in tasks/staticPerfAnalysis.ts).
|
|
202
|
+
const compiler = input.results.find((r) => r.reactCompiler !== undefined)?.reactCompiler;
|
|
203
|
+
if (compiler && compiler.state !== "off") {
|
|
204
|
+
const suppressed = compiler.suppressed ?? [];
|
|
205
|
+
const suppressedFindings = suppressed.reduce((n, s) => n + s.count, 0);
|
|
206
|
+
lines.push(
|
|
207
|
+
compiler.state === "on"
|
|
208
|
+
? `> \`perf-static\`: ${suppressed.length} memo rule(s) suppressed — React Compiler ` +
|
|
209
|
+
`detected (\`${compiler.evidence}\`), so ${suppressedFindings} finding(s) of work ` +
|
|
210
|
+
"it does by itself were not reported. A zero from `unstable-child-prop`, " +
|
|
211
|
+
"`missing-memo-leaf` or `no-memoisation-with-children` here means the compiler " +
|
|
212
|
+
"memoises it, not that nothing was found."
|
|
213
|
+
: "> `perf-static`: React Compiler state undetermined " +
|
|
214
|
+
`(\`${compiler.evidence}\`) — all nine rules ran, and the memo-family findings ` +
|
|
215
|
+
"above may be pre-compiled noise on a compiler-on app.",
|
|
216
|
+
"",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- spec quality ----------------------------------------------------------
|
|
221
|
+
// The other half of the perf-static number, deliberately not in the findings
|
|
222
|
+
// column above. `perf/unresolved-prop` is the analyser failing to invent a
|
|
223
|
+
// value, not the app rerendering: on the dogfood run it was 713 of 926
|
|
224
|
+
// "findings", one component contributing 148, and the 127 findings that needed
|
|
225
|
+
// someone were under it. Bucketed per component because that is the unit a
|
|
226
|
+
// person fixes.
|
|
227
|
+
const specQuality = input.results.find((r) => r.specQuality !== undefined)?.specQuality;
|
|
228
|
+
if (specQuality) {
|
|
229
|
+
lines.push(
|
|
230
|
+
"## Spec quality",
|
|
231
|
+
"",
|
|
232
|
+
`\`${specQuality.rule}\`: ${specQuality.props} prop(s) across ` +
|
|
233
|
+
`${specQuality.components.length} component(s) where the analyser could not synthesise a ` +
|
|
234
|
+
"default, so the generated spec passes `undefined` for that prop. Not performance " +
|
|
235
|
+
"findings, and not in the table above — they are this tool's own fixture limits, and a " +
|
|
236
|
+
"component's specs stay weak until they are filled.",
|
|
237
|
+
"",
|
|
238
|
+
"Fix one with `--props '{\"name\":1}'`, or by editing that spec's `DEFAULTS`.",
|
|
239
|
+
"",
|
|
240
|
+
"| Component | File | Props |",
|
|
241
|
+
"| --- | --- | --- |",
|
|
242
|
+
);
|
|
243
|
+
for (const bucket of specQuality.components.slice(0, 25)) {
|
|
244
|
+
const shown = bucket.props
|
|
245
|
+
.slice(0, 8)
|
|
246
|
+
.map((p) => `\`${p}\``)
|
|
247
|
+
.join(", ");
|
|
248
|
+
const more = bucket.props.length > 8 ? `, +${bucket.props.length - 8} more` : "";
|
|
249
|
+
lines.push(`| \`${bucket.component}\` | \`${rel(bucket.file)}\` | ${shown}${more} |`);
|
|
250
|
+
}
|
|
251
|
+
if (specQuality.components.length > 25) {
|
|
252
|
+
lines.push(`| … | | ${specQuality.components.length - 25} more component(s) |`);
|
|
253
|
+
}
|
|
254
|
+
lines.push("");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- hand-work remaining --------------------------------------------------
|
|
258
|
+
// The one adoption-cost number the component table cannot show: how many
|
|
259
|
+
// TODO(flawless) markers the emitters planted, and of what kind. Counted at
|
|
260
|
+
// the emission sites (TodoCounter), never recovered from the emitted text.
|
|
261
|
+
// The section is ALWAYS present, in one of three states — a table, an
|
|
262
|
+
// explicit "none", or an explicit "not measured" — because a silently absent
|
|
263
|
+
// table reads as zero debt, and on the dogfood app the truth was 181 markers.
|
|
264
|
+
// Which tasks can feed this section at all. Named once, and the copy below is
|
|
265
|
+
// BUILT from who actually reported — B-12 added e2e as a second contributor
|
|
266
|
+
// and a single global "measured / not measured" split immediately started
|
|
267
|
+
// lying in both directions: an e2e-only run with a filled hook printed
|
|
268
|
+
// "none — no TODO markers were emitted" (implying unit-browser measured zero
|
|
269
|
+
// when it never ran), and a unit-browser-only run claimed the e2e auth hook
|
|
270
|
+
// was counted. The scope sentence outliving the set of emitters behind it is
|
|
271
|
+
// exactly how this section lied before.
|
|
272
|
+
const CONTRIBUTING: readonly TaskId[] = ["unit-browser", "e2e"];
|
|
273
|
+
const nameList = (ids: readonly TaskId[]): string =>
|
|
274
|
+
ids.length === 1
|
|
275
|
+
? `\`${ids[0]}\``
|
|
276
|
+
: `${ids
|
|
277
|
+
.slice(0, -1)
|
|
278
|
+
.map((i) => `\`${i}\``)
|
|
279
|
+
.join(", ")} and \`${ids[ids.length - 1]}\``;
|
|
280
|
+
const reported = CONTRIBUTING.filter((id) =>
|
|
281
|
+
input.results.some((r) => r.id === id && r.handWork !== undefined),
|
|
282
|
+
);
|
|
283
|
+
const silent = CONTRIBUTING.filter((id) => !reported.includes(id));
|
|
284
|
+
const measured = input.results.filter((r) => r.handWork !== undefined);
|
|
285
|
+
lines.push("## Hand-work remaining", "");
|
|
286
|
+
if (reported.length === 0) {
|
|
287
|
+
lines.push(
|
|
288
|
+
// "did not include the task" was false when the task ran and threw; this
|
|
289
|
+
// wording is true in both states.
|
|
290
|
+
`Not measured — ${nameList(CONTRIBUTING)} did not report a count (not selected, or it failed).`,
|
|
291
|
+
"Absence of a table here is absence of measurement, not absence of debt.",
|
|
292
|
+
"",
|
|
293
|
+
);
|
|
294
|
+
} else {
|
|
295
|
+
lines.push(
|
|
296
|
+
`Counted for ${nameList(reported)} — the load and provider files carry their`,
|
|
297
|
+
"own `TODO(flawless)` markers, not counted here." +
|
|
298
|
+
(silent.length > 0
|
|
299
|
+
? ` ${nameList(silent)} did not report a count (not selected, or it failed), so this is not a total.`
|
|
300
|
+
: ""),
|
|
301
|
+
"",
|
|
302
|
+
);
|
|
303
|
+
const byKind = new Map<TodoKind, { count: number; exemplar: string }>();
|
|
304
|
+
for (const r of measured) {
|
|
305
|
+
for (const t of r.handWork ?? []) {
|
|
306
|
+
const row = byKind.get(t.kind);
|
|
307
|
+
if (!row) byKind.set(t.kind, { count: t.count, exemplar: t.exemplar });
|
|
308
|
+
else {
|
|
309
|
+
row.count += t.count;
|
|
310
|
+
if (t.exemplar < row.exemplar) row.exemplar = t.exemplar;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (byKind.size === 0) {
|
|
315
|
+
lines.push(`none — ${nameList(reported)} emitted no TODO markers.`, "");
|
|
316
|
+
} else {
|
|
317
|
+
lines.push(
|
|
318
|
+
"Each `TODO(flawless)` marker is a place where a spec needs a human: a",
|
|
319
|
+
"fixture, an addressable target, or an assertion the source could not",
|
|
320
|
+
"support statically. Search the marker to find every site of a kind.",
|
|
321
|
+
"",
|
|
322
|
+
"| Kind | Markers | Example file |",
|
|
323
|
+
"| --- | ---: | --- |",
|
|
324
|
+
);
|
|
325
|
+
for (const kind of [...byKind.keys()].sort()) {
|
|
326
|
+
const row = byKind.get(kind) as { count: number; exemplar: string };
|
|
327
|
+
lines.push(`| \`${kind}\` | ${row.count} | \`${rel(row.exemplar)}\` |`);
|
|
328
|
+
}
|
|
329
|
+
lines.push("");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- inverted predicates (B-19) ------------------------------------------
|
|
334
|
+
{
|
|
335
|
+
const emitted = new Map<string, number>();
|
|
336
|
+
const refused = new Map<string, { count: number; exemplar: string }>();
|
|
337
|
+
for (const c of flat) {
|
|
338
|
+
for (const inv of c.info.predicateInversions ?? []) {
|
|
339
|
+
emitted.set(inv.kind, (emitted.get(inv.kind) ?? 0) + 1);
|
|
340
|
+
}
|
|
341
|
+
for (const r of c.info.predicateRefusals ?? []) {
|
|
342
|
+
const row = refused.get(r.reason);
|
|
343
|
+
const at = `${rel(r.pos.file)}:${r.pos.line}`;
|
|
344
|
+
if (!row) refused.set(r.reason, { count: 1, exemplar: at });
|
|
345
|
+
else row.count += 1;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (emitted.size > 0 || refused.size > 0) {
|
|
349
|
+
lines.push(
|
|
350
|
+
"## Inverted predicates",
|
|
351
|
+
"",
|
|
352
|
+
"Second mounts that exercise the OTHER side of a decidable predicate — a",
|
|
353
|
+
"boolean dep flipped false, a nullable dep flipped null, a list flipped",
|
|
354
|
+
"empty, a prop overridden. Each flip is certified against the values the",
|
|
355
|
+
"mocks actually hold; refusals are the certifier declining by name, not",
|
|
356
|
+
"hand-work to fill.",
|
|
357
|
+
"",
|
|
358
|
+
"| Outcome | Count | Example |",
|
|
359
|
+
"| --- | ---: | --- |",
|
|
360
|
+
);
|
|
361
|
+
for (const kind of [...emitted.keys()].sort()) {
|
|
362
|
+
lines.push(`| emitted \`${kind}\` | ${emitted.get(kind)} | — |`);
|
|
363
|
+
}
|
|
364
|
+
for (const reason of [...refused.keys()].sort()) {
|
|
365
|
+
const row = refused.get(reason) as { count: number; exemplar: string };
|
|
366
|
+
lines.push(`| refused \`${reason}\` | ${row.count} | \`${row.exemplar}\` |`);
|
|
367
|
+
}
|
|
368
|
+
lines.push("");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// --- bootability ---------------------------------------------------------
|
|
373
|
+
// Vitest resolves a spec's imports at COLLECT time, so a package the suite
|
|
374
|
+
// cannot find takes the whole file down before one test runs. Without this
|
|
375
|
+
// section the run reads as "N files failed, no tests" and nothing says why.
|
|
376
|
+
const broken = input.unresolvable ?? [];
|
|
377
|
+
if (broken.length > 0) {
|
|
378
|
+
const affected = broken.reduce((n, b) => n + b.count, 0);
|
|
379
|
+
lines.push("## Imports that will not resolve", "");
|
|
380
|
+
lines.push(
|
|
381
|
+
`${broken.length} package(s) the analysed sources import are installed nowhere the suite`,
|
|
382
|
+
`resolves from, and ${affected} component(s) import them. Vitest resolves imports while it`,
|
|
383
|
+
"collects a spec file, so each of those files fails before a single test runs:",
|
|
384
|
+
"`Failed to resolve import`. Install the package, or leave those components out of the",
|
|
385
|
+
"pattern. Not listed here: modules this suite stubs (`next/*`, the provider shims,",
|
|
386
|
+
"auth libraries), `import type` specifiers, and assets vite loads by extension.",
|
|
387
|
+
"",
|
|
388
|
+
"| Module | Components | Examples |",
|
|
389
|
+
"| --- | --- | --- |",
|
|
390
|
+
);
|
|
391
|
+
for (const b of broken.slice(0, 25)) {
|
|
392
|
+
const shown = b.examples.map((n) => `\`${n}\``).join(", ");
|
|
393
|
+
const more = b.count > b.examples.length ? `, …` : "";
|
|
394
|
+
lines.push(`| \`${b.specifier}\` | ${b.count} | ${shown}${more} |`);
|
|
395
|
+
}
|
|
396
|
+
if (broken.length > 25) lines.push(`| … | | ${broken.length - 25} more module(s) |`);
|
|
397
|
+
lines.push("");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// --- skips ---------------------------------------------------------------
|
|
401
|
+
lines.push("## Not covered, and why", "");
|
|
402
|
+
if (excluded.length === 0) {
|
|
403
|
+
lines.push("Every component is mountable in the browser host.", "");
|
|
404
|
+
} else {
|
|
405
|
+
lines.push(
|
|
406
|
+
`${excluded.length} component(s) have no browser profile spec. Most still have a unit`,
|
|
407
|
+
"spec — these are only the ones that cannot mount in the profiling host. The",
|
|
408
|
+
"exception is `not exported`: nothing can import those, so they have no spec of",
|
|
409
|
+
"any kind and are covered through the parent that renders them.",
|
|
410
|
+
"",
|
|
411
|
+
"| Component | File | Reason |",
|
|
412
|
+
"| --- | --- | --- |",
|
|
413
|
+
);
|
|
414
|
+
for (const e of [...excluded].sort((a, b) =>
|
|
415
|
+
a.file === b.file ? (a.name < b.name ? -1 : 1) : a.file < b.file ? -1 : 1,
|
|
416
|
+
)) {
|
|
417
|
+
lines.push(`| \`${e.name}\` | \`${e.file}\` | ${e.reason} |`);
|
|
418
|
+
}
|
|
419
|
+
lines.push("");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// --- standing limitations ------------------------------------------------
|
|
423
|
+
lines.push(
|
|
424
|
+
"## Standing gaps",
|
|
425
|
+
"",
|
|
426
|
+
"These are properties of the analysis, not of this run. They do not go away by",
|
|
427
|
+
"re-running it.",
|
|
428
|
+
"",
|
|
429
|
+
"- Mocks come from each component's OWN imports. A component that renders",
|
|
430
|
+
" `<Link>` only through a child gets no `next/link` mock in its own spec.",
|
|
431
|
+
"- Props typed through `node_modules` (`ButtonPrimitive.Props`) are not",
|
|
432
|
+
" resolved; the spec passes `undefined` unless the body indexes a",
|
|
433
|
+
" same-file literal table with them. `z.infer` aliases resolve by reading",
|
|
434
|
+
" the zod schema syntactically; only schemas outside the supported subset",
|
|
435
|
+
" (.transform, unions, records, dates) fall back the same way.",
|
|
436
|
+
"- A cva variant is asserted to render DIFFERENTLY from its siblings, never to",
|
|
437
|
+
" carry a specific class: `cn()` is usually tailwind-merge, which drops base",
|
|
438
|
+
" classes a later variant conflicts with.",
|
|
439
|
+
"- An early return this analysis cannot decide means the component may render",
|
|
440
|
+
" nothing at the props a spec can synthesise; its interaction tests are",
|
|
441
|
+
" skipped rather than failed.",
|
|
442
|
+
"- An element is addressed by test id, `aria-label`, role plus name, `id` or",
|
|
443
|
+
" literal copy. The bare host tag is a last resort and only stands when it is",
|
|
444
|
+
" the only element of its kind in that render — otherwise the test is skipped,",
|
|
445
|
+
" because `button` names nothing when there are two of them.",
|
|
446
|
+
"- A form is submitted through the control its markup declares: a host",
|
|
447
|
+
" `<button>` unless its `type` says otherwise, a component tag only with an",
|
|
448
|
+
' explicit `type="submit"`. No such control, or one disabled at these props,',
|
|
449
|
+
" means no submit test — the button's copy is never guessed at.",
|
|
450
|
+
"- A component that calls `console.error`, or `.error()` on an imported",
|
|
451
|
+
" binding, reports errors by design, so its browser spec does not also assert",
|
|
452
|
+
" that mounting produced none.",
|
|
453
|
+
"",
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
return `${lines.join("\n")}\n`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export async function writeReportMarkdown(input: ReportInput): Promise<string> {
|
|
460
|
+
const out = path.join(input.outDir, "REPORT.md");
|
|
461
|
+
await Bun.write(out, renderReportMarkdown(input));
|
|
462
|
+
return out;
|
|
463
|
+
}
|