fastscript 1.0.0 → 3.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 (119) hide show
  1. package/CHANGELOG.md +38 -7
  2. package/LICENSE +33 -21
  3. package/README.md +605 -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 +121 -0
  33. package/node_modules/@fastscript/core-private/src/fs-parser.mjs +1120 -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 +1466 -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 +108 -14
  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/benchmark-discipline.mjs +39 -0
  70. package/src/build.mjs +1 -234
  71. package/src/cache.mjs +210 -20
  72. package/src/cli.mjs +65 -6
  73. package/src/compat.mjs +8 -10
  74. package/src/conversion-manifest.mjs +101 -0
  75. package/src/create.mjs +71 -17
  76. package/src/csp.mjs +26 -0
  77. package/src/db-cli.mjs +152 -8
  78. package/src/db-postgres-collection.mjs +110 -0
  79. package/src/deploy.mjs +1 -65
  80. package/src/diagnostics.mjs +100 -0
  81. package/src/docs-search.mjs +35 -0
  82. package/src/env.mjs +34 -5
  83. package/src/fs-diagnostics.mjs +70 -0
  84. package/src/fs-error-codes.mjs +126 -0
  85. package/src/fs-formatter.mjs +66 -0
  86. package/src/fs-linter.mjs +274 -0
  87. package/src/fs-normalize.mjs +52 -239
  88. package/src/fs-parser.mjs +1 -0
  89. package/src/generated/docs-search-index.mjs +3591 -0
  90. package/src/i18n.mjs +25 -0
  91. package/src/jobs.mjs +283 -32
  92. package/src/metrics.mjs +45 -0
  93. package/src/migrate-rollback.mjs +144 -0
  94. package/src/migrate.mjs +1275 -47
  95. package/src/migration-wizard.mjs +42 -0
  96. package/src/module-loader.mjs +22 -11
  97. package/src/oauth-providers.mjs +103 -0
  98. package/src/permissions-cli.mjs +112 -0
  99. package/src/plugins.mjs +194 -0
  100. package/src/profile.mjs +95 -0
  101. package/src/regression-guard.mjs +245 -0
  102. package/src/retention.mjs +57 -0
  103. package/src/routes.mjs +178 -0
  104. package/src/runtime-permissions.mjs +299 -0
  105. package/src/scheduler.mjs +104 -0
  106. package/src/security.mjs +197 -19
  107. package/src/server-runtime.mjs +1 -339
  108. package/src/serverless-handler.mjs +20 -0
  109. package/src/session-policy.mjs +38 -0
  110. package/src/storage.mjs +1 -56
  111. package/src/style-system.mjs +461 -0
  112. package/src/tenant.mjs +55 -0
  113. package/src/trace.mjs +95 -0
  114. package/src/typecheck.mjs +1 -0
  115. package/src/validate.mjs +13 -1
  116. package/src/validation.mjs +14 -5
  117. package/src/webhook.mjs +1 -71
  118. package/src/worker.mjs +23 -4
  119. package/src/language-spec.mjs +0 -58
package/src/migrate.mjs CHANGED
@@ -1,81 +1,1309 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
- import { dirname, extname, join, resolve } from "node:path";
3
- import { normalizeFastScript, stripTypeScriptHints } from "./fs-normalize.mjs";
3
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { pathToFileURL } from "node:url";
4
6
 
5
- const APP_DIR = resolve("app");
6
- const PAGE_DIR = join(APP_DIR, "pages");
7
- const EXT_INPUT = new Set([".js", ".jsx", ".ts", ".tsx", ".fs"]);
7
+ const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx"]);
8
+ const REWRITE_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".fs", ".mjs", ".cjs"]);
9
+ const DEFAULT_IGNORE_DIRS = new Set([".git", "node_modules", "dist", ".next", ".turbo", ".cache", ".fastscript"]);
10
+
11
+ const DEFAULT_PROTECTED_EXTENSION_REGEX = [
12
+ /\.css$/i,
13
+ /\.scss$/i,
14
+ /\.sass$/i,
15
+ /\.less$/i,
16
+ /\.styl$/i,
17
+ /\.stylus$/i,
18
+ /\.png$/i,
19
+ /\.jpe?g$/i,
20
+ /\.gif$/i,
21
+ /\.webp$/i,
22
+ /\.avif$/i,
23
+ /\.ico$/i,
24
+ /\.bmp$/i,
25
+ /\.svg$/i,
26
+ /\.woff2?$/i,
27
+ /\.ttf$/i,
28
+ /\.otf$/i,
29
+ /\.eot$/i,
30
+ /\.md$/i,
31
+ /\.mdx$/i,
32
+ /\.txt$/i,
33
+ /\.json$/i,
34
+ /\.snap$/i,
35
+ ];
36
+
37
+ const DEFAULT_PROTECTED_DIR_SEGMENTS = new Set([
38
+ "design",
39
+ "assets",
40
+ "public",
41
+ "snapshots",
42
+ "__snapshots__",
43
+ "fixtures",
44
+ "brand",
45
+ "copy",
46
+ "content",
47
+ ]);
48
+
49
+ const DEFAULT_CONFIG = {
50
+ protectedFiles: [],
51
+ protectedDirectories: [],
52
+ protectedGlobs: [],
53
+ protectedExtensions: [],
54
+ blockedFiles: [],
55
+ protectedConfigKeys: [],
56
+ protectedMarkupRegions: [],
57
+ fidelity: {
58
+ requiredProbes: [],
59
+ commands: {},
60
+ },
61
+ };
62
+
63
+ function normalizeSlashes(value) {
64
+ return String(value || "").replace(/\\/g, "/");
65
+ }
66
+
67
+ function ensureDotSlash(value) {
68
+ if (!value.startsWith(".")) return `./${value}`;
69
+ return value;
70
+ }
71
+
72
+ function sha256(content) {
73
+ return createHash("sha256").update(content).digest("hex");
74
+ }
75
+
76
+ function nowRunId() {
77
+ return new Date().toISOString().replace(/[:.]/g, "-");
78
+ }
8
79
 
9
80
  function walk(dir) {
10
81
  const out = [];
11
82
  if (!existsSync(dir)) return out;
12
83
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
13
84
  const full = join(dir, entry.name);
14
- if (entry.isDirectory()) out.push(...walk(full));
15
- else if (entry.isFile()) out.push(full);
85
+ if (entry.isDirectory()) {
86
+ if (DEFAULT_IGNORE_DIRS.has(entry.name)) continue;
87
+ out.push(...walk(full));
88
+ continue;
89
+ }
90
+ if (entry.isFile()) out.push(full);
16
91
  }
17
92
  return out;
18
93
  }
19
94
 
95
+ function readJsonFile(path, fallback) {
96
+ if (!existsSync(path)) return fallback;
97
+ try {
98
+ return JSON.parse(readFileSync(path, "utf8"));
99
+ } catch (error) {
100
+ throw new Error(`migrate: invalid JSON at ${path}: ${error.message}`);
101
+ }
102
+ }
103
+
104
+ function mergeConfig(base, override) {
105
+ const merged = {
106
+ ...base,
107
+ ...override,
108
+ fidelity: {
109
+ ...base.fidelity,
110
+ ...(override?.fidelity || {}),
111
+ commands: {
112
+ ...(base?.fidelity?.commands || {}),
113
+ ...(override?.fidelity?.commands || {}),
114
+ },
115
+ },
116
+ };
117
+
118
+ for (const key of ["protectedFiles", "protectedDirectories", "protectedGlobs", "protectedExtensions", "blockedFiles", "protectedConfigKeys", "protectedMarkupRegions"]) {
119
+ if (!Array.isArray(merged[key])) merged[key] = [];
120
+ }
121
+ if (!Array.isArray(merged?.fidelity?.requiredProbes)) merged.fidelity.requiredProbes = [];
122
+ return merged;
123
+ }
124
+
125
+ function parseArgs(input) {
126
+ const argv = Array.isArray(input) ? [...input] : typeof input === "string" ? [input] : [];
127
+ const options = {
128
+ target: "app",
129
+ dryRun: false,
130
+ reportDir: resolve(".fastscript", "conversion"),
131
+ configPath: resolve("fastscript.compatibility.json"),
132
+ fidelityLevel: "basic",
133
+ failOnUnprovenFidelity: false,
134
+ };
135
+
136
+ let positionalConsumed = false;
137
+
138
+ for (let i = 0; i < argv.length; i += 1) {
139
+ const arg = argv[i];
140
+ if (!arg) continue;
141
+
142
+ if (!arg.startsWith("-") && !positionalConsumed) {
143
+ options.target = arg;
144
+ positionalConsumed = true;
145
+ continue;
146
+ }
147
+
148
+ if (arg === "--dry-run") {
149
+ options.dryRun = true;
150
+ continue;
151
+ }
152
+
153
+ if (arg === "--write") {
154
+ options.dryRun = false;
155
+ continue;
156
+ }
157
+
158
+ if (arg === "--report-dir") {
159
+ options.reportDir = resolve(argv[i + 1] || options.reportDir);
160
+ i += 1;
161
+ continue;
162
+ }
163
+
164
+ if (arg === "--config") {
165
+ options.configPath = resolve(argv[i + 1] || options.configPath);
166
+ i += 1;
167
+ continue;
168
+ }
169
+
170
+ if (arg === "--fidelity-level") {
171
+ const next = String(argv[i + 1] || "basic").toLowerCase();
172
+ options.fidelityLevel = ["off", "basic", "full"].includes(next) ? next : "basic";
173
+ i += 1;
174
+ continue;
175
+ }
176
+
177
+ if (arg === "--fail-on-unproven-fidelity") {
178
+ options.failOnUnprovenFidelity = true;
179
+ continue;
180
+ }
181
+
182
+ if (arg === "--allow-unproven-fidelity") {
183
+ options.failOnUnprovenFidelity = false;
184
+ continue;
185
+ }
186
+ }
187
+
188
+ if (options.fidelityLevel === "full") options.failOnUnprovenFidelity = true;
189
+ return options;
190
+ }
191
+
20
192
  function toFsPath(file) {
21
193
  return file.replace(/\.(js|jsx|ts|tsx)$/, ".fs");
22
194
  }
23
195
 
24
- function rewriteRelativeExt(source) {
25
- let out = source
26
- .replace(/from\s+["'](\.\/[^"']+)\.(js|jsx|ts|tsx)["']/g, 'from "$1.fs"')
27
- .replace(/from\s+["'](\.\.\/[^"']+)\.(js|jsx|ts|tsx)["']/g, 'from "$1.fs"')
28
- .replace(/import\(\s*["'](\.\/[^"']+)\.(js|jsx|ts|tsx)["']\s*\)/g, 'import("$1.fs")')
29
- .replace(/import\(\s*["'](\.\.\/[^"']+)\.(js|jsx|ts|tsx)["']\s*\)/g, 'import("$1.fs")');
196
+ function pathStartsWith(child, parent) {
197
+ const rel = relative(parent, child);
198
+ if (!rel) return true;
199
+ return !rel.startsWith("..") && !isAbsolute(rel);
200
+ }
201
+
202
+ function globToRegex(glob) {
203
+ const escaped = String(glob || "")
204
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
205
+ .replace(/\*\*/g, "::DOUBLE_STAR::")
206
+ .replace(/\*/g, "[^/]*")
207
+ .replace(/::DOUBLE_STAR::/g, ".*");
208
+ return new RegExp(`^${escaped}$`, "i");
209
+ }
210
+
211
+ function isIdentifierChar(char) {
212
+ return /[A-Za-z0-9_$]/.test(char || "");
213
+ }
214
+
215
+ function isIdentifierStart(char) {
216
+ return /[A-Za-z_$]/.test(char || "");
217
+ }
218
+
219
+ function isBoundary(source, index, length) {
220
+ const before = source[index - 1] || "";
221
+ const after = source[index + length] || "";
222
+ return !isIdentifierChar(before) && !isIdentifierChar(after);
223
+ }
224
+
225
+ function shouldTreatSlashAsRegex(previousSignificant) {
226
+ if (!previousSignificant) return true;
227
+ return "([{=:+-*,!&|?;<>%^~".includes(previousSignificant);
228
+ }
229
+
230
+ function skipTrivia(source, start) {
231
+ let i = start;
232
+ while (i < source.length) {
233
+ const char = source[i];
234
+ const next = source[i + 1];
235
+ if (/\s/.test(char)) {
236
+ i += 1;
237
+ continue;
238
+ }
239
+ if (char === "/" && next === "/") {
240
+ i += 2;
241
+ while (i < source.length && source[i] !== "\n") i += 1;
242
+ continue;
243
+ }
244
+ if (char === "/" && next === "*") {
245
+ i += 2;
246
+ while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) i += 1;
247
+ i += 2;
248
+ continue;
249
+ }
250
+ break;
251
+ }
252
+ return i;
253
+ }
254
+
255
+ function readQuotedSpecifier(source, start) {
256
+ const quote = source[start];
257
+ if (quote !== "'" && quote !== "\"") return null;
258
+ let i = start + 1;
259
+ while (i < source.length) {
260
+ const char = source[i];
261
+ if (char === "\\") {
262
+ i += 2;
263
+ continue;
264
+ }
265
+ if (char === quote) {
266
+ return {
267
+ specifier: source.slice(start + 1, i),
268
+ index: start + 1,
269
+ length: i - start - 1,
270
+ end: i + 1,
271
+ };
272
+ }
273
+ i += 1;
274
+ }
275
+ return null;
276
+ }
277
+
278
+ function buildProtectionMatchers({ projectRoot, targetRoot, config }) {
279
+ const protectedFileSet = new Set(
280
+ (config.protectedFiles || []).map((item) => normalizeSlashes(relative(targetRoot, resolve(projectRoot, item))))
281
+ );
282
+
283
+ const protectedDirectorySet = new Set(
284
+ (config.protectedDirectories || []).map((item) => normalizeSlashes(relative(targetRoot, resolve(projectRoot, item))))
285
+ );
286
+
287
+ const blockedFileSet = new Set(
288
+ (config.blockedFiles || []).map((item) => normalizeSlashes(relative(targetRoot, resolve(projectRoot, item))))
289
+ );
290
+
291
+ const protectedGlobs = (config.protectedGlobs || []).map((item) => globToRegex(normalizeSlashes(item)));
292
+ const protectedExtensionRegex = [
293
+ ...DEFAULT_PROTECTED_EXTENSION_REGEX,
294
+ ...(config.protectedExtensions || []).map((ext) => new RegExp(`${String(ext).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i")),
295
+ ];
296
+
297
+ return {
298
+ isBlocked(relPath) {
299
+ return blockedFileSet.has(relPath);
300
+ },
301
+ isProtected(relPath, fileAbs) {
302
+ if (protectedFileSet.has(relPath)) return true;
303
+
304
+ for (const protectedDir of protectedDirectorySet) {
305
+ if (!protectedDir) continue;
306
+ if (relPath === protectedDir || relPath.startsWith(`${protectedDir}/`)) return true;
307
+ }
308
+
309
+ const segments = normalizeSlashes(relative(targetRoot, fileAbs)).split("/").filter(Boolean);
310
+ for (const segment of segments.slice(0, -1)) {
311
+ if (DEFAULT_PROTECTED_DIR_SEGMENTS.has(segment)) return true;
312
+ }
313
+
314
+ for (const regex of protectedGlobs) {
315
+ if (regex.test(relPath)) return true;
316
+ }
317
+
318
+ for (const regex of protectedExtensionRegex) {
319
+ if (regex.test(relPath)) return true;
320
+ }
321
+
322
+ return false;
323
+ },
324
+ };
325
+ }
326
+
327
+ function extractSpecifiers(source) {
328
+ const out = [];
329
+ const push = (type, raw, specifier, index, length) => {
330
+ out.push({ type, raw, specifier, index, length });
331
+ };
332
+
333
+ let i = 0;
334
+ let previousSignificant = "";
335
+
336
+ while (i < source.length) {
337
+ const char = source[i];
338
+ const next = source[i + 1];
339
+
340
+ if (char === "/" && next === "/") {
341
+ i += 2;
342
+ while (i < source.length && source[i] !== "\n") i += 1;
343
+ continue;
344
+ }
345
+
346
+ if (char === "/" && next === "*") {
347
+ i += 2;
348
+ while (i < source.length && !(source[i] === "*" && source[i + 1] === "/")) i += 1;
349
+ i += 2;
350
+ continue;
351
+ }
352
+
353
+ if (char === "'" || char === "\"") {
354
+ const literal = readQuotedSpecifier(source, i);
355
+ i = literal ? literal.end : i + 1;
356
+ previousSignificant = "a";
357
+ continue;
358
+ }
359
+
360
+ if (char === "`") {
361
+ i += 1;
362
+ while (i < source.length) {
363
+ if (source[i] === "\\") {
364
+ i += 2;
365
+ continue;
366
+ }
367
+ if (source[i] === "`") {
368
+ i += 1;
369
+ break;
370
+ }
371
+ i += 1;
372
+ }
373
+ previousSignificant = "a";
374
+ continue;
375
+ }
376
+
377
+ if (char === "/" && shouldTreatSlashAsRegex(previousSignificant)) {
378
+ i += 1;
379
+ let inClass = false;
380
+ while (i < source.length) {
381
+ const current = source[i];
382
+ if (current === "\\") {
383
+ i += 2;
384
+ continue;
385
+ }
386
+ if (current === "[") {
387
+ inClass = true;
388
+ i += 1;
389
+ continue;
390
+ }
391
+ if (current === "]") {
392
+ inClass = false;
393
+ i += 1;
394
+ continue;
395
+ }
396
+ if (current === "/" && !inClass) {
397
+ i += 1;
398
+ while (i < source.length && /[A-Za-z]/.test(source[i])) i += 1;
399
+ break;
400
+ }
401
+ i += 1;
402
+ }
403
+ previousSignificant = "a";
404
+ continue;
405
+ }
406
+
407
+ if (isIdentifierStart(char)) {
408
+ const start = i;
409
+ i += 1;
410
+ while (i < source.length && isIdentifierChar(source[i])) i += 1;
411
+ const word = source.slice(start, i);
412
+ if (!isBoundary(source, start, word.length)) {
413
+ previousSignificant = "a";
414
+ continue;
415
+ }
30
416
 
31
- out = out
32
- .replace(/require\(\s*["'](\.\/[^"']+)\.(js|jsx|ts|tsx)["']\s*\)/g, 'require("$1.fs")')
33
- .replace(/require\(\s*["'](\.\.\/[^"']+)\.(js|jsx|ts|tsx)["']\s*\)/g, 'require("$1.fs")');
417
+ if (word === "from") {
418
+ const nextIndex = skipTrivia(source, i);
419
+ const literal = readQuotedSpecifier(source, nextIndex);
420
+ if (literal) {
421
+ push("from", source.slice(start, literal.end), literal.specifier, literal.index, literal.length);
422
+ }
423
+ } else if (word === "import") {
424
+ let nextIndex = skipTrivia(source, i);
425
+ if (source[nextIndex] === "(") {
426
+ nextIndex = skipTrivia(source, nextIndex + 1);
427
+ const literal = readQuotedSpecifier(source, nextIndex);
428
+ if (literal) {
429
+ const endIndex = skipTrivia(source, literal.end);
430
+ if (source[endIndex] === ")") {
431
+ push("call", source.slice(start, endIndex + 1), literal.specifier, literal.index, literal.length);
432
+ }
433
+ }
434
+ } else {
435
+ const literal = readQuotedSpecifier(source, nextIndex);
436
+ if (literal) {
437
+ push("import", source.slice(start, literal.end), literal.specifier, literal.index, literal.length);
438
+ }
439
+ }
440
+ } else if (word === "require") {
441
+ let nextIndex = skipTrivia(source, i);
442
+ if (source[nextIndex] === "(") {
443
+ nextIndex = skipTrivia(source, nextIndex + 1);
444
+ const literal = readQuotedSpecifier(source, nextIndex);
445
+ if (literal) {
446
+ const endIndex = skipTrivia(source, literal.end);
447
+ if (source[endIndex] === ")") {
448
+ push("call", source.slice(start, endIndex + 1), literal.specifier, literal.index, literal.length);
449
+ }
450
+ }
451
+ }
452
+ }
453
+ previousSignificant = "a";
454
+ continue;
455
+ }
34
456
 
35
- out = out
36
- .replace(/module\.exports\s*=\s*/g, "export default ")
37
- .replace(/exports\.([A-Za-z_$][\w$]*)\s*=\s*/g, "export const $1 = ");
457
+ if (!/\s/.test(char)) previousSignificant = char;
458
+ i += 1;
459
+ }
38
460
 
461
+ out.sort((a, b) => a.index - b.index);
39
462
  return out;
40
463
  }
41
464
 
42
- export async function runMigrate(target = "app/pages") {
43
- const base = resolve(target);
44
- if (!existsSync(base)) throw new Error(`Missing path: ${base}`);
465
+ function rewriteSourceImports({ source, filePath, renameMap }) {
466
+ const specs = extractSpecifiers(source);
467
+ if (!specs.length) return { code: source, rewrites: [] };
468
+
469
+ let offset = 0;
470
+ let code = source;
471
+ const rewrites = [];
472
+
473
+ for (const spec of specs) {
474
+ const value = spec.specifier;
475
+ if (!value.startsWith(".")) continue;
476
+
477
+ const oldTarget = resolve(dirname(filePath), value);
478
+ const normalizedTarget = resolve(oldTarget);
479
+ const nextTarget = renameMap.get(normalizedTarget);
480
+ if (!nextTarget) continue;
481
+
482
+ let nextSpec = normalizeSlashes(relative(dirname(filePath), nextTarget));
483
+ nextSpec = ensureDotSlash(nextSpec);
484
+ if (nextSpec === value) continue;
485
+
486
+ const start = spec.index + offset;
487
+ const end = start + spec.length;
488
+ code = `${code.slice(0, start)}${nextSpec}${code.slice(end)}`;
489
+ offset += nextSpec.length - spec.length;
490
+
491
+ rewrites.push({
492
+ from: value,
493
+ to: nextSpec,
494
+ inFile: filePath,
495
+ });
496
+ }
497
+
498
+ return { code, rewrites };
499
+ }
500
+
501
+ function collectFileState(targetRoot) {
502
+ const files = walk(targetRoot);
503
+ const entries = files.map((abs) => {
504
+ const rel = normalizeSlashes(relative(targetRoot, abs));
505
+ const source = readFileSync(abs, "utf8");
506
+ return {
507
+ abs,
508
+ rel,
509
+ ext: extname(abs).toLowerCase(),
510
+ source,
511
+ size: statSync(abs).size,
512
+ hash: sha256(source),
513
+ };
514
+ });
515
+
516
+ return {
517
+ files,
518
+ byAbs: new Map(entries.map((entry) => [entry.abs, entry])),
519
+ byRel: new Map(entries.map((entry) => [entry.rel, entry])),
520
+ entries,
521
+ };
522
+ }
523
+
524
+ function createAfterVirtualState({ beforeState, plan }) {
525
+ const virtual = new Map(beforeState.entries.map((entry) => [entry.abs, entry.source]));
526
+
527
+ for (const op of plan.writes) {
528
+ virtual.set(op.path, op.contents);
529
+ }
530
+
531
+ for (const op of plan.renames) {
532
+ if (op.from !== op.to) virtual.delete(op.from);
533
+ }
534
+
535
+ const entries = [];
536
+ for (const [abs, source] of virtual.entries()) {
537
+ const rel = normalizeSlashes(relative(plan.targetRoot, abs));
538
+ entries.push({
539
+ abs,
540
+ rel,
541
+ ext: extname(abs).toLowerCase(),
542
+ source,
543
+ size: Buffer.byteLength(source, "utf8"),
544
+ hash: sha256(source),
545
+ });
546
+ }
547
+
548
+ entries.sort((a, b) => a.rel.localeCompare(b.rel));
549
+ return {
550
+ entries,
551
+ byAbs: new Map(entries.map((entry) => [entry.abs, entry])),
552
+ files: entries.map((entry) => entry.abs),
553
+ };
554
+ }
555
+
556
+ function createImportGraph(state, rootAbs) {
557
+ const edges = [];
558
+ const edgeSet = new Set();
559
+
560
+ for (const entry of state.entries) {
561
+ if (!REWRITE_EXTENSIONS.has(entry.ext)) continue;
562
+ const specs = extractSpecifiers(entry.source);
563
+ for (const spec of specs) {
564
+ if (!spec.specifier.startsWith(".")) continue;
565
+ const from = entry.abs;
566
+ const to = resolve(dirname(entry.abs), spec.specifier);
567
+ const fromRel = normalizeSlashes(relative(rootAbs, from));
568
+ const toRel = normalizeSlashes(relative(rootAbs, to));
569
+ const key = `${fromRel}->${toRel}`;
570
+ if (edgeSet.has(key)) continue;
571
+ edgeSet.add(key);
572
+ edges.push({ from: fromRel, to: toRel });
573
+ }
574
+ }
575
+
576
+ edges.sort((a, b) => `${a.from}->${a.to}`.localeCompare(`${b.from}->${b.to}`));
577
+ return edges;
578
+ }
579
+
580
+ function mapImportEdges(edges, targetRoot, renameMap) {
581
+ const out = edges.map((edge) => {
582
+ const fromAbs = resolve(targetRoot, edge.from);
583
+ const toAbs = resolve(targetRoot, edge.to);
584
+ const mappedFrom = renameMap.get(fromAbs) || fromAbs;
585
+ const mappedTo = renameMap.get(toAbs) || toAbs;
586
+ return {
587
+ from: normalizeSlashes(relative(targetRoot, mappedFrom)),
588
+ to: normalizeSlashes(relative(targetRoot, mappedTo)),
589
+ };
590
+ });
591
+
592
+ out.sort((a, b) => `${a.from}->${a.to}`.localeCompare(`${b.from}->${b.to}`));
593
+ return out;
594
+ }
595
+
596
+ function edgesEqual(a, b) {
597
+ if (a.length !== b.length) return false;
598
+ for (let i = 0; i < a.length; i += 1) {
599
+ if (a[i].from !== b[i].from || a[i].to !== b[i].to) return false;
600
+ }
601
+ return true;
602
+ }
603
+
604
+ function resolveRelativeImportPath(baseFile, specifier, state) {
605
+ const abs = resolve(dirname(baseFile), specifier);
606
+ if (state.byAbs.has(abs)) return abs;
607
+ if (existsSync(abs) && statSync(abs).isFile()) return abs;
45
608
 
46
- const files = walk(base).filter((f) => EXT_INPUT.has(extname(f)));
47
- let migrated = 0;
48
- let kept = 0;
609
+ const candidates = [
610
+ `${abs}.fs`,
611
+ `${abs}.js`,
612
+ `${abs}.jsx`,
613
+ `${abs}.ts`,
614
+ `${abs}.tsx`,
615
+ join(abs, "index.fs"),
616
+ join(abs, "index.js"),
617
+ join(abs, "index.ts"),
618
+ join(abs, "index.tsx"),
619
+ join(abs, "index.jsx"),
620
+ ];
49
621
 
50
- for (const file of files) {
51
- const ext = extname(file);
52
- const raw = readFileSync(file, "utf8");
53
- let next = raw;
622
+ for (const candidate of candidates) {
623
+ if (state.byAbs.has(candidate)) return candidate;
624
+ if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
625
+ }
626
+
627
+ return null;
628
+ }
629
+
630
+ function validateImportResolution(state) {
631
+ const unresolved = [];
632
+ for (const entry of state.entries) {
633
+ if (!REWRITE_EXTENSIONS.has(entry.ext)) continue;
634
+ const specs = extractSpecifiers(entry.source);
635
+ for (const spec of specs) {
636
+ if (!spec.specifier.startsWith(".")) continue;
637
+ const resolvedTarget = resolveRelativeImportPath(entry.abs, spec.specifier, state);
638
+ if (!resolvedTarget) {
639
+ unresolved.push({
640
+ file: entry.rel,
641
+ specifier: spec.specifier,
642
+ });
643
+ }
644
+ }
645
+ }
646
+ return unresolved;
647
+ }
648
+
649
+ function calculateHashRows(state) {
650
+ return state.entries
651
+ .map((entry) => ({
652
+ path: entry.rel,
653
+ hash: entry.hash,
654
+ size: entry.size,
655
+ }))
656
+ .sort((a, b) => a.path.localeCompare(b.path));
657
+ }
658
+
659
+ function parseCommand(command) {
660
+ const text = String(command || "").trim();
661
+ if (!text) return null;
662
+ const tokens = text.match(/"[^"]*"|'[^']*'|\S+/g) || [];
663
+ if (!tokens.length) return null;
664
+ return {
665
+ bin: tokens[0].replace(/^['"]|['"]$/g, ""),
666
+ args: tokens.slice(1).map((token) => token.replace(/^['"]|['"]$/g, "")),
667
+ text,
668
+ };
669
+ }
670
+
671
+ function hasRunnableScript(projectRoot, command) {
672
+ const parsed = parseCommand(command);
673
+ if (!parsed) return false;
674
+
675
+ if (/^npm$/i.test(parsed.bin) && /^run$/i.test(parsed.args[0] || "")) return existsSync(resolve(projectRoot, "package.json"));
676
+
677
+ if (/^node(\.exe)?$/i.test(parsed.bin)) {
678
+ const first = parsed.args[0];
679
+ if (!first) return true;
680
+ return existsSync(resolve(projectRoot, first));
681
+ }
682
+
683
+ return true;
684
+ }
54
685
 
55
- if (ext === ".ts" || ext === ".tsx") next = stripTypeScriptHints(next);
56
- next = normalizeFastScript(next);
57
- next = rewriteRelativeExt(next);
686
+ async function runProbe(projectRoot, id, command) {
687
+ const parsed = parseCommand(command);
688
+ if (!parsed) {
689
+ return {
690
+ id,
691
+ command,
692
+ status: "fail",
693
+ exitCode: null,
694
+ durationMs: 0,
695
+ stdoutTail: "",
696
+ stderrTail: "invalid probe command",
697
+ };
698
+ }
58
699
 
59
- const out = ext === ".fs" ? file : toFsPath(file);
60
- mkdirSync(dirname(out), { recursive: true });
61
- writeFileSync(out, next, "utf8");
700
+ const startedAt = Date.now();
62
701
 
63
- if (out !== file) {
64
- rmSync(file, { force: true });
65
- migrated += 1;
66
- } else {
67
- kept += 1;
702
+ if (/^node(\.exe)?$/i.test(parsed.bin) && parsed.args.length >= 1) {
703
+ const scriptPath = resolve(projectRoot, parsed.args[0]);
704
+ if (existsSync(scriptPath) && scriptPath.endsWith(".mjs") && parsed.args.length === 1) {
705
+ try {
706
+ const probeUrl = `${pathToFileURL(scriptPath).href}?probe=${Date.now()}-${Math.random().toString(36).slice(2)}`;
707
+ await import(probeUrl);
708
+ return {
709
+ id,
710
+ command,
711
+ status: "pass",
712
+ exitCode: 0,
713
+ durationMs: Date.now() - startedAt,
714
+ stdoutTail: "",
715
+ stderrTail: "",
716
+ };
717
+ } catch (error) {
718
+ return {
719
+ id,
720
+ command,
721
+ status: "fail",
722
+ exitCode: null,
723
+ durationMs: Date.now() - startedAt,
724
+ stdoutTail: "",
725
+ stderrTail: String(error?.message || error),
726
+ };
727
+ }
68
728
  }
69
729
  }
70
730
 
71
- if (base === PAGE_DIR) {
72
- if (!existsSync(join(PAGE_DIR, "index.fs")) && existsSync(join(PAGE_DIR, "index.js"))) {
73
- const source = readFileSync(join(PAGE_DIR, "index.js"), "utf8");
74
- writeFileSync(join(PAGE_DIR, "index.fs"), normalizeFastScript(source), "utf8");
75
- rmSync(join(PAGE_DIR, "index.js"), { force: true });
76
- migrated += 1;
731
+ const result = spawnSync(parsed.bin, parsed.args, {
732
+ cwd: projectRoot,
733
+ encoding: "utf8",
734
+ env: process.env,
735
+ });
736
+ const status = result.error || result.status !== 0 ? "fail" : "pass";
737
+
738
+ return {
739
+ id,
740
+ command,
741
+ status,
742
+ exitCode: result.status,
743
+ durationMs: Date.now() - startedAt,
744
+ stdoutTail: String(result.stdout || "").trim().split(/\r?\n/).slice(-8).join("\n"),
745
+ stderrTail: [String(result.stderr || "").trim(), result.error ? String(result.error.message || result.error) : ""]
746
+ .filter(Boolean)
747
+ .join("\n")
748
+ .split(/\r?\n/)
749
+ .slice(-8)
750
+ .join("\n"),
751
+ };
752
+ }
753
+
754
+ function buildFidelityProbeCommands(config) {
755
+ const commands = {
756
+ domSnapshotComparison: "node ./scripts/test-conformance.mjs",
757
+ computedStyleComparison: "node ./scripts/test-style-rules.mjs",
758
+ screenshotDiffComparison: "node ./scripts/test-style-rules.mjs",
759
+ routeOutputComparison: "node ./scripts/test-routes.mjs",
760
+ apiContractComparison: "node ./scripts/test-validation.mjs",
761
+ hydrationAndInteractionValidation: "node ./scripts/test-runtime-context-rules.mjs",
762
+ ...(config?.fidelity?.commands || {}),
763
+ };
764
+ return commands;
765
+ }
766
+
767
+ function collectProtectedScopeViolations({ writes, config, targetRoot }) {
768
+ const violations = [];
769
+ const keySet = new Set();
770
+ const protectedConfigKeys = (config?.protectedConfigKeys || []).map((key) => String(key || "").trim()).filter(Boolean);
771
+ const protectedMarkupRegions = (config?.protectedMarkupRegions || []).map((region) => String(region || "").trim()).filter(Boolean);
772
+
773
+ const configExtensions = new Set([".json", ".js", ".jsx", ".ts", ".tsx", ".fs", ".mjs", ".cjs", ".yaml", ".yml", ".toml"]);
774
+ const markupExtensions = new Set([".html", ".htm", ".jsx", ".tsx", ".fs", ".mdx", ".js", ".ts"]);
775
+
776
+ for (const write of writes) {
777
+ if (write.beforeHash === write.afterHash) continue;
778
+ const relPath = normalizeSlashes(relative(targetRoot, write.sourcePath));
779
+ const ext = extname(write.sourcePath).toLowerCase();
780
+ const before = String(write.beforeContents || "");
781
+ const after = String(write.contents || "");
782
+
783
+ if (protectedConfigKeys.length && configExtensions.has(ext)) {
784
+ for (const key of protectedConfigKeys) {
785
+ if (before.includes(key) || after.includes(key)) {
786
+ const id = `${relPath}|protected-config-key:${key}`;
787
+ if (keySet.has(id)) continue;
788
+ keySet.add(id);
789
+ violations.push({ path: relPath, reason: `protected-config-key:${key}` });
790
+ }
791
+ }
792
+ }
793
+
794
+ if (protectedMarkupRegions.length && markupExtensions.has(ext)) {
795
+ for (const region of protectedMarkupRegions) {
796
+ const tokens = [
797
+ region,
798
+ `data-protected=\"${region}\"`,
799
+ `id=\"${region}\"`,
800
+ `class=\"${region}\"`,
801
+ `'${region}'`,
802
+ `"${region}"`,
803
+ ];
804
+ if (!tokens.some((token) => before.includes(token) || after.includes(token))) continue;
805
+ const id = `${relPath}|protected-markup-region:${region}`;
806
+ if (keySet.has(id)) continue;
807
+ keySet.add(id);
808
+ violations.push({ path: relPath, reason: `protected-markup-region:${region}` });
809
+ }
77
810
  }
78
811
  }
79
812
 
80
- console.log(`migrate complete: ${migrated} converted, ${kept} already .fs`);
813
+ return violations;
814
+ }
815
+
816
+ function createPlan({ projectRoot, targetRoot, beforeState, config }) {
817
+ const protection = buildProtectionMatchers({ projectRoot, targetRoot, config });
818
+ const renameMap = new Map();
819
+ const renames = [];
820
+ const blockedFiles = [];
821
+ const protectedFiles = [];
822
+
823
+ for (const entry of beforeState.entries) {
824
+ const rel = entry.rel;
825
+ const abs = entry.abs;
826
+ if (protection.isProtected(rel, abs)) protectedFiles.push(rel);
827
+
828
+ if (!SOURCE_EXTENSIONS.has(entry.ext)) continue;
829
+
830
+ if (protection.isBlocked(rel)) {
831
+ blockedFiles.push({ path: rel, reason: "blocked-by-config" });
832
+ continue;
833
+ }
834
+
835
+ if (protection.isProtected(rel, abs)) {
836
+ blockedFiles.push({ path: rel, reason: "protected-file" });
837
+ continue;
838
+ }
839
+
840
+ const next = toFsPath(abs);
841
+ if (next !== abs && beforeState.byAbs.has(next)) {
842
+ blockedFiles.push({ path: rel, reason: "destination-exists" });
843
+ continue;
844
+ }
845
+
846
+ renameMap.set(abs, next);
847
+ renames.push({
848
+ from: abs,
849
+ to: next,
850
+ fromRel: rel,
851
+ toRel: normalizeSlashes(relative(targetRoot, next)),
852
+ });
853
+ }
854
+
855
+ const writes = [];
856
+ const importRewrites = [];
857
+ const touchedOutputFiles = new Set();
858
+
859
+ for (const entry of beforeState.entries) {
860
+ if (!REWRITE_EXTENSIONS.has(entry.ext)) continue;
861
+
862
+ const targetPath = renameMap.get(entry.abs) || entry.abs;
863
+ const rewritten = rewriteSourceImports({
864
+ source: entry.source,
865
+ filePath: entry.abs,
866
+ renameMap,
867
+ });
868
+
869
+ const shouldWrite = targetPath !== entry.abs || rewritten.code !== entry.source;
870
+ if (!shouldWrite) continue;
871
+
872
+ writes.push({
873
+ path: targetPath,
874
+ sourcePath: entry.abs,
875
+ contents: rewritten.code,
876
+ kind: targetPath !== entry.abs ? "rename" : "rewrite",
877
+ beforeHash: sha256(entry.source),
878
+ afterHash: sha256(rewritten.code),
879
+ beforeContents: entry.source,
880
+ });
881
+
882
+ touchedOutputFiles.add(targetPath);
883
+
884
+ if (rewritten.rewrites.length) {
885
+ importRewrites.push({
886
+ file: normalizeSlashes(relative(targetRoot, targetPath)),
887
+ count: rewritten.rewrites.length,
888
+ rewrites: rewritten.rewrites.map((item) => ({ from: item.from, to: item.to })),
889
+ });
890
+ }
891
+ }
892
+
893
+ const renamedSourceSet = new Set(renames.map((item) => item.from));
894
+ const deletes = renames
895
+ .filter((item) => item.from !== item.to)
896
+ .map((item) => item.from)
897
+ .filter((path) => !touchedOutputFiles.has(path) && renamedSourceSet.has(path));
898
+
899
+ const changedBeforePaths = new Set(writes.map((item) => item.sourcePath));
900
+ const untouchedFiles = beforeState.entries
901
+ .map((entry) => entry.rel)
902
+ .filter((rel) => !changedBeforePaths.has(resolve(targetRoot, rel)));
903
+
904
+ const protectedScopeViolations = collectProtectedScopeViolations({ writes, config, targetRoot });
905
+ for (const violation of protectedScopeViolations) blockedFiles.push(violation);
906
+
907
+ return {
908
+ targetRoot,
909
+ renames,
910
+ renameMap,
911
+ writes,
912
+ deletes,
913
+ blockedFiles,
914
+ protectedFiles: [...new Set(protectedFiles)].sort(),
915
+ protectedScopeViolations,
916
+ importRewrites,
917
+ untouchedFiles: untouchedFiles.sort(),
918
+ };
919
+ }
920
+
921
+ function summarizePlan(plan) {
922
+ return {
923
+ renameCount: plan.renames.length,
924
+ rewriteCount: plan.writes.filter((item) => item.kind === "rewrite").length,
925
+ importRewriteCount: plan.importRewrites.reduce((sum, item) => sum + item.count, 0),
926
+ blockedCount: plan.blockedFiles.length,
927
+ protectedCount: plan.protectedFiles.length,
928
+ };
929
+ }
930
+
931
+ function ensureWithinTarget(pathAbs, targetRoot) {
932
+ if (!pathStartsWith(pathAbs, targetRoot)) {
933
+ throw new Error(`migrate strict failure: attempted to modify non-target file (${pathAbs})`);
934
+ }
935
+ }
936
+
937
+ function applyPlan(plan, runDir) {
938
+ mkdirSync(runDir, { recursive: true });
939
+
940
+ for (const write of plan.writes) {
941
+ ensureWithinTarget(write.path, plan.targetRoot);
942
+ mkdirSync(dirname(write.path), { recursive: true });
943
+ writeFileSync(write.path, write.contents, "utf8");
944
+ }
945
+
946
+ for (const path of plan.deletes) {
947
+ ensureWithinTarget(path, plan.targetRoot);
948
+ rmSync(path, { force: true });
949
+ }
950
+ }
951
+
952
+ function createRollbackPlan(plan) {
953
+ const operations = [];
954
+
955
+ for (const item of plan.renames) {
956
+ if (item.from === item.to) continue;
957
+ operations.push({ type: "rename", from: item.toRel, to: item.fromRel });
958
+ }
959
+
960
+ for (const write of plan.writes.filter((item) => item.beforeHash !== item.afterHash)) {
961
+ operations.push({
962
+ type: "restore-content",
963
+ file: normalizeSlashes(relative(plan.targetRoot, write.sourcePath)),
964
+ fromHash: write.afterHash,
965
+ toHash: write.beforeHash,
966
+ contents: write.beforeContents,
967
+ });
968
+ }
969
+
970
+ return operations;
971
+ }
972
+
973
+ function createDiffPreview(plan) {
974
+ const renameOperations = plan.renames
975
+ .filter((item) => item.from !== item.to)
976
+ .map((item) => ({ from: item.fromRel, to: item.toRel }));
977
+
978
+ const rewriteOperations = plan.writes.map((item) => {
979
+ const relPath = normalizeSlashes(relative(plan.targetRoot, item.path));
980
+ const sourceRel = normalizeSlashes(relative(plan.targetRoot, item.sourcePath));
981
+ const beforeLines = item.beforeContents.split(/\r?\n/).length;
982
+ const afterLines = item.contents.split(/\r?\n/).length;
983
+ return {
984
+ file: relPath,
985
+ sourceFile: sourceRel,
986
+ kind: item.kind,
987
+ beforeHash: item.beforeHash,
988
+ afterHash: item.afterHash,
989
+ beforeLines,
990
+ afterLines,
991
+ lineDelta: afterLines - beforeLines,
992
+ };
993
+ });
994
+
995
+ const deleteOperations = plan.deletes.map((pathAbs) => normalizeSlashes(relative(plan.targetRoot, pathAbs)));
996
+
997
+ return {
998
+ generatedAt: new Date().toISOString(),
999
+ summary: {
1000
+ renameOperationCount: renameOperations.length,
1001
+ rewriteOperationCount: rewriteOperations.length,
1002
+ deleteOperationCount: deleteOperations.length,
1003
+ importRewriteCount: plan.importRewrites.reduce((sum, item) => sum + item.count, 0),
1004
+ blockedCount: plan.blockedFiles.length,
1005
+ protectedCount: plan.protectedFiles.length,
1006
+ },
1007
+ renameOperations,
1008
+ rewriteOperations,
1009
+ importRewrites: plan.importRewrites,
1010
+ deleteOperations,
1011
+ blockedFiles: plan.blockedFiles,
1012
+ protectedFiles: plan.protectedFiles,
1013
+ };
1014
+ }
1015
+
1016
+ function createHumanReport({ manifest, validation, fidelity }) {
1017
+ const lines = [];
1018
+ lines.push("# FastScript Strict Conversion Report");
1019
+ lines.push("");
1020
+ lines.push(`- Run ID: ${manifest.runId}`);
1021
+ lines.push(`- Target: ${manifest.target}`);
1022
+ lines.push(`- Dry run: ${manifest.dryRun ? "yes" : "no"}`);
1023
+ lines.push(`- Renamed files: ${manifest.summary.renameCount}`);
1024
+ lines.push(`- Rewritten files: ${manifest.summary.rewriteCount}`);
1025
+ lines.push(`- Import rewrites: ${manifest.summary.importRewriteCount}`);
1026
+ lines.push(`- Blocked files: ${manifest.summary.blockedCount}`);
1027
+ lines.push(`- Protected files detected: ${manifest.summary.protectedCount}`);
1028
+ lines.push("");
1029
+ lines.push("## Validation");
1030
+ for (const check of validation.checks) {
1031
+ lines.push(`- ${check.id}: ${check.status}`);
1032
+ }
1033
+ lines.push("");
1034
+ lines.push("## Fidelity");
1035
+ for (const probe of fidelity.probes) {
1036
+ lines.push(`- ${probe.id}: ${probe.status}`);
1037
+ }
1038
+ lines.push("");
1039
+ lines.push(`Overall fidelity: ${fidelity.status}`);
1040
+ return `${lines.join("\n")}\n`;
1041
+ }
1042
+
1043
+ export function createStrictConversionPlan(input = []) {
1044
+ const options = parseArgs(input);
1045
+ const projectRoot = resolve(".");
1046
+ const targetRoot = resolve(options.target);
1047
+ if (!existsSync(targetRoot)) throw new Error(`migrate: missing path ${targetRoot}`);
1048
+
1049
+ const userConfig = readJsonFile(options.configPath, {});
1050
+ const config = mergeConfig(DEFAULT_CONFIG, userConfig || {});
1051
+ const beforeState = collectFileState(targetRoot);
1052
+
1053
+ const plan = createPlan({
1054
+ projectRoot,
1055
+ targetRoot,
1056
+ beforeState,
1057
+ config,
1058
+ });
1059
+
1060
+ return {
1061
+ options,
1062
+ projectRoot,
1063
+ targetRoot,
1064
+ config,
1065
+ beforeState,
1066
+ plan,
1067
+ };
81
1068
  }
1069
+
1070
+ export async function runMigrate(input = []) {
1071
+ const prepared = createStrictConversionPlan(input);
1072
+ const { options, projectRoot, targetRoot, config, beforeState, plan } = prepared;
1073
+
1074
+ const summary = summarizePlan(plan);
1075
+ const runId = nowRunId();
1076
+ const runDir = resolve(options.reportDir, runId);
1077
+
1078
+ const manifest = {
1079
+ spec: "FASTSCRIPT_COMPATIBILITY_FIRST_RUNTIME_SPEC#1-#41",
1080
+ mode: "strict",
1081
+ runId,
1082
+ generatedAt: new Date().toISOString(),
1083
+ dryRun: options.dryRun,
1084
+ projectRoot,
1085
+ target: normalizeSlashes(relative(projectRoot, targetRoot)) || ".",
1086
+ summary,
1087
+ convertedFiles: plan.renames.map((item) => ({ from: item.fromRel, to: item.toRel })),
1088
+ importRewrites: plan.importRewrites,
1089
+ untouchedFiles: plan.untouchedFiles,
1090
+ protectedFiles: plan.protectedFiles,
1091
+ blockedFiles: plan.blockedFiles,
1092
+ protectedScopeViolations: plan.protectedScopeViolations,
1093
+ protectedScopeConfig: {
1094
+ protectedFiles: config.protectedFiles,
1095
+ protectedDirectories: config.protectedDirectories,
1096
+ protectedGlobs: config.protectedGlobs,
1097
+ protectedExtensions: config.protectedExtensions,
1098
+ protectedConfigKeys: config.protectedConfigKeys,
1099
+ protectedMarkupRegions: config.protectedMarkupRegions,
1100
+ },
1101
+ rollback: {
1102
+ mode: "manifest-driven",
1103
+ operations: createRollbackPlan(plan),
1104
+ },
1105
+ };
1106
+ const diffPreview = createDiffPreview(plan);
1107
+ manifest.diffPreview = {
1108
+ renameOperationCount: diffPreview.summary.renameOperationCount,
1109
+ rewriteOperationCount: diffPreview.summary.rewriteOperationCount,
1110
+ deleteOperationCount: diffPreview.summary.deleteOperationCount,
1111
+ };
1112
+ manifest.trustWorkflow = {
1113
+ artifacts: {
1114
+ manifest: "conversion-manifest.json",
1115
+ diffPreview: "diff-preview.json",
1116
+ validation: "validation-report.json",
1117
+ fidelity: "fidelity-report.json",
1118
+ report: "conversion-report.md",
1119
+ },
1120
+ };
1121
+
1122
+ if (plan.blockedFiles.length > 0) {
1123
+ mkdirSync(runDir, { recursive: true });
1124
+ const blockedManifestPath = join(runDir, "conversion-manifest.json");
1125
+ const blockedDiffPath = join(runDir, "diff-preview.json");
1126
+ writeFileSync(blockedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
1127
+ writeFileSync(blockedDiffPath, `${JSON.stringify(diffPreview, null, 2)}\n`, "utf8");
1128
+ const latestDir = resolve(options.reportDir, "latest");
1129
+ mkdirSync(latestDir, { recursive: true });
1130
+ writeFileSync(join(latestDir, "conversion-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
1131
+ writeFileSync(join(latestDir, "diff-preview.json"), `${JSON.stringify(diffPreview, null, 2)}\n`, "utf8");
1132
+ throw new Error(
1133
+ `migrate strict failure: conversion blocked by protected/blocked files (${plan.blockedFiles.length}). See ${normalizeSlashes(relative(projectRoot, blockedManifestPath))}.`
1134
+ );
1135
+ }
1136
+
1137
+ const beforeHashes = calculateHashRows(beforeState);
1138
+ let afterState = createAfterVirtualState({ beforeState, plan });
1139
+
1140
+ if (!options.dryRun) {
1141
+ applyPlan(plan, runDir);
1142
+ afterState = collectFileState(targetRoot);
1143
+ }
1144
+
1145
+ const afterHashes = calculateHashRows(afterState);
1146
+
1147
+ const protectedHashViolations = [];
1148
+ for (const relPath of plan.protectedFiles) {
1149
+ const before = beforeHashes.find((item) => item.path === relPath);
1150
+ const after = afterHashes.find((item) => item.path === relPath);
1151
+ if (!before || !after) continue;
1152
+ if (before.hash !== after.hash) {
1153
+ protectedHashViolations.push({ path: relPath, before: before.hash, after: after.hash });
1154
+ }
1155
+ }
1156
+
1157
+ const beforeGraph = createImportGraph(beforeState, targetRoot);
1158
+ const mappedBeforeGraph = mapImportEdges(beforeGraph, targetRoot, plan.renameMap);
1159
+ const afterGraph = createImportGraph(afterState, targetRoot);
1160
+ const importGraphIntegrity = edgesEqual(mappedBeforeGraph, afterGraph);
1161
+
1162
+ const unresolvedImports = validateImportResolution(afterState);
1163
+
1164
+ const idempotencyState = createStrictConversionPlan([
1165
+ targetRoot,
1166
+ "--dry-run",
1167
+ "--config",
1168
+ options.configPath,
1169
+ "--report-dir",
1170
+ options.reportDir,
1171
+ "--fidelity-level",
1172
+ options.fidelityLevel,
1173
+ options.failOnUnprovenFidelity ? "--fail-on-unproven-fidelity" : "--allow-unproven-fidelity",
1174
+ ]);
1175
+
1176
+ const idempotencyOk = idempotencyState.plan.renames.length === 0 && idempotencyState.plan.writes.length === 0;
1177
+
1178
+ const nonTargetMutation = plan.writes.every((item) => pathStartsWith(item.path, targetRoot)) &&
1179
+ plan.deletes.every((item) => pathStartsWith(item, targetRoot));
1180
+
1181
+ const validation = {
1182
+ generatedAt: new Date().toISOString(),
1183
+ checks: [
1184
+ { id: "rename-only-conversion", status: "pass", details: "Only extension renames and import specifier rewrites are emitted." },
1185
+ { id: "protected-file-hash-enforcement", status: protectedHashViolations.length ? "fail" : "pass", details: protectedHashViolations },
1186
+ { id: "dependency-graph-integrity", status: importGraphIntegrity ? "pass" : "fail", details: { beforeEdges: mappedBeforeGraph.length, afterEdges: afterGraph.length } },
1187
+ { id: "import-resolution", status: unresolvedImports.length ? "fail" : "pass", details: unresolvedImports },
1188
+ { id: "idempotency", status: idempotencyOk ? "pass" : "fail", details: { renameCount: idempotencyState.plan.renames.length, rewriteCount: idempotencyState.plan.writes.length } },
1189
+ { id: "non-target-mutation", status: nonTargetMutation ? "pass" : "fail", details: "No writes outside target scope were attempted." },
1190
+ ],
1191
+ };
1192
+
1193
+ const probeCommands = buildFidelityProbeCommands(config);
1194
+ const probes = [];
1195
+
1196
+ const requiredByLevel = options.fidelityLevel === "full"
1197
+ ? [
1198
+ "domSnapshotComparison",
1199
+ "computedStyleComparison",
1200
+ "screenshotDiffComparison",
1201
+ "routeOutputComparison",
1202
+ "apiContractComparison",
1203
+ "hydrationAndInteractionValidation",
1204
+ ]
1205
+ : [];
1206
+
1207
+ const requiredFromConfig = [...(config?.fidelity?.requiredProbes || [])];
1208
+ const required = [...new Set([...requiredByLevel, ...requiredFromConfig])];
1209
+
1210
+ if (options.fidelityLevel === "full" || requiredFromConfig.length > 0) {
1211
+ const probeIdsToRun = options.fidelityLevel === "full"
1212
+ ? Object.keys(probeCommands)
1213
+ : requiredFromConfig;
1214
+
1215
+ for (const id of probeIdsToRun) {
1216
+ const command = probeCommands[id];
1217
+ if (!command) {
1218
+ probes.push({ id, command: "", status: "fail", reason: "missing probe command mapping" });
1219
+ continue;
1220
+ }
1221
+ if (!hasRunnableScript(projectRoot, command)) {
1222
+ probes.push({
1223
+ id,
1224
+ command,
1225
+ status: options.fidelityLevel === "full" ? "fail" : "skipped",
1226
+ reason: "probe command unavailable in project",
1227
+ });
1228
+ continue;
1229
+ }
1230
+ probes.push(await runProbe(projectRoot, id, command));
1231
+ }
1232
+ }
1233
+
1234
+ const missingRequired = required.filter((id) => !probes.some((probe) => probe.id === id && probe.status === "pass"));
1235
+
1236
+ const fidelityChecks = [
1237
+ { id: "before-after-file-hash-tracking", status: "pass", details: { beforeCount: beforeHashes.length, afterCount: afterHashes.length } },
1238
+ { id: "protected-file-hash-enforcement", status: protectedHashViolations.length ? "fail" : "pass", details: protectedHashViolations },
1239
+ { id: "import-graph-meaning", status: importGraphIntegrity ? "pass" : "fail", details: { beforeEdges: mappedBeforeGraph.length, afterEdges: afterGraph.length } },
1240
+ { id: "idempotency", status: idempotencyOk ? "pass" : "fail", details: { renameCount: idempotencyState.plan.renames.length, rewriteCount: idempotencyState.plan.writes.length } },
1241
+ ];
1242
+
1243
+ const fidelityStatus =
1244
+ fidelityChecks.some((item) => item.status === "fail") ||
1245
+ probes.some((item) => item.status === "fail") ||
1246
+ (options.failOnUnprovenFidelity && missingRequired.length > 0)
1247
+ ? "fail"
1248
+ : "pass";
1249
+
1250
+ const fidelity = {
1251
+ generatedAt: new Date().toISOString(),
1252
+ level: options.fidelityLevel,
1253
+ status: fidelityStatus,
1254
+ required,
1255
+ missingRequired,
1256
+ checks: fidelityChecks,
1257
+ probes,
1258
+ };
1259
+
1260
+ manifest.hashes = {
1261
+ before: beforeHashes,
1262
+ after: afterHashes,
1263
+ };
1264
+
1265
+ manifest.validation = {
1266
+ failedChecks: validation.checks.filter((item) => item.status === "fail").map((item) => item.id),
1267
+ };
1268
+
1269
+ manifest.fidelity = {
1270
+ status: fidelity.status,
1271
+ failedChecks: fidelity.checks.filter((item) => item.status === "fail").map((item) => item.id),
1272
+ failedProbes: fidelity.probes.filter((item) => item.status === "fail").map((item) => item.id),
1273
+ missingRequired,
1274
+ };
1275
+
1276
+ mkdirSync(runDir, { recursive: true });
1277
+
1278
+ const manifestPath = join(runDir, "conversion-manifest.json");
1279
+ const diffPreviewPath = join(runDir, "diff-preview.json");
1280
+ const validationPath = join(runDir, "validation-report.json");
1281
+ const fidelityPath = join(runDir, "fidelity-report.json");
1282
+ const markdownPath = join(runDir, "conversion-report.md");
1283
+
1284
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
1285
+ writeFileSync(diffPreviewPath, `${JSON.stringify(diffPreview, null, 2)}\n`, "utf8");
1286
+ writeFileSync(validationPath, `${JSON.stringify(validation, null, 2)}\n`, "utf8");
1287
+ writeFileSync(fidelityPath, `${JSON.stringify(fidelity, null, 2)}\n`, "utf8");
1288
+ writeFileSync(markdownPath, createHumanReport({ manifest, validation, fidelity }), "utf8");
1289
+
1290
+ const latestDir = resolve(options.reportDir, "latest");
1291
+ mkdirSync(latestDir, { recursive: true });
1292
+ writeFileSync(join(latestDir, "conversion-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
1293
+ writeFileSync(join(latestDir, "diff-preview.json"), `${JSON.stringify(diffPreview, null, 2)}\n`, "utf8");
1294
+ writeFileSync(join(latestDir, "validation-report.json"), `${JSON.stringify(validation, null, 2)}\n`, "utf8");
1295
+ writeFileSync(join(latestDir, "fidelity-report.json"), `${JSON.stringify(fidelity, null, 2)}\n`, "utf8");
1296
+ writeFileSync(join(latestDir, "conversion-report.md"), createHumanReport({ manifest, validation, fidelity }), "utf8");
1297
+
1298
+ const validationFailed = validation.checks.some((item) => item.status === "fail");
1299
+ if (validationFailed) {
1300
+ throw new Error(`migrate strict failure: validation failed. See ${normalizeSlashes(relative(projectRoot, validationPath))}.`);
1301
+ }
1302
+
1303
+ if (fidelity.status === "fail") {
1304
+ throw new Error(`migrate strict failure: fidelity checks failed. See ${normalizeSlashes(relative(projectRoot, fidelityPath))}.`);
1305
+ }
1306
+
1307
+ console.log(`migrate strict complete: ${summary.renameCount} renamed, ${summary.rewriteCount} rewritten, ${summary.importRewriteCount} import rewrites`);
1308
+ console.log(`migrate report: ${normalizeSlashes(relative(projectRoot, runDir))}`);
1309
+ }