@triflare/fx 1.0.3 → 2.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +59 -0
  2. package/bin/fx.js +4 -1162
  3. package/package.json +8 -4
  4. package/product.yml +1 -1
  5. package/src/brand.js +9 -3
  6. package/src/catalog.js +103 -0
  7. package/src/cli/commands/build.js +98 -0
  8. package/src/cli/commands/catalog.js +42 -0
  9. package/src/cli/commands/check.js +44 -0
  10. package/src/cli/commands/completion.js +54 -0
  11. package/src/cli/commands/decompile.js +266 -0
  12. package/src/cli/commands/dev.js +179 -0
  13. package/src/cli/commands/extension.js +110 -0
  14. package/src/cli/commands/feature.js +114 -0
  15. package/src/cli/commands/fmt.js +173 -0
  16. package/src/cli/commands/index.js +45 -0
  17. package/src/cli/commands/init.js +169 -0
  18. package/src/cli/commands/lint.js +270 -0
  19. package/src/cli/commands/package.js +137 -0
  20. package/src/cli/commands/sprite/add.js +85 -0
  21. package/src/cli/commands/sprite/build.js +62 -0
  22. package/src/cli/commands/sprite/helpers.js +66 -0
  23. package/src/cli/commands/sprite/index.js +29 -0
  24. package/src/cli/commands/sprite/list.js +31 -0
  25. package/src/cli/commands/sprite/remove.js +51 -0
  26. package/src/cli/commands/vendor/add.js +98 -0
  27. package/src/cli/commands/vendor/helpers.js +50 -0
  28. package/src/cli/commands/vendor/index.js +39 -0
  29. package/src/cli/commands/vendor/install.js +64 -0
  30. package/src/cli/commands/vendor/list.js +45 -0
  31. package/src/cli/commands/vendor/migrate.js +47 -0
  32. package/src/cli/commands/vendor/outdated.js +51 -0
  33. package/src/cli/commands/vendor/remove.js +34 -0
  34. package/src/cli/commands/vendor/update.js +49 -0
  35. package/src/cli/commands/vendor/verify.js +44 -0
  36. package/src/cli/completion/bash.js +10 -0
  37. package/src/cli/completion/fish.js +9 -0
  38. package/src/cli/completion/index.js +97 -0
  39. package/src/cli/completion/zsh.js +11 -0
  40. package/src/cli/context.js +270 -0
  41. package/src/cli/define-command.js +147 -0
  42. package/src/cli/errors.js +111 -0
  43. package/src/cli/format.js +31 -0
  44. package/src/cli/prompts.js +183 -0
  45. package/src/cli/reporter.js +203 -0
  46. package/src/cli/run.js +248 -0
  47. package/src/cli/templates/index.js +112 -0
  48. package/src/cli/theme.js +84 -0
  49. package/src/compiler.js +133 -0
  50. package/src/config-edit.js +399 -1
  51. package/src/config.js +174 -4
  52. package/src/decompiler.js +45 -0
  53. package/src/extensions.js +72 -19
  54. package/src/features.js +339 -0
  55. package/src/formatter.js +499 -0
  56. package/src/index.js +31 -0
  57. package/src/lexer.js +45 -4
  58. package/src/lint/analyze.js +1414 -0
  59. package/src/lint/config-file.js +97 -0
  60. package/src/lint/config.js +174 -0
  61. package/src/lint/rules.js +461 -0
  62. package/src/linter.js +220 -0
  63. package/src/merge.js +109 -0
  64. package/src/parser.js +53 -13
  65. package/src/project.js +7 -87
  66. package/src/vendor.js +1323 -0
  67. package/vendor/pm-packager-dist/.fx-build.json +1 -1
  68. package/vendor/tempvars/fx-tempvars.js +321 -0
@@ -0,0 +1,1414 @@
1
+ /**
2
+ * Browser-safe lint analyzer.
3
+ *
4
+ * This is the part of the linter that only needs source text: parsing, the AST
5
+ * rules, suppressions, severity selection and fixes. It deliberately imports
6
+ * no Node built-ins, so the same implementation powers `fx lint` and the VS
7
+ * Code extension (including its browser extension host). The Node-only project
8
+ * checks (assets on disk, `fx.lock` verification) live in `src/linter.js` and
9
+ * arrive here as `extraDiagnostics`.
10
+ */
11
+
12
+ import { CATALOG, LIST_OPS } from "../catalog.js";
13
+ import { ParseError } from "../errors.js";
14
+ import { tokenizeWithComments } from "../lexer.js";
15
+ import { mergePrograms } from "../merge.js";
16
+ import { parse } from "../parser.js";
17
+ import { DEFAULT_LINT, resolveRuleSeverities } from "./config.js";
18
+ import { RULES, ruleById } from "./rules.js";
19
+
20
+ /** Rules that describe linter/project machinery and are never configurable. */
21
+ export const META_RULES = new Set(["FX000", "FX001", "FX002"]);
22
+
23
+ /** Control blocks nested deeper than this are reported by FX901. */
24
+ export const MAX_NESTING = 4;
25
+
26
+ /** A single hat or function with more statements is reported by FX902. */
27
+ export const MAX_SCRIPT_STATEMENTS = 60;
28
+
29
+ /** List method aliases, mapped to their canonical method name. */
30
+ const LIST_METHODS = new Map();
31
+ for (const [name, entry] of Object.entries(LIST_OPS)) {
32
+ LIST_METHODS.set(name, name);
33
+ for (const alias of entry.aliases ?? []) LIST_METHODS.set(alias, name);
34
+ }
35
+
36
+ /** Catalog blocks that hand control back to the scheduler. */
37
+ const YIELDING_BLOCKS = new Set([
38
+ "control.wait",
39
+ "control.waitUntil",
40
+ "looks.sayForSecs",
41
+ "looks.thinkForSecs",
42
+ "looks.switchBackdropAndWait",
43
+ "sound.playUntilDone",
44
+ "sensing.askAndWait",
45
+ "event.broadcastAndWait",
46
+ "motion.glideTo",
47
+ "motion.glideToXY",
48
+ ]);
49
+
50
+ // ---------------------------------------------------------------- utilities
51
+
52
+ /** Scratch-style truthiness of a static literal value. */
53
+ function truthy(value) {
54
+ if (typeof value === "boolean") return value;
55
+ if (typeof value === "number") return value !== 0 && !Number.isNaN(value);
56
+ const text = String(value).trim().toLowerCase();
57
+ return text !== "" && text !== "0" && text !== "false";
58
+ }
59
+
60
+ /**
61
+ * Evaluate an expression whose value is known before the project runs.
62
+ * Returns `null` when the value depends on runtime state.
63
+ */
64
+ function staticValue(expr) {
65
+ if (!expr) return null;
66
+ switch (expr.type) {
67
+ case "Number":
68
+ case "String":
69
+ case "Boolean":
70
+ return expr.value;
71
+ case "Unary": {
72
+ const value = staticValue(expr.operand);
73
+ if (value === null) return null;
74
+ if (expr.op === "not") return !truthy(value);
75
+ if (expr.op === "-") return typeof value === "number" ? -value : null;
76
+ return null;
77
+ }
78
+ case "Binary": {
79
+ const left = staticValue(expr.left);
80
+ const right = staticValue(expr.right);
81
+ if (expr.op === "and") {
82
+ if (left !== null && !truthy(left)) return false;
83
+ if (left !== null && right !== null) return truthy(right);
84
+ return null;
85
+ }
86
+ if (expr.op === "or") {
87
+ if (left !== null && truthy(left)) return true;
88
+ if (left !== null && right !== null) return truthy(right);
89
+ return null;
90
+ }
91
+ if (left === null || right === null) return null;
92
+ switch (expr.op) {
93
+ case "==":
94
+ return String(left) === String(right);
95
+ case "!=":
96
+ return String(left) !== String(right);
97
+ case "<":
98
+ return Number(left) < Number(right);
99
+ case "<=":
100
+ return Number(left) <= Number(right);
101
+ case ">":
102
+ return Number(left) > Number(right);
103
+ case ">=":
104
+ return Number(left) >= Number(right);
105
+ case "+":
106
+ if (typeof left === "number" && typeof right === "number")
107
+ return left + right;
108
+ if (typeof left === "string" || typeof right === "string")
109
+ return `${left}${right}`;
110
+ return null;
111
+ case "-":
112
+ case "*":
113
+ if (typeof left === "number" && typeof right === "number")
114
+ return expr.op === "-" ? left - right : left * right;
115
+ return null;
116
+ case "/":
117
+ case "%":
118
+ if (typeof left !== "number" || typeof right !== "number")
119
+ return null;
120
+ if (right === 0) return null; // FX404 owns this case
121
+ return expr.op === "/" ? left / right : left % right;
122
+ default:
123
+ return null;
124
+ }
125
+ }
126
+ default:
127
+ return null;
128
+ }
129
+ }
130
+
131
+ /** Static conversion matching the compiler's booleanInput and boolean operators. */
132
+ function staticBoolean(expr) {
133
+ if (expr.type === "Unary" && expr.op === "not") {
134
+ const value = staticBoolean(expr.operand);
135
+ return value === null ? null : !value;
136
+ }
137
+ if (expr.type === "Binary" && ["and", "or"].includes(expr.op)) {
138
+ const left = staticBoolean(expr.left);
139
+ const right = staticBoolean(expr.right);
140
+ if (expr.op === "and") {
141
+ if (left === false || right === false) return false;
142
+ return left === null || right === null ? null : left && right;
143
+ }
144
+ if (left === true || right === true) return true;
145
+ return left === null || right === null ? null : left || right;
146
+ }
147
+ const value = staticValue(expr);
148
+ if (value === null) return null;
149
+ // Primitive inputs are compared with "true"; reporter blocks pass through.
150
+ if (["Number", "String", "Boolean"].includes(expr.type))
151
+ return String(value).toLowerCase() === "true";
152
+ return truthy(value);
153
+ }
154
+
155
+ /** Whether a statement ends the script it belongs to. */
156
+ function isTerminalCall(node) {
157
+ if (node.type === "Return") return true;
158
+ if (node.type !== "BlockCall") return false;
159
+ if (node.name === "control.deleteThisClone") return true;
160
+ if (node.name !== "control.stop") return false;
161
+ const option = node.args[0];
162
+ if (!option) return true;
163
+ return (
164
+ option.type === "String" && ["all", "this script"].includes(option.value)
165
+ );
166
+ }
167
+
168
+ /** Descriptive text for the block that ends a script. */
169
+ function terminalDescription(node) {
170
+ if (node.type === "Return") return "`return`";
171
+ if (node.name === "control.stop") return "`control.stop(...)`";
172
+ return `\`${node.name}()\``;
173
+ }
174
+
175
+ /** Count statements in a hat or function body, including nested blocks. */
176
+ function countStatements(statements) {
177
+ let total = 0;
178
+ const visit = (list) => {
179
+ for (const statement of list ?? []) {
180
+ total += 1;
181
+ if (statement.type === "If") {
182
+ visit(statement.then);
183
+ visit(statement.else);
184
+ } else if (
185
+ statement.type === "Repeat" ||
186
+ statement.type === "While" ||
187
+ statement.type === "Forever"
188
+ ) {
189
+ visit(statement.body);
190
+ } else if (statement.type === "BlockCall") {
191
+ visit(statement.body);
192
+ visit(statement.else);
193
+ }
194
+ }
195
+ };
196
+ visit(statements);
197
+ return total;
198
+ }
199
+
200
+ /** Whether every path through a `returning function` ends with a return. */
201
+ function alwaysReturns(statements) {
202
+ for (const statement of statements ?? []) {
203
+ if (statement.type === "Return") return true;
204
+ if (
205
+ statement.type === "If" &&
206
+ statement.else &&
207
+ alwaysReturns(statement.then) &&
208
+ alwaysReturns(statement.else)
209
+ ) {
210
+ return true;
211
+ }
212
+ }
213
+ return false;
214
+ }
215
+
216
+ /** Declaration tables for one target's top-level items. */
217
+ function collectDeclarations(items) {
218
+ const vars = new Map();
219
+ const lists = new Map();
220
+ const funcs = new Map();
221
+ const order = [];
222
+ items.forEach((item, index) => {
223
+ if (item.type === "VarDecl") {
224
+ vars.set(item.name, item);
225
+ order.push({ kind: "value", node: item, index });
226
+ } else if (item.type === "ListDecl") {
227
+ lists.set(item.name, item);
228
+ order.push({ kind: "value", node: item, index });
229
+ } else if (item.type === "FunctionDecl") {
230
+ funcs.set(item.name, item);
231
+ order.push({ kind: "func", node: item, index });
232
+ }
233
+ });
234
+ return { vars, lists, funcs, order };
235
+ }
236
+
237
+ /** Build stage and sprite targets, mirroring how the compiler hoists names. */
238
+ function buildTargets(program) {
239
+ const stageBody = program.stage ? program.stage.body : [];
240
+ const stageItems = [...stageBody, ...program.topItems];
241
+ const stageLoc = program.stage?.loc ??
242
+ stageItems[0]?.loc ?? { file: null, line: 1, column: 1 };
243
+ const stage = {
244
+ name: "Stage",
245
+ isStage: true,
246
+ loc: stageLoc,
247
+ // Only a real `stage {` block has a brace to insert declarations into.
248
+ open: program.stage?.open ?? null,
249
+ items: stageItems,
250
+ decls: collectDeclarations(stageItems),
251
+ usage: { vars: new Set(), lists: new Set(), funcs: new Set() },
252
+ };
253
+ const sprites = program.sprites.map((sprite) => ({
254
+ name: sprite.name,
255
+ isStage: false,
256
+ loc: sprite.loc,
257
+ open: sprite.open ?? null,
258
+ items: sprite.body,
259
+ decls: collectDeclarations(sprite.body),
260
+ usage: { vars: new Set(), lists: new Set(), funcs: new Set() },
261
+ }));
262
+ return { stage, sprites, globals: stage.decls };
263
+ }
264
+
265
+ /** Normalize a source location into the diagnostic range shape. */
266
+ function rangeOf(loc, length = 1) {
267
+ const line = loc?.line ?? 1;
268
+ const column = loc?.column ?? 1;
269
+ return {
270
+ file: loc?.file ?? null,
271
+ line,
272
+ column,
273
+ endLine: line,
274
+ endColumn: column + Math.max(0, length),
275
+ };
276
+ }
277
+
278
+ /** Last path segment, without importing `node:path`. */
279
+ function baseName(file) {
280
+ const parts = String(file ?? "").split(/[\\/]/);
281
+ return parts[parts.length - 1] || "";
282
+ }
283
+
284
+ // ----------------------------------------------------------------- analyzer
285
+
286
+ const TEMP_SIGIL = /^\$\$?[A-Za-z_][A-Za-z0-9_]*$/;
287
+
288
+ class Analyzer {
289
+ /**
290
+ * @param {{
291
+ * severities: Map<string, string>,
292
+ * catalog: Record<string, object>,
293
+ * extensionIds: Set<string>,
294
+ * declaredExtensions: Set<string>,
295
+ * sourceText: (file: string) => string|null,
296
+ * }} options
297
+ */
298
+ constructor({
299
+ severities,
300
+ catalog,
301
+ extensionIds,
302
+ declaredExtensions,
303
+ sourceText,
304
+ }) {
305
+ this.severities = severities;
306
+ this.catalog = catalog;
307
+ this.extensionIds = extensionIds;
308
+ this.declaredExtensions = declaredExtensions;
309
+ this.sourceText = sourceText;
310
+ this.diagnostics = [];
311
+ this.globalUsage = { vars: new Set(), lists: new Set() };
312
+ this.paramUsage = new Map();
313
+ }
314
+
315
+ /** Whether a rule is enabled for this run. */
316
+ enabled(id) {
317
+ return META_RULES.has(id) || this.severities.get(id) !== "off";
318
+ }
319
+
320
+ /** Append one diagnostic, tagged with its stable metadata. */
321
+ report(id, loc, message, { length = 1, fix = null } = {}) {
322
+ if (!this.enabled(id)) return;
323
+ const rule = RULES[id];
324
+ this.diagnostics.push({
325
+ ruleId: id,
326
+ severity: META_RULES.has(id) ? rule.severity : this.severities.get(id),
327
+ category: rule.category,
328
+ message,
329
+ ...rangeOf(loc, length),
330
+ explanation: rule.explanation,
331
+ // Only advertise fixability when this diagnostic actually carries a
332
+ // fix, so FX204 without a block to insert into is not offered as fixable.
333
+ fixable: Boolean(rule.fixable) && Boolean(fix),
334
+ ...(fix ? { fix } : {}),
335
+ });
336
+ }
337
+
338
+ // ------------------------------------------------------------ resolution
339
+
340
+ resolveName(env, params, name) {
341
+ if (params?.has(name)) return { kind: "param", name, scope: "param" };
342
+ const { target, globals } = env;
343
+ if (target.decls.vars.has(name))
344
+ return {
345
+ kind: "var",
346
+ name,
347
+ scope: target.isStage ? "global" : "local",
348
+ };
349
+ if (target.decls.lists.has(name))
350
+ return {
351
+ kind: "list",
352
+ name,
353
+ scope: target.isStage ? "global" : "local",
354
+ };
355
+ if (globals.vars.has(name)) return { kind: "var", name, scope: "global" };
356
+ if (globals.lists.has(name)) return { kind: "list", name, scope: "global" };
357
+ return null;
358
+ }
359
+
360
+ markUsage(state, resolved) {
361
+ if (resolved.kind === "param") {
362
+ const used = this.paramUsage.get(state.func);
363
+ if (used) used.add(resolved.name);
364
+ return;
365
+ }
366
+ const bucket = resolved.kind === "var" ? "vars" : "lists";
367
+ if (resolved.scope === "local")
368
+ state.env.target.usage[bucket].add(resolved.name);
369
+ else this.globalUsage[bucket].add(resolved.name);
370
+ }
371
+
372
+ checkIdentifier(expr, state) {
373
+ if (TEMP_SIGIL.test(expr.name)) return;
374
+ if (expr.segments.length > 1) {
375
+ this.report(
376
+ "FX201",
377
+ expr.loc,
378
+ `Dotted name ${JSON.stringify(expr.name)} cannot be read as a variable.`,
379
+ { length: expr.name.length },
380
+ );
381
+ return;
382
+ }
383
+ const resolved = this.resolveName(state.env, state.params, expr.name);
384
+ if (!resolved) {
385
+ this.report(
386
+ "FX201",
387
+ expr.loc,
388
+ `Variable ${JSON.stringify(expr.name)} is not declared.`,
389
+ { length: expr.name.length },
390
+ );
391
+ return;
392
+ }
393
+ this.markUsage(state, resolved);
394
+ }
395
+
396
+ /**
397
+ * Resolve a call, reporting the rule that matches its failure mode.
398
+ * @returns {{kind: 'list'|'catalog'|'function'|'extension'|'unknown', entry?: object}}
399
+ */
400
+ resolveCall(node, state) {
401
+ const { segments, name } = node;
402
+ if (segments.length === 2) {
403
+ const method = LIST_METHODS.get(segments[1]);
404
+ if (method) {
405
+ const resolved = this.resolveName(state.env, state.params, segments[0]);
406
+ if (resolved?.kind === "list") {
407
+ this.markUsage(state, resolved);
408
+ return { kind: "list" };
409
+ }
410
+ // A catalog block may share a method name (`looks.show`, `pen.clear`),
411
+ // and the compiler prefers the catalog when the base is not a list.
412
+ const entry = this.catalog[name];
413
+ if (entry) return { kind: "catalog", entry };
414
+ this.report(
415
+ "FX202",
416
+ node.loc,
417
+ resolved
418
+ ? `${JSON.stringify(segments[0])} is not a list, so ${name}() cannot be called on it.`
419
+ : `List ${JSON.stringify(segments[0])} is not declared.`,
420
+ { length: name.length },
421
+ );
422
+ return { kind: "unknown" };
423
+ }
424
+ }
425
+
426
+ const entry = this.catalog[name];
427
+ if (entry) {
428
+ if (
429
+ entry.requiresExtension &&
430
+ entry.extension &&
431
+ this.extensionIds.size > 0 &&
432
+ !this.extensionIds.has(entry.extension)
433
+ )
434
+ this.report(
435
+ "FX709",
436
+ node.loc,
437
+ `${name} needs the ${entry.extension} extension, which is not loaded. Run "fx feature temporaryvariables enable".`,
438
+ { length: name.length },
439
+ );
440
+ return { kind: "catalog", entry };
441
+ }
442
+
443
+ if (segments.length === 1 && state.env.target.decls.funcs.has(name)) {
444
+ state.env.target.usage.funcs.add(name);
445
+ return { kind: "function" };
446
+ }
447
+
448
+ const prefix = segments.length > 1 ? segments[0] : null;
449
+ const verified = Boolean(prefix && this.extensionIds.has(prefix));
450
+ const declared = Boolean(prefix && this.declaredExtensions.has(prefix));
451
+ // Editors know which extensions are declared but do not execute their
452
+ // metadata, so an unverifiable block from a declared extension is not a
453
+ // diagnostic there (the CLI, which loads the catalog, still reports it).
454
+ if (declared && !verified) return { kind: "extension" };
455
+
456
+ const hint = verified
457
+ ? ` Extension ${JSON.stringify(prefix)} is loaded but has no such block.`
458
+ : "";
459
+ this.report(
460
+ "FX709",
461
+ node.loc,
462
+ `Unknown block or function ${JSON.stringify(name)}.${hint}`,
463
+ { length: name.length },
464
+ );
465
+ return { kind: "unknown" };
466
+ }
467
+
468
+ checkYield(node, state) {
469
+ if (!state.inWarp || state.warpYieldReported) return;
470
+ if (!YIELDING_BLOCKS.has(node.name)) return;
471
+ state.warpYieldReported = true;
472
+ this.report(
473
+ "FX506",
474
+ node.loc,
475
+ `Warp function ${JSON.stringify(state.func.name)} calls ${node.name}(), which cannot yield inside warp.`,
476
+ { length: node.name.length },
477
+ );
478
+ }
479
+
480
+ // ----------------------------------------------------------- expressions
481
+
482
+ walkExpression(expr, state) {
483
+ if (!expr) return;
484
+ switch (expr.type) {
485
+ case "Identifier":
486
+ this.checkIdentifier(expr, state);
487
+ break;
488
+ case "Call": {
489
+ this.resolveCall(expr, state);
490
+ this.checkYield(expr, state);
491
+ for (const arg of expr.args) this.walkExpression(arg, state);
492
+ break;
493
+ }
494
+ case "Binary":
495
+ this.checkDivideByZero(expr);
496
+ this.walkExpression(expr.left, state);
497
+ this.walkExpression(expr.right, state);
498
+ break;
499
+ case "Unary":
500
+ this.walkExpression(expr.operand, state);
501
+ break;
502
+ case "ArrayLiteral":
503
+ for (const item of expr.items) this.walkExpression(item, state);
504
+ break;
505
+ default:
506
+ break;
507
+ }
508
+ }
509
+
510
+ checkDivideByZero(expr) {
511
+ if (expr.op !== "/" && expr.op !== "%") return;
512
+ const divisor = staticValue(expr.right);
513
+ if (typeof divisor === "number" && divisor === 0) {
514
+ this.report(
515
+ "FX404",
516
+ expr.loc,
517
+ `Division by a constant zero${expr.op === "%" ? " (modulo)" : ""} yields Infinity or NaN.`,
518
+ { length: 1 },
519
+ );
520
+ }
521
+ }
522
+
523
+ // ------------------------------------------------------------ statements
524
+
525
+ walkStatements(statements, state) {
526
+ let terminal = null;
527
+ for (const statement of statements ?? []) {
528
+ if (terminal) {
529
+ this.report(
530
+ "FX101",
531
+ statement.loc,
532
+ `Unreachable statement: nothing can follow ${terminal}.`,
533
+ { length: 1 },
534
+ );
535
+ }
536
+ this.walkStatement(statement, state);
537
+ if (isTerminalCall(statement)) terminal = terminalDescription(statement);
538
+ }
539
+ }
540
+
541
+ enterBlock(node, state) {
542
+ const nesting = state.nesting + 1;
543
+ if (nesting > MAX_NESTING) {
544
+ this.report(
545
+ "FX901",
546
+ node.loc,
547
+ `Control blocks are nested ${nesting} levels deep; keep nesting at ${MAX_NESTING} or less.`,
548
+ { length: 1 },
549
+ );
550
+ }
551
+ return { ...state, nesting };
552
+ }
553
+
554
+ walkStatement(statement, state) {
555
+ switch (statement.type) {
556
+ case "VarDecl":
557
+ case "ListDecl":
558
+ // Nested declarations are not hoisted by the compiler; their
559
+ // initializers still contribute references.
560
+ if (statement.type === "VarDecl")
561
+ this.walkExpression(statement.init, state);
562
+ else
563
+ for (const item of statement.items) this.walkExpression(item, state);
564
+ break;
565
+ case "PropertyDecl":
566
+ case "FunctionDecl":
567
+ case "HatDecl":
568
+ break;
569
+ case "If": {
570
+ const constant = staticBoolean(statement.cond);
571
+ if (constant !== null) {
572
+ this.report(
573
+ "FX403",
574
+ statement.cond.loc,
575
+ `Condition is always ${constant}, so one branch is dead.`,
576
+ { length: 1 },
577
+ );
578
+ }
579
+ this.walkExpression(statement.cond, state);
580
+ const inner = this.enterBlock(statement, state);
581
+ this.walkStatements(statement.then, inner);
582
+ this.walkStatements(statement.else, inner);
583
+ break;
584
+ }
585
+ case "Repeat": {
586
+ const count = staticValue(statement.count);
587
+ if (typeof count === "number" && count <= 0) {
588
+ this.report(
589
+ "FX304",
590
+ statement.count.loc,
591
+ `repeat (${count}) never runs its body.`,
592
+ { length: 1 },
593
+ );
594
+ }
595
+ this.walkExpression(statement.count, state);
596
+ this.walkStatements(statement.body, this.enterBlock(statement, state));
597
+ break;
598
+ }
599
+ case "While": {
600
+ const constant = staticBoolean(statement.cond);
601
+ if (constant !== null) {
602
+ this.report(
603
+ "FX302",
604
+ statement.cond.loc,
605
+ `Loop condition is always ${constant}.`,
606
+ { length: 1 },
607
+ );
608
+ }
609
+ if (constant === true && !this.bodyYields(statement.body, state)) {
610
+ this.report(
611
+ "FX301",
612
+ statement.loc,
613
+ "Infinite loop body has nothing that yields, so other scripts cannot run.",
614
+ { length: 5 },
615
+ );
616
+ }
617
+ this.walkExpression(statement.cond, state);
618
+ this.walkStatements(statement.body, this.enterBlock(statement, state));
619
+ break;
620
+ }
621
+ case "Forever": {
622
+ if (!this.bodyYields(statement.body, state)) {
623
+ this.report(
624
+ "FX301",
625
+ statement.loc,
626
+ "Infinite loop body has nothing that yields, so other scripts cannot run.",
627
+ { length: 7 },
628
+ );
629
+ }
630
+ this.walkStatements(statement.body, this.enterBlock(statement, state));
631
+ break;
632
+ }
633
+ case "Return":
634
+ if (state.funcDepth === 0) {
635
+ this.report(
636
+ "FX307",
637
+ statement.loc,
638
+ "`return` is only valid inside a function.",
639
+ { length: 6 },
640
+ );
641
+ }
642
+ if (statement.value && state.func && !state.func.returns) {
643
+ this.report(
644
+ "FX309",
645
+ statement.loc,
646
+ `Function ${JSON.stringify(state.func.name)} is not a returning function, so it cannot return a value.`,
647
+ { length: 6 },
648
+ );
649
+ }
650
+ this.walkExpression(statement.value, state);
651
+ break;
652
+ case "TempVarDecl":
653
+ this.walkExpression(statement.init, state);
654
+ break;
655
+ case "Assign":
656
+ this.walkAssign(statement, state);
657
+ break;
658
+ case "BlockCall":
659
+ this.walkBlockCall(statement, state);
660
+ break;
661
+ default:
662
+ break;
663
+ }
664
+ }
665
+
666
+ walkAssign(statement, state) {
667
+ if (TEMP_SIGIL.test(statement.name)) {
668
+ this.walkExpression(statement.value, state);
669
+ return;
670
+ }
671
+ const resolved = this.resolveName(state.env, state.params, statement.name);
672
+ if (!resolved || resolved.kind !== "var") {
673
+ this.report(
674
+ "FX204",
675
+ statement.loc,
676
+ `Assignment to undeclared variable ${JSON.stringify(statement.name)}; declare it with \`let ${statement.name} = <value>;\`.`,
677
+ {
678
+ length: statement.name.length,
679
+ fix:
680
+ state.env.target.open == null
681
+ ? null
682
+ : {
683
+ kind: "declare-variable",
684
+ name: statement.name,
685
+ file:
686
+ state.env.target.open.file ??
687
+ state.env.target.loc?.file ??
688
+ null,
689
+ // 1-based position of the opening brace; the fix inserts
690
+ // just after it so a brace on its own line still works.
691
+ line: state.env.target.open.line,
692
+ column: state.env.target.open.column,
693
+ indent: " ",
694
+ },
695
+ },
696
+ );
697
+ } else {
698
+ this.markUsage(state, resolved);
699
+ }
700
+ if (statement.op === "/=") {
701
+ const divisor = staticValue(statement.value);
702
+ if (typeof divisor === "number" && divisor === 0) {
703
+ this.report(
704
+ "FX404",
705
+ statement.loc,
706
+ "Dividing by a constant zero yields Infinity or NaN.",
707
+ { length: statement.name.length },
708
+ );
709
+ }
710
+ }
711
+ this.walkExpression(statement.value, state);
712
+ }
713
+
714
+ walkBlockCall(statement, state) {
715
+ this.resolveCall(statement, state);
716
+ this.checkYield(statement, state);
717
+ for (const arg of statement.args) this.walkExpression(arg, state);
718
+ if (statement.body) {
719
+ const inner = this.enterBlock(statement, state);
720
+ this.walkStatements(statement.body, inner);
721
+ this.walkStatements(statement.else, inner);
722
+ }
723
+ }
724
+
725
+ /** Whether a loop body contains a block that lets the scheduler step. */
726
+ bodyYields(statements, state) {
727
+ for (const statement of statements ?? []) {
728
+ if (
729
+ statement.type === "Forever" ||
730
+ statement.type === "Repeat" ||
731
+ statement.type === "While"
732
+ ) {
733
+ return true; // loops yield at the end of every iteration
734
+ }
735
+ if (statement.type === "BlockCall") {
736
+ if (
737
+ YIELDING_BLOCKS.has(statement.name) ||
738
+ state.env.target.decls.funcs.has(statement.name)
739
+ ) {
740
+ return true;
741
+ }
742
+ if (
743
+ this.bodyYields(statement.body, state) ||
744
+ this.bodyYields(statement.else, state)
745
+ ) {
746
+ return true;
747
+ }
748
+ } else if (statement.type === "If") {
749
+ if (
750
+ this.bodyYields(statement.then, state) ||
751
+ this.bodyYields(statement.else, state)
752
+ ) {
753
+ return true;
754
+ }
755
+ }
756
+ }
757
+ return false;
758
+ }
759
+
760
+ // ---------------------------------------------------------- target rules
761
+
762
+ analyzeProgram(program) {
763
+ const { stage, sprites, globals } = buildTargets(program);
764
+ for (const target of [stage, ...sprites])
765
+ this.analyzeTarget(target, globals);
766
+ // Sprite reads feed `globalUsage`, so unused stage names are decided only
767
+ // after every target has been analyzed.
768
+ this.checkGlobalUsage(globals);
769
+ return this.diagnostics;
770
+ }
771
+
772
+ analyzeTarget(target, globals) {
773
+ const env = { target, globals };
774
+ for (const item of target.items) {
775
+ if (item.type === "VarDecl" || item.type === "ListDecl")
776
+ this.checkInitializer(item, target, globals);
777
+ }
778
+
779
+ for (const item of target.items) {
780
+ if (item.type === "FunctionDecl") {
781
+ this.checkDocumentation(item);
782
+ this.checkScriptLength(item.body, item.loc);
783
+ if (item.returns && !alwaysReturns(item.body)) {
784
+ this.report(
785
+ "FX308",
786
+ item.loc,
787
+ `Returning function ${JSON.stringify(item.name)} has a path that does not return a value.`,
788
+ { length: item.name.length },
789
+ );
790
+ }
791
+ this.paramUsage.set(item, new Set());
792
+ const state = {
793
+ env,
794
+ params: new Map(item.params.map((param) => [param.name, param])),
795
+ func: item,
796
+ funcDepth: 1,
797
+ nesting: 0,
798
+ inWarp: Boolean(item.warp),
799
+ warpYieldReported: false,
800
+ };
801
+ this.walkStatements(item.body, state);
802
+ } else if (item.type === "HatDecl") {
803
+ this.checkScriptLength(item.body, item.loc);
804
+ const state = {
805
+ env,
806
+ params: null,
807
+ func: null,
808
+ funcDepth: 0,
809
+ nesting: 0,
810
+ inWarp: false,
811
+ warpYieldReported: false,
812
+ };
813
+ this.walkStatements(item.body, state);
814
+ }
815
+ }
816
+
817
+ this.checkDeclarations(target, globals);
818
+ }
819
+
820
+ /** FX203: initializers that read a declaration made later in the target. */
821
+ checkInitializer(decl, target, globals) {
822
+ const index = new Map();
823
+ target.items.forEach((item, position) => {
824
+ if (item.type !== "VarDecl" && item.type !== "ListDecl") return;
825
+ if (!index.has(item.name)) index.set(item.name, position);
826
+ });
827
+ const position = target.items.indexOf(decl);
828
+ const state = {
829
+ env: { target, globals },
830
+ params: null,
831
+ func: null,
832
+ funcDepth: 0,
833
+ nesting: 0,
834
+ inWarp: false,
835
+ warpYieldReported: false,
836
+ };
837
+ const visit = (expr) => {
838
+ if (!expr) return;
839
+ if (expr.type === "Identifier") {
840
+ const later = index.get(expr.name);
841
+ if (later !== undefined && later > position) {
842
+ this.report(
843
+ "FX203",
844
+ expr.loc,
845
+ `${JSON.stringify(expr.name)} is declared later in this target, so this initializer reads its default value.`,
846
+ { length: expr.name.length },
847
+ );
848
+ }
849
+ this.walkExpression(expr, state);
850
+ return;
851
+ }
852
+ if (expr.type === "Binary") {
853
+ visit(expr.left);
854
+ visit(expr.right);
855
+ } else if (expr.type === "Unary") {
856
+ visit(expr.operand);
857
+ } else if (expr.type === "ArrayLiteral") {
858
+ for (const item of expr.items) visit(item);
859
+ } else if (expr.type === "Call") {
860
+ this.walkExpression(expr, state);
861
+ }
862
+ };
863
+ if (decl.type === "VarDecl") visit(decl.init);
864
+ else for (const item of decl.items) visit(item);
865
+ }
866
+
867
+ checkScriptLength(statements, loc) {
868
+ const total = countStatements(statements);
869
+ if (total > MAX_SCRIPT_STATEMENTS) {
870
+ this.report(
871
+ "FX902",
872
+ loc,
873
+ `Script has ${total} statements; split it into smaller functions.`,
874
+ { length: 1 },
875
+ );
876
+ }
877
+ }
878
+
879
+ /** FX908: a public function should carry a `//` comment above it. */
880
+ checkDocumentation(decl) {
881
+ const text = decl.loc?.file ? this.sourceText(decl.loc.file) : null;
882
+ if (!text) return;
883
+ const lines = text.split(/\r?\n/);
884
+ let above = (decl.loc?.line ?? 1) - 2;
885
+ while (above >= 0 && lines[above].trim() === "") above -= 1;
886
+ if (above < 0) return;
887
+ const trimmed = lines[above].trim();
888
+ if (trimmed.startsWith("//") || trimmed.endsWith("*/")) return;
889
+ this.report(
890
+ "FX908",
891
+ decl.loc,
892
+ `Function ${JSON.stringify(decl.name)} has no documentation comment.`,
893
+ { length: decl.name.length },
894
+ );
895
+ }
896
+
897
+ /** Declaration-level rules: shadowing, unused names, case collisions. */
898
+ checkDeclarations(target, globals) {
899
+ const { decls } = target;
900
+
901
+ for (const [, decl] of [...decls.vars, ...decls.lists]) {
902
+ if (decl.name.startsWith("$")) {
903
+ this.report(
904
+ "FX214",
905
+ decl.loc,
906
+ `Reserved temporary-variable name ${JSON.stringify(decl.name)}; names starting with "$" are read as temporary variables.`,
907
+ { length: decl.name.length },
908
+ );
909
+ }
910
+ if (decl.type === "VarDecl" && decl.cloud && !target.isStage) {
911
+ this.report(
912
+ "FX212",
913
+ decl.loc,
914
+ `Cloud variable ${JSON.stringify(decl.name)} must be declared at the top level or inside \`stage\`.`,
915
+ { length: decl.name.length },
916
+ );
917
+ }
918
+ }
919
+
920
+ // Shadowing: target-level names hide parameters and stage variables.
921
+ for (const [, fn] of decls.funcs) {
922
+ for (const param of fn.params) {
923
+ if (param.name.startsWith("$"))
924
+ this.report(
925
+ "FX214",
926
+ fn.decl?.loc ?? fn.loc,
927
+ `Reserved temporary-variable name ${JSON.stringify(param.name)}; rename the parameter.`,
928
+ { length: param.name.length },
929
+ );
930
+ }
931
+ for (const param of fn.params) {
932
+ const local = decls.vars.get(param.name) ?? decls.lists.get(param.name);
933
+ if (local) {
934
+ this.report(
935
+ "FX205",
936
+ local.loc,
937
+ `Local ${local.type === "ListDecl" ? "list" : "variable"} ${JSON.stringify(param.name)} shadows a parameter of function ${JSON.stringify(fn.name)}.`,
938
+ { length: param.name.length },
939
+ );
940
+ }
941
+ }
942
+ }
943
+ if (!target.isStage) {
944
+ for (const [, decl] of [...decls.vars, ...decls.lists]) {
945
+ if (globals.vars.has(decl.name) || globals.lists.has(decl.name)) {
946
+ this.report(
947
+ "FX206",
948
+ decl.loc,
949
+ `${JSON.stringify(decl.name)} shadows the project variable of the same name declared on the stage.`,
950
+ { length: decl.name.length },
951
+ );
952
+ }
953
+ }
954
+ }
955
+
956
+ // Case-only collisions within each namespace.
957
+ const collisions = new Map();
958
+ for (const entry of decls.order) {
959
+ const key = `${entry.kind}:${entry.node.name.toLowerCase()}`;
960
+ const first = collisions.get(key);
961
+ if (!first) collisions.set(key, entry);
962
+ else if (first.node.name !== entry.node.name) {
963
+ this.report(
964
+ "FX211",
965
+ entry.node.loc,
966
+ `Declarations ${JSON.stringify(first.node.name)} and ${JSON.stringify(entry.node.name)} differ only by case.`,
967
+ { length: entry.node.name.length },
968
+ );
969
+ }
970
+ }
971
+
972
+ // Unused declarations. Stage (global) names are checked separately, once
973
+ // every sprite has had a chance to read them.
974
+ if (!target.isStage) {
975
+ for (const [name, decl] of decls.vars) {
976
+ if (!target.usage.vars.has(name))
977
+ this.reportUnused(decl, "variable", name);
978
+ }
979
+ for (const [name, decl] of decls.lists) {
980
+ if (!target.usage.lists.has(name))
981
+ this.reportUnused(decl, "list", name);
982
+ }
983
+ }
984
+ for (const [name, fn] of decls.funcs) {
985
+ if (!target.usage.funcs.has(name)) {
986
+ this.report(
987
+ "FX209",
988
+ fn.loc,
989
+ `Function ${JSON.stringify(name)} is never called.`,
990
+ { length: name.length },
991
+ );
992
+ }
993
+ const used = this.paramUsage.get(fn) ?? new Set();
994
+ for (const param of fn.params) {
995
+ if (!used.has(param.name)) {
996
+ this.report(
997
+ "FX207",
998
+ fn.loc,
999
+ `Parameter ${JSON.stringify(param.name)} of ${JSON.stringify(name)} is never used.`,
1000
+ { length: name.length },
1001
+ );
1002
+ }
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ /** FX208: stage names that no target reads, after all targets ran. */
1008
+ checkGlobalUsage(globals) {
1009
+ for (const [name, decl] of globals.vars) {
1010
+ if (!this.globalUsage.vars.has(name))
1011
+ this.reportUnused(decl, "variable", name);
1012
+ }
1013
+ for (const [name, decl] of globals.lists) {
1014
+ if (!this.globalUsage.lists.has(name))
1015
+ this.reportUnused(decl, "list", name);
1016
+ }
1017
+ }
1018
+
1019
+ reportUnused(decl, kind, name) {
1020
+ this.report("FX208", decl.loc, `Unused ${kind} ${JSON.stringify(name)}.`, {
1021
+ length: name.length,
1022
+ });
1023
+ }
1024
+ }
1025
+
1026
+ // ------------------------------------------------------------- suppressions
1027
+
1028
+ const DIRECTIVE =
1029
+ /^fx-lint-(disable-next-line|disable-file|disable|enable)\b(.*)$/;
1030
+
1031
+ /**
1032
+ * Read `// fx-lint-*` directives from one source file's comments.
1033
+ * @returns {{directives: object[], diagnostics: object[]}}
1034
+ */
1035
+ function collectSuppressions(source, file) {
1036
+ const directives = [];
1037
+ const diagnostics = [];
1038
+ let comments;
1039
+ try {
1040
+ ({ comments } = tokenizeWithComments(source, file));
1041
+ } catch {
1042
+ return { directives, diagnostics };
1043
+ }
1044
+ for (const comment of comments) {
1045
+ const body = comment.text
1046
+ .replace(/^\/\/+/, "")
1047
+ .replace(/^\/\*+/, "")
1048
+ .replace(/\*+\/$/, "")
1049
+ .trim();
1050
+ const match = DIRECTIVE.exec(body);
1051
+ if (!match) continue;
1052
+ const kind = match[1];
1053
+ const rest = match[2].trim();
1054
+ let rules = null;
1055
+ if (rest) {
1056
+ const valid = new Set();
1057
+ const invalid = [];
1058
+ for (const token of rest.split(/[\s,]+/).filter(Boolean)) {
1059
+ const rule = ruleById(token.replace(/[.,;]+$/, ""));
1060
+ if (rule) valid.add(rule.id);
1061
+ else invalid.push(token);
1062
+ }
1063
+ if (invalid.length) {
1064
+ for (const token of invalid) {
1065
+ diagnostics.push({
1066
+ ruleId: "FX001",
1067
+ severity: RULES.FX001.severity,
1068
+ category: RULES.FX001.category,
1069
+ message: `Unknown rule ${JSON.stringify(token)} in fx-lint directive.`,
1070
+ ...rangeOf(
1071
+ { file, line: comment.line, column: comment.column },
1072
+ body.length,
1073
+ ),
1074
+ explanation: RULES.FX001.explanation,
1075
+ fixable: false,
1076
+ });
1077
+ }
1078
+ }
1079
+ rules = valid;
1080
+ }
1081
+ directives.push({
1082
+ kind,
1083
+ line: comment.line,
1084
+ column: comment.column,
1085
+ length: body.length,
1086
+ file,
1087
+ rules,
1088
+ used: false,
1089
+ });
1090
+ }
1091
+ return { directives, diagnostics };
1092
+ }
1093
+
1094
+ function suppressionFor(directive, diagnostic) {
1095
+ if (directive.rules && !directive.rules.has(diagnostic.ruleId)) return false;
1096
+ if (directive.kind === "disable-file") return true;
1097
+ if (directive.kind === "disable-next-line")
1098
+ return diagnostic.line === directive.line + 1;
1099
+ return false;
1100
+ }
1101
+
1102
+ /** Fold `disable`/`enable` ranges up to the diagnostic's line. */
1103
+ function rangeSuppression(directives, diagnostic) {
1104
+ let state = false;
1105
+ let matched = null;
1106
+ for (const directive of directives) {
1107
+ if (directive.kind !== "disable" && directive.kind !== "enable") continue;
1108
+ if (directive.line > diagnostic.line) continue;
1109
+ if (directive.rules && !directive.rules.has(diagnostic.ruleId)) continue;
1110
+ state = directive.kind === "disable";
1111
+ matched = directive;
1112
+ }
1113
+ return state ? matched : null;
1114
+ }
1115
+
1116
+ /**
1117
+ * Remove suppressed diagnostics and, in strict mode, report suppressions that
1118
+ * never matched anything.
1119
+ */
1120
+ function applySuppressions(diagnostics, directivesByFile, { strict }) {
1121
+ const remaining = [];
1122
+ let suppressed = 0;
1123
+ for (const diagnostic of diagnostics) {
1124
+ if (META_RULES.has(diagnostic.ruleId) || !diagnostic.file) {
1125
+ remaining.push(diagnostic);
1126
+ continue;
1127
+ }
1128
+ const directives = directivesByFile.get(diagnostic.file) ?? [];
1129
+ const direct = directives.find((directive) =>
1130
+ suppressionFor(directive, diagnostic),
1131
+ );
1132
+ const ranged = direct ? null : rangeSuppression(directives, diagnostic);
1133
+ const match = direct ?? ranged;
1134
+ if (match) {
1135
+ match.used = true;
1136
+ suppressed += 1;
1137
+ } else {
1138
+ remaining.push(diagnostic);
1139
+ }
1140
+ }
1141
+
1142
+ if (strict) {
1143
+ for (const directives of directivesByFile.values()) {
1144
+ for (const directive of directives) {
1145
+ if (directive.used) continue;
1146
+ if (directive.rules && directive.rules.size === 0) continue;
1147
+ remaining.push({
1148
+ ruleId: "FX002",
1149
+ severity: RULES.FX002.severity,
1150
+ category: RULES.FX002.category,
1151
+ message: `Unused lint suppression for ${directive.rules ? [...directive.rules].join(", ") : "all rules"}.`,
1152
+ ...rangeOf(
1153
+ {
1154
+ file: directive.file,
1155
+ line: directive.line,
1156
+ column: directive.column,
1157
+ },
1158
+ directive.length,
1159
+ ),
1160
+ explanation: RULES.FX002.explanation,
1161
+ fixable: false,
1162
+ });
1163
+ }
1164
+ }
1165
+ }
1166
+ return { diagnostics: remaining, suppressed };
1167
+ }
1168
+
1169
+ // ---------------------------------------------------------------- public API
1170
+
1171
+ /** Deterministic diagnostic order. */
1172
+ export function sortDiagnostics(diagnostics) {
1173
+ return [...diagnostics].sort(
1174
+ (a, b) =>
1175
+ String(a.file ?? "").localeCompare(String(b.file ?? "")) ||
1176
+ a.line - b.line ||
1177
+ a.column - b.column ||
1178
+ a.ruleId.localeCompare(b.ruleId) ||
1179
+ a.message.localeCompare(b.message),
1180
+ );
1181
+ }
1182
+
1183
+ /**
1184
+ * Lint a set of sources with the browser-safe rules.
1185
+ *
1186
+ * `extraDiagnostics` lets the Node entry point (`src/linter.js`) add
1187
+ * filesystem-backed project checks while sharing severity handling,
1188
+ * suppressions and ordering.
1189
+ *
1190
+ * @param {{
1191
+ * sources: Array<{file: string, source: string}>,
1192
+ * config?: object|null,
1193
+ * configPath?: string|null,
1194
+ * configDir?: string|null,
1195
+ * rootDir?: string|null,
1196
+ * extensionCatalog?: Record<string, object>,
1197
+ * declaredExtensions?: string[],
1198
+ * lintConfig?: object,
1199
+ * strict?: boolean,
1200
+ * overrides?: Record<string, string>,
1201
+ * extraDiagnostics?: object[],
1202
+ * }} options
1203
+ * @returns {{
1204
+ * diagnostics: object[],
1205
+ * summary: {errors: number, warnings: number, suppressed: number, files: number},
1206
+ * severities: Map<string, string>,
1207
+ * sources: Map<string, string>,
1208
+ * }}
1209
+ */
1210
+ export function lintSources({
1211
+ sources = [],
1212
+ config = null,
1213
+ configPath = null,
1214
+ extensionCatalog = {},
1215
+ declaredExtensions = [],
1216
+ lintConfig = DEFAULT_LINT,
1217
+ strict = false,
1218
+ overrides = {},
1219
+ extraDiagnostics = [],
1220
+ } = {}) {
1221
+ const severities = resolveRuleSeverities(lintConfig, { strict, overrides });
1222
+ const textByFile = new Map(sources.map(({ file, source }) => [file, source]));
1223
+ const verifiedExtensions = new Set(
1224
+ Object.keys(extensionCatalog)
1225
+ .filter((name) => name.includes("."))
1226
+ .map((name) => name.slice(0, name.indexOf("."))),
1227
+ );
1228
+ const analyzer = new Analyzer({
1229
+ severities,
1230
+ catalog: { ...CATALOG, ...extensionCatalog },
1231
+ extensionIds: verifiedExtensions,
1232
+ declaredExtensions: new Set(declaredExtensions.map(String)),
1233
+ sourceText: (file) => textByFile.get(file) ?? null,
1234
+ });
1235
+
1236
+ // Parse everything up front; syntax errors are reported as FX000 and the
1237
+ // file is excluded from the AST rules.
1238
+ const parsed = [];
1239
+ for (const { file, source } of sources) {
1240
+ try {
1241
+ parsed.push({ file, ast: parse(source, file) });
1242
+ } catch (error) {
1243
+ const loc =
1244
+ error instanceof ParseError
1245
+ ? {
1246
+ file: error.file ?? file,
1247
+ line: error.line ?? 1,
1248
+ column: error.column ?? 1,
1249
+ }
1250
+ : { file, line: 1, column: 1 };
1251
+ analyzer.report("FX000", loc, error.message, { length: 1 });
1252
+ }
1253
+ }
1254
+
1255
+ // Partial sprites are resolved by the project merge, which throws when the
1256
+ // feature is off or a base declaration is missing. Report those first.
1257
+ const partialSprites = Boolean(config?.fx?.partialSprites);
1258
+ let partialBlocked = false;
1259
+ if (!partialSprites) {
1260
+ for (const { ast } of parsed) {
1261
+ for (const sprite of ast.sprites) {
1262
+ if (!sprite.partial) continue;
1263
+ partialBlocked = true;
1264
+ analyzer.report(
1265
+ "FX803",
1266
+ sprite.loc,
1267
+ `Partial sprite ${JSON.stringify(sprite.name)} needs "fx.partialSprites: true" in ${configPath ? baseName(configPath) : "fx.yml"}.`,
1268
+ { length: sprite.name.length },
1269
+ );
1270
+ }
1271
+ }
1272
+ }
1273
+ const groups = new Map();
1274
+ for (const { ast } of parsed) {
1275
+ for (const sprite of ast.sprites) {
1276
+ const group = groups.get(sprite.name) ?? { base: false, fragment: null };
1277
+ if (sprite.partial) group.fragment ??= sprite;
1278
+ else group.base = true;
1279
+ groups.set(sprite.name, group);
1280
+ }
1281
+ }
1282
+ for (const [name, group] of groups) {
1283
+ if (group.base || !group.fragment) continue;
1284
+ analyzer.report(
1285
+ "FX804",
1286
+ group.fragment.loc,
1287
+ `Partial sprite ${JSON.stringify(name)} has no plain \`sprite ${JSON.stringify(name)} { }\` declaration.`,
1288
+ { length: name.length },
1289
+ );
1290
+ }
1291
+
1292
+ let program = null;
1293
+ if (!partialBlocked) {
1294
+ try {
1295
+ program = mergePrograms(
1296
+ parsed.map(({ file, ast }) => ({ ast, file })),
1297
+ { partialSprites },
1298
+ );
1299
+ } catch (error) {
1300
+ const loc =
1301
+ error instanceof ParseError
1302
+ ? { file: error.file, line: error.line, column: error.column }
1303
+ : { file: null, line: 1, column: 1 };
1304
+ analyzer.report("FX000", loc, error.message, { length: 1 });
1305
+ }
1306
+ }
1307
+ if (program) analyzer.analyzeProgram(program);
1308
+ else for (const { ast } of parsed) analyzer.analyzeProgram(ast);
1309
+
1310
+ // Diagnostics from filesystem-backed project checks (assets, fx.lock).
1311
+ for (const diagnostic of extraDiagnostics) {
1312
+ const rule = RULES[diagnostic.ruleId];
1313
+ if (!rule || !analyzer.enabled(diagnostic.ruleId)) continue;
1314
+ analyzer.diagnostics.push({
1315
+ ruleId: rule.id,
1316
+ severity: severities.get(rule.id) ?? rule.severity,
1317
+ category: rule.category,
1318
+ message: diagnostic.message,
1319
+ ...rangeOf(
1320
+ {
1321
+ file: diagnostic.file ?? null,
1322
+ line: diagnostic.line ?? 1,
1323
+ column: diagnostic.column ?? 1,
1324
+ },
1325
+ diagnostic.length ?? 1,
1326
+ ),
1327
+ explanation: rule.explanation,
1328
+ fixable: Boolean(rule.fixable),
1329
+ });
1330
+ }
1331
+
1332
+ // Suppressions are per file, so parse each file's comments after analysis.
1333
+ const directivesByFile = new Map();
1334
+ for (const { file, source } of sources) {
1335
+ const { directives, diagnostics } = collectSuppressions(source, file);
1336
+ directivesByFile.set(file, directives);
1337
+ analyzer.diagnostics.push(...diagnostics);
1338
+ }
1339
+
1340
+ const { diagnostics, suppressed } = applySuppressions(
1341
+ analyzer.diagnostics,
1342
+ directivesByFile,
1343
+ { strict },
1344
+ );
1345
+ const sorted = sortDiagnostics(diagnostics);
1346
+ const summary = {
1347
+ errors: sorted.filter((entry) => entry.severity === "error").length,
1348
+ warnings: sorted.filter((entry) => entry.severity === "warning").length,
1349
+ suppressed,
1350
+ files: sources.length,
1351
+ };
1352
+ return { diagnostics: sorted, summary, severities, sources: textByFile };
1353
+ }
1354
+
1355
+ /**
1356
+ * Apply safe, explicit fixes. Today only FX204 (implicit variable) is fixable:
1357
+ * the declaration is inserted just after the target's opening brace, so the
1358
+ * fix also works when that brace is on its own line.
1359
+ *
1360
+ * @param {Array<{file: string, source: string}>} sources
1361
+ * @param {object[]} diagnostics
1362
+ * @returns {{sources: Map<string, string>, files: string[], fixed: number}}
1363
+ */
1364
+ export function applyFixes(sources, diagnostics) {
1365
+ const textByFile = new Map(sources.map(({ file, source }) => [file, source]));
1366
+ const inserts = new Map(); // file -> Map<"line:column", {line, column, names}>
1367
+ let fixed = 0;
1368
+ for (const diagnostic of diagnostics) {
1369
+ if (!diagnostic.fix || diagnostic.fix.kind !== "declare-variable") continue;
1370
+ const { file, line, column } = diagnostic.fix;
1371
+ if (!diagnostic.file || !textByFile.has(diagnostic.file)) continue;
1372
+ // A target in another file has no brace to insert into; never apply a fix
1373
+ // to a file other than the one that reported it.
1374
+ if (file !== diagnostic.file) continue;
1375
+ if (!Number.isInteger(line) || !Number.isInteger(column)) continue;
1376
+ const byPosition = inserts.get(file) ?? new Map();
1377
+ const key = `${line}:${column}`;
1378
+ const insert = byPosition.get(key) ?? {
1379
+ line,
1380
+ column,
1381
+ names: new Set(),
1382
+ };
1383
+ const indent = diagnostic.fix.indent ?? " ";
1384
+ insert.names.add(`${indent}let ${diagnostic.fix.name} = 0;`);
1385
+ byPosition.set(key, insert);
1386
+ inserts.set(file, byPosition);
1387
+ fixed += 1;
1388
+ }
1389
+
1390
+ const files = [];
1391
+ for (const [file, byPosition] of inserts) {
1392
+ const text = textByFile.get(file);
1393
+ // Insertion must not rewrite the file's line endings.
1394
+ const newline = text.includes("\r\n") ? "\r\n" : "\n";
1395
+ const lines = text.split(/\r?\n/);
1396
+ const ordered = [...byPosition.values()].sort(
1397
+ (a, b) => b.line - a.line || b.column - a.column,
1398
+ );
1399
+ for (const { line, column, names } of ordered) {
1400
+ const index = Math.min(Math.max(0, line - 1), lines.length - 1);
1401
+ const current = lines[index];
1402
+ // `column` is the 1-based brace column, which is also the 0-based index
1403
+ // just past the brace character.
1404
+ const offset = Math.min(Math.max(0, column), current.length);
1405
+ const insert = [...names]
1406
+ .map((declaration) => `${newline}${declaration}`)
1407
+ .join("");
1408
+ lines[index] = current.slice(0, offset) + insert + current.slice(offset);
1409
+ }
1410
+ textByFile.set(file, lines.join(newline));
1411
+ files.push(file);
1412
+ }
1413
+ return { sources: textByFile, files, fixed };
1414
+ }