fastscript 1.0.0 → 2.0.0

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 (109) hide show
  1. package/CHANGELOG.md +32 -7
  2. package/LICENSE +33 -21
  3. package/README.md +567 -73
  4. package/node_modules/@fastscript/core-private/BOUNDARY.json +15 -0
  5. package/node_modules/@fastscript/core-private/README.md +5 -0
  6. package/node_modules/@fastscript/core-private/package.json +34 -0
  7. package/node_modules/@fastscript/core-private/src/asset-optimizer.mjs +67 -0
  8. package/node_modules/@fastscript/core-private/src/audit-log.mjs +50 -0
  9. package/node_modules/@fastscript/core-private/src/auth-flows.mjs +29 -0
  10. package/node_modules/@fastscript/core-private/src/auth.mjs +115 -0
  11. package/node_modules/@fastscript/core-private/src/bench.mjs +45 -0
  12. package/node_modules/@fastscript/core-private/src/build.mjs +670 -0
  13. package/node_modules/@fastscript/core-private/src/cache.mjs +248 -0
  14. package/node_modules/@fastscript/core-private/src/check.mjs +22 -0
  15. package/node_modules/@fastscript/core-private/src/cli.mjs +95 -0
  16. package/node_modules/@fastscript/core-private/src/compat.mjs +128 -0
  17. package/node_modules/@fastscript/core-private/src/create.mjs +278 -0
  18. package/node_modules/@fastscript/core-private/src/csp.mjs +26 -0
  19. package/node_modules/@fastscript/core-private/src/db-cli.mjs +185 -0
  20. package/node_modules/@fastscript/core-private/src/db-postgres-collection.mjs +110 -0
  21. package/node_modules/@fastscript/core-private/src/db-postgres.mjs +40 -0
  22. package/node_modules/@fastscript/core-private/src/db.mjs +103 -0
  23. package/node_modules/@fastscript/core-private/src/deploy.mjs +662 -0
  24. package/node_modules/@fastscript/core-private/src/dev.mjs +5 -0
  25. package/node_modules/@fastscript/core-private/src/docs-search.mjs +35 -0
  26. package/node_modules/@fastscript/core-private/src/env.mjs +118 -0
  27. package/node_modules/@fastscript/core-private/src/export.mjs +83 -0
  28. package/node_modules/@fastscript/core-private/src/fs-diagnostics.mjs +70 -0
  29. package/node_modules/@fastscript/core-private/src/fs-error-codes.mjs +141 -0
  30. package/node_modules/@fastscript/core-private/src/fs-formatter.mjs +66 -0
  31. package/node_modules/@fastscript/core-private/src/fs-linter.mjs +274 -0
  32. package/node_modules/@fastscript/core-private/src/fs-normalize.mjs +91 -0
  33. package/node_modules/@fastscript/core-private/src/fs-parser.mjs +980 -0
  34. package/node_modules/@fastscript/core-private/src/generated/docs-search-index.mjs +3182 -0
  35. package/node_modules/@fastscript/core-private/src/i18n.mjs +25 -0
  36. package/node_modules/@fastscript/core-private/src/interop.mjs +16 -0
  37. package/node_modules/@fastscript/core-private/src/jobs.mjs +378 -0
  38. package/node_modules/@fastscript/core-private/src/logger.mjs +27 -0
  39. package/node_modules/@fastscript/core-private/src/metrics.mjs +45 -0
  40. package/node_modules/@fastscript/core-private/src/middleware.mjs +14 -0
  41. package/node_modules/@fastscript/core-private/src/migrate.mjs +81 -0
  42. package/node_modules/@fastscript/core-private/src/migration-wizard.mjs +16 -0
  43. package/node_modules/@fastscript/core-private/src/module-loader.mjs +46 -0
  44. package/node_modules/@fastscript/core-private/src/oauth-providers.mjs +103 -0
  45. package/node_modules/@fastscript/core-private/src/observability.mjs +21 -0
  46. package/node_modules/@fastscript/core-private/src/plugins.mjs +194 -0
  47. package/node_modules/@fastscript/core-private/src/retention.mjs +57 -0
  48. package/node_modules/@fastscript/core-private/src/routes.mjs +178 -0
  49. package/node_modules/@fastscript/core-private/src/scheduler.mjs +104 -0
  50. package/node_modules/@fastscript/core-private/src/security.mjs +233 -0
  51. package/node_modules/@fastscript/core-private/src/server-runtime.mjs +849 -0
  52. package/node_modules/@fastscript/core-private/src/serverless-handler.mjs +20 -0
  53. package/node_modules/@fastscript/core-private/src/session-policy.mjs +38 -0
  54. package/node_modules/@fastscript/core-private/src/start.mjs +10 -0
  55. package/node_modules/@fastscript/core-private/src/storage.mjs +155 -0
  56. package/node_modules/@fastscript/core-private/src/style-primitives.mjs +538 -0
  57. package/node_modules/@fastscript/core-private/src/style-system.mjs +461 -0
  58. package/node_modules/@fastscript/core-private/src/tenant.mjs +55 -0
  59. package/node_modules/@fastscript/core-private/src/typecheck.mjs +1464 -0
  60. package/node_modules/@fastscript/core-private/src/validate.mjs +22 -0
  61. package/node_modules/@fastscript/core-private/src/validation.mjs +88 -0
  62. package/node_modules/@fastscript/core-private/src/webhook.mjs +81 -0
  63. package/node_modules/@fastscript/core-private/src/worker.mjs +24 -0
  64. package/package.json +86 -13
  65. package/src/asset-optimizer.mjs +67 -0
  66. package/src/audit-log.mjs +50 -0
  67. package/src/auth.mjs +1 -115
  68. package/src/bench.mjs +20 -7
  69. package/src/build.mjs +1 -234
  70. package/src/cache.mjs +210 -20
  71. package/src/cli.mjs +29 -5
  72. package/src/compat.mjs +8 -10
  73. package/src/create.mjs +71 -17
  74. package/src/csp.mjs +26 -0
  75. package/src/db-cli.mjs +152 -8
  76. package/src/db-postgres-collection.mjs +110 -0
  77. package/src/deploy.mjs +1 -65
  78. package/src/docs-search.mjs +35 -0
  79. package/src/env.mjs +34 -5
  80. package/src/fs-diagnostics.mjs +70 -0
  81. package/src/fs-error-codes.mjs +126 -0
  82. package/src/fs-formatter.mjs +66 -0
  83. package/src/fs-linter.mjs +274 -0
  84. package/src/fs-normalize.mjs +21 -238
  85. package/src/fs-parser.mjs +1 -0
  86. package/src/generated/docs-search-index.mjs +3220 -0
  87. package/src/i18n.mjs +25 -0
  88. package/src/jobs.mjs +283 -32
  89. package/src/metrics.mjs +45 -0
  90. package/src/migration-wizard.mjs +16 -0
  91. package/src/module-loader.mjs +11 -12
  92. package/src/oauth-providers.mjs +103 -0
  93. package/src/plugins.mjs +194 -0
  94. package/src/retention.mjs +57 -0
  95. package/src/routes.mjs +178 -0
  96. package/src/scheduler.mjs +104 -0
  97. package/src/security.mjs +197 -19
  98. package/src/server-runtime.mjs +1 -339
  99. package/src/serverless-handler.mjs +20 -0
  100. package/src/session-policy.mjs +38 -0
  101. package/src/storage.mjs +1 -56
  102. package/src/style-system.mjs +461 -0
  103. package/src/tenant.mjs +55 -0
  104. package/src/typecheck.mjs +1 -0
  105. package/src/validate.mjs +5 -1
  106. package/src/validation.mjs +14 -5
  107. package/src/webhook.mjs +1 -71
  108. package/src/worker.mjs +23 -4
  109. package/src/language-spec.mjs +0 -58
@@ -0,0 +1,70 @@
1
+ import { formatDiagnostic, parseFastScript } from "./fs-parser.mjs";
2
+
3
+ function displayPath(file) {
4
+ return file || "<memory>";
5
+ }
6
+
7
+ function safeLine(lines, line) {
8
+ const index = Math.max(0, Math.min(lines.length - 1, line - 1));
9
+ return lines[index] ?? "";
10
+ }
11
+
12
+ function renderPrimarySnippet(diagnostic, source) {
13
+ if (!source || !diagnostic) return "";
14
+ const lines = String(source).split(/\r?\n/);
15
+ const lineText = safeLine(lines, diagnostic.line);
16
+ const markerStart = Math.max(1, diagnostic.column || 1);
17
+ const markerEnd = Math.max(markerStart, diagnostic.endColumn || markerStart);
18
+ const width = Math.max(1, markerEnd - markerStart);
19
+ const pointer = `${" ".repeat(markerStart - 1)}${"^".repeat(width)}`;
20
+ return `${String(diagnostic.line).padStart(4, " ")} | ${lineText}\n | ${pointer}`;
21
+ }
22
+
23
+ export function formatDiagnosticsReport(diagnostics, { source = "" } = {}) {
24
+ if (!diagnostics?.length) return "";
25
+ const ordered = [...diagnostics].sort((a, b) => {
26
+ const startA = a.span?.start ?? 0;
27
+ const startB = b.span?.start ?? 0;
28
+ if (startA !== startB) return startA - startB;
29
+ return a.code.localeCompare(b.code);
30
+ });
31
+
32
+ return ordered
33
+ .map((diagnostic) => {
34
+ const details = [formatDiagnostic(diagnostic)];
35
+ const snippet = renderPrimarySnippet(diagnostic, source);
36
+ if (snippet) details.push(snippet);
37
+ for (const related of diagnostic.related || []) {
38
+ details.push(
39
+ ` related ${displayPath(related.file)}:${related.line}:${related.column} ${related.message}`,
40
+ );
41
+ }
42
+ for (const fix of diagnostic.fixes || []) {
43
+ const text = String(fix.text ?? "").replace(/\n/g, "\\n");
44
+ details.push(` fix ${fix.message || "Apply fix"} -> ${text}`);
45
+ }
46
+ return details.join("\n");
47
+ })
48
+ .join("\n\n");
49
+ }
50
+
51
+ export function analyzeFastScript(source, { file = "", mode = "lenient" } = {}) {
52
+ const ast = parseFastScript(source, { file, mode, recover: true });
53
+ return ast.diagnostics;
54
+ }
55
+
56
+ export function assertFastScript(source, { file = "", mode = "strict" } = {}) {
57
+ const diagnostics = analyzeFastScript(source, { file, mode: "lenient" });
58
+ if (!diagnostics.length) return;
59
+ const blocking = diagnostics.filter((diagnostic) => diagnostic.severity !== "warning");
60
+ if (!blocking.length && mode !== "strict") return;
61
+
62
+ const primary = (blocking.length ? blocking : diagnostics)[0];
63
+ const report = formatDiagnosticsReport(diagnostics, { source });
64
+ const path = displayPath(primary.file);
65
+ const error = new Error(`${path}:${primary.line}:${primary.column} ${primary.code} ${primary.message}\n${report}`);
66
+ error.status = 1;
67
+ error.details = diagnostics;
68
+ error.report = report;
69
+ throw error;
70
+ }
@@ -0,0 +1,126 @@
1
+ export const FS_ERROR_CODES = Object.freeze({
2
+ FS1001: {
3
+ severity: "error",
4
+ message: "Invalid reactive declaration.",
5
+ hint: "Reactive declarations must be `~name = expression`.",
6
+ },
7
+ FS1002: {
8
+ severity: "error",
9
+ message: "Invalid state declaration.",
10
+ hint: "State declarations must be `state name = expression`.",
11
+ },
12
+ FS1003: {
13
+ severity: "error",
14
+ message: "Invalid function declaration.",
15
+ hint: "Function declarations must use `fn name(...) { ... }` or `export fn ...`.",
16
+ },
17
+ FS1004: {
18
+ severity: "error",
19
+ message: "Type declarations are not valid runtime FastScript syntax.",
20
+ hint: "Move `type`, `interface`, and `enum` definitions to `.d.ts` files or remove them from `.fs` files.",
21
+ },
22
+ FS1005: {
23
+ severity: "error",
24
+ message: "FastScript parse error.",
25
+ hint: "Check punctuation and statement structure near the reported location.",
26
+ },
27
+ FS1006: {
28
+ severity: "error",
29
+ message: "Invalid identifier.",
30
+ hint: "Identifiers must start with a letter, `_`, or `$`.",
31
+ },
32
+ FS1007: {
33
+ severity: "warning",
34
+ message: "Suspicious `TODO_ERROR` token found.",
35
+ hint: "Remove placeholder tokens before shipping.",
36
+ },
37
+ FS1010: {
38
+ severity: "error",
39
+ message: "Unterminated token.",
40
+ hint: "Close the string, template, regex, or comment that starts near this location.",
41
+ },
42
+ FS1101: {
43
+ severity: "warning",
44
+ message: "Unsupported language directive.",
45
+ hint: "Use directives listed in the FastScript language specification.",
46
+ },
47
+ FS2001: {
48
+ severity: "warning",
49
+ message: "Prefer `const` when a binding is never reassigned.",
50
+ hint: "Use lint autofix to rewrite `let` to `const` when safe.",
51
+ },
52
+ FS2002: {
53
+ severity: "warning",
54
+ message: "Avoid inline `<script>` tags in templates.",
55
+ hint: "Move script behavior into module functions or hydration handlers.",
56
+ },
57
+ FS3001: {
58
+ severity: "error",
59
+ message: "`TODO_ERROR` token is not allowed in committed code.",
60
+ hint: "Delete placeholder tokens before merging.",
61
+ },
62
+ FS3002: {
63
+ severity: "warning",
64
+ message: "`var` is discouraged in FastScript modules.",
65
+ hint: "Replace `var` with `let` or `const`.",
66
+ },
67
+ FS3003: {
68
+ severity: "warning",
69
+ message: "Inline `<script>` tag detected in template literal.",
70
+ hint: "Use external module code instead of embedding scripts in markup strings.",
71
+ },
72
+ FS3004: {
73
+ severity: "warning",
74
+ message: "Binding can be `const`.",
75
+ hint: "Apply the autofix to preserve intent and reduce accidental reassignment.",
76
+ },
77
+ FS4001: {
78
+ severity: "error",
79
+ message: "Duplicate route mapping detected.",
80
+ hint: "Ensure each route path+slot pair is unique.",
81
+ },
82
+ FS4002: {
83
+ severity: "warning",
84
+ message: "Route param is declared but not referenced.",
85
+ hint: "Use `params.<name>` or remove the dynamic segment.",
86
+ },
87
+ FS4101: {
88
+ severity: "error",
89
+ message: "Unknown symbol.",
90
+ hint: "Declare the symbol before use or import it from another module.",
91
+ },
92
+ FS4102: {
93
+ severity: "error",
94
+ message: "Cannot reassign `const` binding.",
95
+ hint: "Change the declaration to `let` or remove the assignment.",
96
+ },
97
+ FS4103: {
98
+ severity: "error",
99
+ message: "Type mismatch in assignment.",
100
+ hint: "Align assigned expression type with the declared/inferred variable type.",
101
+ },
102
+ FS4104: {
103
+ severity: "error",
104
+ message: "Incorrect function argument count.",
105
+ hint: "Pass the required number of arguments or update the function signature.",
106
+ },
107
+ FS4105: {
108
+ severity: "error",
109
+ message: "Incompatible return type.",
110
+ hint: "Ensure all return paths in the function resolve to compatible types.",
111
+ },
112
+ FS4106: {
113
+ severity: "error",
114
+ message: "Attempted to call a non-function value.",
115
+ hint: "Ensure the callee resolves to a function.",
116
+ },
117
+ FS4107: {
118
+ severity: "error",
119
+ message: "Invalid operand types for operator.",
120
+ hint: "Use operands compatible with the operator semantics.",
121
+ },
122
+ });
123
+
124
+ export function resolveErrorMeta(code) {
125
+ return FS_ERROR_CODES[code] || { severity: "error", message: "FastScript compiler error." };
126
+ }
@@ -0,0 +1,66 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { extname, join, resolve } from "node:path";
3
+ import { generate } from "astring";
4
+ import { parseFastScript } from "./fs-parser.mjs";
5
+
6
+ function walk(dir) {
7
+ if (!existsSync(dir)) return [];
8
+ const out = [];
9
+ for (const entry of readdirSync(dir)) {
10
+ const full = join(dir, entry);
11
+ const st = statSync(full);
12
+ if (st.isDirectory()) out.push(...walk(full));
13
+ else if (st.isFile()) out.push(full);
14
+ }
15
+ return out;
16
+ }
17
+
18
+ function shouldThrowForFormat(diagnostics) {
19
+ return diagnostics.some((diagnostic) => diagnostic.severity !== "warning");
20
+ }
21
+
22
+ export function formatFastScriptSource(source, { file = "" } = {}) {
23
+ const parsed = parseFastScript(source, { file, mode: "lenient", recover: true });
24
+ if (!parsed.estree || shouldThrowForFormat(parsed.diagnostics)) {
25
+ return String(source ?? "");
26
+ }
27
+
28
+ const formatted = generate(parsed.estree, {
29
+ comments: false,
30
+ indent: " ",
31
+ lineEnd: "\n",
32
+ });
33
+
34
+ return formatted.endsWith("\n") ? formatted : `${formatted}\n`;
35
+ }
36
+
37
+ export async function runFormat(args = []) {
38
+ let target = "app";
39
+ let write = true;
40
+ for (let i = 0; i < args.length; i += 1) {
41
+ if (args[i] === "--path") target = args[i + 1] || target;
42
+ if (args[i] === "--check") write = false;
43
+ if (args[i] === "--write") write = true;
44
+ }
45
+
46
+ const base = resolve(target);
47
+ const files = walk(base).filter((file) => extname(file) === ".fs");
48
+ let changed = 0;
49
+
50
+ for (const file of files) {
51
+ const current = readFileSync(file, "utf8");
52
+ const next = formatFastScriptSource(current, { file });
53
+ if (current !== next) {
54
+ changed += 1;
55
+ if (write) writeFileSync(file, next, "utf8");
56
+ }
57
+ }
58
+
59
+ if (!write && changed > 0) {
60
+ const error = new Error(`format check failed: ${changed} file(s) need formatting`);
61
+ error.status = 1;
62
+ throw error;
63
+ }
64
+
65
+ console.log(`format ${write ? "write" : "check"} complete: ${files.length} file(s), ${changed} changed`);
66
+ }
@@ -0,0 +1,274 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { extname, join, resolve } from "node:path";
3
+ import { parseFastScript } from "./fs-parser.mjs";
4
+
5
+ function walk(dir) {
6
+ if (!existsSync(dir)) return [];
7
+ const out = [];
8
+ for (const entry of readdirSync(dir)) {
9
+ const full = join(dir, entry);
10
+ const st = statSync(full);
11
+ if (st.isDirectory()) out.push(...walk(full));
12
+ else if (st.isFile()) out.push(full);
13
+ }
14
+ return out;
15
+ }
16
+
17
+ function spanFromNode(node, sourceLength) {
18
+ const start = Math.max(0, Math.min(sourceLength, Number(node?.fsRange?.[0] ?? node?.start ?? 0)));
19
+ const end = Math.max(start, Math.min(sourceLength, Number(node?.fsRange?.[1] ?? node?.end ?? start)));
20
+ return { start, end };
21
+ }
22
+
23
+ function createIssue({ file, source, lineStarts, code, severity, message, span, fix = null }) {
24
+ const start = Math.max(0, Math.min(source.length, span?.start ?? 0));
25
+ const end = Math.max(start, Math.min(source.length, span?.end ?? start + 1));
26
+ const loc = lineFromOffset(lineStarts, start);
27
+ return {
28
+ file,
29
+ line: loc.line,
30
+ column: loc.column,
31
+ span: { start, end },
32
+ code,
33
+ severity,
34
+ message,
35
+ fix,
36
+ };
37
+ }
38
+
39
+ function createLineStarts(source) {
40
+ const out = [0];
41
+ for (let i = 0; i < source.length; i += 1) {
42
+ if (source[i] === "\n") out.push(i + 1);
43
+ }
44
+ return out;
45
+ }
46
+
47
+ function lineFromOffset(lineStarts, offset) {
48
+ let lo = 0;
49
+ let hi = lineStarts.length - 1;
50
+ while (lo <= hi) {
51
+ const mid = (lo + hi) >> 1;
52
+ const start = lineStarts[mid];
53
+ const next = lineStarts[mid + 1] ?? Number.POSITIVE_INFINITY;
54
+ if (offset < start) hi = mid - 1;
55
+ else if (offset >= next) lo = mid + 1;
56
+ else return { line: mid + 1, column: offset - start + 1 };
57
+ }
58
+ return { line: 1, column: 1 };
59
+ }
60
+
61
+ function traverse(node, visitor, parent = null) {
62
+ if (!node || typeof node !== "object") return;
63
+ visitor(node, parent);
64
+ for (const value of Object.values(node)) {
65
+ if (!value) continue;
66
+ if (Array.isArray(value)) {
67
+ for (const item of value) {
68
+ if (item && typeof item === "object") traverse(item, visitor, node);
69
+ }
70
+ } else if (typeof value === "object" && typeof value.type === "string") {
71
+ traverse(value, visitor, node);
72
+ }
73
+ }
74
+ }
75
+
76
+ function collectReassignedNames(estree) {
77
+ const names = new Set();
78
+ traverse(estree, (node) => {
79
+ if (node.type === "AssignmentExpression" && node.left?.type === "Identifier") {
80
+ names.add(node.left.name);
81
+ }
82
+ if (node.type === "UpdateExpression" && node.argument?.type === "Identifier") {
83
+ names.add(node.argument.name);
84
+ }
85
+ });
86
+ return names;
87
+ }
88
+
89
+ function declarationKeywordFixSpan(source, declaration, keyword) {
90
+ const span = spanFromNode(declaration, source.length);
91
+ const window = source.slice(span.start, Math.min(span.end, span.start + 64));
92
+ const pattern = new RegExp(`\\b${keyword}\\b`);
93
+ const match = pattern.exec(window);
94
+ if (!match) return null;
95
+ return {
96
+ start: span.start + match.index,
97
+ end: span.start + match.index + keyword.length,
98
+ };
99
+ }
100
+
101
+ function applyFixes(source, fixes) {
102
+ if (!fixes.length) return source;
103
+ const sorted = [...fixes]
104
+ .filter((fix) => fix && Number.isFinite(fix.start) && Number.isFinite(fix.end) && fix.start <= fix.end)
105
+ .sort((a, b) => {
106
+ if (a.start !== b.start) return a.start - b.start;
107
+ return a.end - b.end;
108
+ });
109
+
110
+ const nonOverlapping = [];
111
+ let cursor = -1;
112
+ for (const fix of sorted) {
113
+ if (fix.start < cursor) continue;
114
+ nonOverlapping.push(fix);
115
+ cursor = fix.end;
116
+ }
117
+
118
+ let output = source;
119
+ for (let i = nonOverlapping.length - 1; i >= 0; i -= 1) {
120
+ const fix = nonOverlapping[i];
121
+ output = `${output.slice(0, fix.start)}${fix.text}${output.slice(fix.end)}`;
122
+ }
123
+ return output;
124
+ }
125
+
126
+ function lintSource(source, { file }) {
127
+ const text = String(source ?? "");
128
+ const lineStarts = createLineStarts(text);
129
+ const parsed = parseFastScript(text, { file, mode: "lenient", recover: true });
130
+ const issues = [];
131
+ const fixes = [];
132
+
133
+ for (const diagnostic of parsed.diagnostics) {
134
+ issues.push(
135
+ createIssue({
136
+ file,
137
+ source: text,
138
+ lineStarts,
139
+ code: diagnostic.code,
140
+ severity: diagnostic.severity || "error",
141
+ message: diagnostic.message,
142
+ span: diagnostic.span,
143
+ }),
144
+ );
145
+ }
146
+
147
+ const todoPattern = /\bTODO_ERROR\b/g;
148
+ for (const match of text.matchAll(todoPattern)) {
149
+ const start = match.index ?? 0;
150
+ issues.push(
151
+ createIssue({
152
+ file,
153
+ source: text,
154
+ lineStarts,
155
+ code: "FS3001",
156
+ severity: "error",
157
+ message: "Remove TODO_ERROR token.",
158
+ span: { start, end: start + match[0].length },
159
+ }),
160
+ );
161
+ }
162
+
163
+ if (!parsed.estree) return { issues, fixes };
164
+
165
+ const reassignedNames = collectReassignedNames(parsed.estree);
166
+ traverse(parsed.estree, (node) => {
167
+ if (node.type === "VariableDeclaration") {
168
+ if (node.kind === "var") {
169
+ const fixSpan = declarationKeywordFixSpan(text, node, "var");
170
+ const fix = fixSpan ? { ...fixSpan, text: "let" } : null;
171
+ issues.push(
172
+ createIssue({
173
+ file,
174
+ source: text,
175
+ lineStarts,
176
+ code: "FS3002",
177
+ severity: "warning",
178
+ message: "Prefer `let` or `const` over `var`.",
179
+ span: spanFromNode(node, text.length),
180
+ fix,
181
+ }),
182
+ );
183
+ if (fix) fixes.push(fix);
184
+ }
185
+
186
+ if (node.kind === "let") {
187
+ const names = [];
188
+ for (const declaration of node.declarations || []) {
189
+ if (declaration.id?.type === "Identifier") names.push(declaration.id.name);
190
+ }
191
+ if (names.length > 0 && names.every((name) => !reassignedNames.has(name))) {
192
+ const fixSpan = declarationKeywordFixSpan(text, node, "let");
193
+ const fix = fixSpan ? { ...fixSpan, text: "const" } : null;
194
+ issues.push(
195
+ createIssue({
196
+ file,
197
+ source: text,
198
+ lineStarts,
199
+ code: "FS3004",
200
+ severity: "warning",
201
+ message: "Binding can be `const`.",
202
+ span: spanFromNode(node, text.length),
203
+ fix,
204
+ }),
205
+ );
206
+ if (fix) fixes.push(fix);
207
+ }
208
+ }
209
+ }
210
+
211
+ if (node.type === "TemplateElement" && /<script[\s>]/i.test(node.value?.raw || "")) {
212
+ issues.push(
213
+ createIssue({
214
+ file,
215
+ source: text,
216
+ lineStarts,
217
+ code: "FS3003",
218
+ severity: "warning",
219
+ message: "Avoid inline <script> tags in template literals.",
220
+ span: spanFromNode(node, text.length),
221
+ }),
222
+ );
223
+ }
224
+ });
225
+
226
+ return { issues, fixes };
227
+ }
228
+
229
+ export async function runLint(args = []) {
230
+ let target = "app";
231
+ let mode = "fail";
232
+ let fix = false;
233
+ for (let i = 0; i < args.length; i += 1) {
234
+ if (args[i] === "--path") target = args[i + 1] || target;
235
+ if (args[i] === "--mode") mode = (args[i + 1] || mode).toLowerCase();
236
+ if (args[i] === "--fix") fix = true;
237
+ }
238
+
239
+ const base = resolve(target);
240
+ const files = walk(base).filter((file) => extname(file) === ".fs");
241
+ const issues = [];
242
+
243
+ for (const file of files) {
244
+ const source = readFileSync(file, "utf8");
245
+ const result = lintSource(source, { file });
246
+ let next = source;
247
+ if (fix && result.fixes.length > 0) {
248
+ next = applyFixes(source, result.fixes);
249
+ if (next !== source) writeFileSync(file, next, "utf8");
250
+ }
251
+ issues.push(...result.issues);
252
+ }
253
+
254
+ issues.sort((a, b) => {
255
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
256
+ if (a.span.start !== b.span.start) return a.span.start - b.span.start;
257
+ if (a.code !== b.code) return a.code.localeCompare(b.code);
258
+ return a.message.localeCompare(b.message);
259
+ });
260
+
261
+ for (const issue of issues) {
262
+ console.log(`${issue.file}:${issue.line}:${issue.column} ${issue.code} ${issue.severity} ${issue.message}`);
263
+ }
264
+
265
+ const blocking = issues.filter((issue) => issue.severity === "error");
266
+ if (blocking.length > 0 && mode !== "pass") {
267
+ const error = new Error(`lint failed: ${blocking.length} blocking issue(s)`);
268
+ error.status = 1;
269
+ error.details = blocking;
270
+ throw error;
271
+ }
272
+
273
+ console.log(`lint complete: ${files.length} file(s), ${issues.length} issue(s), mode=${mode}, fix=${fix}`);
274
+ }