@decocms/blocks-cli 7.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 (93) hide show
  1. package/package.json +44 -0
  2. package/scripts/analyze-traces.mjs +1117 -0
  3. package/scripts/audit-observability-config.test.ts +446 -0
  4. package/scripts/audit-observability-config.ts +511 -0
  5. package/scripts/deco-migrate-cli.ts +444 -0
  6. package/scripts/fast-deploy-kv.test.ts +131 -0
  7. package/scripts/generate-blocks.test.ts +94 -0
  8. package/scripts/generate-blocks.ts +274 -0
  9. package/scripts/generate-invoke.test.ts +195 -0
  10. package/scripts/generate-invoke.ts +469 -0
  11. package/scripts/generate-loaders.ts +217 -0
  12. package/scripts/generate-schema.ts +1287 -0
  13. package/scripts/generate-sections.ts +237 -0
  14. package/scripts/htmx-analyze.ts +226 -0
  15. package/scripts/lib/blocks-dedupe.test.ts +179 -0
  16. package/scripts/lib/blocks-dedupe.ts +142 -0
  17. package/scripts/lib/cf-kv-rest.ts +78 -0
  18. package/scripts/lib/jsonc.ts +122 -0
  19. package/scripts/lib/kv-snapshot.ts +51 -0
  20. package/scripts/lib/read-decofile.ts +70 -0
  21. package/scripts/lib/sync-helpers.ts +44 -0
  22. package/scripts/migrate/analyzers/htmx-analyze.test.ts +372 -0
  23. package/scripts/migrate/analyzers/htmx-analyze.ts +425 -0
  24. package/scripts/migrate/analyzers/island-classifier.ts +96 -0
  25. package/scripts/migrate/analyzers/loader-inventory.ts +63 -0
  26. package/scripts/migrate/analyzers/section-metadata.ts +147 -0
  27. package/scripts/migrate/analyzers/theme-extractor.ts +122 -0
  28. package/scripts/migrate/colors.ts +46 -0
  29. package/scripts/migrate/config.test.ts +202 -0
  30. package/scripts/migrate/config.ts +186 -0
  31. package/scripts/migrate/phase-analyze.test.ts +63 -0
  32. package/scripts/migrate/phase-analyze.ts +782 -0
  33. package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
  34. package/scripts/migrate/phase-cleanup-audit.ts +105 -0
  35. package/scripts/migrate/phase-cleanup.test.ts +141 -0
  36. package/scripts/migrate/phase-cleanup.ts +1588 -0
  37. package/scripts/migrate/phase-compile.test.ts +193 -0
  38. package/scripts/migrate/phase-compile.ts +177 -0
  39. package/scripts/migrate/phase-report.ts +243 -0
  40. package/scripts/migrate/phase-scaffold.ts +593 -0
  41. package/scripts/migrate/phase-transform.ts +310 -0
  42. package/scripts/migrate/phase-verify.test.ts +127 -0
  43. package/scripts/migrate/phase-verify.ts +572 -0
  44. package/scripts/migrate/post-cleanup/rules.ts +1708 -0
  45. package/scripts/migrate/post-cleanup/runner.test.ts +1771 -0
  46. package/scripts/migrate/post-cleanup/runner.ts +137 -0
  47. package/scripts/migrate/post-cleanup/shim-classify.test.ts +352 -0
  48. package/scripts/migrate/post-cleanup/shim-classify.ts +246 -0
  49. package/scripts/migrate/post-cleanup/types.ts +106 -0
  50. package/scripts/migrate/source-layout.test.ts +111 -0
  51. package/scripts/migrate/source-layout.ts +103 -0
  52. package/scripts/migrate/templates/app-css.ts +366 -0
  53. package/scripts/migrate/templates/cache-config.ts +26 -0
  54. package/scripts/migrate/templates/commerce-loaders.ts +230 -0
  55. package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
  56. package/scripts/migrate/templates/cursor-rules.ts +70 -0
  57. package/scripts/migrate/templates/hooks.test.ts +141 -0
  58. package/scripts/migrate/templates/hooks.ts +152 -0
  59. package/scripts/migrate/templates/knip-config.ts +27 -0
  60. package/scripts/migrate/templates/lib-utils.test.ts +139 -0
  61. package/scripts/migrate/templates/lib-utils.ts +326 -0
  62. package/scripts/migrate/templates/lockfile-check-yml.test.ts +26 -0
  63. package/scripts/migrate/templates/lockfile-check-yml.ts +66 -0
  64. package/scripts/migrate/templates/package-json.ts +177 -0
  65. package/scripts/migrate/templates/routes.ts +237 -0
  66. package/scripts/migrate/templates/sdk-gen.ts +59 -0
  67. package/scripts/migrate/templates/section-loaders.ts +456 -0
  68. package/scripts/migrate/templates/server-entry.ts +561 -0
  69. package/scripts/migrate/templates/setup.ts +148 -0
  70. package/scripts/migrate/templates/tsconfig.ts +21 -0
  71. package/scripts/migrate/templates/types-gen.ts +174 -0
  72. package/scripts/migrate/templates/ui-components.ts +144 -0
  73. package/scripts/migrate/templates/vite-config.ts +101 -0
  74. package/scripts/migrate/transforms/dead-code.ts +455 -0
  75. package/scripts/migrate/transforms/deno-isms.ts +85 -0
  76. package/scripts/migrate/transforms/fresh-apis.ts +223 -0
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +305 -0
  78. package/scripts/migrate/transforms/htmx-on-events.ts +193 -0
  79. package/scripts/migrate/transforms/imports.ts +385 -0
  80. package/scripts/migrate/transforms/jsx.ts +317 -0
  81. package/scripts/migrate/transforms/section-conventions.ts +210 -0
  82. package/scripts/migrate/transforms/tailwind.ts +739 -0
  83. package/scripts/migrate/types.ts +244 -0
  84. package/scripts/migrate-blocks-to-kv.ts +104 -0
  85. package/scripts/migrate-post-cleanup.ts +191 -0
  86. package/scripts/migrate-to-cf-observability.test.ts +215 -0
  87. package/scripts/migrate-to-cf-observability.ts +699 -0
  88. package/scripts/migrate.ts +282 -0
  89. package/scripts/smoke-otlp-errorlog.ts +46 -0
  90. package/scripts/smoke-otlp-meter.ts +48 -0
  91. package/scripts/sync-blocks-to-kv.ts +153 -0
  92. package/scripts/tailwind-lint.ts +518 -0
  93. package/tsconfig.json +7 -0
@@ -0,0 +1,1708 @@
1
+ /**
2
+ * Post-migration cleanup audit — rule implementations.
3
+ *
4
+ * Each rule mirrors a section in
5
+ * `.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md`.
6
+ * The intent is to take the human checklist and make it programmatically
7
+ * detectable so future migrations get the same scrubbing automatically.
8
+ *
9
+ * Rules are intentionally read-only here — `--fix` is a follow-up.
10
+ */
11
+
12
+ import { analyzeFile as analyzeHtmxFile } from "../analyzers/htmx-analyze";
13
+ import { classifyShimExports, type ExportClass } from "./shim-classify";
14
+ import type { Finding, FixAction, FsWriter, Rule, RuleContext } from "./types";
15
+
16
+ const SRC_GLOB_EXCLUDES = ["node_modules", "dist", ".wrangler", ".vite", ".tanstack", "build"];
17
+
18
+ /**
19
+ * Rewrite all `from "<oldSpec>"` (or `from '<oldSpec>'`) imports in
20
+ * `src/**` to `from "<newSpec>"`. Returns the list of site-relative
21
+ * paths actually changed so fix-action summaries can quote a count.
22
+ * Uses the write side of the FS adapter — never touches disk in unit
23
+ * tests.
24
+ *
25
+ * Intentionally string-anchored on the exact spec; will not pick up
26
+ * partial-prefix matches like `~/types/widgets-extra`.
27
+ */
28
+ function rewriteImportSpec(
29
+ ctx: RuleContext,
30
+ writer: FsWriter,
31
+ oldSpec: string,
32
+ newSpec: string,
33
+ ): string[] {
34
+ const { siteDir, fs } = ctx;
35
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
36
+ const escaped = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ const re = new RegExp(`from\\s+(['"])${escaped}\\1`, "g");
38
+ const updated: string[] = [];
39
+ for (const abs of tsFiles) {
40
+ const content = fs.readText(abs);
41
+ if (!re.test(content)) {
42
+ re.lastIndex = 0;
43
+ continue;
44
+ }
45
+ re.lastIndex = 0;
46
+ const next = content.replace(re, (_m, q) => `from ${q}${newSpec}${q}`);
47
+ if (next !== content) {
48
+ writer.writeText(abs, next);
49
+ updated.push(abs.slice(siteDir.length + 1));
50
+ }
51
+ }
52
+ return updated;
53
+ }
54
+
55
+ /* ------------------------------------------------------------------ */
56
+ /* Rule 1 — dead `src/lib/*` shims */
57
+ /* ------------------------------------------------------------------ */
58
+
59
+ const EXPORT_RE = /^export\s+(?:function|const|interface|type|class)\s+([A-Za-z_][A-Za-z0-9_]*)/gm;
60
+
61
+ function extractExports(content: string): string[] {
62
+ const out: string[] = [];
63
+ for (const m of content.matchAll(EXPORT_RE)) {
64
+ out.push(m[1]);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function symbolUsedOutsideLib(siteDir: string, fs: RuleContext["fs"], symbol: string): boolean {
70
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
71
+ const re = new RegExp(`\\b${symbol}\\b`);
72
+ for (const file of tsFiles) {
73
+ if (file.includes("/src/lib/")) continue;
74
+ const content = fs.readText(file);
75
+ if (re.test(content)) return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ const ruleDeadLibShims: Rule = {
81
+ id: "dead-lib-shims",
82
+ title: "Dead src/lib/* shims",
83
+ run({ siteDir, fs }: RuleContext): Finding[] {
84
+ const libFiles = fs.glob(siteDir, "src/lib/*.ts", SRC_GLOB_EXCLUDES);
85
+ if (libFiles.length === 0) return [];
86
+
87
+ const findings: Finding[] = [];
88
+ for (const abs of libFiles) {
89
+ const rel = abs.slice(siteDir.length + 1);
90
+ const content = fs.readText(abs);
91
+ const exports = extractExports(content);
92
+ if (exports.length === 0) continue;
93
+ const allDead = exports.every((s) => !symbolUsedOutsideLib(siteDir, fs, s));
94
+ if (!allDead) continue;
95
+ findings.push({
96
+ rule: "dead-lib-shims",
97
+ severity: "info",
98
+ file: rel,
99
+ message: `${exports.length} export(s), 0 external imports`,
100
+ fix: `rm ${rel}`,
101
+ meta: { exports },
102
+ });
103
+ }
104
+ return findings;
105
+ },
106
+ applyFix({ siteDir }, findings, writer): FixAction[] {
107
+ const actions: FixAction[] = [];
108
+ for (const f of findings) {
109
+ writer.deleteFile(`${siteDir}/${f.file}`);
110
+ actions.push({
111
+ file: f.file,
112
+ kind: "delete",
113
+ detail: "deleted (all exports verified unused)",
114
+ });
115
+ }
116
+ return actions;
117
+ },
118
+ };
119
+
120
+ /* ------------------------------------------------------------------ */
121
+ /* Rule 2 — obsolete inline vite plugins */
122
+ /* ------------------------------------------------------------------ */
123
+
124
+ const OBSOLETE_VITE_PLUGINS: { name: string; reason: string }[] = [
125
+ {
126
+ name: "site-manual-chunks",
127
+ reason: "framework's decoVitePlugin() now owns chunking",
128
+ },
129
+ {
130
+ name: "deco-stub-meta-gen",
131
+ reason: "framework now stubs meta.gen.{json,ts} on the client by default",
132
+ },
133
+ ];
134
+
135
+ const ruleObsoleteVitePlugins: Rule = {
136
+ id: "obsolete-vite-plugins",
137
+ title: "Obsolete inline Vite plugins",
138
+ run({ siteDir, fs }: RuleContext): Finding[] {
139
+ const findings: Finding[] = [];
140
+ const candidates = ["vite.config.ts", "vite.config.js", "vite.config.mjs"];
141
+ for (const rel of candidates) {
142
+ const abs = `${siteDir}/${rel}`;
143
+ if (!fs.exists(abs)) continue;
144
+ const content = fs.readText(abs);
145
+ for (const plugin of OBSOLETE_VITE_PLUGINS) {
146
+ const re = new RegExp(`name:\\s*["']${plugin.name}["']`);
147
+ if (!re.test(content)) continue;
148
+ findings.push({
149
+ rule: "obsolete-vite-plugins",
150
+ severity: "warning",
151
+ file: rel,
152
+ message: `'${plugin.name}' plugin is obsolete — ${plugin.reason}`,
153
+ fix: `delete the inline '${plugin.name}' plugin from ${rel}`,
154
+ meta: { plugin: plugin.name },
155
+ });
156
+ }
157
+ }
158
+ return findings;
159
+ },
160
+ applyFix({ siteDir, fs }, findings, writer): FixAction[] {
161
+ // Group findings by file so we rewrite each vite.config in one pass.
162
+ const byFile = new Map<string, string[]>();
163
+ for (const f of findings) {
164
+ const plugin = (f.meta?.plugin as string | undefined) ?? "";
165
+ if (!plugin) continue;
166
+ const arr = byFile.get(f.file) ?? [];
167
+ arr.push(plugin);
168
+ byFile.set(f.file, arr);
169
+ }
170
+ const actions: FixAction[] = [];
171
+ for (const [rel, pluginNames] of byFile) {
172
+ const abs = `${siteDir}/${rel}`;
173
+ if (!fs.exists(abs)) continue;
174
+ const before = fs.readText(abs);
175
+ const removed: string[] = [];
176
+ let next = before;
177
+ // Process plugins right-to-left in document order so each removal
178
+ // does not invalidate the indices of the next one.
179
+ const ordered = pluginNames
180
+ .map((name) => ({ name, span: findInlineVitePluginSpan(next, name) }))
181
+ .filter((p): p is { name: string; span: PluginSpan } => p.span !== null)
182
+ .sort((a, b) => b.span.startIdx - a.span.startIdx);
183
+ for (const p of ordered) {
184
+ next = next.slice(0, p.span.startIdx) + next.slice(p.span.endIdx);
185
+ removed.push(p.name);
186
+ }
187
+ if (next !== before) {
188
+ writer.writeText(abs, next);
189
+ actions.push({
190
+ file: rel,
191
+ kind: "rewrite-vite-config",
192
+ detail: `removed obsolete plugin(s): ${removed
193
+ .reverse()
194
+ .join(", ")}`,
195
+ });
196
+ }
197
+ }
198
+ return actions;
199
+ },
200
+ };
201
+
202
+ /**
203
+ * Span of an inline plugin object literal inside vite.config.ts that
204
+ * the auto-fixer should strip. Includes:
205
+ *
206
+ * - `startIdx`: position of the first attached `// ...` line above the
207
+ * `{`, or the `{` itself if no leading comment is attached. Leading
208
+ * indentation is included.
209
+ * - `endIdx`: exclusive — points just past the trailing `,\n` (or `\n`
210
+ * if there's no comma). The next character is the start of the next
211
+ * plugin (or the closing `]`).
212
+ *
213
+ * Removing `[startIdx, endIdx)` produces a clean diff: comment + literal
214
+ * gone, no orphan separator, surrounding plugins still paired with their
215
+ * own commas / comments.
216
+ */
217
+ type PluginSpan = { startIdx: number; endIdx: number };
218
+
219
+ /**
220
+ * Brace-balanced search for an inline `{ name: "<plugin>", ... }`
221
+ * object literal inside a vite config. Properly skips over strings,
222
+ * template literals, line comments and block comments so it doesn't
223
+ * miscount braces inside e.g. a `config()` body that contains
224
+ * `{ build: { rollupOptions: ... } }` or template-string interpolation.
225
+ *
226
+ * Returns null when the plugin name isn't present, or when we can't
227
+ * walk the braces unambiguously (defensive — the rule's `run()` will
228
+ * still flag the finding for a manual fix).
229
+ */
230
+ export function findInlineVitePluginSpan(
231
+ content: string,
232
+ pluginName: string,
233
+ ): PluginSpan | null {
234
+ const re = new RegExp(`name:\\s*(['"\`])${escapeRegex(pluginName)}\\1`);
235
+ const m = re.exec(content);
236
+ if (!m) return null;
237
+ const namePropIdx = m.index;
238
+
239
+ // Walk backwards from the name property to find the enclosing `{`.
240
+ // Track string / comment state so we don't false-match on `{` inside
241
+ // a string literal somewhere earlier in the file.
242
+ const openIdx = findEnclosingObjectOpen(content, namePropIdx);
243
+ if (openIdx < 0) return null;
244
+
245
+ // Walk forward from the open brace counting matching braces, again
246
+ // skipping strings / comments. Returns the index of the matching `}`.
247
+ const closeIdx = findMatchingClose(content, openIdx);
248
+ if (closeIdx < 0) return null;
249
+
250
+ // Compute trailingEnd: consume `,` (optional) then whitespace up to
251
+ // and including the first `\n` after the closing brace. If we never
252
+ // hit `\n`, just stop at the comma / next non-whitespace.
253
+ let trailingEnd = closeIdx + 1;
254
+ while (
255
+ trailingEnd < content.length &&
256
+ (content[trailingEnd] === " " || content[trailingEnd] === "\t")
257
+ ) {
258
+ trailingEnd++;
259
+ }
260
+ if (content[trailingEnd] === ",") trailingEnd++;
261
+ while (
262
+ trailingEnd < content.length &&
263
+ (content[trailingEnd] === " " || content[trailingEnd] === "\t")
264
+ ) {
265
+ trailingEnd++;
266
+ }
267
+ if (content[trailingEnd] === "\n") trailingEnd++;
268
+
269
+ // Compute leadingStart: walk backwards over consecutive `//`-only
270
+ // lines that are immediately attached to the `{` (no blank line
271
+ // between them and the literal). Block comments are left alone.
272
+ const leadingStart = findAttachedLeadingComments(content, openIdx);
273
+
274
+ return { startIdx: leadingStart, endIdx: trailingEnd };
275
+ }
276
+
277
+ function escapeRegex(s: string): string {
278
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
279
+ }
280
+
281
+ /**
282
+ * Walk backwards from `fromIdx` to find the index of the `{` that
283
+ * opens the object literal currently containing `fromIdx`. Returns
284
+ * -1 if no such `{` is found before the start of the file or if
285
+ * the walk is too ambiguous (mismatched balance).
286
+ *
287
+ * Skips over string, template-literal and comment regions so braces
288
+ * inside those don't affect the count.
289
+ */
290
+ function findEnclosingObjectOpen(content: string, fromIdx: number): number {
291
+ // Strategy: scan the file from the start to fromIdx, maintaining a
292
+ // stack of open `{` positions, skipping inside strings/comments.
293
+ // The top of the stack at fromIdx is our enclosing open.
294
+ const stack: number[] = [];
295
+ let i = 0;
296
+ const n = Math.min(content.length, fromIdx);
297
+ while (i < n) {
298
+ const ch = content[i];
299
+ const next = content[i + 1];
300
+ if (ch === "/" && next === "/") {
301
+ i += 2;
302
+ while (i < n && content[i] !== "\n") i++;
303
+ continue;
304
+ }
305
+ if (ch === "/" && next === "*") {
306
+ i += 2;
307
+ while (i < n && !(content[i] === "*" && content[i + 1] === "/")) i++;
308
+ i += 2;
309
+ continue;
310
+ }
311
+ if (ch === '"' || ch === "'") {
312
+ const quote = ch;
313
+ i++;
314
+ while (i < n && content[i] !== quote) {
315
+ if (content[i] === "\\") i++;
316
+ i++;
317
+ }
318
+ i++;
319
+ continue;
320
+ }
321
+ if (ch === "`") {
322
+ i++;
323
+ while (i < n && content[i] !== "`") {
324
+ if (content[i] === "\\") {
325
+ i += 2;
326
+ continue;
327
+ }
328
+ if (content[i] === "$" && content[i + 1] === "{") {
329
+ i += 2;
330
+ // Recursively skip until matching `}` of the interpolation.
331
+ let depth = 1;
332
+ while (i < n && depth > 0) {
333
+ if (content[i] === "{") depth++;
334
+ else if (content[i] === "}") depth--;
335
+ if (depth === 0) break;
336
+ i++;
337
+ }
338
+ }
339
+ i++;
340
+ }
341
+ i++;
342
+ continue;
343
+ }
344
+ if (ch === "{") {
345
+ stack.push(i);
346
+ i++;
347
+ continue;
348
+ }
349
+ if (ch === "}") {
350
+ stack.pop();
351
+ i++;
352
+ continue;
353
+ }
354
+ i++;
355
+ }
356
+ return stack.length > 0 ? stack[stack.length - 1] : -1;
357
+ }
358
+
359
+ /**
360
+ * From the `{` at `openIdx`, walk forward to find the matching `}`.
361
+ * Skips strings / template literals / comments.
362
+ */
363
+ function findMatchingClose(content: string, openIdx: number): number {
364
+ let i = openIdx + 1;
365
+ let depth = 1;
366
+ const n = content.length;
367
+ while (i < n) {
368
+ const ch = content[i];
369
+ const next = content[i + 1];
370
+ if (ch === "/" && next === "/") {
371
+ i += 2;
372
+ while (i < n && content[i] !== "\n") i++;
373
+ continue;
374
+ }
375
+ if (ch === "/" && next === "*") {
376
+ i += 2;
377
+ while (i < n && !(content[i] === "*" && content[i + 1] === "/")) i++;
378
+ i += 2;
379
+ continue;
380
+ }
381
+ if (ch === '"' || ch === "'") {
382
+ const quote = ch;
383
+ i++;
384
+ while (i < n && content[i] !== quote) {
385
+ if (content[i] === "\\") i++;
386
+ i++;
387
+ }
388
+ i++;
389
+ continue;
390
+ }
391
+ if (ch === "`") {
392
+ i++;
393
+ while (i < n && content[i] !== "`") {
394
+ if (content[i] === "\\") {
395
+ i += 2;
396
+ continue;
397
+ }
398
+ if (content[i] === "$" && content[i + 1] === "{") {
399
+ i += 2;
400
+ let d = 1;
401
+ while (i < n && d > 0) {
402
+ if (content[i] === "{") d++;
403
+ else if (content[i] === "}") d--;
404
+ if (d === 0) break;
405
+ i++;
406
+ }
407
+ }
408
+ i++;
409
+ }
410
+ i++;
411
+ continue;
412
+ }
413
+ if (ch === "{") {
414
+ depth++;
415
+ i++;
416
+ continue;
417
+ }
418
+ if (ch === "}") {
419
+ depth--;
420
+ if (depth === 0) return i;
421
+ i++;
422
+ continue;
423
+ }
424
+ i++;
425
+ }
426
+ return -1;
427
+ }
428
+
429
+ /**
430
+ * Walk backwards from `openIdx` consuming the contiguous block of
431
+ * `//`-only lines immediately preceding the `{`. Stops at the first
432
+ * blank line, the first non-comment line, or block-comment territory.
433
+ * Returns the absolute index where the leading comment block (plus
434
+ * its indentation) starts — equal to `openIdx` minus its own line's
435
+ * indentation when no comment is attached.
436
+ */
437
+ function findAttachedLeadingComments(content: string, openIdx: number): number {
438
+ // Walk back to start of the line containing `{`.
439
+ let lineStart = openIdx;
440
+ while (lineStart > 0 && content[lineStart - 1] !== "\n") lineStart--;
441
+ // Now climb up: each iteration considers the line ending at
442
+ // `lineStart - 1`. If it is a `//`-only line, include it; else stop.
443
+ let cursor = lineStart;
444
+ while (cursor > 0) {
445
+ const prevLineEnd = cursor - 1; // index of the `\n` separating
446
+ if (prevLineEnd <= 0) break;
447
+ let prevLineStart = prevLineEnd;
448
+ while (prevLineStart > 0 && content[prevLineStart - 1] !== "\n") {
449
+ prevLineStart--;
450
+ }
451
+ const line = content.slice(prevLineStart, prevLineEnd);
452
+ if (/^\s*\/\/.*$/.test(line)) {
453
+ cursor = prevLineStart;
454
+ continue;
455
+ }
456
+ break;
457
+ }
458
+ return cursor;
459
+ }
460
+
461
+ /* ------------------------------------------------------------------ */
462
+ /* Rule 3 — dead `src/runtime.ts` invoke shim */
463
+ /* ------------------------------------------------------------------ */
464
+
465
+ /**
466
+ * Detection covers two shapes of `src/runtime.ts`:
467
+ *
468
+ * 1. Legacy inline proxy (pre-Wave 15-A migration template) — defines
469
+ * `createNestedInvokeProxy` plus `invoke` and `Runtime` constants.
470
+ * The whole 40-50 LOC body duplicates `@decocms/start/sdk`'s `invoke`.
471
+ *
472
+ * 2. Simple re-export shim — the file only re-exports `invoke` /
473
+ * `createNestedInvokeProxy` (no inline proxy body, but also not yet
474
+ * pointing at `@decocms/start/sdk`).
475
+ *
476
+ * Both should be replaced with `import { invoke } from "@decocms/start/sdk"`
477
+ * at every callsite, and the file deleted. The Wave 15-A migration template
478
+ * scaffolds a thin re-export form that's also acceptable (re-exports
479
+ * `invoke` from `@decocms/start/sdk` and rebuilds `Runtime = { invoke }`);
480
+ * we explicitly skip it via the "imports invoke from @decocms/start/sdk
481
+ * AND no inline proxy" check below.
482
+ */
483
+ const INLINE_PROXY_RE =
484
+ /(?:function|const)\s+createNestedInvokeProxy\b|new\s+Proxy\s*\(\s*Object\.assign\s*\(\s*async\s*\(\s*props/;
485
+ const FRAMEWORK_INVOKE_IMPORT_RE =
486
+ /import\s+\{[^}]*\binvoke\b[^}]*\}\s+from\s+['"]@decocms\/start(?:\/sdk)?['"]/;
487
+
488
+ const ALLOWED_RUNTIME_EXPORTS = new Set(["invoke", "createNestedInvokeProxy", "Runtime"]);
489
+
490
+ const ruleDeadRuntimeShim: Rule = {
491
+ id: "dead-runtime-shim",
492
+ title: "Dead src/runtime.ts invoke shim",
493
+ run({ siteDir, fs }: RuleContext): Finding[] {
494
+ const abs = `${siteDir}/src/runtime.ts`;
495
+ if (!fs.exists(abs)) return [];
496
+ const content = fs.readText(abs);
497
+
498
+ const hasInlineProxy = INLINE_PROXY_RE.test(content);
499
+ const reExportsFromFramework = FRAMEWORK_INVOKE_IMPORT_RE.test(content);
500
+ const exports = extractExports(content);
501
+ const onlyKnownInvokeExports =
502
+ exports.length > 0 && exports.every((e) => ALLOWED_RUNTIME_EXPORTS.has(e));
503
+
504
+ // Wave 15-A canonical template: re-exports invoke from @decocms/start/sdk
505
+ // and exposes `Runtime = { invoke }` for legacy callers. No inline proxy
506
+ // body. This is the desired shape — skip.
507
+ if (reExportsFromFramework && !hasInlineProxy) return [];
508
+
509
+ // Site-specific helpers alongside invoke: don't flag — the file has its
510
+ // own purpose beyond shimming. (Old behavior preserved.)
511
+ if (!hasInlineProxy && !onlyKnownInvokeExports) return [];
512
+
513
+ const exportSummary = exports.length > 0 ? exports.join(", ") : "(re-exports only)";
514
+ const flavor = hasInlineProxy ? "inline createNestedInvokeProxy body" : "shim re-exports";
515
+ // Only safe to auto-delete when exports are pure invoke surface; if a
516
+ // legacy file mixes the inline proxy with custom helpers, we still flag
517
+ // it but skip the destructive fix.
518
+ const safeToAutoFix = onlyKnownInvokeExports;
519
+
520
+ return [
521
+ {
522
+ rule: "dead-runtime-shim",
523
+ severity: "info",
524
+ file: "src/runtime.ts",
525
+ message: safeToAutoFix
526
+ ? `${flavor} [${exportSummary}] — replace with @decocms/start/sdk`
527
+ : `${flavor} [${exportSummary}] — manual review: file mixes the runtime proxy with site-specific exports`,
528
+ fix: safeToAutoFix
529
+ ? 'rg -l "from \\"~/runtime\\"" src/ | xargs sed -i \'\' \'s|from "~/runtime"|from "@decocms/start/sdk"|g\' && rm src/runtime.ts'
530
+ : "Move the inline `createNestedInvokeProxy` body to call @decocms/start/sdk's `invoke`; relocate site-specific helpers to a dedicated module before deleting src/runtime.ts",
531
+ meta: {
532
+ hasInlineProxy,
533
+ exports,
534
+ safeToAutoFix,
535
+ },
536
+ },
537
+ ];
538
+ },
539
+ applyFix(ctx, findings, writer): FixAction[] {
540
+ if (findings.length === 0) return [];
541
+ // Honor the per-finding safety gate emitted by run() — never auto-delete
542
+ // a runtime.ts that mixes the proxy with site-specific helpers.
543
+ const safe = findings.every((f) => f.meta?.safeToAutoFix !== false);
544
+ if (!safe) return [];
545
+ const updated = rewriteImportSpec(ctx, writer, "~/runtime", "@decocms/start/sdk");
546
+ writer.deleteFile(`${ctx.siteDir}/src/runtime.ts`);
547
+ return [
548
+ {
549
+ file: "src/runtime.ts",
550
+ kind: "rewrite-imports+delete",
551
+ detail: `rewrote ${updated.length} import(s) → @decocms/start/sdk and deleted src/runtime.ts`,
552
+ },
553
+ ];
554
+ },
555
+ };
556
+
557
+ /* ------------------------------------------------------------------ */
558
+ /* Rule 4 — site-local `withSiteGlobals` workaround */
559
+ /* ------------------------------------------------------------------ */
560
+
561
+ const ruleSiteLocalGlobals: Rule = {
562
+ id: "site-local-with-globals",
563
+ title: "Site-local withSiteGlobals wrapper",
564
+ run({ siteDir, fs }: RuleContext): Finding[] {
565
+ const findings: Finding[] = [];
566
+ const candidates = fs.glob(siteDir, "src/**/withSiteGlobals.ts", SRC_GLOB_EXCLUDES);
567
+ for (const abs of candidates) {
568
+ const content = fs.readText(abs);
569
+ // Heuristic: any local definition (function/const) of withSiteGlobals or
570
+ // cmsRouteWithGlobals indicates a local wrapper, not a re-export from
571
+ // the framework. The framework version would just re-export.
572
+ const definesWrapper =
573
+ /(?:export\s+)?(?:function|const)\s+(?:withSiteGlobals|cmsRouteWithGlobals)\b/.test(
574
+ content,
575
+ );
576
+ const reExportsFromFramework = /from\s+['"]@decocms\/start\/routes['"]/.test(content);
577
+ if (!definesWrapper || reExportsFromFramework) continue;
578
+ const rel = abs.slice(siteDir.length + 1);
579
+ const lineCount = content.split("\n").length;
580
+ findings.push({
581
+ rule: "site-local-with-globals",
582
+ severity: "warning",
583
+ file: rel,
584
+ message: `Local wrapper (~${lineCount} LOC) — framework now exports withSiteGlobals from @decocms/start/routes`,
585
+ fix: "delete the local wrapper and import { withSiteGlobals } from '@decocms/start/routes'",
586
+ meta: { lineCount },
587
+ });
588
+ }
589
+ return findings;
590
+ },
591
+ };
592
+
593
+ /* ------------------------------------------------------------------ */
594
+ /* Rule 5 — `~/lib/vtex-*` shim regression */
595
+ /* ------------------------------------------------------------------ */
596
+
597
+ /**
598
+ * Per-symbol guidance for the canonical replacement of each known
599
+ * shim stub. Used by the `vtex-shim-regression` rule to compose
600
+ * actionable `fix:` messages instead of the generic "Repoint imports"
601
+ * fallback.
602
+ *
603
+ * Kept as data (not code) so the JSON output of the audit can carry
604
+ * structured fix metadata for downstream tooling (CI dashboards,
605
+ * follow-up auto-fix rules, etc.).
606
+ *
607
+ * Categories:
608
+ * - `swap`: 1:1 import swap is safe — caller imports the symbol from
609
+ * `canonical` instead of the local shim. Note may flag a signature
610
+ * gotcha that the caller has to address at the call site.
611
+ * - `refactor`: a call-site rewrite is required (typically because the
612
+ * stub's "bag-based" API has no analog on TanStack Start; the request
613
+ * headers are the new source of truth). The note explains the pattern.
614
+ *
615
+ * Symbols absent from this table fall back to the generic guidance.
616
+ * The rule still flags them — only the `fix:` prose changes.
617
+ */
618
+ export type FixHint =
619
+ | { kind: "swap"; canonical: string; note?: string }
620
+ | { kind: "refactor"; note: string };
621
+
622
+ export const STUB_FIX_HINTS: Record<string, FixHint> = {
623
+ // src/lib/vtex-transform
624
+ toProduct: {
625
+ kind: "swap",
626
+ canonical: "@decocms/apps/vtex/utils/transform",
627
+ note:
628
+ "canonical signature is `toProduct(product, sku, level, options)`; " +
629
+ "1-arg call sites need to expand args first — see skill § 5",
630
+ },
631
+ // src/lib/vtex-segment
632
+ getSegmentFromBag: {
633
+ kind: "refactor",
634
+ note:
635
+ "read cookies via `request.headers.get('cookie')` then call " +
636
+ "`buildSegmentFromCookies()` from '@decocms/apps/vtex/utils/segment'. " +
637
+ "The bag-based lookup mechanism does not exist on TanStack Start.",
638
+ },
639
+ withSegmentCookie: {
640
+ kind: "swap",
641
+ canonical: "@decocms/apps/vtex/utils/segment",
642
+ note:
643
+ "canonical signature is `withSegmentCookie(segment, headers?)`; " +
644
+ "if you currently pass only headers, also pass a segment object",
645
+ },
646
+ // src/lib/vtex-intelligent-search
647
+ getISCookiesFromBag: {
648
+ kind: "refactor",
649
+ note:
650
+ "extract IS cookies from `request.headers.get('cookie')` directly. " +
651
+ "The bag-based lookup mechanism does not exist on TanStack Start.",
652
+ },
653
+ };
654
+
655
+ /**
656
+ * Format a single symbol's fix guidance as a one-liner suitable for
657
+ * the audit's `fix:` field. Returns undefined when the symbol has no
658
+ * specific entry in `STUB_FIX_HINTS`.
659
+ */
660
+ export function formatFixHint(symbol: string): string | undefined {
661
+ const hint = STUB_FIX_HINTS[symbol];
662
+ if (!hint) return undefined;
663
+ if (hint.kind === "swap") {
664
+ const head = `${symbol} → ${hint.canonical} (1:1 import swap)`;
665
+ return hint.note ? `${head} — ${hint.note}` : head;
666
+ }
667
+ return `${symbol} → call-site refactor: ${hint.note}`;
668
+ }
669
+
670
+ /**
671
+ * Compose the `fix:` message for a finding from the per-shim stub map.
672
+ * Splits symbols into "have specific guidance" vs "fall back to generic".
673
+ * Output joins each piece with ` | ` so the message stays one logical
674
+ * line even when there are several stubs.
675
+ */
676
+ export function buildVtexShimFixMessage(stubsBySim: Map<string, string[]>): string {
677
+ const known: string[] = [];
678
+ const unknown: string[] = [];
679
+ for (const syms of stubsBySim.values()) {
680
+ for (const s of syms) {
681
+ const hint = formatFixHint(s);
682
+ if (hint) known.push(hint);
683
+ else unknown.push(s);
684
+ }
685
+ }
686
+ const parts: string[] = [...known];
687
+ if (unknown.length > 0) {
688
+ parts.push(
689
+ `${unknown.join(", ")} → repoint to '@decocms/apps/vtex/...' or 'apps/commerce/utils/...'`,
690
+ );
691
+ }
692
+ return parts.length > 0
693
+ ? parts.join(" | ")
694
+ : "Repoint imports to '@decocms/apps/vtex/...' or 'apps/commerce/utils/...'";
695
+ }
696
+
697
+ /**
698
+ * Build the structured `fixHints` payload for `meta` so JSON consumers
699
+ * (CI dashboards, follow-up tooling) can render their own UI. Each
700
+ * entry is keyed by symbol; symbols without specific guidance are
701
+ * omitted (the prose fallback covers them).
702
+ */
703
+ function fixHintsToMeta(stubsBySim: Map<string, string[]>): Record<string, FixHint> {
704
+ const out: Record<string, FixHint> = {};
705
+ for (const syms of stubsBySim.values()) {
706
+ for (const s of syms) {
707
+ const hint = STUB_FIX_HINTS[s];
708
+ if (hint) out[s] = hint;
709
+ }
710
+ }
711
+ return out;
712
+ }
713
+
714
+ /**
715
+ * Parse one or more ES `import { a, b as c, type d } from "spec"` blocks
716
+ * targeting a specific source spec out of a file. Returns the list of
717
+ * imported names (resolved to their original symbol, ignoring `as`
718
+ * rebinds), with `import type {…}` and inline `type` modifiers stripped
719
+ * — those carry no runtime, so the rule treats them as out-of-scope.
720
+ */
721
+ function namedRuntimeImportsFrom(content: string, spec: string): string[] {
722
+ const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
723
+ // `(type\s+)?` captures the entire-import `import type { … }` form.
724
+ // Per-symbol `type` modifiers inside the braces are stripped below.
725
+ const re = new RegExp(
726
+ `import\\s+(type\\s+)?\\{([^}]+)\\}\\s+from\\s+['\"]${escaped}['\"]`,
727
+ "g",
728
+ );
729
+ const out: string[] = [];
730
+ for (const m of content.matchAll(re)) {
731
+ if (m[1]) continue; // entire import is type-only
732
+ for (const raw of m[2].split(",")) {
733
+ const trimmed = raw.trim();
734
+ if (!trimmed || trimmed.startsWith("type ")) continue;
735
+ // `foo as bar` → `foo` (we want the source symbol, not the local alias).
736
+ const sourceName = trimmed.split(/\s+as\s+/)[0].trim();
737
+ if (sourceName) out.push(sourceName);
738
+ }
739
+ }
740
+ return out;
741
+ }
742
+
743
+ const ruleVtexShimRegression: Rule = {
744
+ id: "vtex-shim-regression",
745
+ title: "Imports from ~/lib/vtex-* (silent stub regression)",
746
+ run({ siteDir, fs }: RuleContext): Finding[] {
747
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
748
+ const findings: Finding[] = [];
749
+
750
+ // Per-shim classification cache. Each shim file is read at most once
751
+ // per audit run, even when imported by dozens of consumers.
752
+ const shimClasses = new Map<string, Map<string, ExportClass>>();
753
+ function classOf(shim: string, symbol: string): ExportClass {
754
+ let map = shimClasses.get(shim);
755
+ if (!map) {
756
+ const abs = `${siteDir}/src/lib/${shim}.ts`;
757
+ map = new Map<string, ExportClass>();
758
+ if (fs.exists(abs)) {
759
+ for (const ce of classifyShimExports(fs.readText(abs))) {
760
+ map.set(ce.name, ce.class);
761
+ }
762
+ }
763
+ shimClasses.set(shim, map);
764
+ }
765
+ // Unknown symbols (file missing or not exported) default to "stub" —
766
+ // pessimistic on purpose. If the symbol can't be found locally, the
767
+ // import is at best dead code, at worst a TS error; either way the
768
+ // user wants visibility into it. Compile phase catches the TS side.
769
+ return map.get(symbol) ?? "stub";
770
+ }
771
+
772
+ // Match the bare `from "~/lib/vtex-X"` to know which shims are touched.
773
+ const fromRe = /from\s+['"]~\/lib\/vtex-([A-Za-z0-9-]+)['"]/g;
774
+ for (const abs of tsFiles) {
775
+ if (abs.includes("/src/lib/")) continue;
776
+ const content = fs.readText(abs);
777
+ const usedShims = new Set<string>(
778
+ [...content.matchAll(fromRe)].map((m) => `vtex-${m[1]}`),
779
+ );
780
+ if (usedShims.size === 0) continue;
781
+
782
+ // Per-file: which shim → which stub symbols are imported.
783
+ const stubsBySim = new Map<string, string[]>();
784
+ for (const shim of usedShims) {
785
+ const symbols = namedRuntimeImportsFrom(content, `~/lib/${shim}`);
786
+ const stubs = symbols.filter((s) => classOf(shim, s) === "stub");
787
+ if (stubs.length > 0) stubsBySim.set(shim, stubs);
788
+ }
789
+ if (stubsBySim.size === 0) continue;
790
+
791
+ const rel = abs.slice(siteDir.length + 1);
792
+ const detail = [...stubsBySim.entries()]
793
+ .map(([s, syms]) => `${s} (${syms.join(", ")})`)
794
+ .join("; ");
795
+ const fixHintsMeta = fixHintsToMeta(stubsBySim);
796
+ findings.push({
797
+ rule: "vtex-shim-regression",
798
+ severity: "warning",
799
+ file: rel,
800
+ message: `Imports stub-only symbols from ${detail} — runtime is silently stubbed`,
801
+ fix: buildVtexShimFixMessage(stubsBySim),
802
+ meta: {
803
+ stubsBySim: Object.fromEntries(stubsBySim),
804
+ ...(Object.keys(fixHintsMeta).length > 0 ? { fixHints: fixHintsMeta } : {}),
805
+ },
806
+ });
807
+ }
808
+ return findings;
809
+ },
810
+ applyFix({ siteDir, fs }, findings, writer): FixAction[] {
811
+ if (findings.length === 0) return [];
812
+ const actions: FixAction[] = [];
813
+
814
+ // Per-file rewrite. Conservative: only swap the import path when EVERY
815
+ // imported symbol from the shim is a `kind: "swap"` hint pointing at
816
+ // the same canonical module. Mixed surfaces (some swap + some
817
+ // refactor, or a real impl + a stub) stay untouched — those need a
818
+ // human looking at call-site signatures.
819
+ for (const finding of findings) {
820
+ const stubsBySim = (finding.meta?.stubsBySim ?? {}) as Record<string, string[]>;
821
+ const abs = `${siteDir}/${finding.file}`;
822
+ if (!fs.exists(abs)) continue;
823
+
824
+ let content = fs.readText(abs);
825
+ let modified = false;
826
+
827
+ for (const [shim, _stubSyms] of Object.entries(stubsBySim)) {
828
+ const oldSpec = `~/lib/${shim}`;
829
+ const importedSymbols = namedRuntimeImportsFrom(content, oldSpec);
830
+ if (importedSymbols.length === 0) continue;
831
+
832
+ // Every imported symbol must be a swap-kind hint AND every hint
833
+ // must point at the same canonical module — otherwise we'd
834
+ // either drop a real impl or split the import across two paths,
835
+ // both of which are unsafe to do mechanically here.
836
+ const hints = importedSymbols.map((s) => STUB_FIX_HINTS[s]);
837
+ const allSwap = hints.every((h) => h && h.kind === "swap");
838
+ if (!allSwap) continue;
839
+ const targets = new Set(
840
+ hints.map((h) => (h as { kind: "swap"; canonical: string }).canonical),
841
+ );
842
+ if (targets.size !== 1) continue;
843
+ const target = [...targets][0];
844
+
845
+ const escaped = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
846
+ const importLineRe = new RegExp(`from\\s+(['"])${escaped}\\1`, "g");
847
+ const next = content.replace(importLineRe, (_m, q) => `from ${q}${target}${q}`);
848
+ if (next !== content) {
849
+ content = next;
850
+ modified = true;
851
+ actions.push({
852
+ file: finding.file,
853
+ kind: "rewrite-imports",
854
+ detail: `${oldSpec} → ${target} (${importedSymbols.join(", ")})`,
855
+ });
856
+ }
857
+ }
858
+
859
+ if (modified) writer.writeText(abs, content);
860
+ }
861
+
862
+ return actions;
863
+ },
864
+ };
865
+
866
+ /* ------------------------------------------------------------------ */
867
+ /* Rule 6 — local `src/types/widgets.ts` shadowing framework */
868
+ /* ------------------------------------------------------------------ */
869
+
870
+ const ruleLocalWidgetsTypes: Rule = {
871
+ id: "local-widgets-types",
872
+ title: "Local src/types/widgets.ts shadowing framework",
873
+ run({ siteDir, fs }: RuleContext): Finding[] {
874
+ const abs = `${siteDir}/src/types/widgets.ts`;
875
+ if (!fs.exists(abs)) return [];
876
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
877
+ const re = /from\s+['"]~\/types\/widgets['"]/;
878
+ let importCount = 0;
879
+ for (const f of tsFiles) {
880
+ if (f === abs) continue;
881
+ if (re.test(fs.readText(f))) importCount++;
882
+ }
883
+ return [
884
+ {
885
+ rule: "local-widgets-types",
886
+ severity: "info",
887
+ file: "src/types/widgets.ts",
888
+ message: `Local file shadows @decocms/start/types/widgets (used in ${importCount} place(s))`,
889
+ fix: 'rewrite imports to "@decocms/start/types/widgets" and rm src/types/widgets.ts',
890
+ meta: { importCount },
891
+ },
892
+ ];
893
+ },
894
+ applyFix(ctx, findings, writer): FixAction[] {
895
+ if (findings.length === 0) return [];
896
+ const updated = rewriteImportSpec(
897
+ ctx,
898
+ writer,
899
+ "~/types/widgets",
900
+ "@decocms/start/types/widgets",
901
+ );
902
+ writer.deleteFile(`${ctx.siteDir}/src/types/widgets.ts`);
903
+ return [
904
+ {
905
+ file: "src/types/widgets.ts",
906
+ kind: "rewrite-imports+delete",
907
+ detail: `rewrote ${updated.length} import(s) → @decocms/start/types/widgets and deleted src/types/widgets.ts`,
908
+ },
909
+ ];
910
+ },
911
+ };
912
+
913
+ /* ------------------------------------------------------------------ */
914
+ /* Rule 7 — orphan "TODO: framework" comments */
915
+ /* ------------------------------------------------------------------ */
916
+
917
+ const ruleFrameworkTodos: Rule = {
918
+ id: "framework-todos",
919
+ title: "Orphan TODOs deferring to the framework",
920
+ run({ siteDir, fs }: RuleContext): Finding[] {
921
+ const tsFiles = [
922
+ ...fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES),
923
+ ...fs.glob(siteDir, "vite.config.ts", SRC_GLOB_EXCLUDES),
924
+ ];
925
+ const findings: Finding[] = [];
926
+ const re = /TODO[^\n]*?(?:deco|framework|move into)/i;
927
+ for (const abs of tsFiles) {
928
+ const content = fs.readText(abs);
929
+ const lines = content.split("\n");
930
+ for (let i = 0; i < lines.length; i++) {
931
+ if (!re.test(lines[i])) continue;
932
+ const rel = abs.slice(siteDir.length + 1);
933
+ findings.push({
934
+ rule: "framework-todos",
935
+ severity: "info",
936
+ file: `${rel}:${i + 1}`,
937
+ message: lines[i].trim().slice(0, 120),
938
+ fix: "Triage: shipped → adopt; deferred → file issue; obsolete → delete",
939
+ });
940
+ }
941
+ }
942
+ return findings;
943
+ },
944
+ };
945
+
946
+ /* ------------------------------------------------------------------ */
947
+ /* Rule — `local-framework-duplicate` — site-local copy of fwk code */
948
+ /* ------------------------------------------------------------------ */
949
+
950
+ /**
951
+ * Registry of files we expect sites to NOT carry locally because the
952
+ * canonical implementation already lives in `@decocms/start` (or a
953
+ * sibling apps package).
954
+ *
955
+ * Two flavours:
956
+ * - `safeToAutoFix: true` — site file is a behaviour-equivalent dup
957
+ * of the framework export. `--fix` rewrites every `from "~/<path>"`
958
+ * importer to `from "<canonicalImport>"` and deletes the file.
959
+ * - `safeToAutoFix: false` — site file *overlaps* with framework code
960
+ * but isn't a clean drop-in (different typing, partial coverage,
961
+ * stricter behaviour, etc.). The rule still flags it so the entry
962
+ * surfaces in audits, but never deletes — the `reason` explains why
963
+ * a human has to make the call.
964
+ *
965
+ * `contentSignature` regexes ALL must match the site file's contents
966
+ * before the rule fires. They are deliberately specific enough to
967
+ * avoid catching forks that happen to share a filename but have
968
+ * diverged.
969
+ */
970
+ interface FrameworkDuplicate {
971
+ /** Stable id surfaced in finding meta and CLI/JSON output. */
972
+ id: string;
973
+ /** Site-relative path of the duplicated file (e.g. "src/sdk/clx.ts"). */
974
+ sitePath: string;
975
+ /** Canonical import to rewrite to. */
976
+ canonicalImport: string;
977
+ /**
978
+ * Heuristic content fingerprint. The site file must match every
979
+ * regex for the rule to consider it the framework dup.
980
+ */
981
+ contentSignature: RegExp[];
982
+ /**
983
+ * When true, the rule's `applyFix` will rewrite all importers and
984
+ * delete the file. When false, the rule emits a warning only —
985
+ * `reason` explains the manual judgement required.
986
+ */
987
+ safeToAutoFix: boolean;
988
+ /**
989
+ * Required when `safeToAutoFix: false`. Surfaces in the finding's
990
+ * `fix:` field so users see *why* the auto-fix is gated.
991
+ */
992
+ reason?: string;
993
+ /**
994
+ * Human-readable one-liner shown in the finding message and used
995
+ * to compose the `fix:` hint when auto-fixable.
996
+ */
997
+ description: string;
998
+ }
999
+
1000
+ /**
1001
+ * Add an entry here when:
1002
+ * - 1+ migrated sites carry their own copy of code that already
1003
+ * exists in `@decocms/start` (or a sibling apps package), AND
1004
+ * - the canonical version is at least feature-equivalent.
1005
+ *
1006
+ * Per D4 in the migration tooling policy, the framework promotion
1007
+ * itself happens at 3+ sites — but once promoted, this registry is
1008
+ * how we *enforce* convergence on the remaining sites.
1009
+ */
1010
+ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [
1011
+ {
1012
+ id: "clx",
1013
+ sitePath: "src/sdk/clx.ts",
1014
+ canonicalImport: "@decocms/start/sdk/clx",
1015
+ contentSignature: [
1016
+ /export\s+const\s+clx\s*=/,
1017
+ /args\.filter\(Boolean\)\.join/,
1018
+ ],
1019
+ safeToAutoFix: true,
1020
+ description: "src/sdk/clx.ts duplicates @decocms/start/sdk/clx",
1021
+ },
1022
+ {
1023
+ id: "use-send-event",
1024
+ sitePath: "src/sdk/useSendEvent.ts",
1025
+ canonicalImport: "@decocms/start/sdk/analytics",
1026
+ contentSignature: [
1027
+ /export\s+(?:const|function)\s+useSendEvent/,
1028
+ /data-event/,
1029
+ /encodeURIComponent/,
1030
+ ],
1031
+ safeToAutoFix: false,
1032
+ reason:
1033
+ "site copy uses a typed AnalyticsEvent generic; the framework export is permissive. " +
1034
+ "Replacing 1:1 weakens type-safety. Either widen the framework export (preferred), or " +
1035
+ "rewrite call sites to drop the generic. Manual review required.",
1036
+ description:
1037
+ "src/sdk/useSendEvent.ts overlaps with @decocms/start/sdk/analytics → useSendEvent",
1038
+ },
1039
+ {
1040
+ id: "location-matcher",
1041
+ sitePath: "src/matchers/location.ts",
1042
+ canonicalImport: "@decocms/start/matchers/builtins",
1043
+ contentSignature: [
1044
+ /registerMatcher\(\s*['"]website\/matchers\/location\.ts['"]/,
1045
+ /__cf_geo/,
1046
+ ],
1047
+ safeToAutoFix: false,
1048
+ reason:
1049
+ "framework's registerBuiltinMatchers() ships a richer location matcher (request.cf + " +
1050
+ "geo cookies + headers + 10 sibling matchers). Adopting it changes behaviour: " +
1051
+ "verify country-name lookup parity (resolveCountryCode vs site's inline table) and " +
1052
+ "swap setup.ts's customMatchers entry to call registerBuiltinMatchers().",
1053
+ description:
1054
+ "src/matchers/location.ts overlaps with @decocms/start/matchers/builtins → registerBuiltinMatchers()",
1055
+ },
1056
+ {
1057
+ id: "url-relative",
1058
+ sitePath: "src/sdk/url.ts",
1059
+ canonicalImport: "@decocms/apps/commerce/sdk/url",
1060
+ // Fingerprint: site fork carries a positional `removeIdSku?: boolean`
1061
+ // flag + hardcoded VTEX-specific keys (`idsku`, `skuId`). Canonical
1062
+ // apps export uses an options object — `{ stripSearchParams: string[] }`
1063
+ // — which is generic and platform-agnostic.
1064
+ contentSignature: [
1065
+ /export\s+const\s+relative\s*=/,
1066
+ /removeIdSku\s*\?\s*:\s*boolean/,
1067
+ /['"](idsku|skuId)['"]/,
1068
+ ],
1069
+ safeToAutoFix: false,
1070
+ reason:
1071
+ "rewrite imports to '@decocms/apps/commerce/sdk/url'. " +
1072
+ "Each call site using the boolean form `relative(url, true)` becomes " +
1073
+ "`relative(url, { stripSearchParams: [\"idsku\", \"skuId\"] })`. " +
1074
+ "1-arg calls are unchanged. Then delete src/sdk/url.ts. " +
1075
+ "Auto-fix is gated because the call-site rewrite needs JSX/TS-aware " +
1076
+ "transformation (positional bool → options object), not pure import " +
1077
+ "rewrite.",
1078
+ description:
1079
+ "src/sdk/url.ts overlaps with @decocms/apps/commerce/sdk/url → relative() (extended in @decocms/apps@1.9+)",
1080
+ },
1081
+ {
1082
+ id: "use-suggestions",
1083
+ sitePath: "src/sdk/useSuggestions.ts",
1084
+ canonicalImport: "@decocms/start/sdk/useSuggestions",
1085
+ // Fingerprint: hand-rolled hook with the module-level signal +
1086
+ // serial-queue + latestQuery cancel pattern. Both casaevideo and
1087
+ // baggagio independently invented this exact shape. Sites that
1088
+ // already adopted `createUseSuggestions(…)` factory calls won't
1089
+ // match this signature.
1090
+ contentSignature: [
1091
+ /export\s+const\s+useSuggestions\s*=/,
1092
+ /\/deco\/invoke\//,
1093
+ /latestQuery/,
1094
+ ],
1095
+ safeToAutoFix: false,
1096
+ reason:
1097
+ "rewrite to a 5-line factory shim: " +
1098
+ "`export const { useSuggestions } = createUseSuggestions<MySuggestion>({ onError });` " +
1099
+ "where MySuggestion is the site's payload type. The call sites " +
1100
+ "(`const { setQuery, payload, loading } = useSuggestions(loader)`) are unchanged. " +
1101
+ "Then delete src/sdk/useSuggestions.ts. Auto-fix is gated because " +
1102
+ "the per-site type parameter and onError wiring need site-specific " +
1103
+ "decisions. See references/platform-hooks-factories.md § useSuggestions.",
1104
+ description:
1105
+ "src/sdk/useSuggestions.ts duplicates @decocms/start/sdk/useSuggestions → createUseSuggestions() (added in @decocms/start@2.25+)",
1106
+ },
1107
+ ];
1108
+
1109
+ const ruleLocalFrameworkDuplicate: Rule = {
1110
+ id: "local-framework-duplicate",
1111
+ title: "Site-local copy of framework code",
1112
+ run({ siteDir, fs }: RuleContext): Finding[] {
1113
+ const findings: Finding[] = [];
1114
+ for (const dup of FRAMEWORK_DUPLICATES) {
1115
+ const abs = `${siteDir}/${dup.sitePath}`;
1116
+ if (!fs.exists(abs)) continue;
1117
+ const content = fs.readText(abs);
1118
+ const matchesAll = dup.contentSignature.every((re) => re.test(content));
1119
+ if (!matchesAll) continue;
1120
+
1121
+ const fixMessage = dup.safeToAutoFix
1122
+ ? `Auto-fixable: rewrite \`from "~/${stripExt(dup.sitePath.replace(/^src\//, ""))}"\` → \`from "${dup.canonicalImport}"\` and delete ${dup.sitePath}.`
1123
+ : dup.reason ?? "Manual review required.";
1124
+
1125
+ findings.push({
1126
+ rule: "local-framework-duplicate",
1127
+ severity: "warning",
1128
+ file: dup.sitePath,
1129
+ message: `${dup.description}${dup.safeToAutoFix ? " (pure dup)" : " (partial overlap)"}`,
1130
+ fix: fixMessage,
1131
+ meta: {
1132
+ id: dup.id,
1133
+ canonicalImport: dup.canonicalImport,
1134
+ safeToAutoFix: dup.safeToAutoFix,
1135
+ ...(dup.reason ? { reason: dup.reason } : {}),
1136
+ },
1137
+ });
1138
+ }
1139
+ return findings;
1140
+ },
1141
+ applyFix(ctx, findings, writer): FixAction[] {
1142
+ const actions: FixAction[] = [];
1143
+ for (const f of findings) {
1144
+ const id = f.meta?.id as string | undefined;
1145
+ const safe = f.meta?.safeToAutoFix === true;
1146
+ if (!safe || !id) continue;
1147
+ const dup = FRAMEWORK_DUPLICATES.find((d) => d.id === id);
1148
+ if (!dup) continue;
1149
+
1150
+ const siteImportSpec = `~/${stripExt(dup.sitePath.replace(/^src\//, ""))}`;
1151
+ const updated = rewriteImportSpec(
1152
+ ctx,
1153
+ writer,
1154
+ siteImportSpec,
1155
+ dup.canonicalImport,
1156
+ );
1157
+ writer.deleteFile(`${ctx.siteDir}/${dup.sitePath}`);
1158
+ actions.push({
1159
+ file: dup.sitePath,
1160
+ kind: "rewrite-imports+delete",
1161
+ detail: `rewrote ${updated.length} import(s) "${siteImportSpec}" → "${dup.canonicalImport}" and deleted ${dup.sitePath}`,
1162
+ });
1163
+ }
1164
+ return actions;
1165
+ },
1166
+ };
1167
+
1168
+ function stripExt(path: string): string {
1169
+ return path.replace(/\.(ts|tsx|js|jsx|mjs)$/, "");
1170
+ }
1171
+
1172
+ /* ------------------------------------------------------------------ */
1173
+ /* Rule 8 — `htmx-residue` — leftover hx-* attrs in migrated src/ */
1174
+ /* ------------------------------------------------------------------ */
1175
+
1176
+ /**
1177
+ * Per D2 in the migration tooling policy, every `hx-*` attribute is
1178
+ * rewritten on migration; nothing in `@decocms/start` ships an htmx
1179
+ * runtime. This rule is the verification gate: a migrated site is
1180
+ * "rewrite-complete" when there are zero `hx-*` attributes left in
1181
+ * `src/`.
1182
+ *
1183
+ * Implementation reuses the htmx analyzer (`analyzeFile` from
1184
+ * `analyzers/htmx-analyze.ts`) so categorisation and the JSX walker
1185
+ * stay consistent with the standalone `deco-htmx-analyze` CLI. The
1186
+ * rule restricts to `src/**` (the migrated React tree) and excludes
1187
+ * test files — tests are allowed to mention `hx-*` for fixtures or
1188
+ * regression checks.
1189
+ *
1190
+ * Severity is `warning`, so `--strict` exits 2 on any finding. The
1191
+ * rule is intentionally detect-only: rewrites are non-mechanical
1192
+ * (state machine + sub-route + mutation choices vary per call site)
1193
+ * — the
1194
+ * `references/htmx-rewrite.md` skill is the playbook.
1195
+ */
1196
+ const ruleHtmxResidue: Rule = {
1197
+ id: "htmx-residue",
1198
+ title: "HTMX residue in migrated src/",
1199
+ run({ siteDir, fs }: RuleContext): Finding[] {
1200
+ const findings: Finding[] = [];
1201
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1202
+ for (const abs of tsFiles) {
1203
+ const rel = abs.slice(siteDir.length + 1);
1204
+ // Skip test files — tests legitimately reference hx-* in fixtures
1205
+ // or regression checks. Same exclusion shape as vitest's default.
1206
+ if (/\.(test|spec)\.(ts|tsx)$/.test(rel)) continue;
1207
+ if (rel.startsWith("src/__tests__/") || rel.includes("/__tests__/")) {
1208
+ continue;
1209
+ }
1210
+ const content = fs.readText(abs);
1211
+ const occurrences = analyzeHtmxFile(rel, content);
1212
+ if (occurrences.length === 0) continue;
1213
+
1214
+ // Aggregate per-file: total + categories present.
1215
+ const byCat = new Map<string, number>();
1216
+ for (const occ of occurrences) {
1217
+ byCat.set(occ.category, (byCat.get(occ.category) ?? 0) + 1);
1218
+ }
1219
+ const catSummary = [...byCat.entries()]
1220
+ .sort(([a], [b]) => a.localeCompare(b))
1221
+ .map(([cat, n]) => `${cat}=${n}`)
1222
+ .join(", ");
1223
+ const firstLine = occurrences[0].line;
1224
+
1225
+ findings.push({
1226
+ rule: "htmx-residue",
1227
+ severity: "warning",
1228
+ file: `${rel}:${firstLine}`,
1229
+ message: `${occurrences.length} hx-* element(s) — ${catSummary}`,
1230
+ fix: `Rewrite per .agents/skills/deco-to-tanstack-migration/references/htmx-rewrite.md (run \`deco-htmx-analyze\` for the per-category breakdown)`,
1231
+ meta: {
1232
+ total: occurrences.length,
1233
+ byCategory: Object.fromEntries(byCat),
1234
+ firstLine,
1235
+ },
1236
+ });
1237
+ }
1238
+ return findings;
1239
+ },
1240
+ };
1241
+
1242
+ /* ------------------------------------------------------------------ */
1243
+ /* Rule 9 — `lockfile-multiple` — multiple lockfiles tracked */
1244
+ /* ------------------------------------------------------------------ */
1245
+
1246
+ /**
1247
+ * Per the fleet-wide bun-canonical decision, every storefront commits
1248
+ * exactly one lockfile: `bun.lock`. This rule fires when any of the
1249
+ * non-bun lockfiles co-exist with bun.lock — that's the exact pattern
1250
+ * that broke Cloudflare Workers Builds with `lockfile had changes, but
1251
+ * lockfile is frozen` (the dual-lockfile drift).
1252
+ *
1253
+ * The `--fix` deletes the offending non-bun lockfiles. Adding the
1254
+ * `.gitignore` bans is a separate concern handled by `packageManager-missing`
1255
+ * (which also nudges the site to add the bans), so this rule stays
1256
+ * focused.
1257
+ */
1258
+ const NON_BUN_LOCKFILES = ["package-lock.json", "yarn.lock", "pnpm-lock.yaml"];
1259
+
1260
+ const ruleLockfileMultiple: Rule = {
1261
+ id: "lockfile-multiple",
1262
+ title: "Multiple lockfiles tracked alongside bun.lock",
1263
+ run({ siteDir, fs }: RuleContext): Finding[] {
1264
+ const bunLock = `${siteDir}/bun.lock`;
1265
+ if (!fs.exists(bunLock)) return [];
1266
+ const findings: Finding[] = [];
1267
+ for (const name of NON_BUN_LOCKFILES) {
1268
+ const abs = `${siteDir}/${name}`;
1269
+ if (!fs.exists(abs)) continue;
1270
+ findings.push({
1271
+ rule: "lockfile-multiple",
1272
+ severity: "warning",
1273
+ file: name,
1274
+ message: `${name} co-exists with bun.lock — Cloudflare Workers Builds picks bun and the other will silently drift`,
1275
+ fix: `rm ${name} (bun.lock is the canonical lockfile)`,
1276
+ meta: { lockfile: name },
1277
+ });
1278
+ }
1279
+ return findings;
1280
+ },
1281
+ applyFix({ siteDir }, findings, writer): FixAction[] {
1282
+ const actions: FixAction[] = [];
1283
+ for (const f of findings) {
1284
+ writer.deleteFile(`${siteDir}/${f.file}`);
1285
+ actions.push({
1286
+ file: f.file,
1287
+ kind: "delete",
1288
+ detail: `deleted (bun.lock is canonical)`,
1289
+ });
1290
+ }
1291
+ return actions;
1292
+ },
1293
+ };
1294
+
1295
+ /* ------------------------------------------------------------------ */
1296
+ /* Rule 10 — `lockfile-missing` — package.json without bun.lock */
1297
+ /* ------------------------------------------------------------------ */
1298
+
1299
+ /**
1300
+ * A storefront with `package.json` but no committed lockfile cannot
1301
+ * run `bun install --frozen-lockfile` in CI — Cloudflare Workers
1302
+ * Builds either falls back to a non-reproducible install or fails
1303
+ * outright depending on the build image. Detect-only because the
1304
+ * fix (`bun install`) requires network access and a working bun
1305
+ * toolchain that the audit shouldn't shell out to from inside its
1306
+ * own runner.
1307
+ */
1308
+ const ruleLockfileMissing: Rule = {
1309
+ id: "lockfile-missing",
1310
+ title: "Lockfile missing",
1311
+ run({ siteDir, fs }: RuleContext): Finding[] {
1312
+ const pkg = `${siteDir}/package.json`;
1313
+ if (!fs.exists(pkg)) return [];
1314
+ const bunLock = `${siteDir}/bun.lock`;
1315
+ if (fs.exists(bunLock)) return [];
1316
+ // If a non-bun lockfile is present, `lockfile-multiple` doesn't
1317
+ // apply but the site is still mid-migration to bun. We flag the
1318
+ // missing bun.lock here so the operator runs `bun install` to
1319
+ // produce the canonical one.
1320
+ return [
1321
+ {
1322
+ rule: "lockfile-missing",
1323
+ severity: "warning",
1324
+ file: "bun.lock",
1325
+ message: `No bun.lock committed — frozen installs cannot run on CF Workers Builds`,
1326
+ fix: `Run \`bun install\` and commit the resulting bun.lock`,
1327
+ meta: {},
1328
+ },
1329
+ ];
1330
+ },
1331
+ };
1332
+
1333
+ /* ------------------------------------------------------------------ */
1334
+ /* Rule 11 — `lockfile-drift` — bun.lock out of sync with package.json */
1335
+ /* ------------------------------------------------------------------ */
1336
+
1337
+ /**
1338
+ * Detects the head-on case behind the `lockfile had changes, but
1339
+ * lockfile is frozen` Workers Builds error: a direct dependency in
1340
+ * `package.json` has no version in `bun.lock` that satisfies the
1341
+ * declared range.
1342
+ *
1343
+ * Coverage is intentionally pragmatic — we recognise the four most
1344
+ * common range shapes (`^a.b.c`, `~a.b.c`, plain `a.b.c`, and the
1345
+ * sentinels `*` / `latest` / `next` / git/github specs which we skip).
1346
+ * Everything else falls back to "present in lockfile?", treating
1347
+ * unknown ranges as satisfied as long as the name appears at all.
1348
+ * The goal is high signal on the storefront fleet's actual failure
1349
+ * mode; full npm-semver fidelity is out of scope for this rule.
1350
+ *
1351
+ * Detect-only: regenerating bun.lock requires running `bun install`,
1352
+ * which the audit script doesn't do (would need network + bun in
1353
+ * PATH). Operators run it manually and re-run the audit.
1354
+ */
1355
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-[\w.+-]+)?$/;
1356
+
1357
+ function parseSemver(v: string): [number, number, number] | null {
1358
+ const m = SEMVER_RE.exec(v.trim());
1359
+ if (!m) return null;
1360
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
1361
+ }
1362
+
1363
+ function gte(a: [number, number, number], b: [number, number, number]): boolean {
1364
+ if (a[0] !== b[0]) return a[0] > b[0];
1365
+ if (a[1] !== b[1]) return a[1] > b[1];
1366
+ return a[2] >= b[2];
1367
+ }
1368
+
1369
+ function lt(a: [number, number, number], b: [number, number, number]): boolean {
1370
+ if (a[0] !== b[0]) return a[0] < b[0];
1371
+ if (a[1] !== b[1]) return a[1] < b[1];
1372
+ return a[2] < b[2];
1373
+ }
1374
+
1375
+ /**
1376
+ * Minimal `range satisfies version` check. Returns:
1377
+ * - `true` when the range is satisfied,
1378
+ * - `false` when it is definitely violated,
1379
+ * - `null` when we don't recognise the range shape and the caller
1380
+ * should fall back to a presence check.
1381
+ */
1382
+ function satisfiesRange(version: string, range: string): boolean | null {
1383
+ const trimmed = range.trim();
1384
+ // Sentinels and non-numeric specs we don't try to evaluate.
1385
+ if (
1386
+ trimmed === "*" ||
1387
+ trimmed === "latest" ||
1388
+ trimmed === "next" ||
1389
+ trimmed.startsWith("workspace:") ||
1390
+ trimmed.startsWith("file:") ||
1391
+ trimmed.startsWith("link:") ||
1392
+ trimmed.startsWith("git+") ||
1393
+ trimmed.startsWith("github:") ||
1394
+ trimmed.includes("://")
1395
+ ) {
1396
+ return null;
1397
+ }
1398
+ const ver = parseSemver(version);
1399
+ if (!ver) return null;
1400
+ // Caret: ^a.b.c → >=a.b.c <(a+1).0.0 when a > 0
1401
+ // ^0.b.c → >=0.b.c <0.(b+1).0 when a == 0 && b > 0
1402
+ // ^0.0.c → exactly 0.0.c
1403
+ if (trimmed.startsWith("^")) {
1404
+ const base = parseSemver(trimmed.slice(1));
1405
+ if (!base) return null;
1406
+ if (!gte(ver, base)) return false;
1407
+ let upper: [number, number, number];
1408
+ if (base[0] > 0) upper = [base[0] + 1, 0, 0];
1409
+ else if (base[1] > 0) upper = [0, base[1] + 1, 0];
1410
+ else upper = [0, 0, base[2] + 1];
1411
+ return lt(ver, upper);
1412
+ }
1413
+ // Tilde: ~a.b.c → >=a.b.c <a.(b+1).0
1414
+ if (trimmed.startsWith("~")) {
1415
+ const base = parseSemver(trimmed.slice(1));
1416
+ if (!base) return null;
1417
+ return gte(ver, base) && lt(ver, [base[0], base[1] + 1, 0]);
1418
+ }
1419
+ // Plain pin: a.b.c → exact match.
1420
+ const exact = parseSemver(trimmed);
1421
+ if (exact) return exact[0] === ver[0] && exact[1] === ver[1] && exact[2] === ver[2];
1422
+ return null;
1423
+ }
1424
+
1425
+ /**
1426
+ * Pull every `"<name>@<version>"` token out of bun.lock for a given
1427
+ * package name. Bun's lockfile format embeds the version inside the
1428
+ * package descriptor array, e.g.:
1429
+ *
1430
+ * "@decocms/start": ["@decocms/start@2.1.1", ...]
1431
+ *
1432
+ * We scan all such occurrences (a single package may appear multiple
1433
+ * times if the dep tree pulled different versions).
1434
+ */
1435
+ function lockfileVersionsOf(lockfile: string, name: string): string[] {
1436
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1437
+ const re = new RegExp(`['"]${escaped}@([^'"\\s]+)['"]`, "g");
1438
+ const seen = new Set<string>();
1439
+ for (const m of lockfile.matchAll(re)) {
1440
+ seen.add(m[1]);
1441
+ }
1442
+ return [...seen];
1443
+ }
1444
+
1445
+ const ruleLockfileDrift: Rule = {
1446
+ id: "lockfile-drift",
1447
+ title: "bun.lock drifted vs package.json direct dependencies",
1448
+ run({ siteDir, fs }: RuleContext): Finding[] {
1449
+ const pkgPath = `${siteDir}/package.json`;
1450
+ const lockPath = `${siteDir}/bun.lock`;
1451
+ if (!fs.exists(pkgPath) || !fs.exists(lockPath)) return [];
1452
+ let parsed: unknown;
1453
+ try {
1454
+ parsed = JSON.parse(fs.readText(pkgPath));
1455
+ } catch {
1456
+ return [];
1457
+ }
1458
+ if (typeof parsed !== "object" || parsed === null) return [];
1459
+ const pkg = parsed as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
1460
+ const lockText = fs.readText(lockPath);
1461
+ const drifted: { name: string; range: string; locked: string[] }[] = [];
1462
+ const buckets: Record<string, string>[] = [pkg.dependencies ?? {}, pkg.devDependencies ?? {}];
1463
+ for (const bucket of buckets) {
1464
+ for (const [name, range] of Object.entries(bucket)) {
1465
+ const versions = lockfileVersionsOf(lockText, name);
1466
+ if (versions.length === 0) {
1467
+ drifted.push({ name, range, locked: [] });
1468
+ continue;
1469
+ }
1470
+ // If at least one locked version satisfies the range, we're fine.
1471
+ // For unknown range shapes (return null), treat presence as
1472
+ // satisfaction — pragmatic, see rule docstring.
1473
+ let satisfied = false;
1474
+ for (const v of versions) {
1475
+ const result = satisfiesRange(v, range);
1476
+ if (result === true || result === null) {
1477
+ satisfied = true;
1478
+ break;
1479
+ }
1480
+ }
1481
+ if (!satisfied) drifted.push({ name, range, locked: versions });
1482
+ }
1483
+ }
1484
+ if (drifted.length === 0) return [];
1485
+ return [
1486
+ {
1487
+ rule: "lockfile-drift",
1488
+ severity: "warning",
1489
+ file: "bun.lock",
1490
+ message: `${drifted.length} direct dep(s) not satisfied by bun.lock — frozen install will fail`,
1491
+ fix: `Run \`bun install\` to refresh bun.lock, then commit. Drift: ${drifted
1492
+ .slice(0, 5)
1493
+ .map((d) => `${d.name} ${d.range} (locked: ${d.locked.length > 0 ? d.locked.join(", ") : "none"})`)
1494
+ .join("; ")}${drifted.length > 5 ? `; +${drifted.length - 5} more` : ""}`,
1495
+ meta: { drifted },
1496
+ },
1497
+ ];
1498
+ },
1499
+ };
1500
+
1501
+ /* ------------------------------------------------------------------ */
1502
+ /* Rule 12 — `package-manager-missing` — no packageManager field */
1503
+ /* ------------------------------------------------------------------ */
1504
+
1505
+ /**
1506
+ * Without a `packageManager` field in package.json, neither developers
1507
+ * nor CI are forced to agree on a PM. Anyone running `npm install`
1508
+ * or `yarn` produces an alternate lockfile that risks drifting from
1509
+ * `bun.lock` and breaking Workers Builds. The fleet-wide canonical
1510
+ * value is `bun@<CANONICAL_BUN_VERSION>`; bumping that constant here
1511
+ * propagates to all `--fix` runs.
1512
+ */
1513
+ const CANONICAL_PACKAGE_MANAGER = "bun@1.3.5";
1514
+
1515
+ const rulePackageManagerMissing: Rule = {
1516
+ id: "package-manager-missing",
1517
+ title: "Missing packageManager field in package.json",
1518
+ run({ siteDir, fs }: RuleContext): Finding[] {
1519
+ const pkgPath = `${siteDir}/package.json`;
1520
+ if (!fs.exists(pkgPath)) return [];
1521
+ let parsed: unknown;
1522
+ try {
1523
+ parsed = JSON.parse(fs.readText(pkgPath));
1524
+ } catch {
1525
+ return [];
1526
+ }
1527
+ if (typeof parsed !== "object" || parsed === null) return [];
1528
+ const pkg = parsed as { packageManager?: string };
1529
+ if (typeof pkg.packageManager === "string" && pkg.packageManager.length > 0) {
1530
+ return [];
1531
+ }
1532
+ return [
1533
+ {
1534
+ rule: "package-manager-missing",
1535
+ severity: "info",
1536
+ file: "package.json",
1537
+ message: `Missing "packageManager" field — contributors and CF Workers Builds may pick different PMs`,
1538
+ fix: `Set "packageManager": "${CANONICAL_PACKAGE_MANAGER}" in package.json`,
1539
+ meta: { canonical: CANONICAL_PACKAGE_MANAGER },
1540
+ },
1541
+ ];
1542
+ },
1543
+ applyFix({ siteDir, fs }, findings, writer): FixAction[] {
1544
+ if (findings.length === 0) return [];
1545
+ const pkgPath = `${siteDir}/package.json`;
1546
+ if (!fs.exists(pkgPath)) return [];
1547
+ const content = fs.readText(pkgPath);
1548
+ let parsed: Record<string, unknown>;
1549
+ try {
1550
+ parsed = JSON.parse(content) as Record<string, unknown>;
1551
+ } catch {
1552
+ return [];
1553
+ }
1554
+ if (typeof parsed.packageManager === "string" && parsed.packageManager.length > 0) {
1555
+ return [];
1556
+ }
1557
+ // Insert `packageManager` directly after `license` to match the
1558
+ // convention used across the storefront fleet. Falls back to the
1559
+ // end of the object when `license` is absent.
1560
+ const ordered: Record<string, unknown> = {};
1561
+ let inserted = false;
1562
+ for (const [k, v] of Object.entries(parsed)) {
1563
+ ordered[k] = v;
1564
+ if (!inserted && k === "license") {
1565
+ ordered.packageManager = CANONICAL_PACKAGE_MANAGER;
1566
+ inserted = true;
1567
+ }
1568
+ }
1569
+ if (!inserted) ordered.packageManager = CANONICAL_PACKAGE_MANAGER;
1570
+ writer.writeText(pkgPath, `${JSON.stringify(ordered, null, 2)}\n`);
1571
+ return [
1572
+ {
1573
+ file: "package.json",
1574
+ kind: "edit",
1575
+ detail: `set packageManager: "${CANONICAL_PACKAGE_MANAGER}"`,
1576
+ },
1577
+ ];
1578
+ },
1579
+ };
1580
+
1581
+ /* ------------------------------------------------------------------ */
1582
+ /* Rule N — `vtex-proxy-handler-missing` — worker-entry without proxy */
1583
+ /* ------------------------------------------------------------------ */
1584
+
1585
+ /**
1586
+ * Every VTEX storefront on @decocms/start needs a reverse proxy for
1587
+ * `/checkout/*`, `/account/*`, `/api/*`, `/files/*`, etc. — those paths
1588
+ * must hit the VTEX origin (not TanStack Start) so the user lands on
1589
+ * the real checkout UI carrying their VTEX session cookies.
1590
+ *
1591
+ * The canonical wiring lives in `src/worker-entry.ts` via
1592
+ * `createDecoWorkerEntry(..., { proxyHandler })`, where the handler
1593
+ * calls `shouldProxyToVtex(url.pathname)` + a `createVtexCheckoutProxy`
1594
+ * instance. The migration scaffold (`scripts/migrate/templates/server-entry.ts`)
1595
+ * emits this by default for VTEX sites, but two regressions sneak it out:
1596
+ *
1597
+ * 1. A site migrated by a pre-scaffold version of the script (e.g.
1598
+ * before the VTEX worker-entry template existed).
1599
+ * 2. Someone refactors `worker-entry.ts` and drops the proxy block.
1600
+ *
1601
+ * Without the proxy, add-to-cart still "succeeds" (the action runs
1602
+ * server-side via TanStack RPC), but clicking "Finalizar" navigates
1603
+ * to `/checkout` on the storefront — which returns the SPA shell —
1604
+ * and the user never reaches VTEX checkout. The VTEX-side orderForm
1605
+ * lives at vtexcommercestable.com.br with no way to see it.
1606
+ *
1607
+ * Detection is cheap: VTEX sites should import `shouldProxyToVtex`
1608
+ * (or wire a `proxyHandler:` to `createDecoWorkerEntry`). We flag
1609
+ * absence as `info` (not warning) because old in-prod sites we
1610
+ * deliberately don't touch would otherwise stay noisy.
1611
+ */
1612
+ const ruleVtexProxyHandlerMissing: Rule = {
1613
+ id: "vtex-proxy-handler-missing",
1614
+ title: "VTEX worker-entry missing the checkout/API proxy handler",
1615
+ run({ siteDir, fs }: RuleContext): Finding[] {
1616
+ // Only run when the site is actually VTEX. The cheapest signal is
1617
+ // any import from `@decocms/apps/vtex/...` in src/ — every VTEX
1618
+ // site has at least one (commerceLoaders, hooks, types, etc.).
1619
+ const srcFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1620
+ const isVtex = srcFiles.some((abs) =>
1621
+ fs.readText(abs).includes("@decocms/apps/vtex"),
1622
+ );
1623
+ if (!isVtex) return [];
1624
+
1625
+ const workerEntryAbs = `${siteDir}/src/worker-entry.ts`;
1626
+ if (!fs.exists(workerEntryAbs)) {
1627
+ return [
1628
+ {
1629
+ rule: "vtex-proxy-handler-missing",
1630
+ severity: "info",
1631
+ file: "src/worker-entry.ts",
1632
+ message:
1633
+ "VTEX site has no src/worker-entry.ts — /checkout proxy can't run, the user will see the SPA shell instead of VTEX checkout",
1634
+ fix: "Re-run `deco-migrate` (the scaffold emits a worker-entry with createVtexCheckoutProxy), or copy from scripts/migrate/templates/server-entry.ts",
1635
+ },
1636
+ ];
1637
+ }
1638
+
1639
+ const content = fs.readText(workerEntryAbs);
1640
+ // Match either symbol — sites use the factory function OR the
1641
+ // shouldProxyToVtex predicate as the entry point. Presence of
1642
+ // either is a strong signal the proxy block exists; absence of
1643
+ // both means it was dropped.
1644
+ const hasProxyImport =
1645
+ /from\s+["']@decocms\/apps\/vtex\/utils\/proxy["']/.test(content);
1646
+ // Match both long form (`proxyHandler: async (...) => ...`) and
1647
+ // object-shorthand wiring (`{ proxyHandler }`, `{ proxyHandler, admin }`).
1648
+ // The anchor `[{,]` requires the identifier to appear as a property —
1649
+ // not as a bare `const proxyHandler = ...` declaration, which is
1650
+ // followed by `=` and wouldn't match either branch.
1651
+ const hasProxyHandler = /[{,]\s*proxyHandler\s*[:,}]/.test(content);
1652
+
1653
+ if (hasProxyImport && hasProxyHandler) return [];
1654
+
1655
+ return [
1656
+ {
1657
+ rule: "vtex-proxy-handler-missing",
1658
+ severity: "info",
1659
+ file: "src/worker-entry.ts",
1660
+ message: hasProxyImport
1661
+ ? "imports proxy helpers but no `proxyHandler:` is wired into createDecoWorkerEntry — /checkout requests will fall through to TanStack and render the SPA shell"
1662
+ : "no `@decocms/apps/vtex/utils/proxy` import — VTEX /checkout, /account, /api won't be proxied to the origin",
1663
+ fix: "Add `proxyHandler` to createDecoWorkerEntry; see scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry) for the canonical block",
1664
+ },
1665
+ ];
1666
+ },
1667
+ };
1668
+
1669
+ export const ALL_RULES: Rule[] = [
1670
+ ruleDeadLibShims,
1671
+ ruleObsoleteVitePlugins,
1672
+ ruleDeadRuntimeShim,
1673
+ ruleSiteLocalGlobals,
1674
+ ruleVtexShimRegression,
1675
+ ruleVtexProxyHandlerMissing,
1676
+ ruleLocalWidgetsTypes,
1677
+ ruleFrameworkTodos,
1678
+ ruleLocalFrameworkDuplicate,
1679
+ ruleHtmxResidue,
1680
+ ruleLockfileMultiple,
1681
+ ruleLockfileMissing,
1682
+ ruleLockfileDrift,
1683
+ rulePackageManagerMissing,
1684
+ ];
1685
+
1686
+ /** Exported for direct unit tests. */
1687
+ export const _internals = {
1688
+ extractExports,
1689
+ symbolUsedOutsideLib,
1690
+ satisfiesRange,
1691
+ lockfileVersionsOf,
1692
+ rules: {
1693
+ ruleDeadLibShims,
1694
+ ruleObsoleteVitePlugins,
1695
+ ruleDeadRuntimeShim,
1696
+ ruleSiteLocalGlobals,
1697
+ ruleVtexShimRegression,
1698
+ ruleVtexProxyHandlerMissing,
1699
+ ruleHtmxResidue,
1700
+ ruleLocalWidgetsTypes,
1701
+ ruleFrameworkTodos,
1702
+ ruleLocalFrameworkDuplicate,
1703
+ ruleLockfileMultiple,
1704
+ ruleLockfileMissing,
1705
+ ruleLockfileDrift,
1706
+ rulePackageManagerMissing,
1707
+ },
1708
+ };