flet-box 1.1.1 → 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 (156) hide show
  1. package/AGENTS_FOR_CODE_GEN.md +284 -0
  2. package/CONTRIBUTING.md +238 -0
  3. package/README.md +17 -13
  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 +18 -1
  11. package/bin/commands/createBundle.js +20 -8
  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/bundleConfig.js +47 -3
  20. package/bin/utils/contract.js +27 -0
  21. package/bin/utils/dtsProps.js +5 -1
  22. package/bin/utils/migrationGuide.js +146 -0
  23. package/bin/utils/moduleExports.js +9 -5
  24. package/bin/utils/templates.js +248 -30
  25. package/bin/utils/toolkit.js +191 -0
  26. package/docs/README.md +26 -1
  27. package/docs/arquitectura.md +1 -1
  28. package/docs/cli/README.md +201 -11
  29. package/docs/guides/README.md +6 -0
  30. package/docs/guides/accessibility.md +2 -1
  31. package/docs/guides/ai-toolkit.md +166 -0
  32. package/docs/guides/compatibility.md +1 -1
  33. package/docs/guides/first-app.md +6 -7
  34. package/docs/guides/migration.md +92 -0
  35. package/docs/guides/minification-and-protection.md +2 -1
  36. package/docs/guides/mobile-and-platforms.md +1 -1
  37. package/docs/guides/online-updates.md +20 -6
  38. package/docs/server/deployment.md +1 -1
  39. package/docs/tools/styling.md +2 -2
  40. package/docs/tools/theme.md +139 -4
  41. package/docs/widget/Accordion.md +74 -8
  42. package/docs/widget/AdMob.md +68 -3
  43. package/docs/widget/AdSense.md +67 -2
  44. package/docs/widget/AlertDialog.md +1 -1
  45. package/docs/widget/AnimatedBox.md +80 -39
  46. package/docs/widget/AnimatedText.md +74 -18
  47. package/docs/widget/Audio.md +73 -14
  48. package/docs/widget/Avatar.md +69 -4
  49. package/docs/widget/Badge.md +68 -3
  50. package/docs/widget/BottomSheet.md +2 -2
  51. package/docs/widget/Button.md +69 -3
  52. package/docs/widget/Card.md +69 -4
  53. package/docs/widget/Carousel.md +67 -2
  54. package/docs/widget/Chart.md +67 -2
  55. package/docs/widget/Checkbox.md +74 -8
  56. package/docs/widget/Chip.md +68 -3
  57. package/docs/widget/CircularBar.md +74 -15
  58. package/docs/widget/CircularChart.md +71 -10
  59. package/docs/widget/CodeViewer.md +67 -2
  60. package/docs/widget/Column.md +68 -3
  61. package/docs/widget/Container.md +68 -3
  62. package/docs/widget/DataTable.md +67 -2
  63. package/docs/widget/Divider.md +67 -2
  64. package/docs/widget/DraggBox.md +67 -2
  65. package/docs/widget/Dropdown.md +76 -10
  66. package/docs/widget/DroppBox.md +67 -2
  67. package/docs/widget/FloatingActionButton.md +68 -3
  68. package/docs/widget/GridView.md +67 -2
  69. package/docs/widget/Icon.md +71 -5
  70. package/docs/widget/Image.md +68 -3
  71. package/docs/widget/Input.md +68 -3
  72. package/docs/widget/Inspector.md +2 -2
  73. package/docs/widget/InstallButton.md +73 -13
  74. package/docs/widget/ListTile.md +69 -3
  75. package/docs/widget/ListView.md +67 -2
  76. package/docs/widget/Maps.md +68 -3
  77. package/docs/widget/Markdown.md +74 -15
  78. package/docs/widget/MatrixRain.md +72 -11
  79. package/docs/widget/Menu.md +209 -0
  80. package/docs/widget/Modal.md +3 -3
  81. package/docs/widget/Pagination.md +68 -3
  82. package/docs/widget/ParallaxBox.md +72 -12
  83. package/docs/widget/ProgressBar.md +69 -4
  84. package/docs/widget/QRCode.md +67 -2
  85. package/docs/widget/README.md +54 -50
  86. package/docs/widget/Radio.md +76 -10
  87. package/docs/widget/Rating.md +70 -6
  88. package/docs/widget/Row.md +68 -3
  89. package/docs/widget/Skeleton.md +68 -3
  90. package/docs/widget/Slider.md +75 -9
  91. package/docs/widget/SnackBar.md +2 -2
  92. package/docs/widget/Stack.md +68 -3
  93. package/docs/widget/Stepper.md +68 -3
  94. package/docs/widget/Switch.md +74 -8
  95. package/docs/widget/Text.md +71 -4
  96. package/docs/widget/ThemePicker.md +197 -0
  97. package/docs/widget/Tooltip.md +72 -7
  98. package/docs/widget/TreeView.md +70 -10
  99. package/docs/widget/Video.md +71 -10
  100. package/package.json +10 -3
  101. package/skills/fletbox-design/SKILL.md +89 -5
  102. package/skills/fletbox-motion/SKILL.md +1 -1
  103. package/skills/fletbox-rules/SKILL.md +56 -4
  104. package/src/core/runApp.js +12 -0
  105. package/src/index.d.ts +374 -101
  106. package/src/index.js +9 -0
  107. package/src/navigations/AdaptiveScaffold.js +2 -2
  108. package/src/navigations/BottomNavigation.js +1 -1
  109. package/src/navigations/Scaffold.js +18 -6
  110. package/src/tools/useState.js +11 -4
  111. package/src/utils/colors.js +275 -0
  112. package/src/utils/errorBoundary.js +288 -0
  113. package/src/utils/index.js +10 -2
  114. package/src/utils/stackPosition.js +39 -24
  115. package/src/utils/syntaxHighlight.js +4 -1
  116. package/src/utils/themes.js +279 -3
  117. package/src/widget-factory/addChildren.js +17 -10
  118. package/src/widget-factory/assignProps.js +18 -6
  119. package/src/widget-factory/createWidget.js +7 -0
  120. package/src/widget-factory/deprecations.js +77 -0
  121. package/src/widget-factory/translateProps.js +11 -1
  122. package/src/widget-factory/warnings.js +1 -27
  123. package/src/widget-factory/widgetFactory.js +59 -17
  124. package/src/widgets/Accordion.js +21 -7
  125. package/src/widgets/Badge.js +11 -2
  126. package/src/widgets/Button.js +15 -3
  127. package/src/widgets/Carousel.js +12 -4
  128. package/src/widgets/Chart.js +7 -2
  129. package/src/widgets/Checkbox.js +1 -2
  130. package/src/widgets/Chip.js +24 -10
  131. package/src/widgets/CircularBar.js +9 -3
  132. package/src/widgets/CircularChart.js +5 -3
  133. package/src/widgets/Container.js +6 -0
  134. package/src/widgets/Divider.js +3 -1
  135. package/src/widgets/DraggBox.js +9 -0
  136. package/src/widgets/Dropdown.js +3 -3
  137. package/src/widgets/DroppBox.js +5 -0
  138. package/src/widgets/FloatingActionButton.js +1 -1
  139. package/src/widgets/Icon.js +5 -1
  140. package/src/widgets/InstallButton.js +4 -2
  141. package/src/widgets/Markdown.js +171 -103
  142. package/src/widgets/Menu.js +340 -0
  143. package/src/widgets/Modal.js +7 -1
  144. package/src/widgets/Pagination.js +10 -5
  145. package/src/widgets/ProgressBar.js +5 -4
  146. package/src/widgets/QRCode.js +4 -0
  147. package/src/widgets/Radio.js +1 -2
  148. package/src/widgets/Rating.js +9 -0
  149. package/src/widgets/Slider.js +4 -1
  150. package/src/widgets/SnackBar.js +2 -2
  151. package/src/widgets/Switch.js +1 -2
  152. package/src/widgets/Text.js +9 -5
  153. package/src/widgets/ThemePicker.js +234 -0
  154. package/src/widgets/TreeView.js +2 -1
  155. package/src/widgets/index.js +2 -0
  156. package/docs/CONTRIBUTING.md +0 -156
@@ -0,0 +1,879 @@
1
+ /**
2
+ * @file bin/commands/verify.js
3
+ * @description One command, one report, one exit code — every gate at once.
4
+ *
5
+ * An agent that writes code needs a single question answered: "may I say this
6
+ * is done?". Asking it once per gate (`npm run check`, then `npm run ui`, then
7
+ * `npm run api`) means the agent stops at the first green one, and the report
8
+ * it hands back names a gate instead of a line.
9
+ *
10
+ * So the gates run here, in parallel, and the runner only counts and prints:
11
+ *
12
+ * syntax — the parse gate: every module is handed to the Node parser, so a
13
+ * file that cannot load is named by line and column before any
14
+ * other gate reads it as text.
15
+ * api — the anti-hallucination gate: invented widgets and props,
16
+ * positional arguments, foreign imports, broken imports.
17
+ * types — the editor's own question, asked outside the editor: the
18
+ * project's `jsconfig.json`, run through the TypeScript checker,
19
+ * so `estado.update()` on a widget and a prop the declaration
20
+ * forgot are reported as `file:line:column`, not as a squiggle
21
+ * somebody learns to ignore. Skipped when TypeScript is absent
22
+ * (the editor brings its own, an installed app has neither).
23
+ * ui — the design gate: tokens, the 8-based scale, tap targets,
24
+ * labels, contrast.
25
+ * structure — how the app's own modules are wired: a cycle between two of
26
+ * them, a module outside the folders the project is built from,
27
+ * markup written by hand.
28
+ * surface — the prop contract: a prop the widget reads but nothing declares
29
+ * is an error (the surface lies); a prop declared but nobody
30
+ * reads is a warning (the docs promise more than the code does).
31
+ * tests — the project's own suites (`test`, `test:browser`, `test:visual`),
32
+ * run one after another; a failing suite's own last words are the
33
+ * finding, because that is where its rule is named.
34
+ *
35
+ * A new gate is one entry in `GATES` and `GATE_ORDER` and nothing else.
36
+ *
37
+ * Node core only: this file ships inside every app `flet-box create` builds,
38
+ * so it may not reach for a devDependency.
39
+ */
40
+
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+ import { execFile } from "node:child_process";
44
+ import { createRequire } from "node:module";
45
+ import { checkApi, loadApi, SEVERITY } from "./checkApi.js";
46
+ import { checkUi, ERROR_CODES, WARN_CODES, INFO_CODES } from "./checkUi.js";
47
+ import { scanStructure } from "./checkStructure.js";
48
+ import { scanSyntax } from "./checkSyntax.js";
49
+ import { auditFix } from "./auditFix.js";
50
+ import { metricsLog } from "./metrics.js";
51
+ import { definitionIndex, propsSource, unpackWidget } from "./unpack.js";
52
+ import { isFrameworkProject } from "../utils/migrationGuide.js";
53
+ import { c, banner, section } from "../utils/colors.js";
54
+
55
+ /**
56
+ * The severity a design-gate code carries. The gate knows which codes are
57
+ * errors and which are a reviewer's job; the report must not call a review
58
+ * an error, or an agent will "fix" a warning and break the screen.
59
+ *
60
+ * @param {string} code
61
+ * @returns {"error"|"warn"|"info"}
62
+ */
63
+ const designSeverity = (code) => {
64
+ if (ERROR_CODES.has(code)) return "error";
65
+ if (WARN_CODES.has(code)) return "warn";
66
+ if (INFO_CODES.has(code)) return "info";
67
+ return "error";
68
+ };
69
+
70
+ /**
71
+ * Normalise a finding so the report does not care which gate produced it.
72
+ *
73
+ * @param {object} finding
74
+ * @param {"error"|"warn"|"info"} [severity]
75
+ * @returns {object}
76
+ */
77
+ const shape = (finding, severity) => ({
78
+ code: finding.code || "UNKNOWN",
79
+ file: finding.file || null,
80
+ line: finding.line || 0,
81
+ column: finding.column || 0,
82
+ message: finding.message || "",
83
+ detail: finding.detail || "",
84
+ suggestion: finding.suggestion || null,
85
+ severity: severity || finding.severity || "error",
86
+ });
87
+
88
+ /**
89
+ * The path a finding names, relative to the project it belongs to.
90
+ *
91
+ * @param {string|null} file
92
+ * @param {string} root
93
+ * @returns {string|null}
94
+ */
95
+ const relative = (file, root) => {
96
+ if (!file) return null;
97
+ const absolute = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
98
+ const rel = path.relative(root, absolute);
99
+ return rel && !rel.startsWith("..") ? rel : absolute;
100
+ };
101
+
102
+ /* ------------------------------------------------------------------ gates */
103
+
104
+ const syntaxGate = async (root) => {
105
+ const findings = await scanSyntax(root);
106
+ return {
107
+ name: "syntax",
108
+ title: "Parse gate",
109
+ findings: findings.map((finding) => shape(finding)),
110
+ exitCode: findings.length ? 1 : 0,
111
+ };
112
+ };
113
+
114
+ const apiGate = async (root) => {
115
+ const { findings, exitCode } = await checkApi(["--root", root], { print: () => {} });
116
+ return {
117
+ name: "api",
118
+ title: "Anti-hallucination gate",
119
+ findings: findings.map((finding) =>
120
+ shape(finding, SEVERITY[finding.code] === "error" ? "error" : "warn"),
121
+ ),
122
+ exitCode,
123
+ };
124
+ };
125
+
126
+ /**
127
+ * The design findings this checkout has already decided about.
128
+ *
129
+ * The framework runs its own clean-UI gate over `src/`, and two of its findings
130
+ * are not defects: they are rules written for a screen, applied to a module that
131
+ * is not one. `scripts/check-framework-ui.mjs` already knows that, through
132
+ * `scripts/framework-ui-baseline.json`, where every entry carries its reason
133
+ * (and a new one cannot be recorded without one). `verify` did not, so every run
134
+ * printed those two under "what to review" — and a review list that never
135
+ * changes is a list an agent learns to skip, which costs it the finding that
136
+ * does matter.
137
+ *
138
+ * Only the framework checkout reads the file: a consumer's app has no such
139
+ * baseline, and its findings are its own to answer.
140
+ *
141
+ * @param {string} root
142
+ * @returns {Map<string, string>} `file:code` → the reason it was accepted
143
+ */
144
+ const acceptedDesignFindings = (root) => {
145
+ if (!isFrameworkProject(root)) return new Map();
146
+ try {
147
+ const text = fs.readFileSync(path.join(root, "scripts", "framework-ui-baseline.json"), "utf8");
148
+ return new Map(Object.entries(JSON.parse(text)));
149
+ } catch {
150
+ return new Map();
151
+ }
152
+ };
153
+
154
+ const uiGate = async (root) => {
155
+ const { findings } = await checkUi(["--root", root], { print: () => {} });
156
+ const accepted = acceptedDesignFindings(root);
157
+ // The baseline key drops the line number on purpose: editing anything above a
158
+ // finding must not invalidate a decision about the file.
159
+ const keyOf = (finding) =>
160
+ finding.file ? `${relative(finding.file, root).split(path.sep).join("/")}:${finding.code}` : "";
161
+ const kept = [];
162
+ const decided = [];
163
+ for (const finding of findings) {
164
+ const why = accepted.get(keyOf(finding));
165
+ if (why === undefined) kept.push(finding);
166
+ else decided.push({ file: relative(finding.file, root), code: finding.code, why });
167
+ }
168
+ return {
169
+ name: "ui",
170
+ title: "Design gate",
171
+ findings: kept.map((finding) => shape(finding, designSeverity(finding.code))),
172
+ accepted: decided,
173
+ // An accepted finding is decided, so it cannot fail the run — including one
174
+ // whose code is an error elsewhere, which is what its written reason is for.
175
+ exitCode: kept.some((finding) => designSeverity(finding.code) === "error") ? 1 : 0,
176
+ };
177
+ };
178
+
179
+ const structureGate = async (root) => {
180
+ const findings = await scanStructure(root);
181
+ return {
182
+ name: "structure",
183
+ title: "Structural gate",
184
+ findings: findings.map((finding) => shape(finding)),
185
+ exitCode: findings.some((item) => item.severity === "error") ? 1 : 0,
186
+ };
187
+ };
188
+
189
+ const surfaceGate = async (root) => {
190
+ const api = await loadApi(root);
191
+ const definitions = definitionIndex(api.dir);
192
+ const widgets = api.exports
193
+ .filter((name) => /^[A-Z]/.test(name) && propsSource(api, name))
194
+ .sort();
195
+ const findings = [];
196
+ // The same pass that produces the findings also produces the per-widget
197
+ // ledger `flet-box report` prints. `unpackWidget` is the expensive half of
198
+ // this gate, and a report that unpacked the surface a second time would make
199
+ // the diagnosis cost more than the run it diagnoses.
200
+ const ledger = [];
201
+ for (const name of widgets) {
202
+ const data = unpackWidget(api, name, definitions);
203
+ const declaration = data.interface || `${name}Props`;
204
+ ledger.push({
205
+ name,
206
+ interface: data.interface,
207
+ props: data.props.length,
208
+ invented: data.gaps.invented.map((prop) => prop.prop),
209
+ dead: data.gaps.dead.map((prop) => prop.prop),
210
+ notDeclared: data.gaps.hidden.map((prop) => prop.prop),
211
+ docOnly: data.gaps.docOnly,
212
+ codeOnly: data.gaps.codeOnly,
213
+ });
214
+ for (const prop of data.gaps.invented) {
215
+ findings.push(
216
+ shape({
217
+ code: "INVENTED_PROP",
218
+ file: data.sourceFile,
219
+ message: `${name}.${prop.prop} is read by the widget but declared nowhere — nobody can discover it`,
220
+ suggestion: `declare it in ${declaration} (src/index.d.ts)`,
221
+ }),
222
+ );
223
+ }
224
+ for (const prop of data.gaps.dead) {
225
+ findings.push(
226
+ shape({
227
+ code: "DEAD_PROP",
228
+ file: data.sourceFile,
229
+ message: `${name}.${prop.prop} is declared but nothing routes or reads it`,
230
+ suggestion: "wire it in the widget, or drop it from the declaration",
231
+ severity: "warn",
232
+ }),
233
+ );
234
+ }
235
+ }
236
+ const errors = findings.filter((item) => item.severity === "error");
237
+ return {
238
+ name: "surface",
239
+ title: "Prop contract",
240
+ findings,
241
+ widgets: ledger,
242
+ exitCode: errors.length ? 1 : 0,
243
+ };
244
+ };
245
+
246
+ /**
247
+ * The suites a project may declare, in the order they run.
248
+ *
249
+ * They are the project's own detection scripts — in this repository the static
250
+ * checkers, the browser harnesses and the visual baseline. The order is the
251
+ * cheap one first, so a broken rule is named before Chrome is launched.
252
+ */
253
+ const SUITES = ["test", "test:browser", "test:visual"];
254
+
255
+ /**
256
+ * The tail of a child's output, which is where a check says why it failed.
257
+ *
258
+ * @param {string} text
259
+ * @param {number} [count]
260
+ * @returns {string}
261
+ */
262
+ const lastLines = (text, count = 15) =>
263
+ String(text)
264
+ .split(/\r?\n/)
265
+ .map((line) => line.trimEnd())
266
+ .filter(Boolean)
267
+ .slice(-count)
268
+ .join("\n");
269
+
270
+ /**
271
+ * The project's own test scripts.
272
+ *
273
+ * A generated app declares none, so the gate says so instead of pretending to
274
+ * have run something. In this repository it is every suite `package.json`
275
+ * declares: the static checkers, the browser harnesses and the visual
276
+ * baseline — the command an agent runs after an edit has to be the same one
277
+ * that finds a parse error, a broken rule and a moved pixel, or the agent
278
+ * stops at the first green command it knows.
279
+ *
280
+ * The children run with `FLETBOX_VERIFY_NESTED` set, so a `test` script that
281
+ * itself calls `flet-box verify` cannot recurse: the nested run answers with
282
+ * the fast gates and returns.
283
+ *
284
+ * @param {string} root
285
+ * @param {{print?: (line: string) => void}} [context]
286
+ * @returns {Promise<object>}
287
+ */
288
+ const testsGate = async (root, context = {}) => {
289
+ const skipped = (why) => ({
290
+ name: "tests",
291
+ title: "Test suite",
292
+ findings: [],
293
+ exitCode: 0,
294
+ skipped: why,
295
+ });
296
+ if (process.env.FLETBOX_VERIFY_NESTED) return skipped("already inside a verify run");
297
+
298
+ let scripts = {};
299
+ try {
300
+ scripts = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).scripts ?? {};
301
+ } catch {
302
+ /* a project without a manifest declares no suite */
303
+ }
304
+ const declared = SUITES.filter((script) => scripts[script]);
305
+ if (!declared.length) return skipped("this project declares no test script");
306
+
307
+ const say = context.print ?? (() => {});
308
+ const findings = [];
309
+ let missingNpm = false;
310
+ for (const script of declared) {
311
+ say(c("gray", ` tests npm run ${script} …`));
312
+ const started = Date.now();
313
+ const result = await runTestSuite(root, script);
314
+ const seconds = ((Date.now() - started) / 1000).toFixed(1);
315
+ if (result.missing) {
316
+ missingNpm = true;
317
+ say(c("gray", ` tests npm run ${script} — skipped, npm is not on PATH`));
318
+ continue;
319
+ }
320
+ say(
321
+ result.code
322
+ ? c("red", ` tests npm run ${script} — exit ${result.code} (${seconds}s)`)
323
+ : c("gray", ` tests npm run ${script} — green (${seconds}s)`),
324
+ );
325
+ if (!result.code) continue;
326
+ findings.push(
327
+ shape({
328
+ code: "TEST_SUITE_FAILED",
329
+ message: `npm run ${script} exited ${result.code}`,
330
+ detail: lastLines(result.output),
331
+ suggestion: `run \`npm run ${script}\` alone — the check that failed names its own rule, and this is how its output ended`,
332
+ }),
333
+ );
334
+ }
335
+ if (missingNpm && !findings.length) return skipped("npm is not on PATH");
336
+ return {
337
+ name: "tests",
338
+ title: "Test suite",
339
+ findings,
340
+ exitCode: findings.length ? 1 : 0,
341
+ };
342
+ };
343
+
344
+ /**
345
+ * One diagnostic line from `tsc --pretty false`:
346
+ * `src/file.js(12,5): error TS2339: Property 'update' does not exist …`
347
+ *
348
+ * The message is kept with its `TS####` code: it is what the editor prints and
349
+ * what the reader can paste into a search, and it tells a missing property
350
+ * (2339) from a wrong union (2769) without parsing English.
351
+ */
352
+ const TYPE_DIAGNOSTIC = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/;
353
+
354
+ /**
355
+ * Resolve the compiler without depending on it.
356
+ *
357
+ * This file ships inside every app, so `typescript` is never a declared
358
+ * dependency of the project being checked: an editor brings its own, and an app
359
+ * installed from the registry has none. A missing compiler means the gate
360
+ * cannot answer and says so — it does not pass silently and it does not fail
361
+ * the build. `FLETBOX_TYPESCRIPT` points at a directory with `lib/tsc.js`.
362
+ *
363
+ * @returns {string|null} Absolute path to `tsc.js`.
364
+ */
365
+ const resolveTsc = () => {
366
+ const explicit = process.env.FLETBOX_TYPESCRIPT;
367
+ if (explicit) {
368
+ const candidate = path.join(explicit, "lib", "tsc.js");
369
+ return fs.existsSync(candidate) ? candidate : null;
370
+ }
371
+ try {
372
+ const require = createRequire(import.meta.url);
373
+ const candidate = path.join(path.dirname(require.resolve("typescript")), "tsc.js");
374
+ return fs.existsSync(candidate) ? candidate : null;
375
+ } catch {
376
+ return null;
377
+ }
378
+ };
379
+
380
+ /**
381
+ * The type gate: the project's own `jsconfig.json` through the real compiler.
382
+ *
383
+ * The check the editor runs is the one that was missing from `verify`: a widget
384
+ * declared `HTMLElement` made the documented `widget.update({ … })` a red
385
+ * squiggle in eight files, and nothing in the pipeline noticed, because
386
+ * `check-app-types` only proves the five generated templates. This gate reads
387
+ * the same `jsconfig.json` the editor reads, so the app and the editor cannot
388
+ * disagree about whose fault an error is.
389
+ *
390
+ * @param {string} root Project root.
391
+ * @returns {Promise<object>}
392
+ */
393
+ const typesGate = async (root) => {
394
+ const skipped = (why) => ({
395
+ name: "types",
396
+ title: "Type check",
397
+ findings: [],
398
+ exitCode: 0,
399
+ skipped: why,
400
+ });
401
+ const config = ["jsconfig.json", "tsconfig.json"].find((name) =>
402
+ fs.existsSync(path.join(root, name)),
403
+ );
404
+ if (!config) return skipped("no jsconfig.json — the editor has nothing to check");
405
+ const tsc = resolveTsc();
406
+ if (!tsc) return skipped("typescript is not installed — the editor brings its own");
407
+
408
+ const output = await new Promise((resolve) => {
409
+ execFile(
410
+ process.execPath,
411
+ [tsc, "-p", config, "--noEmit", "--pretty", "false"],
412
+ { cwd: root, maxBuffer: 32 * 1024 * 1024 },
413
+ (error, stdout, stderr) =>
414
+ resolve({
415
+ code: error ? (typeof error.code === "number" ? error.code : 1) : 0,
416
+ text: `${stdout ?? ""}${stderr ?? ""}`,
417
+ }),
418
+ );
419
+ });
420
+
421
+ const findings = [];
422
+ let last = null;
423
+ for (const raw of output.text.split("\n")) {
424
+ const line = raw.replace(/\r$/, "");
425
+ if (!line.trim()) continue;
426
+ const match = TYPE_DIAGNOSTIC.exec(line);
427
+ if (match) {
428
+ last = shape({
429
+ code: "TYPE_ERROR",
430
+ file: path.resolve(root, match[1]),
431
+ line: Number(match[2]),
432
+ column: Number(match[3]),
433
+ message: `${match[4]} ${match[5]}`,
434
+ });
435
+ findings.push(last);
436
+ } else if (last && /^\s/.test(line)) {
437
+ // tsc elaborates a type error over the next indented lines; they belong
438
+ // to the finding above, not to a finding of their own.
439
+ last.detail = last.detail ? `${last.detail}\n${line.trim()}` : line.trim();
440
+ } else if (!findings.length) {
441
+ // A failure that is not about one line (a bad `include`, a missing file)
442
+ // still has to be named, or a red gate would print no reason.
443
+ last = shape({ code: "TYPE_ERROR", message: line.trim() });
444
+ findings.push(last);
445
+ }
446
+ }
447
+ if (output.code !== 0 && !findings.length) {
448
+ findings.push(shape({ code: "TYPE_ERROR", message: `tsc exited ${output.code}` }));
449
+ }
450
+ return {
451
+ name: "types",
452
+ title: "Type check",
453
+ findings,
454
+ exitCode: output.code === 0 ? 0 : 1,
455
+ };
456
+ };
457
+
458
+ /**
459
+ * Every gate, by name.
460
+ *
461
+ * Exported with `GATE_ORDER` because `scripts/selftest-gates.mjs` holds the
462
+ * inventory: it proves the two lists agree (a gate in only one of them is a
463
+ * gate that never runs, or a name that runs nothing) and that every gate has a
464
+ * fixture. Without that, the coverage could shrink one entry at a time.
465
+ */
466
+ export const GATES = {
467
+ syntax: syntaxGate,
468
+ api: apiGate,
469
+ types: typesGate,
470
+ ui: uiGate,
471
+ structure: structureGate,
472
+ surface: surfaceGate,
473
+ tests: testsGate,
474
+ };
475
+
476
+ /** Every gate, in the order the report prints them. */
477
+ export const GATE_ORDER = ["syntax", "api", "types", "ui", "structure", "surface", "tests"];
478
+
479
+ /* ----------------------------------------------------------------- report */
480
+
481
+ /**
482
+ * How many review findings the report prints before it points at `--json`.
483
+ *
484
+ * A warning is a reading, not a failure, and a report nobody can scan is one
485
+ * nobody reads: past this many, the count still prints.
486
+ */
487
+ const REVIEW_LIMIT = 12;
488
+
489
+ /**
490
+ * The report: one line per gate, then the findings of the gates that failed,
491
+ * then the ones that are a reading rather than a failure.
492
+ *
493
+ * A warning is printed with its file, its line and its suggestion too. It used
494
+ * to be counted and never named — `ui 2 warning(s)` told an agent that
495
+ * something was wrong somewhere, which is the one thing it cannot act on.
496
+ *
497
+ * @param {object} report
498
+ * @returns {string[]}
499
+ */
500
+ export const formatReport = (report) => {
501
+ const lines = [];
502
+ const count = (severity) =>
503
+ report.steps.reduce(
504
+ (n, step) => n + step.findings.filter((item) => item.severity === severity).length,
505
+ 0,
506
+ );
507
+ const errors = count("error");
508
+ const warnings = count("warn");
509
+ const infos = count("info");
510
+ const failed = report.steps.filter((step) => step.exitCode);
511
+ const ok = failed.length === 0;
512
+
513
+ for (const step of report.steps) {
514
+ const stepErrors = step.findings.filter((item) => item.severity === "error").length;
515
+ const stepWarns = step.findings.filter((item) => item.severity === "warn").length;
516
+ const stepInfos = step.findings.filter((item) => item.severity === "info").length;
517
+ const mark = step.exitCode ? c("red", "✗") : c("green", "✓");
518
+ const counts = [
519
+ stepErrors ? c("red", `${stepErrors} error(s)`) : "",
520
+ stepWarns ? c("yellow", `${stepWarns} warning(s)`) : "",
521
+ stepInfos ? c("gray", `${stepInfos} to review`) : "",
522
+ ]
523
+ .filter(Boolean)
524
+ .join(", ");
525
+ // An accepted finding is a decision with a reason in the baseline file, not
526
+ // work: it is counted once, by name, so the number stays visible without
527
+ // asking the same question on every run.
528
+ const decided = step.accepted?.length
529
+ ? c("gray", `${step.accepted.length} accepted (framework-ui-baseline.json)`)
530
+ : "";
531
+ const status = step.skipped
532
+ ? c("gray", `skipped — ${step.skipped}`)
533
+ : [counts, decided].filter(Boolean).join(", ") || c("gray", "clean");
534
+ lines.push(` ${mark} ${c("brightCyan", step.name.padEnd(8))} ${status}`);
535
+ }
536
+
537
+ const failing = failed.flatMap((step) =>
538
+ step.findings
539
+ .filter((item) => item.severity === "error")
540
+ .map((item) => ({ ...item, gate: step.name })),
541
+ );
542
+ const printFinding = (item) => {
543
+ const at = item.column ? `${item.line}:${item.column}` : `${item.line}`;
544
+ const where = item.file
545
+ ? c("brightCyan", `${relative(item.file, report.root)}:${at}`)
546
+ : c("gray", item.gate);
547
+ lines.push(` ${where} ${c("yellow", item.code)} — ${item.message}`);
548
+ if (item.detail) {
549
+ for (const text of String(item.detail).split("\n")) lines.push(` ${c("gray", text)}`);
550
+ }
551
+ if (item.suggestion) lines.push(` ${c("gray", "→ " + item.suggestion)}`);
552
+ };
553
+
554
+ if (failing.length) {
555
+ lines.push("");
556
+ lines.push(section("•", "what to fix", "#f87171"));
557
+ for (const item of failing) printFinding(item);
558
+ }
559
+
560
+ const review = report.steps.flatMap((step) =>
561
+ step.findings
562
+ .filter((item) => item.severity !== "error")
563
+ .map((item) => ({ ...item, gate: step.name })),
564
+ );
565
+ if (review.length) {
566
+ lines.push("");
567
+ lines.push(section("•", "what to review", "#fbbf24"));
568
+ for (const item of review.slice(0, REVIEW_LIMIT)) printFinding(item);
569
+ if (review.length > REVIEW_LIMIT) {
570
+ lines.push(
571
+ ` ${c("gray", `… ${review.length - REVIEW_LIMIT} more — \`flet-box verify --json\` carries them all`)}`,
572
+ );
573
+ }
574
+ }
575
+
576
+ const suitesRan = report.steps.some((step) => step.name === "tests" && !step.skipped);
577
+ const counted = [`${errors} error(s)`];
578
+ if (warnings) counted.push(`${warnings} warning(s)`);
579
+ if (infos) counted.push(`${infos} to review`);
580
+ lines.push("");
581
+ lines.push(
582
+ ok
583
+ ? c(
584
+ "green",
585
+ `✅ ${counted.join(", ")} — the code parses, is legal, on the scale and on the tokens${suitesRan ? ", and every suite is green" : ""}.`,
586
+ )
587
+ : c(
588
+ "red",
589
+ `❌ ${errors} error(s), ${warnings} warning(s) — fix the line named above, then run it again.`,
590
+ ),
591
+ );
592
+ return lines;
593
+ };
594
+
595
+ /**
596
+ * The machine-readable report.
597
+ *
598
+ * @param {object} report
599
+ * @returns {object}
600
+ */
601
+ const asJson = (report) => ({
602
+ root: report.root,
603
+ package: report.package,
604
+ ok: report.steps.every((step) => !step.exitCode),
605
+ steps: report.steps.map((step) => ({
606
+ name: step.name,
607
+ title: step.title,
608
+ exitCode: step.exitCode,
609
+ skipped: step.skipped || null,
610
+ findings: step.findings,
611
+ // Present only for a gate that can accept a finding: the entries this
612
+ // checkout has decided about, with the reason it wrote down.
613
+ ...(step.accepted?.length ? { accepted: step.accepted } : {}),
614
+ })),
615
+ });
616
+
617
+ /* --------------------------------------------------------------- the log */
618
+
619
+ /**
620
+ * Append one row to the metrics log.
621
+ *
622
+ * The row is the run, not the session: when it happened, which project, whether
623
+ * the gates were green, which gates failed and which codes they reported. The
624
+ * metric script groups the rows into generations — a run of failing rows closed
625
+ * by the first green one — and reads the north metric off that.
626
+ *
627
+ * @param {string} root
628
+ * @param {object} report
629
+ * @returns {string|null} the row that was written
630
+ */
631
+ export const recordGeneration = (root, report) => {
632
+ const file = metricsLog(root);
633
+ if (!file) return null;
634
+ const failed = report.steps.filter((step) => step.exitCode);
635
+ const codes = [
636
+ ...new Set(
637
+ report.steps.flatMap((step) =>
638
+ step.findings.filter((item) => item.severity === "error").map((item) => item.code),
639
+ ),
640
+ ),
641
+ ];
642
+ const row = [
643
+ new Date().toISOString(),
644
+ path.basename(root),
645
+ failed.length ? "✗" : "✓",
646
+ failed.length ? failed.map((step) => step.name).join(", ") : "—",
647
+ codes.length ? codes.join(", ") : "—",
648
+ ].join(" | ");
649
+ try {
650
+ const text = fs.readFileSync(file, "utf8");
651
+ fs.appendFileSync(file, `${text.endsWith("\n") ? "" : "\n"}| ${row} |\n`, "utf8");
652
+ } catch {
653
+ return null;
654
+ }
655
+ return row;
656
+ };
657
+
658
+ /* -------------------------------------------------------------------- run */
659
+
660
+ /**
661
+ * Run every gate, in parallel, and collect what they say.
662
+ *
663
+ * @param {string} root Project root.
664
+ * @param {string[]} names Gate names to run.
665
+ * @param {{print?: (line: string) => void}} [context] Passed to a gate that reports progress.
666
+ * @returns {Promise<object>}
667
+ */
668
+ export const runGates = async (root, names, context = {}) => {
669
+ const steps = await Promise.all(
670
+ names.map(async (name) => {
671
+ const gate = GATES[name];
672
+ try {
673
+ const result = await gate(root, context);
674
+ return { ...result, name };
675
+ } catch (error) {
676
+ return {
677
+ name,
678
+ title: name,
679
+ findings: [
680
+ shape({
681
+ code: "GATE_FAILED",
682
+ message: error.message,
683
+ suggestion: "the gate could not run — check that flet-box is installed",
684
+ }),
685
+ ],
686
+ exitCode: 1,
687
+ };
688
+ }
689
+ }),
690
+ );
691
+ let version = "?";
692
+ try {
693
+ version = (await loadApi(root)).pkg.version;
694
+ } catch {
695
+ /* the gates report the missing package themselves */
696
+ }
697
+ return { root, package: version, steps };
698
+ };
699
+
700
+ /**
701
+ * The `flet-box verify` command.
702
+ *
703
+ * @param {string[]} argv Flags after the command name.
704
+ * @param {{print?: (line: string) => void, root?: string}} [io]
705
+ * @returns {Promise<{exitCode: number, report: object}>}
706
+ */
707
+ export const verify = async (argv = [], io = {}) => {
708
+ const print = io.print ?? ((line) => console.log(line));
709
+ const rootIndex = argv.indexOf("--root");
710
+ const root = path.resolve(
711
+ rootIndex >= 0 && argv[rootIndex + 1] ? argv[rootIndex + 1] : io.root || process.cwd(),
712
+ );
713
+ const only = argv.includes("--only") ? argv[argv.indexOf("--only") + 1] : null;
714
+ const watching = argv.includes("--watch");
715
+ // `--watch` starts the loop without the suites: the gate an editor reruns on
716
+ // every keystroke cannot spend a minute in Chrome. `--no-tests` is the same
717
+ // choice stated by the caller — a CI job that runs the suites in their own
718
+ // jobs uses it, so the report says what happened instead of borrowing the
719
+ // "already inside a verify run" note that only tells the truth about nesting.
720
+ // A plain `verify` still runs them, and `--only tests` runs them alone.
721
+ const skipTests = watching || argv.includes("--no-tests");
722
+ const names = only
723
+ ? GATE_ORDER.filter((name) => name === only)
724
+ : GATE_ORDER.filter((name) => !skipTests || name !== "tests");
725
+
726
+ if (!names.length) {
727
+ print(c("red", `Unknown gate "${only}" — the gates are: ${GATE_ORDER.join(", ")}`));
728
+ return { exitCode: 1, report: null };
729
+ }
730
+
731
+ // `--fix` runs the mechanical repair first, so a mistyped prop or a wrong
732
+ // relative path is corrected by the tool that can prove the correction and
733
+ // never reaches the agent as work to do.
734
+ if (argv.includes("--fix")) {
735
+ const fix = await auditFix(["--root", root], { print });
736
+ if (fix.changed.length) {
737
+ print(
738
+ c("gray", ` ${fix.changed.length} file(s) rewritten — the gates below read the result.`),
739
+ );
740
+ }
741
+ print("");
742
+ }
743
+
744
+ const report = await runGates(root, names, argv.includes("--json") ? {} : { print });
745
+ const failed = report.steps.some((step) => step.exitCode);
746
+ if (argv.includes("--json")) {
747
+ print(JSON.stringify(asJson(report), null, 2));
748
+ } else {
749
+ print(
750
+ banner(
751
+ failed ? "❌ FletBox verify" : "✅ FletBox verify",
752
+ `${path.basename(root)} · flet-box@${report.package}`,
753
+ ),
754
+ );
755
+ print("");
756
+ for (const line of formatReport(report)) print(line);
757
+ print("");
758
+ }
759
+
760
+ // The log is written for every run, not only for the green ones: a metric
761
+ // that counts only the successes cannot tell a correction from a first try.
762
+ const row = recordGeneration(root, report);
763
+ if (row && !argv.includes("--json")) {
764
+ print(c("gray", ` logged → ${metricsLog(root)}`));
765
+ print("");
766
+ }
767
+
768
+ if (argv.includes("--watch")) {
769
+ print(
770
+ c(
771
+ "dim",
772
+ "👀 watching src/ — every edit is checked again (500 ms debounce); Ctrl+C to stop",
773
+ ),
774
+ );
775
+ watchVerify({
776
+ root,
777
+ onReport: (live) => {
778
+ console.log("");
779
+ for (const line of formatReport(live)) console.log(line);
780
+ console.log("");
781
+ },
782
+ });
783
+ return { exitCode: 0, report };
784
+ }
785
+
786
+ return { exitCode: failed ? 1 : 0, report };
787
+ };
788
+
789
+ /* ------------------------------------------------------------------ watch */
790
+
791
+ /**
792
+ * Watch the project and re-run the gates on every change.
793
+ *
794
+ * The debounce is 500 ms: an agent writes a file and the editor writes a
795
+ * second one, and two runs would report the same finding twice.
796
+ *
797
+ * @param {{root: string, onReport: (report: object) => void, debounce?: number}} options
798
+ * @returns {fs.FSWatcher}
799
+ */
800
+ export const watchVerify = ({ root, onReport, debounce = 500 }) => {
801
+ const watchDir = fs.existsSync(path.join(root, "src")) ? path.join(root, "src") : root;
802
+ let timer = null;
803
+ let running = false;
804
+ let queued = false;
805
+ let previous = "";
806
+
807
+ const tick = async () => {
808
+ if (running) {
809
+ queued = true;
810
+ return;
811
+ }
812
+ running = true;
813
+ const report = await runGates(root, GATE_ORDER.filter((name) => name !== "tests"));
814
+ running = false;
815
+ const signature = report.steps
816
+ .map((step) =>
817
+ `${step.name}:${step.exitCode}:${step.findings
818
+ .map((item) => `${item.code}@${item.file}:${item.line}`)
819
+ .join(",")}`,
820
+ )
821
+ .join("|");
822
+ if (signature !== previous) {
823
+ previous = signature;
824
+ onReport(report);
825
+ }
826
+ if (queued) {
827
+ queued = false;
828
+ await tick();
829
+ }
830
+ };
831
+
832
+ const watcher = fs.watch(watchDir, { recursive: true }, (_event, filename) => {
833
+ if (!filename) return;
834
+ const name = String(filename);
835
+ if (name.includes("node_modules") || name.startsWith(".")) return;
836
+ if (!/\.(m?js|css|html|json)$/.test(name)) return;
837
+ if (timer) clearTimeout(timer);
838
+ timer = setTimeout(() => {
839
+ timer = null;
840
+ tick();
841
+ }, debounce);
842
+ });
843
+ return watcher;
844
+ };
845
+
846
+ /**
847
+ * Run one of the project's own npm scripts and keep what it said.
848
+ *
849
+ * `--silent` matters: without it npm prints its own banner and, on failure, a
850
+ * block of `npm error` lines that bury the one line the check wrote. With it
851
+ * the output is the check's own words, which is the whole point of running the
852
+ * suite from here.
853
+ *
854
+ * @param {string} root
855
+ * @param {string} script The npm script name.
856
+ * @returns {Promise<{code: number|null, output: string, missing?: string}>}
857
+ */
858
+ export const runTestSuite = (root, script = "test") =>
859
+ new Promise((resolve) => {
860
+ execFile(
861
+ process.platform === "win32" ? "npm.cmd" : "npm",
862
+ ["run", script, "--silent"],
863
+ {
864
+ cwd: root,
865
+ env: { ...process.env, FLETBOX_VERIFY_NESTED: "1" },
866
+ timeout: 15 * 60 * 1000,
867
+ maxBuffer: 32 * 1024 * 1024,
868
+ shell: process.platform === "win32",
869
+ },
870
+ (error, stdout, stderr) => {
871
+ const output = `${stdout}${stderr}`;
872
+ if (!error) return resolve({ code: 0, output });
873
+ if (error.code === "ENOENT") return resolve({ code: null, output, missing: "npm" });
874
+ resolve({ code: typeof error.code === "number" ? error.code : 1, output });
875
+ },
876
+ );
877
+ });
878
+
879
+ export default verify;