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,368 @@
1
+ /**
2
+ * @file bin/commands/checkStructure.js
3
+ * @description The structural gate — how the app's own modules are wired.
4
+ *
5
+ * Three mistakes a static pass can prove, and that no other gate in this repo
6
+ * covers:
7
+ *
8
+ * CIRCULAR_IMPORT — two modules import each other. The cycle is legal
9
+ * JavaScript and it loads; what it breaks is the *next*
10
+ * edit, because the module being changed is half
11
+ * initialised when the other one reads it.
12
+ * SOURCE_OUTSIDE_ROOT — a module that lives outside the folders the project
13
+ * is built from. It works today and is invisible to
14
+ * everyone who opens the project expecting `src/`.
15
+ * HAND_WRITTEN_DOM — `innerHTML` and friends in app code. The framework
16
+ * has exactly one rendering engine; writing markup by
17
+ * hand is how a screen ends up speaking two visual
18
+ * languages at once.
19
+ *
20
+ * The plan named four structure rules. Two of them already have an owner and
21
+ * are deliberately not repeated here: `check-api` reports `MODULE_NOT_FOUND`
22
+ * and `MISSING_EXPORT` for a broken import, and `check-ui` reports the
23
+ * hex / off-scale / unlabelled-control rules. A second copy of a rule is a
24
+ * second thing to keep in sync, so this file owns only what nothing else does.
25
+ *
26
+ * Node core only: this file ships inside every app `flet-box create` builds.
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import { loadApi } from "./checkApi.js";
32
+ import { resolveLocalModule } from "../utils/moduleExports.js";
33
+ import { neutralise } from "../utils/neutralise.js";
34
+ import { c, banner, section } from "../utils/colors.js";
35
+
36
+ /**
37
+ * The severity each code carries.
38
+ */
39
+ export const STRUCTURE_CODES = {
40
+ CIRCULAR_IMPORT: "error",
41
+ SOURCE_OUTSIDE_ROOT: "error",
42
+ HAND_WRITTEN_DOM: "error",
43
+ };
44
+
45
+ /** Folders that hold build output or another toolchain's sources. */
46
+ const IGNORED_FOLDERS = new Set([
47
+ "node_modules",
48
+ "www",
49
+ "dist",
50
+ "build",
51
+ "coverage",
52
+ "android",
53
+ "ios",
54
+ "Pods",
55
+ "DerivedData",
56
+ ]);
57
+
58
+ /** Where an app's own code lives, and the files that may sit at its root. */
59
+ const APP_FOLDERS = ["src", "scripts", "tools", "assets", "public"];
60
+ const APP_ENTRIES = [
61
+ "app.js",
62
+ "main.js",
63
+ "index.js",
64
+ "loader.js",
65
+ "service-worker.js",
66
+ "sw.js",
67
+ ];
68
+
69
+ /** The framework checkout has its own shape, and its own root file. */
70
+ const FRAMEWORK_FOLDERS = ["src", "bin", "scripts", "tests", "tools", "docs"];
71
+ const FRAMEWORK_ENTRIES = ["service-worker.js"];
72
+
73
+ /**
74
+ * Markup written by hand. `innerHTML` is also the one that turns a string from
75
+ * the network into executable markup, so it is the loudest of the four.
76
+ */
77
+ const MARKUP_WRITES = [
78
+ { pattern: /\binnerHTML\b/, word: "innerHTML" },
79
+ { pattern: /\bouterHTML\b/, word: "outerHTML" },
80
+ { pattern: /\binsertAdjacentHTML\b/, word: "insertAdjacentHTML" },
81
+ { pattern: /\bdocument\s*\.\s*write\b/, word: "document.write" },
82
+ ];
83
+
84
+ /** An element built by hand is a widget built by hand, one layer down. */
85
+ const ELEMENT_WRITES = [{ pattern: /\bdocument\s*\.\s*createElement\b/, word: "document.createElement" }];
86
+
87
+ /**
88
+ * Every JavaScript file of a project, skipping build output and dot folders.
89
+ *
90
+ * @param {string} root
91
+ * @returns {string[]} absolute paths
92
+ */
93
+ export const collectProjectSources = (root) => {
94
+ const files = [];
95
+ const walk = (dir) => {
96
+ let entries;
97
+ try {
98
+ entries = fs.readdirSync(dir, { withFileTypes: true });
99
+ } catch {
100
+ return;
101
+ }
102
+ for (const entry of entries) {
103
+ const full = path.join(dir, entry.name);
104
+ if (entry.isDirectory()) {
105
+ if (entry.name.startsWith(".") || IGNORED_FOLDERS.has(entry.name)) continue;
106
+ walk(full);
107
+ } else if (/\.(m?js)$/.test(entry.name)) {
108
+ files.push(full);
109
+ }
110
+ }
111
+ };
112
+ walk(root);
113
+ return files;
114
+ };
115
+
116
+ /**
117
+ * The line a match sits on.
118
+ *
119
+ * @param {string} code
120
+ * @param {number} index
121
+ * @returns {number}
122
+ */
123
+ const lineAt = (code, index) => code.slice(0, index).split("\n").length;
124
+
125
+ /**
126
+ * Whether a file belongs to the app being judged, or to the framework itself.
127
+ *
128
+ * The framework *is* the rendering engine, so `document.createElement` inside
129
+ * it is the job, not a violation. The package directory is what tells the two
130
+ * apart: inside the framework checkout it is the checkout, and every file
131
+ * under it belongs to the package.
132
+ *
133
+ * @param {string} file
134
+ * @param {string} packageDir
135
+ * @returns {boolean}
136
+ */
137
+ const isAppCode = (file, packageDir) => {
138
+ const rel = path.relative(packageDir, file);
139
+ return rel.startsWith("..") || path.isAbsolute(rel);
140
+ };
141
+
142
+ /**
143
+ * The local modules a file imports, as absolute paths.
144
+ *
145
+ * Only relative specifiers matter here: a bare `flet-box` goes to the
146
+ * package, which cannot be part of the app's own cycle.
147
+ *
148
+ * @param {string} file
149
+ * @param {string} code neutralised source, strings kept
150
+ * @returns {Array<{target: string, line: number}>}
151
+ */
152
+ export const localImports = (file, code) => {
153
+ const found = [];
154
+ const specRe = /(?:\bfrom\s*|\bimport\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]/g;
155
+ let match;
156
+ while ((match = specRe.exec(code))) {
157
+ const spec = match[1];
158
+ if (!spec.startsWith(".") && !spec.startsWith("/")) continue;
159
+ const target = resolveLocalModule(file, spec);
160
+ if (!target) continue;
161
+ found.push({ target, line: lineAt(code, match.index) });
162
+ }
163
+ return found;
164
+ };
165
+
166
+ /**
167
+ * Every cycle in the project's import graph.
168
+ *
169
+ * A depth-first walk with three colours: a node still on the stack is a back
170
+ * edge, and the path from it back to the top of the stack is the cycle.
171
+ *
172
+ * @param {string[]} files
173
+ * @returns {Array<{cycle: string[], file: string, line: number}>}
174
+ */
175
+ export const findCycles = (files) => {
176
+ const graph = new Map();
177
+ for (const file of files) {
178
+ let raw;
179
+ try {
180
+ raw = fs.readFileSync(file, "utf8");
181
+ } catch {
182
+ continue;
183
+ }
184
+ const code = neutralise(raw, { keepStrings: true });
185
+ graph.set(file, localImports(file, code));
186
+ }
187
+
188
+ const cycles = [];
189
+ const seen = new Set();
190
+ const state = new Map();
191
+
192
+ const walk = (node, stack) => {
193
+ state.set(node, "open");
194
+ stack.push(node);
195
+ for (const edge of graph.get(node) || []) {
196
+ if (!graph.has(edge.target)) continue;
197
+ if (state.get(edge.target) === "open") {
198
+ const start = stack.indexOf(edge.target);
199
+ const cycle = stack.slice(start).concat(edge.target);
200
+ const key = [...cycle].sort().join("|");
201
+ if (!seen.has(key)) {
202
+ seen.add(key);
203
+ cycles.push({
204
+ cycle,
205
+ file: edge.target,
206
+ line: edge.line,
207
+ });
208
+ }
209
+ continue;
210
+ }
211
+ if (!state.has(edge.target)) walk(edge.target, stack);
212
+ }
213
+ stack.pop();
214
+ state.set(node, "closed");
215
+ };
216
+
217
+ for (const file of files) {
218
+ if (!state.has(file)) walk(file, []);
219
+ }
220
+ return cycles;
221
+ };
222
+
223
+ /**
224
+ * Scan a project for the three structural mistakes.
225
+ *
226
+ * @param {string} root
227
+ * @param {{ packageDir?: string }} [options]
228
+ * @returns {Promise<object[]>} findings
229
+ */
230
+ export const scanStructure = async (root, options = {}) => {
231
+ const findings = [];
232
+ let packageDir = options.packageDir;
233
+ if (!packageDir) {
234
+ try {
235
+ packageDir = (await loadApi(root)).dir;
236
+ } catch {
237
+ packageDir = root;
238
+ }
239
+ }
240
+
241
+ const files = collectProjectSources(root);
242
+ const isFramework = path.resolve(root) === path.resolve(packageDir);
243
+ const folders = isFramework ? FRAMEWORK_FOLDERS : APP_FOLDERS;
244
+ const entries = isFramework ? FRAMEWORK_ENTRIES : APP_ENTRIES;
245
+
246
+ // --- a module outside the folders the project is built from --------------
247
+ for (const file of files) {
248
+ const rel = path.relative(root, file);
249
+ if (!rel || rel.startsWith("..")) continue;
250
+ const parts = rel.split(path.sep);
251
+ const allowed = parts.length === 1 ? entries.includes(parts[0]) : folders.includes(parts[0]);
252
+ if (allowed) continue;
253
+ findings.push({
254
+ file,
255
+ line: 1,
256
+ code: "SOURCE_OUTSIDE_ROOT",
257
+ severity: STRUCTURE_CODES.SOURCE_OUTSIDE_ROOT,
258
+ message: `${rel} is a module outside the folders this project is built from`,
259
+ detail: `Only ${folders.join(", ")} belong to the project, and ${entries.join(", ")} may sit at its root.`,
260
+ suggestion: `move it to src/ and update the specifiers that point at it`,
261
+ });
262
+ }
263
+
264
+ // --- a cycle between two of the project's own modules --------------------
265
+ for (const cycle of findCycles(files)) {
266
+ const names = cycle.cycle.map((file) => path.relative(root, file));
267
+ findings.push({
268
+ file: cycle.file,
269
+ line: cycle.line,
270
+ code: "CIRCULAR_IMPORT",
271
+ severity: STRUCTURE_CODES.CIRCULAR_IMPORT,
272
+ message: `circular import: ${names.join(" → ")}`,
273
+ detail:
274
+ "The cycle loads, but every module in it is half-initialised when the next one reads it, so the next edit is the one that breaks.",
275
+ suggestion: "move the shared piece into a third module that both of them import",
276
+ });
277
+ }
278
+
279
+ // --- markup written by hand in app code ----------------------------------
280
+ for (const file of files) {
281
+ if (!isAppCode(file, packageDir)) continue;
282
+ let raw;
283
+ try {
284
+ raw = fs.readFileSync(file, "utf8");
285
+ } catch {
286
+ continue;
287
+ }
288
+ const code = neutralise(raw);
289
+ for (const { pattern, word } of MARKUP_WRITES) {
290
+ const match = pattern.exec(code);
291
+ if (!match) continue;
292
+ findings.push({
293
+ file,
294
+ line: lineAt(code, match.index),
295
+ code: "HAND_WRITTEN_DOM",
296
+ severity: STRUCTURE_CODES.HAND_WRITTEN_DOM,
297
+ message: `${word} writes the DOM by hand in app code`,
298
+ detail:
299
+ "There is one rendering engine in FletBox. A hand-written element skips it, so it misses the theme, the scale and the accessibility the widgets already have.",
300
+ suggestion: "build the screen with the widgets — Container({ … }), Text({ … }) — instead of markup",
301
+ });
302
+ }
303
+ for (const { pattern, word } of ELEMENT_WRITES) {
304
+ const match = pattern.exec(code);
305
+ if (!match) continue;
306
+ findings.push({
307
+ file,
308
+ line: lineAt(code, match.index),
309
+ code: "HAND_WRITTEN_DOM",
310
+ severity: "warn",
311
+ message: `${word} builds an element by hand in app code`,
312
+ detail:
313
+ "It works, and it is the one place where a screen stops speaking the framework's visual language.",
314
+ suggestion: "use the widget that draws it — if none does, say so and ask for one",
315
+ });
316
+ }
317
+ }
318
+
319
+ return findings;
320
+ };
321
+
322
+ /**
323
+ * The `flet-box check-structure` command.
324
+ *
325
+ * @param {string[]} argv
326
+ * @param {{print?: (line: string) => void}} [io]
327
+ * @returns {Promise<{findings: object[], exitCode: number}>}
328
+ */
329
+ export const checkStructure = async (argv = [], io = {}) => {
330
+ const print = io.print ?? ((line) => console.log(line));
331
+ const rootIndex = argv.indexOf("--root");
332
+ const root = path.resolve(
333
+ rootIndex >= 0 && argv[rootIndex + 1] ? argv[rootIndex + 1] : process.cwd(),
334
+ );
335
+ const asJson = argv.includes("--json");
336
+
337
+ const findings = await scanStructure(root);
338
+ const errors = findings.filter((item) => item.severity === "error");
339
+
340
+ if (asJson) {
341
+ print(JSON.stringify({ root, findings }, null, 2));
342
+ } else {
343
+ print(banner("🧱 FletBox structure", path.basename(root)));
344
+ print("");
345
+ if (!findings.length) {
346
+ print(c("green", "✓ every module sits where the project expects it, and nothing imports in a circle"));
347
+ } else {
348
+ for (const item of findings) {
349
+ const where = c("brightCyan", `${path.relative(root, item.file)}:${item.line}`);
350
+ const mark = item.severity === "error" ? c("red", "✗") : c("yellow", "!");
351
+ print(` ${mark} ${where} ${c("yellow", item.code)} — ${item.message}`);
352
+ if (item.detail) print(` ${c("gray", item.detail)}`);
353
+ if (item.suggestion) print(` ${c("gray", "→ " + item.suggestion)}`);
354
+ }
355
+ print("");
356
+ print(
357
+ errors.length
358
+ ? c("red", `❌ ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
359
+ : c("yellow", `${findings.length} warning(s) — no error`),
360
+ );
361
+ }
362
+ print("");
363
+ }
364
+
365
+ return { findings, exitCode: errors.length ? 1 : 0 };
366
+ };
367
+
368
+ export default checkStructure;
@@ -0,0 +1,293 @@
1
+ /**
2
+ * @file bin/commands/checkSyntax.js
3
+ * @description The parse gate — every module is handed to the parser Node runs.
4
+ *
5
+ * The other gates read code as text: `check-api` resolves names, `check-ui`
6
+ * scans literals, `check-structure` follows imports. None of them compiles a
7
+ * file, so a missing brace or an unclosed backtick passed all four and the app
8
+ * failed in the browser with a blank screen and one console line — after an
9
+ * agent had already been told the code was legal.
10
+ *
11
+ * `node --check` is the parser the runtime itself uses, run through
12
+ * `process.execPath` so the gate cannot be kinder than the Node that will load
13
+ * the file. It parses without executing, which matters: importing an app module
14
+ * in a check would run its side effects and need a DOM.
15
+ *
16
+ * A failure carries the file, the line, the column, the source line with a
17
+ * caret under the character the parser stopped at, and a suggestion drawn from
18
+ * the message. The suggestion is the point: an agent reads "a backtick is never
19
+ * closed" and fixes the line, instead of reading `Unexpected token` and
20
+ * guessing.
21
+ *
22
+ * Node core only: this file ships inside every app `flet-box create` builds.
23
+ */
24
+
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+ import { execFile } from "node:child_process";
28
+
29
+ export const SYNTAX_CODES = { SYNTAX_ERROR: "error" };
30
+
31
+ /** Build output, dependencies and another toolchain's sources. */
32
+ const IGNORED_FOLDERS = new Set([
33
+ "node_modules",
34
+ "www",
35
+ "dist",
36
+ "build",
37
+ "coverage",
38
+ "android",
39
+ "ios",
40
+ "Pods",
41
+ "DerivedData",
42
+ ]);
43
+
44
+ /**
45
+ * Hidden folders that are a database or somebody else's cache. Everything else
46
+ * that starts with a dot is walked: `.qoder/skills`, `.claude/skills` and
47
+ * `.fletbox` hold scripts this project runs, and skipping every dot-folder let a
48
+ * broken probe live in the repo without the gate that exists to parse it.
49
+ */
50
+ const HIDDEN_IGNORED = new Set([
51
+ ".git",
52
+ ".hg",
53
+ ".svn",
54
+ ".cache",
55
+ ".next",
56
+ ".nuxt",
57
+ ".output",
58
+ ".turbo",
59
+ ".yarn",
60
+ ".pnpm-store",
61
+ ".venv",
62
+ ".idea",
63
+ ]);
64
+
65
+ const MODULE_FILE = /\.(?:mjs|cjs|js)$/;
66
+
67
+ /**
68
+ * The recurring parse errors, and what the line above usually got wrong.
69
+ *
70
+ * Only the message is matched — the caret and the source line are the parser's
71
+ * own and are printed as it wrote them.
72
+ */
73
+ const HINTS = [
74
+ [
75
+ /Unexpected end of input|Unexpected token '\}'/,
76
+ "a brace, bracket or parenthesis opens and never closes — start at the unclosed block above this line",
77
+ ],
78
+ [
79
+ /missing \) after argument list/,
80
+ "a call is not closed — count the parentheses of the call that ends on this line",
81
+ ],
82
+ [
83
+ /missing \} after/,
84
+ "a block or an object literal is not closed — find the '{' this line was meant to close",
85
+ ],
86
+ [
87
+ /Unterminated template/,
88
+ "a backtick is never closed — and a backtick inside a template literal must be escaped",
89
+ ],
90
+ [/Unterminated string/, "a quote is never closed on that line"],
91
+ [
92
+ /Cannot use import statement outside a module|Unexpected token 'export'|Unexpected token 'import'/,
93
+ 'the file is parsed as CommonJS — package.json needs "type": "module"',
94
+ ],
95
+ [
96
+ /Invalid or unexpected token/,
97
+ "a character the parser cannot read — a smart quote, a non-breaking space or a backtick that only looks like the ASCII one",
98
+ ],
99
+ [
100
+ /Identifier '.*' has already been declared/,
101
+ "the same name is declared twice in one scope — rename one of them",
102
+ ],
103
+ [
104
+ /Unexpected identifier/,
105
+ "a comma, an operator or a closing bracket is missing on the line before",
106
+ ],
107
+ [
108
+ /Invalid left-hand side/,
109
+ "the left of '=' cannot be assigned — it is a call, a literal or a `const`",
110
+ ],
111
+ [/Missing semicolon/, "two statements are not separated"],
112
+ ];
113
+
114
+ /**
115
+ * Every module a project owns, sorted, with build output left out.
116
+ *
117
+ * Symlinked folders are not followed: a link back up the tree would loop, and
118
+ * a linked dependency is not the project's own code.
119
+ *
120
+ * Hidden folders are walked, because a script under `.qoder/skills` or
121
+ * `.claude/skills` is code the project runs. Only the database and the vendor
122
+ * caches in HIDDEN_IGNORED are skipped.
123
+ *
124
+ * @param {string} root
125
+ * @returns {string[]}
126
+ */
127
+ export const syntaxFiles = (root) => {
128
+ const files = [];
129
+ const walk = (dir) => {
130
+ let entries;
131
+ try {
132
+ entries = fs.readdirSync(dir, { withFileTypes: true });
133
+ } catch {
134
+ return;
135
+ }
136
+ for (const entry of entries) {
137
+ if (HIDDEN_IGNORED.has(entry.name)) continue;
138
+ const full = path.join(dir, entry.name);
139
+ if (entry.isDirectory()) {
140
+ if (!IGNORED_FOLDERS.has(entry.name)) walk(full);
141
+ } else if (entry.isFile() && MODULE_FILE.test(entry.name)) {
142
+ files.push(full);
143
+ }
144
+ }
145
+ };
146
+ walk(root);
147
+ return files.sort();
148
+ };
149
+
150
+ /**
151
+ * The hint a parse message deserves.
152
+ *
153
+ * @param {string} message
154
+ * @returns {string}
155
+ */
156
+ const hintFor = (message) =>
157
+ HINTS.find(([pattern]) => pattern.test(message))?.[1] ??
158
+ "the parser stopped at the marked character — the cause is on that line or the one above";
159
+
160
+ /**
161
+ * Read `node --check` output into a finding.
162
+ *
163
+ * @param {string} file Absolute path, as it was passed to the parser.
164
+ * @param {string} stderr
165
+ * @returns {object|null} null when the output does not name the file
166
+ */
167
+ export const parseFailure = (file, stderr) => {
168
+ const lines = String(stderr).split(/\r?\n/);
169
+ // The parser prints the path it resolved, which on macOS can differ from the
170
+ // one we passed (`/var` is `/private/var`), so the file is matched by name:
171
+ // one process parses one file, and its header is the first line that says so.
172
+ const base = path.basename(file);
173
+ const header = lines.findIndex((line) => {
174
+ const match = line.match(/^(.*):(\d+)$/);
175
+ return Boolean(match) && path.basename(match[1]) === base;
176
+ });
177
+ if (header < 0) return null;
178
+ const line = Number(lines[header].match(/:(\d+)$/)[1]);
179
+ const source = lines[header + 1] ?? "";
180
+ const caretLine = lines[header + 2] ?? "";
181
+ const column = caretLine.indexOf("^") + 1;
182
+ const errorLine = lines.slice(header + 2).find((text) => /^\w*Error: /.test(text)) ?? "";
183
+ const message = errorLine.replace(/^\w*Error: /, "").trim() || "the file does not parse";
184
+ return {
185
+ code: "SYNTAX_ERROR",
186
+ file,
187
+ line,
188
+ column,
189
+ message,
190
+ detail: source ? `${source}\n${column > 0 ? " ".repeat(column - 1) : ""}^` : "",
191
+ suggestion: hintFor(message),
192
+ };
193
+ };
194
+
195
+ /**
196
+ * Sharpen a parse failure with what only the file can tell.
197
+ *
198
+ * `Unexpected end of input` is what a backtick that never closes looks like
199
+ * from the parser's side — the template literal swallows the rest of the file
200
+ * and the end arrives inside it. The parity of the backticks says which one it
201
+ * is, and that is the hint that fixes the line in one edit.
202
+ *
203
+ * @param {object} failure
204
+ * @param {string} file
205
+ * @returns {object}
206
+ */
207
+ const refine = (failure, file) => {
208
+ if (!/Unexpected end of input/.test(failure.message)) return failure;
209
+ let text = "";
210
+ try {
211
+ text = fs.readFileSync(file, "utf8");
212
+ } catch {
213
+ return failure;
214
+ }
215
+ const backticks = text.split("`").length - 1;
216
+ if (backticks % 2 === 0) return failure;
217
+ return {
218
+ ...failure,
219
+ suggestion:
220
+ "a backtick opens and never closes — the template literal swallows everything below it",
221
+ };
222
+ };
223
+
224
+ /**
225
+ * Parse one module. Resolves to a finding, or null when it parses.
226
+ *
227
+ * @param {string} file
228
+ * @returns {Promise<object|null>}
229
+ */
230
+ const checkOne = (file) =>
231
+ new Promise((resolve) => {
232
+ execFile(
233
+ process.execPath,
234
+ ["--check", file],
235
+ { timeout: 20000 },
236
+ (error, _stdout, stderr) => {
237
+ if (!error) {
238
+ resolve(null);
239
+ return;
240
+ }
241
+ const failure = parseFailure(file, stderr);
242
+ resolve(
243
+ failure
244
+ ? refine(failure, file)
245
+ : {
246
+ code: "SYNTAX_ERROR",
247
+ file,
248
+ line: 0,
249
+ column: 0,
250
+ message: error.message,
251
+ detail: "",
252
+ suggestion: "the parser could not run — try `node --check` on that file yourself",
253
+ },
254
+ );
255
+ },
256
+ );
257
+ });
258
+
259
+ /**
260
+ * Run a worker over a list with a fixed number of slots.
261
+ *
262
+ * @template T, R
263
+ * @param {T[]} items
264
+ * @param {number} slots
265
+ * @param {(item: T) => Promise<R>} worker
266
+ * @returns {Promise<R[]>}
267
+ */
268
+ const pool = async (items, slots, worker) => {
269
+ const results = new Array(items.length);
270
+ let next = 0;
271
+ const run = async () => {
272
+ while (next < items.length) {
273
+ const index = next;
274
+ next += 1;
275
+ results[index] = await worker(items[index]);
276
+ }
277
+ };
278
+ await Promise.all(Array.from({ length: Math.min(slots, items.length) }, run));
279
+ return results;
280
+ };
281
+
282
+ /**
283
+ * Parse every module of a project.
284
+ *
285
+ * @param {string} root
286
+ * @param {{concurrency?: number}} [options]
287
+ * @returns {Promise<object[]>}
288
+ */
289
+ export const scanSyntax = async (root, { concurrency = 8 } = {}) => {
290
+ const files = syntaxFiles(root);
291
+ const findings = (await pool(files, concurrency, checkOne)).filter(Boolean);
292
+ return findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file.localeCompare(b.file)));
293
+ };