flet-box 1.1.2 → 1.1.3

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 (154) hide show
  1. package/AGENTS_FOR_CODE_GEN.md +284 -0
  2. package/CONTRIBUTING.md +238 -0
  3. package/README.md +2 -2
  4. package/bin/cli.js +88 -1
  5. package/bin/commands/auditFix.js +181 -27
  6. package/bin/commands/checkApi.js +14 -3
  7. package/bin/commands/checkStructure.js +368 -0
  8. package/bin/commands/checkSyntax.js +293 -0
  9. package/bin/commands/checkUi.js +666 -14
  10. package/bin/commands/create.js +14 -0
  11. package/bin/commands/createBundle.js +2 -2
  12. package/bin/commands/metrics.js +448 -0
  13. package/bin/commands/release.js +18 -0
  14. package/bin/commands/report.js +849 -0
  15. package/bin/commands/tools.js +76 -0
  16. package/bin/commands/unpack.js +39 -11
  17. package/bin/commands/verify.js +879 -0
  18. package/bin/mcp.js +280 -0
  19. package/bin/utils/contract.js +27 -0
  20. package/bin/utils/dtsProps.js +5 -1
  21. package/bin/utils/migrationGuide.js +146 -0
  22. package/bin/utils/moduleExports.js +9 -5
  23. package/bin/utils/templates.js +225 -18
  24. package/bin/utils/toolkit.js +191 -0
  25. package/docs/README.md +5 -1
  26. package/docs/arquitectura.md +1 -1
  27. package/docs/cli/README.md +168 -2
  28. package/docs/guides/README.md +6 -0
  29. package/docs/guides/accessibility.md +2 -1
  30. package/docs/guides/ai-toolkit.md +166 -0
  31. package/docs/guides/compatibility.md +1 -1
  32. package/docs/guides/migration.md +92 -0
  33. package/docs/guides/minification-and-protection.md +2 -1
  34. package/docs/guides/mobile-and-platforms.md +1 -1
  35. package/docs/guides/online-updates.md +2 -0
  36. package/docs/server/deployment.md +1 -1
  37. package/docs/tools/styling.md +2 -2
  38. package/docs/tools/theme.md +139 -4
  39. package/docs/widget/Accordion.md +74 -8
  40. package/docs/widget/AdMob.md +68 -3
  41. package/docs/widget/AdSense.md +67 -2
  42. package/docs/widget/AlertDialog.md +1 -1
  43. package/docs/widget/AnimatedBox.md +80 -39
  44. package/docs/widget/AnimatedText.md +74 -18
  45. package/docs/widget/Audio.md +73 -14
  46. package/docs/widget/Avatar.md +69 -4
  47. package/docs/widget/Badge.md +68 -3
  48. package/docs/widget/BottomSheet.md +2 -2
  49. package/docs/widget/Button.md +69 -3
  50. package/docs/widget/Card.md +69 -4
  51. package/docs/widget/Carousel.md +67 -2
  52. package/docs/widget/Chart.md +67 -2
  53. package/docs/widget/Checkbox.md +74 -8
  54. package/docs/widget/Chip.md +68 -3
  55. package/docs/widget/CircularBar.md +74 -15
  56. package/docs/widget/CircularChart.md +71 -10
  57. package/docs/widget/CodeViewer.md +67 -2
  58. package/docs/widget/Column.md +68 -3
  59. package/docs/widget/Container.md +68 -3
  60. package/docs/widget/DataTable.md +67 -2
  61. package/docs/widget/Divider.md +67 -2
  62. package/docs/widget/DraggBox.md +67 -2
  63. package/docs/widget/Dropdown.md +76 -10
  64. package/docs/widget/DroppBox.md +67 -2
  65. package/docs/widget/FloatingActionButton.md +68 -3
  66. package/docs/widget/GridView.md +67 -2
  67. package/docs/widget/Icon.md +71 -5
  68. package/docs/widget/Image.md +68 -3
  69. package/docs/widget/Input.md +68 -3
  70. package/docs/widget/Inspector.md +2 -2
  71. package/docs/widget/InstallButton.md +73 -13
  72. package/docs/widget/ListTile.md +69 -3
  73. package/docs/widget/ListView.md +67 -2
  74. package/docs/widget/Maps.md +68 -3
  75. package/docs/widget/Markdown.md +74 -15
  76. package/docs/widget/MatrixRain.md +72 -11
  77. package/docs/widget/Menu.md +209 -0
  78. package/docs/widget/Modal.md +3 -3
  79. package/docs/widget/Pagination.md +68 -3
  80. package/docs/widget/ParallaxBox.md +72 -12
  81. package/docs/widget/ProgressBar.md +69 -4
  82. package/docs/widget/QRCode.md +67 -2
  83. package/docs/widget/README.md +54 -50
  84. package/docs/widget/Radio.md +76 -10
  85. package/docs/widget/Rating.md +70 -6
  86. package/docs/widget/Row.md +68 -3
  87. package/docs/widget/Skeleton.md +68 -3
  88. package/docs/widget/Slider.md +75 -9
  89. package/docs/widget/SnackBar.md +2 -2
  90. package/docs/widget/Stack.md +68 -3
  91. package/docs/widget/Stepper.md +68 -3
  92. package/docs/widget/Switch.md +74 -8
  93. package/docs/widget/Text.md +71 -4
  94. package/docs/widget/ThemePicker.md +197 -0
  95. package/docs/widget/Tooltip.md +72 -7
  96. package/docs/widget/TreeView.md +70 -10
  97. package/docs/widget/Video.md +71 -10
  98. package/package.json +10 -3
  99. package/skills/fletbox-design/SKILL.md +89 -5
  100. package/skills/fletbox-motion/SKILL.md +1 -1
  101. package/skills/fletbox-rules/SKILL.md +56 -4
  102. package/src/core/runApp.js +12 -0
  103. package/src/index.d.ts +374 -101
  104. package/src/index.js +9 -0
  105. package/src/navigations/AdaptiveScaffold.js +2 -2
  106. package/src/navigations/BottomNavigation.js +1 -1
  107. package/src/navigations/Scaffold.js +18 -6
  108. package/src/tools/useState.js +11 -4
  109. package/src/utils/colors.js +275 -0
  110. package/src/utils/errorBoundary.js +288 -0
  111. package/src/utils/index.js +10 -2
  112. package/src/utils/stackPosition.js +39 -24
  113. package/src/utils/syntaxHighlight.js +4 -1
  114. package/src/utils/themes.js +279 -3
  115. package/src/widget-factory/addChildren.js +17 -10
  116. package/src/widget-factory/assignProps.js +18 -6
  117. package/src/widget-factory/createWidget.js +7 -0
  118. package/src/widget-factory/deprecations.js +77 -0
  119. package/src/widget-factory/translateProps.js +11 -1
  120. package/src/widget-factory/warnings.js +1 -27
  121. package/src/widget-factory/widgetFactory.js +59 -17
  122. package/src/widgets/Accordion.js +21 -7
  123. package/src/widgets/Badge.js +11 -2
  124. package/src/widgets/Button.js +15 -3
  125. package/src/widgets/Carousel.js +12 -4
  126. package/src/widgets/Chart.js +7 -2
  127. package/src/widgets/Checkbox.js +1 -2
  128. package/src/widgets/Chip.js +24 -10
  129. package/src/widgets/CircularBar.js +9 -3
  130. package/src/widgets/CircularChart.js +5 -3
  131. package/src/widgets/Container.js +6 -0
  132. package/src/widgets/Divider.js +3 -1
  133. package/src/widgets/DraggBox.js +9 -0
  134. package/src/widgets/Dropdown.js +3 -3
  135. package/src/widgets/DroppBox.js +5 -0
  136. package/src/widgets/FloatingActionButton.js +1 -1
  137. package/src/widgets/Icon.js +5 -1
  138. package/src/widgets/InstallButton.js +4 -2
  139. package/src/widgets/Markdown.js +171 -103
  140. package/src/widgets/Menu.js +340 -0
  141. package/src/widgets/Modal.js +7 -1
  142. package/src/widgets/Pagination.js +10 -5
  143. package/src/widgets/ProgressBar.js +5 -4
  144. package/src/widgets/QRCode.js +4 -0
  145. package/src/widgets/Radio.js +1 -2
  146. package/src/widgets/Rating.js +9 -0
  147. package/src/widgets/Slider.js +4 -1
  148. package/src/widgets/SnackBar.js +2 -2
  149. package/src/widgets/Switch.js +1 -2
  150. package/src/widgets/Text.js +9 -5
  151. package/src/widgets/ThemePicker.js +234 -0
  152. package/src/widgets/TreeView.js +2 -1
  153. package/src/widgets/index.js +2 -0
  154. package/docs/CONTRIBUTING.md +0 -156
@@ -0,0 +1,849 @@
1
+ /**
2
+ * @file bin/commands/report.js
3
+ * @description The diagnosis: one artifact that answers "where is the work?".
4
+ *
5
+ * `verify` answers a yes/no question — may I say this is done — and answers it
6
+ * gate by gate, because that is what an exit code needs. An agent that is
7
+ * *lost* needs the other reading of the same facts: not which gate failed, but
8
+ * which file is wrong, how wrong, which widget is missing a declaration, and
9
+ * which one thing to do next. That is this command.
10
+ *
11
+ * It runs the six static gates once, then re-groups their findings by the file
12
+ * the reader will open. The surface gate hands back the per-widget ledger it
13
+ * built while checking (it is the expensive half of the run), the metrics log
14
+ * says which mistakes keep coming back, and every finding is paired with the
15
+ * rule that owns its code — so the report is a route, not a list.
16
+ *
17
+ * Three rules make it a tool rather than another number:
18
+ *
19
+ * 1. **Read-only.** It edits nothing, deletes nothing, runs no repair and
20
+ * appends no metrics row (unlike `verify`). The only file it can write is
21
+ * the HTML report itself, and only when `--html` asks for it.
22
+ * 2. **Every file is named**, including the clean ones, with its verdict,
23
+ * its line count and the widgets it imports. A report that lists only the
24
+ * failures cannot tell "this file is fine" from "this file was not read".
25
+ * 3. **Every finding carries a next step and a rule.** A code with no
26
+ * suggestion is printed with the tool that proves it instead, because
27
+ * "something is wrong somewhere" is not a finding.
28
+ *
29
+ * The suites are opt-in (`--tests`): a report is asked for while the code is
30
+ * broken, and a broken app's browser suite is a minute of Chrome to learn what
31
+ * the static gates already named.
32
+ *
33
+ * Node core only: this file ships inside every app `flet-box create` builds.
34
+ */
35
+
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import { runGates } from "./verify.js";
39
+ import { collectSources, loadApi } from "./checkApi.js";
40
+ import { metricsLog, parseLog, groupGenerations, rankCodes, owner } from "./metrics.js";
41
+ import { neutralise } from "../utils/neutralise.js";
42
+ import { c, banner, section } from "../utils/colors.js";
43
+
44
+ /** The gates a report runs unless `--tests` is asked for. */
45
+ export const REPORT_GATES = ["syntax", "api", "types", "ui", "structure", "surface"];
46
+
47
+ const GATE_RANK = new Map(REPORT_GATES.map((name, index) => [name, index]));
48
+ const SEVERITY_RANK = { error: 0, warn: 1, info: 2 };
49
+ const NEXT_LIMIT = 20;
50
+
51
+ /** How many times each severity appears in a list of findings. */
52
+ const tally = (findings) => ({
53
+ errors: findings.filter((item) => item.severity === "error").length,
54
+ warnings: findings.filter((item) => item.severity === "warn").length,
55
+ review: findings.filter((item) => item.severity === "info").length,
56
+ });
57
+
58
+ const read = (file) => {
59
+ try {
60
+ return fs.readFileSync(file, "utf8");
61
+ } catch {
62
+ return "";
63
+ }
64
+ };
65
+
66
+ /**
67
+ * A finding's file as a project-relative path, or null when it lives outside
68
+ * the project (the framework's own sources, in an app's report).
69
+ *
70
+ * Gates disagree about their base: `check-api` writes paths relative to the
71
+ * working directory, `check-ui` and `check-structure` absolute ones. Both are
72
+ * resolved here once, and an unresolved path is kept as an absolute one.
73
+ *
74
+ * @param {string|null} file
75
+ * @param {string} root
76
+ * @returns {string|null}
77
+ */
78
+ const relInside = (file, root) => {
79
+ if (!file) return null;
80
+ let absolute = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
81
+ if (!fs.existsSync(absolute)) {
82
+ const fromRoot = path.resolve(root, file);
83
+ if (fs.existsSync(fromRoot)) absolute = fromRoot;
84
+ }
85
+ const rel = path.relative(root, absolute);
86
+ return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : null;
87
+ };
88
+
89
+ const WIDGET_IMPORT = /import\s+([^;]+?)\s+from\s+["']flet-box["'];?/g;
90
+
91
+ /**
92
+ * The widgets a file imports from the installed package.
93
+ *
94
+ * Read from the import clause rather than from every `Name({` in the file: a
95
+ * constructor call or a local helper shaped like a widget would otherwise be
96
+ * counted as one, and this list is a fact the reader trusts.
97
+ *
98
+ * @param {string} text
99
+ * @param {object|null} api
100
+ * @returns {string[]}
101
+ */
102
+ const widgetsIn = (text, api) => {
103
+ if (!api) return [];
104
+ const names = new Set();
105
+ // `keepStrings`: the module specifier and the imported names are both inside
106
+ // strings after the call-site neutraliser strips them, and this list is read
107
+ // from the import clause on purpose (a constructor or a local helper shaped
108
+ // like a widget must not be reported as one).
109
+ for (const match of neutralise(text, { keepStrings: true }).matchAll(WIDGET_IMPORT)) {
110
+ const braces = /\{([\s\S]*)\}/.exec(match[1]);
111
+ if (!braces) continue;
112
+ for (const part of braces[1].split(",")) {
113
+ const name = part.trim().split(/\s+as\s+/)[0].trim();
114
+ if (/^[A-Z]/.test(name) && api.exports.includes(name)) names.add(name);
115
+ }
116
+ }
117
+ return [...names].sort();
118
+ };
119
+
120
+ /* ------------------------------------------------------------------ build */
121
+
122
+ /**
123
+ * Run the gates and turn their output into the report a reader can act on.
124
+ *
125
+ * @param {{root?: string, tests?: boolean, print?: (line: string) => void}} [options]
126
+ * @returns {Promise<object>}
127
+ */
128
+ export const buildReport = async ({ root = process.cwd(), tests = false, print } = {}) => {
129
+ const absoluteRoot = path.resolve(root);
130
+ const names = tests ? [...REPORT_GATES, "tests"] : REPORT_GATES;
131
+ const run = await runGates(absoluteRoot, names, tests ? { print } : {});
132
+
133
+ let api = null;
134
+ const notes = [];
135
+ try {
136
+ api = await loadApi(absoluteRoot);
137
+ } catch {
138
+ notes.push("the installed flet-box package could not be read — widget facts are missing");
139
+ }
140
+
141
+ // Every source file is a row, clean or not: a report that only prints the
142
+ // failures cannot distinguish a file it read and cleared from one it never
143
+ // opened.
144
+ const files = new Map();
145
+ const addFile = (abs) => {
146
+ const rel = relInside(abs, absoluteRoot);
147
+ if (!rel || files.has(rel)) return files.get(rel) || null;
148
+ const text = read(abs);
149
+ const entry = {
150
+ file: rel,
151
+ lines: text ? text.split("\n").length : 0,
152
+ widgets: widgetsIn(text, api),
153
+ findings: [],
154
+ verdict: "clean",
155
+ errors: 0,
156
+ warnings: 0,
157
+ review: 0,
158
+ };
159
+ files.set(rel, entry);
160
+ return entry;
161
+ };
162
+ for (const abs of collectSources(absoluteRoot)) addFile(abs);
163
+
164
+ const flat = [];
165
+ const outside = [];
166
+ for (const step of run.steps) {
167
+ for (const finding of step.findings) {
168
+ const item = { ...finding, gate: step.name, rule: owner(finding.code) };
169
+ flat.push(item);
170
+ const rel = relInside(finding.file, absoluteRoot);
171
+ const entry = rel ? addFile(path.resolve(absoluteRoot, rel)) : null;
172
+ if (entry) entry.findings.push(item);
173
+ else outside.push(item);
174
+ }
175
+ }
176
+
177
+ for (const entry of files.values()) {
178
+ const counts = tally(entry.findings);
179
+ entry.errors = counts.errors;
180
+ entry.warnings = counts.warnings;
181
+ entry.review = counts.review;
182
+ entry.verdict = counts.errors
183
+ ? "error"
184
+ : counts.warnings
185
+ ? "warn"
186
+ : counts.review
187
+ ? "review"
188
+ : "clean";
189
+ }
190
+
191
+ const ledger = run.steps.find((step) => step.name === "surface")?.widgets || [];
192
+ // A contract gap is a prop that lies about the API: read but undeclared,
193
+ // declared but dead, or routed by the engine and declared nowhere. `docOnly`
194
+ // and `codeOnly` are documentation precision — an inherited `padding` read by
195
+ // every widget is documented once, in the Common props table — so they are
196
+ // carried in the row and never counted as debt, or four widgets would look
197
+ // broken because their parent's props are documented somewhere else.
198
+ const withDebt = ledger
199
+ .map((row) => ({
200
+ ...row,
201
+ total: row.invented.length + row.dead.length + row.notDeclared.length,
202
+ docs: row.docOnly.length + row.codeOnly.length,
203
+ }))
204
+ .filter((row) => row.total > 0)
205
+ .sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
206
+ const widgets = ledger.length
207
+ ? { total: ledger.length, clean: ledger.length - withDebt.length, withDebt }
208
+ : null;
209
+
210
+ let metrics = null;
211
+ const log = metricsLog(absoluteRoot);
212
+ if (log) {
213
+ try {
214
+ const rows = parseLog(log);
215
+ const generations = groupGenerations(rows);
216
+ const firstTry = generations.filter((generation) => generation.firstTry).length;
217
+ const ranked = [...rankCodes(rows).entries()].sort(
218
+ (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]),
219
+ );
220
+ metrics = {
221
+ file: relInside(log, absoluteRoot) || log,
222
+ runs: rows.length,
223
+ generations: generations.length,
224
+ firstTry,
225
+ north: generations.length ? firstTry / generations.length : 0,
226
+ top: ranked.slice(0, 3).map(([code, count]) => ({ code, count, rule: owner(code) })),
227
+ };
228
+ } catch {
229
+ notes.push("the metrics log is unreadable — the history is not counted");
230
+ }
231
+ }
232
+
233
+ const next = flat
234
+ .slice()
235
+ .sort(
236
+ (a, b) =>
237
+ SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] ||
238
+ GATE_RANK.get(a.gate) - GATE_RANK.get(b.gate) ||
239
+ String(a.file || "").localeCompare(String(b.file || "")) ||
240
+ a.line - b.line,
241
+ )
242
+ .map((finding, index) => ({
243
+ rank: index + 1,
244
+ severity: finding.severity,
245
+ gate: finding.gate,
246
+ code: finding.code,
247
+ file: relInside(finding.file, absoluteRoot),
248
+ line: finding.line,
249
+ column: finding.column,
250
+ message: finding.message,
251
+ fix: finding.suggestion,
252
+ rule: finding.rule,
253
+ }));
254
+
255
+ const totals = tally(flat);
256
+ const ordered = [...files.values()].sort(
257
+ (a, b) =>
258
+ SEVERITY_RANK[a.verdict === "review" ? "info" : a.verdict] -
259
+ SEVERITY_RANK[b.verdict === "review" ? "info" : b.verdict] ||
260
+ a.file.localeCompare(b.file),
261
+ );
262
+
263
+ return {
264
+ tool: "flet-box report",
265
+ readOnly: true,
266
+ generatedAt: new Date().toISOString(),
267
+ root: absoluteRoot,
268
+ package: run.package,
269
+ ok: run.steps.every((step) => !step.exitCode),
270
+ summary: {
271
+ files: files.size,
272
+ clean: ordered.filter((entry) => entry.verdict === "clean").length,
273
+ withFindings: ordered.filter((entry) => entry.verdict !== "clean").length,
274
+ errors: totals.errors,
275
+ warnings: totals.warnings,
276
+ review: totals.review,
277
+ widgets: widgets?.total ?? 0,
278
+ widgetsWithDebt: widgets?.withDebt.length ?? 0,
279
+ metrics: metrics ? `${metrics.firstTry}/${metrics.generations} first-try` : null,
280
+ },
281
+ notes,
282
+ gates: run.steps.map((step) => {
283
+ const counts = tally(step.findings);
284
+ return {
285
+ name: step.name,
286
+ title: step.title,
287
+ exitCode: step.exitCode,
288
+ skipped: step.skipped || null,
289
+ errors: counts.errors,
290
+ warnings: counts.warnings,
291
+ review: counts.review,
292
+ };
293
+ }),
294
+ files: ordered,
295
+ outside,
296
+ widgets,
297
+ metrics,
298
+ next,
299
+ };
300
+ };
301
+
302
+ /* ------------------------------------------------------------------- text */
303
+
304
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
305
+
306
+ const verdictMark = (verdict) => {
307
+ if (verdict === "error") return c("red", "✗");
308
+ if (verdict === "warn") return c("yellow", "!");
309
+ if (verdict === "review") return c("gray", "·");
310
+ return c("green", "✓");
311
+ };
312
+
313
+ /**
314
+ * The report as the lines a terminal prints.
315
+ *
316
+ * @param {object} report
317
+ * @returns {string[]}
318
+ */
319
+ export const renderText = (report) => {
320
+ const lines = [];
321
+ const { summary } = report;
322
+ lines.push(
323
+ banner("🩺 FletBox report", `${path.basename(report.root)} · flet-box@${report.package} · read-only`),
324
+ );
325
+ lines.push("");
326
+ lines.push(
327
+ ` ${plural(summary.files, "file", "files")}: ${summary.clean} clean · ${summary.withFindings} with findings`,
328
+ );
329
+ lines.push(
330
+ ` ${summary.errors} error(s), ${summary.warnings} warning(s)` +
331
+ (summary.review ? `, ${summary.review} to review` : "") +
332
+ (report.widgets ? ` · ${report.widgets.total} widgets, ${summary.widgetsWithDebt} with contract gaps` : ""),
333
+ );
334
+ lines.push("");
335
+
336
+ for (const gate of report.gates) {
337
+ const mark = gate.exitCode ? c("red", "✗") : c("green", "✓");
338
+ const counts = [
339
+ gate.errors ? c("red", `${gate.errors} error(s)`) : "",
340
+ gate.warnings ? c("yellow", `${gate.warnings} warning(s)`) : "",
341
+ gate.review ? c("gray", `${gate.review} to review`) : "",
342
+ ]
343
+ .filter(Boolean)
344
+ .join(", ");
345
+ const status = gate.skipped
346
+ ? c("gray", `skipped — ${gate.skipped}`)
347
+ : counts || c("gray", "clean");
348
+ lines.push(` ${mark} ${c("brightCyan", gate.name.padEnd(9))} ${status}`);
349
+ }
350
+
351
+ if (report.notes.length) {
352
+ lines.push("");
353
+ for (const note of report.notes) lines.push(` ${c("yellow", `⚠ ${note}`)}`);
354
+ }
355
+
356
+ if (report.next.length) {
357
+ lines.push("");
358
+ lines.push(section("•", "next", "#fbbf24"));
359
+ for (const item of report.next.slice(0, NEXT_LIMIT)) {
360
+ const at = item.file ? `${item.file}:${item.line}${item.column ? `:${item.column}` : ""}` : item.gate;
361
+ const tint = item.severity === "error" ? "red" : item.severity === "warn" ? "yellow" : "gray";
362
+ lines.push(` ${String(item.rank).padStart(2)}. ${c("brightCyan", at)} ${c(tint, item.code)} — ${item.message}`);
363
+ if (item.fix) lines.push(` ${c("gray", `→ ${item.fix}`)}`);
364
+ if (item.rule) lines.push(` ${c("dim", `rule: ${item.rule}`)}`);
365
+ }
366
+ if (report.next.length > NEXT_LIMIT) {
367
+ lines.push(
368
+ ` ${c("gray", `… ${report.next.length - NEXT_LIMIT} more — \`flet-box report --json\` carries them all`)}`,
369
+ );
370
+ }
371
+ }
372
+
373
+ const dirty = report.files.filter((entry) => entry.verdict !== "clean");
374
+ if (dirty.length) {
375
+ lines.push("");
376
+ lines.push(section("•", "files", "#67e8f9"));
377
+ for (const entry of dirty) {
378
+ const counts = [
379
+ entry.errors ? c("red", `${entry.errors} error(s)`) : "",
380
+ entry.warnings ? c("yellow", `${entry.warnings} warning(s)`) : "",
381
+ entry.review ? c("gray", `${entry.review} to review`) : "",
382
+ ]
383
+ .filter(Boolean)
384
+ .join(", ");
385
+ lines.push(
386
+ ` ${verdictMark(entry.verdict)} ${c("brightWhite", entry.file)} ${counts}` +
387
+ (entry.widgets.length ? ` ${c("gray", `· ${entry.widgets.join(", ")}`)}` : ""),
388
+ );
389
+ }
390
+ }
391
+
392
+ if (report.outside.length) {
393
+ lines.push("");
394
+ lines.push(section("•", "outside the project", "#a5b4fc"));
395
+ lines.push(
396
+ ` ${c("gray", "these findings name the installed package, not your code — they are the framework's debt:")}`,
397
+ );
398
+ for (const item of report.outside.slice(0, NEXT_LIMIT)) {
399
+ lines.push(` ${c("gray", item.code)} — ${item.message}`);
400
+ }
401
+ }
402
+
403
+ if (report.widgets?.withDebt.length) {
404
+ lines.push("");
405
+ lines.push(section("•", "widget contract", "#a5b4fc"));
406
+ for (const row of report.widgets.withDebt.slice(0, NEXT_LIMIT)) {
407
+ const parts = [
408
+ row.invented.length && `invented: ${row.invented.join(" ")}`,
409
+ row.dead.length && `dead: ${row.dead.join(" ")}`,
410
+ row.notDeclared.length && `not in .d.ts: ${row.notDeclared.join(" ")}`,
411
+ row.docs ? `${row.docs} doc gap(s)` : "",
412
+ ].filter(Boolean);
413
+ lines.push(` ${c("brightWhite", row.name.padEnd(18))} ${c("gray", parts.join(" · "))}`);
414
+ }
415
+ if (report.widgets.withDebt.length > NEXT_LIMIT) {
416
+ lines.push(
417
+ ` ${c("gray", `… ${report.widgets.withDebt.length - NEXT_LIMIT} more — \`flet-box unpack --all --diff\``)}`,
418
+ );
419
+ }
420
+ }
421
+
422
+ if (report.metrics) {
423
+ lines.push("");
424
+ const top = report.metrics.top.length
425
+ ? report.metrics.top.map((row) => `${row.code} ×${row.count}`).join(", ")
426
+ : "no failure logged yet";
427
+ lines.push(
428
+ ` ${c("gray", `metrics: ${report.metrics.firstTry}/${report.metrics.generations} generation(s) passed first try (${(report.metrics.north * 100).toFixed(0)}%) · ${top}`)}`,
429
+ );
430
+ }
431
+
432
+ lines.push("");
433
+ lines.push(
434
+ report.ok
435
+ ? c(
436
+ "green",
437
+ `✅ 0 error(s) — ${summary.clean} file(s) clean${report.widgets ? `, ${report.widgets.clean}/${report.widgets.total} widgets with no contract gap` : ""}.`,
438
+ )
439
+ : c(
440
+ "red",
441
+ `❌ ${summary.errors} error(s), ${summary.warnings} warning(s) — start with ${report.next[0].file ? `${report.next[0].file}:${report.next[0].line}` : report.next[0].gate}, then run \`flet-box report\` again.`,
442
+ ),
443
+ );
444
+ return lines;
445
+ };
446
+
447
+ /* ------------------------------------------------------------------- markdown */
448
+
449
+ const mdCell = (value) => String(value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
450
+
451
+ /**
452
+ * The report as Markdown, for a commit comment or a handover note.
453
+ *
454
+ * @param {object} report
455
+ * @returns {string}
456
+ */
457
+ export const renderMarkdown = (report) => {
458
+ const { summary } = report;
459
+ const out = [];
460
+ out.push(`# FletBox report — ${path.basename(report.root)}`);
461
+ out.push("");
462
+ out.push(
463
+ `\`flet-box@${report.package}\` · ${report.generatedAt} · read-only · ${report.ok ? "**no errors**" : `**${summary.errors} error(s)**`}`,
464
+ );
465
+ out.push("");
466
+ out.push(
467
+ `${summary.files} file(s): ${summary.clean} clean, ${summary.withFindings} with findings · ` +
468
+ `${summary.errors} error(s), ${summary.warnings} warning(s), ${summary.review} to review` +
469
+ (report.widgets ? ` · ${summary.widgetsWithDebt}/${report.widgets.total} widgets with contract gaps` : ""),
470
+ );
471
+ out.push("");
472
+
473
+ out.push("## Gates");
474
+ out.push("");
475
+ out.push("| gate | result | errors | warnings | review |");
476
+ out.push("| --- | --- | --- | --- | --- |");
477
+ for (const gate of report.gates) {
478
+ const result = gate.skipped ? `skipped — ${gate.skipped}` : gate.exitCode ? "failed" : "clean";
479
+ out.push(`| ${gate.name} | ${mdCell(result)} | ${gate.errors} | ${gate.warnings} | ${gate.review} |`);
480
+ }
481
+ out.push("");
482
+
483
+ if (report.next.length) {
484
+ out.push("## Next");
485
+ out.push("");
486
+ for (const item of report.next) {
487
+ const at = item.file ? `\`${item.file}:${item.line}${item.column ? `:${item.column}` : ""}\`` : `_${item.gate}_`;
488
+ out.push(`${item.rank}. **${item.code}** at ${at} — ${mdCell(item.message)}`);
489
+ if (item.fix) out.push(` - fix: ${mdCell(item.fix)}`);
490
+ if (item.rule) out.push(` - rule: ${mdCell(item.rule)}`);
491
+ }
492
+ out.push("");
493
+ }
494
+
495
+ const dirty = report.files.filter((entry) => entry.verdict !== "clean");
496
+ if (dirty.length) {
497
+ out.push("## Files with findings");
498
+ out.push("");
499
+ out.push("| file | verdict | errors | warnings | widgets |");
500
+ out.push("| --- | --- | --- | --- | --- |");
501
+ for (const entry of dirty) {
502
+ out.push(
503
+ `| \`${mdCell(entry.file)}\` | ${entry.verdict} | ${entry.errors} | ${entry.warnings} | ${mdCell(entry.widgets.join(", "))} |`,
504
+ );
505
+ }
506
+ out.push("");
507
+ }
508
+
509
+ if (report.widgets?.withDebt.length) {
510
+ out.push("## Widget contract gaps");
511
+ out.push("");
512
+ out.push("| widget | props | invented | dead | not in .d.ts |");
513
+ out.push("| --- | --- | --- | --- | --- |");
514
+ for (const row of report.widgets.withDebt) {
515
+ out.push(
516
+ `| ${row.name} | ${row.props} | ${mdCell(row.invented.join(" "))} | ${mdCell(row.dead.join(" "))} | ${mdCell(row.notDeclared.join(" "))} |`,
517
+ );
518
+ }
519
+ out.push("");
520
+ }
521
+
522
+ if (report.metrics) {
523
+ out.push("## Recurring failures");
524
+ out.push("");
525
+ out.push(
526
+ `${report.metrics.firstTry}/${report.metrics.generations} generation(s) passed first try (${(report.metrics.north * 100).toFixed(0)}%), ${report.metrics.runs} run(s) logged.`,
527
+ );
528
+ if (report.metrics.top.length) {
529
+ out.push("");
530
+ out.push("| code | times | rule |");
531
+ out.push("| --- | --- | --- |");
532
+ for (const row of report.metrics.top) {
533
+ out.push(`| ${row.code} | ${row.count} | ${mdCell(row.rule || "— no rule owns this one yet")} |`);
534
+ }
535
+ }
536
+ out.push("");
537
+ }
538
+
539
+ if (report.notes.length) {
540
+ out.push("## Notes");
541
+ out.push("");
542
+ for (const note of report.notes) out.push(`- ${mdCell(note)}`);
543
+ out.push("");
544
+ }
545
+
546
+ out.push("---");
547
+ out.push("");
548
+ out.push("Read-only diagnosis. Regenerate with `flet-box report`, or `flet-box report --html` for the same page as a file.");
549
+ return out.join("\n");
550
+ };
551
+
552
+ /* ------------------------------------------------------------------- html */
553
+
554
+ const esc = (value) =>
555
+ String(value ?? "")
556
+ .replace(/&/g, "&amp;")
557
+ .replace(/</g, "&lt;")
558
+ .replace(/>/g, "&gt;")
559
+ .replace(/"/g, "&quot;");
560
+
561
+ const badge = (verdict) => {
562
+ const label = { error: "error", warn: "warning", review: "review", clean: "clean" }[verdict] || verdict;
563
+ return `<span class="badge ${esc(verdict)}">${esc(label)}</span>`;
564
+ };
565
+
566
+ /**
567
+ * The report as one self-contained page — no fonts, no scripts, no requests.
568
+ *
569
+ * A report that needs the network to render is a report that cannot be opened
570
+ * on the machine where the code lives. Everything here is inline, the only
571
+ * external thing named is the command that regenerates it.
572
+ *
573
+ * @param {object} report
574
+ * @returns {string}
575
+ */
576
+ export const renderHtml = (report) => {
577
+ const { summary } = report;
578
+ const title = `FletBox report — ${path.basename(report.root)}`;
579
+ const createdAt = report.generatedAt.replace("T", " ").replace(/\.\d+Z$/, " UTC");
580
+ const cards = [
581
+ ["Files", summary.files, `${summary.clean} clean`],
582
+ ["Errors", summary.errors, `${summary.warnings} warning(s)`],
583
+ ["Widgets", summary.widgets, `${summary.widgetsWithDebt} with gaps`],
584
+ ["First try", report.metrics ? `${(report.metrics.north * 100).toFixed(0)}%` : "—", report.metrics ? `${report.metrics.firstTry}/${report.metrics.generations} generations` : "no log"],
585
+ ]
586
+ .map(
587
+ ([label, value, detail]) => `<div class="card"><span class="card-label">${esc(label)}</span><strong class="card-value">${esc(value)}</strong><span class="card-detail">${esc(detail)}</span></div>`,
588
+ )
589
+ .join("");
590
+
591
+ const gateRows = report.gates
592
+ .map((gate) => {
593
+ const state = gate.skipped ? `skipped — ${gate.skipped}` : gate.exitCode ? "failed" : "clean";
594
+ return `<tr><td><code>${esc(gate.name)}</code></td><td>${gate.exitCode ? '<span class="badge error">failed</span>' : '<span class="badge clean">clean</span>'}</td><td>${esc(state)}</td><td class="num">${gate.errors}</td><td class="num">${gate.warnings}</td><td class="num">${gate.review}</td></tr>`;
595
+ })
596
+ .join("");
597
+
598
+ const fileRows = report.files
599
+ .map((entry) => {
600
+ const findings = entry.findings.length
601
+ ? `<details><summary>${entry.findings.length} finding(s)</summary><ul class="findings">${entry.findings
602
+ .map(
603
+ (finding) =>
604
+ `<li><code>${esc(finding.line)}${finding.column ? `:${finding.column}` : ""}</code> <code class="code">${esc(finding.code)}</code> ${esc(finding.message)}${finding.suggestion ? `<br><span class="fix">→ ${esc(finding.suggestion)}</span>` : ""}</li>`,
605
+ )
606
+ .join("")}</ul></details>`
607
+ : "";
608
+ return `<tr class="${esc(entry.verdict)}">
609
+ <td><code>${esc(entry.file)}</code>${findings}</td>
610
+ <td>${badge(entry.verdict)}</td>
611
+ <td class="num">${entry.lines}</td>
612
+ <td class="num">${entry.errors}/${entry.warnings}/${entry.review}</td>
613
+ <td>${esc(entry.widgets.join(", ") || "—")}</td>
614
+ </tr>`;
615
+ })
616
+ .join("");
617
+
618
+ const debtRows = report.widgets?.withDebt.length
619
+ ? report.widgets.withDebt
620
+ .map(
621
+ (row) => `<tr>
622
+ <td><strong>${esc(row.name)}</strong></td>
623
+ <td class="num">${row.props}</td>
624
+ <td>${esc(row.invented.join(" ") || "—")}</td>
625
+ <td>${esc(row.dead.join(" ") || "—")}</td>
626
+ <td>${esc(row.notDeclared.join(" ") || "—")}</td>
627
+ <td>${esc(row.docOnly.join(" ") || "—")}</td>
628
+ </tr>`,
629
+ )
630
+ .join("")
631
+ : "";
632
+
633
+ const outsideBlock = report.outside.length
634
+ ? `<section><h2>Outside the project</h2><p class="muted">These findings name the installed package, not your code — the framework's own debt.</p><ul class="findings">${report.outside
635
+ .map((item) => `<li><code class="code">${esc(item.code)}</code> ${esc(item.message)}</li>`)
636
+ .join("")}</ul></section>`
637
+ : "";
638
+
639
+ const metricsBlock = report.metrics
640
+ ? `<section><h2>Recurring failures</h2>
641
+ <p>${report.metrics.firstTry}/${report.metrics.generations} generation(s) passed with no correction (${(report.metrics.north * 100).toFixed(0)}%), ${report.metrics.runs} run(s) logged.</p>
642
+ ${
643
+ report.metrics.top.length
644
+ ? `<table><thead><tr><th>code</th><th class="num">times</th><th>rule</th></tr></thead><tbody>${report.metrics.top
645
+ .map((row) => `<tr><td><code class="code">${esc(row.code)}</code></td><td class="num">${row.count}</td><td>${esc(row.rule || "— no rule owns this one yet")}</td></tr>`)
646
+ .join("")}</tbody></table>`
647
+ : ""
648
+ }</section>`
649
+ : "";
650
+
651
+ return `<!doctype html>
652
+ <html lang="en">
653
+ <head>
654
+ <meta charset="utf-8">
655
+ <meta name="viewport" content="width=device-width, initial-scale=1">
656
+ <title>${esc(title)}</title>
657
+ <style>
658
+ :root {
659
+ --bg: #f6f7fb; --surface: #ffffff; --text: #0f172a; --muted: #526077;
660
+ --border: #e2e8f0; --accent: #4f46e5; --accent-soft: #eef2ff;
661
+ --error: #b91c1c; --error-soft: #fee2e2;
662
+ --warn: #92400e; --warn-soft: #fef3c7;
663
+ --ok: #166534; --ok-soft: #dcfce7;
664
+ --radius: 12px;
665
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
666
+ }
667
+ * { box-sizing: border-box; }
668
+ body {
669
+ margin: 0; background: var(--bg); color: var(--text);
670
+ font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
671
+ }
672
+ main { max-width: 1080px; margin: 0 auto; padding: 32px 20px 64px; }
673
+ header { margin-bottom: 24px; }
674
+ h1 { margin: 0 0 4px; font-size: 28px; line-height: 1.2; letter-spacing: -0.01em; }
675
+ .sub { color: var(--muted); font-size: 14px; }
676
+ .verdict { display: inline-flex; align-items: center; gap: 8px; margin-top: 12px; padding: 6px 12px; border-radius: 999px; font-weight: 600; font-size: 14px; }
677
+ .verdict.ok { background: var(--ok-soft); color: var(--ok); }
678
+ .verdict.bad { background: var(--error-soft); color: var(--error); }
679
+ .cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 20px 0 28px; }
680
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
681
+ .card-label { display: block; color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: .04em; }
682
+ .card-value { display: block; font-size: 28px; line-height: 1.2; margin: 4px 0 2px; }
683
+ .card-detail { color: var(--muted); font-size: 13px; }
684
+ section, .panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 20px; }
685
+ h2 { margin: 0 0 12px; font-size: 18px; }
686
+ p { margin: 8px 0; }
687
+ .muted { color: var(--muted); font-size: 14px; }
688
+ code { font-family: var(--mono); font-size: 13px; background: var(--accent-soft); border-radius: 6px; padding: 1px 5px; }
689
+ .code { color: var(--accent); background: transparent; font-weight: 600; }
690
+ table { width: 100%; border-collapse: collapse; font-size: 14px; }
691
+ th, td { text-align: left; vertical-align: top; padding: 8px; border-bottom: 1px solid var(--border); }
692
+ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
693
+ tr:last-child td { border-bottom: 0; }
694
+ .num { text-align: right; font-variant-numeric: tabular-nums; }
695
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 600; }
696
+ .badge.error { background: var(--error-soft); color: var(--error); }
697
+ .badge.warn { background: var(--warn-soft); color: var(--warn); }
698
+ .badge.review { background: var(--accent-soft); color: var(--accent); }
699
+ .badge.clean { background: var(--ok-soft); color: var(--ok); }
700
+ ol.next { list-style: none; margin: 0; padding: 0; }
701
+ .next-item { border-left: 3px solid var(--border); padding: 10px 0 10px 14px; margin-bottom: 10px; }
702
+ .next-item.error { border-color: var(--error); }
703
+ .next-item.warn { border-color: #d97706; }
704
+ .next-item.info { border-color: var(--accent); }
705
+ .next-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
706
+ .fix { color: var(--muted); font-size: 14px; }
707
+ .rule { color: var(--muted); font-size: 13px; font-style: italic; }
708
+ .empty { color: var(--ok); font-weight: 600; }
709
+ details { margin-top: 6px; }
710
+ summary { cursor: pointer; color: var(--accent); font-size: 13px; }
711
+ .findings { margin: 8px 0 0; padding-left: 18px; }
712
+ .findings li { margin-bottom: 6px; font-size: 13px; }
713
+ footer { color: var(--muted); font-size: 13px; text-align: center; margin-top: 8px; }
714
+ @media (max-width: 760px) {
715
+ .cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
716
+ main { padding: 20px 14px 48px; }
717
+ table { font-size: 13px; }
718
+ }
719
+ @media print {
720
+ body { background: #fff; }
721
+ section, .panel { break-inside: avoid; }
722
+ }
723
+ </style>
724
+ </head>
725
+ <body>
726
+ <main>
727
+ <header>
728
+ <h1>${esc(title)}</h1>
729
+ <div class="sub">flet-box@${esc(report.package)} · ${esc(createdAt)} · read-only diagnosis · ${report.next.length} finding(s)</div>
730
+ <div class="verdict ${report.ok ? "ok" : "bad"}">${report.ok ? "No errors" : `${summary.errors} error(s), ${summary.warnings} warning(s)`}</div>
731
+ </header>
732
+
733
+ <div class="cards">${cards}</div>
734
+
735
+ <section>
736
+ <h2>Gates</h2>
737
+ <table>
738
+ <thead><tr><th>gate</th><th>result</th><th>detail</th><th class="num">errors</th><th class="num">warnings</th><th class="num">review</th></tr></thead>
739
+ <tbody>${gateRows}</tbody>
740
+ </table>
741
+ </section>
742
+
743
+ <section>
744
+ <h2>Next${report.next.length > NEXT_LIMIT ? ` (first ${NEXT_LIMIT} of ${report.next.length})` : ""}</h2>
745
+ <ol class="next">${report.next
746
+ .slice(0, NEXT_LIMIT)
747
+ .map((item) => {
748
+ const at = item.file
749
+ ? `<code>${esc(item.file)}:${item.line}${item.column ? `:${item.column}` : ""}</code>`
750
+ : `<code>${esc(item.gate)}</code>`;
751
+ return `<li class="next-item ${esc(item.severity)}">
752
+ <div class="next-head">${badge(item.severity)} ${at} <code class="code">${esc(item.code)}</code></div>
753
+ <p>${esc(item.message)}</p>
754
+ ${item.fix ? `<p class="fix">→ ${esc(item.fix)}</p>` : ""}
755
+ ${item.rule ? `<p class="rule">rule: ${esc(item.rule)}</p>` : ""}
756
+ </li>`;
757
+ })
758
+ .join("")}</ol>
759
+ ${report.next.length > NEXT_LIMIT ? `<p class="muted">… ${report.next.length - NEXT_LIMIT} more in <code>flet-box report --json</code>.</p>` : ""}
760
+ </section>
761
+
762
+ <section>
763
+ <h2>Files (${summary.files})</h2>
764
+ <p class="muted">Verdict per file · errors / warnings / review · widgets imported from the package.</p>
765
+ <table>
766
+ <thead><tr><th>file</th><th>verdict</th><th class="num">lines</th><th class="num">E / W / R</th><th>widgets</th></tr></thead>
767
+ <tbody>${fileRows}</tbody>
768
+ </table>
769
+ </section>
770
+
771
+ ${
772
+ debtRows
773
+ ? `<section>
774
+ <h2>Widget contract gaps (${report.widgets.withDebt.length}/${report.widgets.total})</h2>
775
+ <p class="muted">“invented” is read but declared nowhere · “dead” is declared but read by nothing · “not in .d.ts” is a real prop the declaration forgets.</p>
776
+ <table>
777
+ <thead><tr><th>widget</th><th class="num">props</th><th>invented</th><th>dead</th><th>not in .d.ts</th><th>doc-only</th></tr></thead>
778
+ <tbody>${debtRows}</tbody>
779
+ </table>
780
+ </section>`
781
+ : ""
782
+ }
783
+
784
+ ${outsideBlock}
785
+ ${metricsBlock}
786
+ ${report.notes.length ? `<section><h2>Notes</h2><ul>${report.notes.map((note) => `<li>${esc(note)}</li>`).join("")}</ul></section>` : ""}
787
+
788
+ <footer>
789
+ Generated by <code>flet-box report</code> on ${esc(createdAt)}. This file changes nothing;
790
+ re-run the command after the next edit.
791
+ </footer>
792
+ </main>
793
+ </body>
794
+ </html>
795
+ `;
796
+ };
797
+
798
+ /* -------------------------------------------------------------------- run */
799
+
800
+ /**
801
+ * `flet-box report [--json | --md | --html] [--out <file>] [--tests] [--root <dir>]`
802
+ *
803
+ * @param {string[]} argv
804
+ * @param {{print?: (line: string) => void, root?: string}} [io]
805
+ * @returns {Promise<{exitCode: number, report: object}>}
806
+ */
807
+ export const report = async (argv = [], io = {}) => {
808
+ const print = io.print ?? ((line) => console.log(line));
809
+ const rootIndex = argv.indexOf("--root");
810
+ const root = path.resolve(
811
+ rootIndex >= 0 && argv[rootIndex + 1] ? argv[rootIndex + 1] : io.root || process.cwd(),
812
+ );
813
+
814
+ const built = await buildReport({
815
+ root,
816
+ tests: argv.includes("--tests"),
817
+ print: argv.includes("--tests") ? print : undefined,
818
+ });
819
+ const exitCode = built.ok ? 0 : 1;
820
+
821
+ if (argv.includes("--json")) {
822
+ print(JSON.stringify(built, null, 2));
823
+ return { exitCode, report: built };
824
+ }
825
+ if (argv.includes("--md")) {
826
+ print(renderMarkdown(built));
827
+ return { exitCode, report: built };
828
+ }
829
+ if (argv.includes("--html")) {
830
+ const outIndex = argv.indexOf("--out");
831
+ const target = path.resolve(
832
+ root,
833
+ outIndex >= 0 && argv[outIndex + 1]
834
+ ? argv[outIndex + 1]
835
+ : path.join(".fletbox", "reports", "report.html"),
836
+ );
837
+ fs.mkdirSync(path.dirname(target), { recursive: true });
838
+ fs.writeFileSync(target, renderHtml(built), "utf8");
839
+ for (const line of renderText(built)) print(line);
840
+ print("");
841
+ print(c("gray", ` page → ${path.relative(root, target) || target}`));
842
+ return { exitCode, report: built };
843
+ }
844
+
845
+ for (const line of renderText(built)) print(line);
846
+ return { exitCode, report: built };
847
+ };
848
+
849
+ export default report;