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.
- package/AGENTS_FOR_CODE_GEN.md +284 -0
- package/CONTRIBUTING.md +238 -0
- package/README.md +2 -2
- package/bin/cli.js +88 -1
- package/bin/commands/auditFix.js +181 -27
- package/bin/commands/checkApi.js +14 -3
- package/bin/commands/checkStructure.js +368 -0
- package/bin/commands/checkSyntax.js +293 -0
- package/bin/commands/checkUi.js +666 -14
- package/bin/commands/create.js +14 -0
- package/bin/commands/createBundle.js +2 -2
- package/bin/commands/metrics.js +448 -0
- package/bin/commands/release.js +18 -0
- package/bin/commands/report.js +849 -0
- package/bin/commands/tools.js +76 -0
- package/bin/commands/unpack.js +39 -11
- package/bin/commands/verify.js +879 -0
- package/bin/mcp.js +280 -0
- package/bin/utils/contract.js +27 -0
- package/bin/utils/dtsProps.js +5 -1
- package/bin/utils/migrationGuide.js +146 -0
- package/bin/utils/moduleExports.js +9 -5
- package/bin/utils/templates.js +225 -18
- package/bin/utils/toolkit.js +191 -0
- package/docs/README.md +5 -1
- package/docs/arquitectura.md +1 -1
- package/docs/cli/README.md +168 -2
- package/docs/guides/README.md +6 -0
- package/docs/guides/accessibility.md +2 -1
- package/docs/guides/ai-toolkit.md +166 -0
- package/docs/guides/compatibility.md +1 -1
- package/docs/guides/migration.md +92 -0
- package/docs/guides/minification-and-protection.md +2 -1
- package/docs/guides/mobile-and-platforms.md +1 -1
- package/docs/guides/online-updates.md +2 -0
- package/docs/server/deployment.md +1 -1
- package/docs/tools/styling.md +2 -2
- package/docs/tools/theme.md +139 -4
- package/docs/widget/Accordion.md +74 -8
- package/docs/widget/AdMob.md +68 -3
- package/docs/widget/AdSense.md +67 -2
- package/docs/widget/AlertDialog.md +1 -1
- package/docs/widget/AnimatedBox.md +80 -39
- package/docs/widget/AnimatedText.md +74 -18
- package/docs/widget/Audio.md +73 -14
- package/docs/widget/Avatar.md +69 -4
- package/docs/widget/Badge.md +68 -3
- package/docs/widget/BottomSheet.md +2 -2
- package/docs/widget/Button.md +69 -3
- package/docs/widget/Card.md +69 -4
- package/docs/widget/Carousel.md +67 -2
- package/docs/widget/Chart.md +67 -2
- package/docs/widget/Checkbox.md +74 -8
- package/docs/widget/Chip.md +68 -3
- package/docs/widget/CircularBar.md +74 -15
- package/docs/widget/CircularChart.md +71 -10
- package/docs/widget/CodeViewer.md +67 -2
- package/docs/widget/Column.md +68 -3
- package/docs/widget/Container.md +68 -3
- package/docs/widget/DataTable.md +67 -2
- package/docs/widget/Divider.md +67 -2
- package/docs/widget/DraggBox.md +67 -2
- package/docs/widget/Dropdown.md +76 -10
- package/docs/widget/DroppBox.md +67 -2
- package/docs/widget/FloatingActionButton.md +68 -3
- package/docs/widget/GridView.md +67 -2
- package/docs/widget/Icon.md +71 -5
- package/docs/widget/Image.md +68 -3
- package/docs/widget/Input.md +68 -3
- package/docs/widget/Inspector.md +2 -2
- package/docs/widget/InstallButton.md +73 -13
- package/docs/widget/ListTile.md +69 -3
- package/docs/widget/ListView.md +67 -2
- package/docs/widget/Maps.md +68 -3
- package/docs/widget/Markdown.md +74 -15
- package/docs/widget/MatrixRain.md +72 -11
- package/docs/widget/Menu.md +209 -0
- package/docs/widget/Modal.md +3 -3
- package/docs/widget/Pagination.md +68 -3
- package/docs/widget/ParallaxBox.md +72 -12
- package/docs/widget/ProgressBar.md +69 -4
- package/docs/widget/QRCode.md +67 -2
- package/docs/widget/README.md +54 -50
- package/docs/widget/Radio.md +76 -10
- package/docs/widget/Rating.md +70 -6
- package/docs/widget/Row.md +68 -3
- package/docs/widget/Skeleton.md +68 -3
- package/docs/widget/Slider.md +75 -9
- package/docs/widget/SnackBar.md +2 -2
- package/docs/widget/Stack.md +68 -3
- package/docs/widget/Stepper.md +68 -3
- package/docs/widget/Switch.md +74 -8
- package/docs/widget/Text.md +71 -4
- package/docs/widget/ThemePicker.md +197 -0
- package/docs/widget/Tooltip.md +72 -7
- package/docs/widget/TreeView.md +70 -10
- package/docs/widget/Video.md +71 -10
- package/package.json +10 -3
- package/skills/fletbox-design/SKILL.md +89 -5
- package/skills/fletbox-motion/SKILL.md +1 -1
- package/skills/fletbox-rules/SKILL.md +56 -4
- package/src/core/runApp.js +12 -0
- package/src/index.d.ts +374 -101
- package/src/index.js +9 -0
- package/src/navigations/AdaptiveScaffold.js +2 -2
- package/src/navigations/BottomNavigation.js +1 -1
- package/src/navigations/Scaffold.js +18 -6
- package/src/tools/useState.js +11 -4
- package/src/utils/colors.js +275 -0
- package/src/utils/errorBoundary.js +288 -0
- package/src/utils/index.js +10 -2
- package/src/utils/stackPosition.js +39 -24
- package/src/utils/syntaxHighlight.js +4 -1
- package/src/utils/themes.js +279 -3
- package/src/widget-factory/addChildren.js +17 -10
- package/src/widget-factory/assignProps.js +18 -6
- package/src/widget-factory/createWidget.js +7 -0
- package/src/widget-factory/deprecations.js +77 -0
- package/src/widget-factory/translateProps.js +11 -1
- package/src/widget-factory/warnings.js +1 -27
- package/src/widget-factory/widgetFactory.js +59 -17
- package/src/widgets/Accordion.js +21 -7
- package/src/widgets/Badge.js +11 -2
- package/src/widgets/Button.js +15 -3
- package/src/widgets/Carousel.js +12 -4
- package/src/widgets/Chart.js +7 -2
- package/src/widgets/Checkbox.js +1 -2
- package/src/widgets/Chip.js +24 -10
- package/src/widgets/CircularBar.js +9 -3
- package/src/widgets/CircularChart.js +5 -3
- package/src/widgets/Container.js +6 -0
- package/src/widgets/Divider.js +3 -1
- package/src/widgets/DraggBox.js +9 -0
- package/src/widgets/Dropdown.js +3 -3
- package/src/widgets/DroppBox.js +5 -0
- package/src/widgets/FloatingActionButton.js +1 -1
- package/src/widgets/Icon.js +5 -1
- package/src/widgets/InstallButton.js +4 -2
- package/src/widgets/Markdown.js +171 -103
- package/src/widgets/Menu.js +340 -0
- package/src/widgets/Modal.js +7 -1
- package/src/widgets/Pagination.js +10 -5
- package/src/widgets/ProgressBar.js +5 -4
- package/src/widgets/QRCode.js +4 -0
- package/src/widgets/Radio.js +1 -2
- package/src/widgets/Rating.js +9 -0
- package/src/widgets/Slider.js +4 -1
- package/src/widgets/SnackBar.js +2 -2
- package/src/widgets/Switch.js +1 -2
- package/src/widgets/Text.js +9 -5
- package/src/widgets/ThemePicker.js +234 -0
- package/src/widgets/TreeView.js +2 -1
- package/src/widgets/index.js +2 -0
- 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;
|