@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,1771 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { _internals, ALL_RULES } from "./rules";
3
+ import { runAudit } from "./runner";
4
+ import type { FsAdapter, FsWriter } from "./types";
5
+
6
+ /**
7
+ * In-memory FsAdapter for tests. Maps absolute path → file content.
8
+ * `glob` does a literal substring-pattern match — good enough for our
9
+ * tests which never use complex globs.
10
+ */
11
+ function makeFs(files: Record<string, string>): FsAdapter {
12
+ const norm = Object.fromEntries(
13
+ Object.entries(files).map(([k, v]) => [k.replace(/\\/g, "/"), v]),
14
+ );
15
+ return {
16
+ exists(absPath) {
17
+ return absPath.replace(/\\/g, "/") in norm;
18
+ },
19
+ readText(absPath) {
20
+ const key = absPath.replace(/\\/g, "/");
21
+ if (!(key in norm)) throw new Error(`ENOENT: ${absPath}`);
22
+ return norm[key];
23
+ },
24
+ glob(siteDir, pattern, excludeDirs = []) {
25
+ const root = siteDir.replace(/\\/g, "/");
26
+ const all = Object.keys(norm).filter((p) => p.startsWith(`${root}/`));
27
+ const filtered = all.filter((p) => {
28
+ const rel = p.slice(root.length + 1);
29
+ return !excludeDirs.some((dir) => rel.startsWith(`${dir}/`));
30
+ });
31
+ // Build a regex that handles ** and {a,b} the same way the real
32
+ // adapter does — but lighter, just enough for the test patterns.
33
+ const branches = pattern.includes("{")
34
+ ? pattern
35
+ .match(/\{([^{}]+)\}/)![1]
36
+ .split(",")
37
+ .map((b) => pattern.replace(/\{[^{}]+\}/, b.trim()))
38
+ : [pattern];
39
+ const regexes = branches.map((p) => {
40
+ const re = p
41
+ .replace(/[.+^$()|]/g, "\\$&")
42
+ .replace(/\*\*\//g, "<<DBL>>")
43
+ .replace(/\*\*/g, "<<DBL>>")
44
+ .replace(/\*/g, "[^/]*")
45
+ .replace(/<<DBL>>/g, "(?:.*/)?");
46
+ return new RegExp(`^${re}$`);
47
+ });
48
+ return filtered
49
+ .filter((p) => {
50
+ const rel = p.slice(root.length + 1);
51
+ return regexes.some((re) => re.test(rel));
52
+ })
53
+ .sort();
54
+ },
55
+ };
56
+ }
57
+
58
+ const SITE = "/site";
59
+
60
+ /**
61
+ * Mutable in-memory FS — read AND write share one backing store. Used
62
+ * for fix-mode tests. The `store` is exposed so tests can assert what
63
+ * the writer left behind (deletions and content rewrites).
64
+ */
65
+ function makeMutableFs(initial: Record<string, string>): {
66
+ fs: FsAdapter;
67
+ writer: FsWriter;
68
+ store: Record<string, string>;
69
+ log: { kind: "delete" | "write"; absPath: string }[];
70
+ } {
71
+ const store = Object.fromEntries(
72
+ Object.entries(initial).map(([k, v]) => [k.replace(/\\/g, "/"), v]),
73
+ );
74
+ const log: { kind: "delete" | "write"; absPath: string }[] = [];
75
+ const fs: FsAdapter = {
76
+ exists(absPath) {
77
+ return absPath.replace(/\\/g, "/") in store;
78
+ },
79
+ readText(absPath) {
80
+ const k = absPath.replace(/\\/g, "/");
81
+ if (!(k in store)) throw new Error(`ENOENT: ${absPath}`);
82
+ return store[k];
83
+ },
84
+ glob(siteDir, pattern, excludeDirs = []) {
85
+ const root = siteDir.replace(/\\/g, "/");
86
+ const all = Object.keys(store).filter((p) => p.startsWith(`${root}/`));
87
+ const filtered = all.filter((p) => {
88
+ const rel = p.slice(root.length + 1);
89
+ return !excludeDirs.some((dir) => rel.startsWith(`${dir}/`));
90
+ });
91
+ const branches = pattern.includes("{")
92
+ ? pattern
93
+ .match(/\{([^{}]+)\}/)![1]
94
+ .split(",")
95
+ .map((b) => pattern.replace(/\{[^{}]+\}/, b.trim()))
96
+ : [pattern];
97
+ const regexes = branches.map((p) => {
98
+ const re = p
99
+ .replace(/[.+^$()|]/g, "\\$&")
100
+ .replace(/\*\*\//g, "<<DBL>>")
101
+ .replace(/\*\*/g, "<<DBL>>")
102
+ .replace(/\*/g, "[^/]*")
103
+ .replace(/<<DBL>>/g, "(?:.*/)?");
104
+ return new RegExp(`^${re}$`);
105
+ });
106
+ return filtered
107
+ .filter((p) => {
108
+ const rel = p.slice(root.length + 1);
109
+ return regexes.some((re) => re.test(rel));
110
+ })
111
+ .sort();
112
+ },
113
+ };
114
+ const writer: FsWriter = {
115
+ deleteFile(absPath) {
116
+ const k = absPath.replace(/\\/g, "/");
117
+ delete store[k];
118
+ log.push({ kind: "delete", absPath: k });
119
+ },
120
+ writeText(absPath, content) {
121
+ const k = absPath.replace(/\\/g, "/");
122
+ store[k] = content;
123
+ log.push({ kind: "write", absPath: k });
124
+ },
125
+ };
126
+ return { fs, writer, store, log };
127
+ }
128
+
129
+ describe("runAudit — empty site", () => {
130
+ it("returns zero findings on an empty tree", () => {
131
+ const fs = makeFs({});
132
+ const report = runAudit(SITE, fs);
133
+ expect(report.site).toBe(SITE);
134
+ expect(report.totalFindings).toBe(0);
135
+ expect(report.rules).toHaveLength(ALL_RULES.length);
136
+ for (const r of report.rules) expect(r.findings).toEqual([]);
137
+ });
138
+ });
139
+
140
+ describe("rule: dead-lib-shims", () => {
141
+ it("flags a shim whose only export is unreferenced", () => {
142
+ const fs = makeFs({
143
+ "/site/src/lib/dead.ts": "export const foo = 1;\n",
144
+ "/site/src/sections/Other.tsx": 'export const x = "y";\n',
145
+ });
146
+ const report = runAudit(SITE, fs);
147
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
148
+ expect(r.findings).toHaveLength(1);
149
+ expect(r.findings[0].file).toBe("src/lib/dead.ts");
150
+ expect(r.findings[0].fix).toContain("rm src/lib/dead.ts");
151
+ });
152
+
153
+ it("does not flag a shim referenced from outside src/lib", () => {
154
+ const fs = makeFs({
155
+ "/site/src/lib/used.ts": "export function helper() { return 1; }\n",
156
+ "/site/src/sections/Caller.tsx": 'import { helper } from "~/lib/used";\nhelper();\n',
157
+ });
158
+ const report = runAudit(SITE, fs);
159
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
160
+ expect(r.findings).toEqual([]);
161
+ });
162
+
163
+ it("does not flag a shim with no exports at all (likely intentional empty file)", () => {
164
+ const fs = makeFs({
165
+ "/site/src/lib/empty.ts": "// nothing here\n",
166
+ });
167
+ const report = runAudit(SITE, fs);
168
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
169
+ expect(r.findings).toEqual([]);
170
+ });
171
+
172
+ it("flags only when ALL exports are dead — partial use spares the file", () => {
173
+ const fs = makeFs({
174
+ "/site/src/lib/mixed.ts": "export const used = 1;\nexport const unused = 2;\n",
175
+ "/site/src/sections/Caller.tsx": 'import { used } from "~/lib/mixed";\nconsole.log(used);\n',
176
+ });
177
+ const report = runAudit(SITE, fs);
178
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
179
+ expect(r.findings).toEqual([]);
180
+ });
181
+ });
182
+
183
+ describe("rule: obsolete-vite-plugins", () => {
184
+ it("detects site-manual-chunks", () => {
185
+ const fs = makeFs({
186
+ "/site/vite.config.ts": `
187
+ export default defineConfig({
188
+ plugins: [
189
+ { name: "site-manual-chunks", config() { return {}; } },
190
+ ],
191
+ });
192
+ `,
193
+ });
194
+ const report = runAudit(SITE, fs);
195
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
196
+ expect(r.findings).toHaveLength(1);
197
+ expect(r.findings[0].meta?.plugin).toBe("site-manual-chunks");
198
+ });
199
+
200
+ it("detects deco-stub-meta-gen", () => {
201
+ const fs = makeFs({
202
+ "/site/vite.config.ts": 'plugins: [{ name: "deco-stub-meta-gen", enforce: "pre" }]',
203
+ });
204
+ const report = runAudit(SITE, fs);
205
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
206
+ expect(r.findings).toHaveLength(1);
207
+ expect(r.findings[0].meta?.plugin).toBe("deco-stub-meta-gen");
208
+ });
209
+
210
+ it("returns zero findings when both are absent", () => {
211
+ const fs = makeFs({
212
+ "/site/vite.config.ts": 'plugins: [{ name: "react", enforce: "pre" }]',
213
+ });
214
+ const report = runAudit(SITE, fs);
215
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
216
+ expect(r.findings).toEqual([]);
217
+ });
218
+ });
219
+
220
+ describe("rule: dead-runtime-shim", () => {
221
+ it("flags an invoke-only runtime.ts", () => {
222
+ const fs = makeFs({
223
+ "/site/src/runtime.ts":
224
+ "export const invoke = createNestedInvokeProxy();\nexport function createNestedInvokeProxy() { return {}; }\n",
225
+ });
226
+ const report = runAudit(SITE, fs);
227
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
228
+ expect(r.findings).toHaveLength(1);
229
+ expect(r.findings[0].file).toBe("src/runtime.ts");
230
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(true);
231
+ expect(r.findings[0].meta?.hasInlineProxy).toBe(true);
232
+ });
233
+
234
+ it("does not flag a runtime.ts that exports site-specific helpers (no inline proxy)", () => {
235
+ const fs = makeFs({
236
+ "/site/src/runtime.ts": "export const invoke = {};\nexport const customHelper = () => 1;\n",
237
+ });
238
+ const report = runAudit(SITE, fs);
239
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
240
+ expect(r.findings).toEqual([]);
241
+ });
242
+
243
+ it("does NOT flag the Wave 15-A canonical re-export shape", () => {
244
+ // The migration template now scaffolds a thin re-export from
245
+ // @decocms/start/sdk plus a Runtime alias. No inline proxy body.
246
+ const fs = makeFs({
247
+ "/site/src/runtime.ts":
248
+ 'import { invoke } from "@decocms/start/sdk";\nexport { invoke };\nexport const Runtime = { invoke };\n',
249
+ });
250
+ const report = runAudit(SITE, fs);
251
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
252
+ expect(r.findings).toEqual([]);
253
+ });
254
+
255
+ it("flags the legacy 47-line inline createNestedInvokeProxy body (with Runtime export)", () => {
256
+ // The pre-Wave-15-A migration template emitted a full Proxy body
257
+ // alongside `Runtime = { invoke }`. The earlier rule heuristic
258
+ // missed this shape because `Runtime` was not in its allowlist.
259
+ const fs = makeFs({
260
+ "/site/src/runtime.ts": `
261
+ function createNestedInvokeProxy(path: string[] = []): any {
262
+ return new Proxy(
263
+ Object.assign(async (props: any) => {
264
+ const key = path.join("/");
265
+ const response = await fetch(\`/deco/invoke/\${key}\`, {
266
+ method: "POST",
267
+ headers: { "Content-Type": "application/json" },
268
+ body: JSON.stringify(props ?? {}),
269
+ });
270
+ return response.json();
271
+ }, {}),
272
+ {
273
+ get(_target: any, prop: string) {
274
+ if (prop === "then") return undefined;
275
+ return createNestedInvokeProxy([...path, prop]);
276
+ },
277
+ },
278
+ );
279
+ }
280
+
281
+ export const invoke = createNestedInvokeProxy() as any;
282
+ export const Runtime = { invoke };
283
+ `,
284
+ });
285
+ const report = runAudit(SITE, fs);
286
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
287
+ expect(r.findings).toHaveLength(1);
288
+ expect(r.findings[0].meta?.hasInlineProxy).toBe(true);
289
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(true);
290
+ expect(r.findings[0].message).toContain("inline createNestedInvokeProxy body");
291
+ });
292
+
293
+ it("flags but does NOT auto-fix when inline proxy coexists with site-specific helpers", () => {
294
+ // Defensive: if a site has hand-tuned the runtime file with extra
295
+ // exports beyond invoke/Runtime, deletion would lose data. Surface
296
+ // the issue but skip the destructive fix.
297
+ const fs = makeFs({
298
+ "/site/src/runtime.ts": `
299
+ function createNestedInvokeProxy(path: string[] = []): any {
300
+ return new Proxy(Object.assign(async (props: any) => {}, {}), {});
301
+ }
302
+ export const invoke = createNestedInvokeProxy();
303
+ export const trackPageView = () => console.log("custom tracker");
304
+ `,
305
+ });
306
+ const report = runAudit(SITE, fs);
307
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
308
+ expect(r.findings).toHaveLength(1);
309
+ expect(r.findings[0].meta?.hasInlineProxy).toBe(true);
310
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
311
+ expect(r.findings[0].message).toContain("manual review");
312
+ // applyFix should be a no-op when safeToAutoFix is false — verified
313
+ // implicitly by the runner test for --fix below; here we only
314
+ // assert the metadata gate.
315
+ });
316
+ });
317
+
318
+ describe("rule: site-local-with-globals", () => {
319
+ it("flags a local cmsRouteWithGlobals wrapper", () => {
320
+ const lines = Array(120).fill("// boilerplate").join("\n");
321
+ const fs = makeFs({
322
+ "/site/src/server/routes/withSiteGlobals.ts": `${lines}\nexport function cmsRouteWithGlobals() { return {}; }\n`,
323
+ });
324
+ const report = runAudit(SITE, fs);
325
+ const r = report.rules.find((r) => r.rule === "site-local-with-globals")!;
326
+ expect(r.findings).toHaveLength(1);
327
+ expect(r.findings[0].meta?.lineCount).toBeGreaterThan(100);
328
+ });
329
+
330
+ it("does not flag a re-export from the framework", () => {
331
+ const fs = makeFs({
332
+ "/site/src/server/routes/withSiteGlobals.ts":
333
+ 'export { withSiteGlobals } from "@decocms/start/routes";\n',
334
+ });
335
+ const report = runAudit(SITE, fs);
336
+ const r = report.rules.find((r) => r.rule === "site-local-with-globals")!;
337
+ expect(r.findings).toEqual([]);
338
+ });
339
+ });
340
+
341
+ describe("rule: vtex-shim-regression", () => {
342
+ // Default-pessimistic case: shim file missing → unknown symbols treated
343
+ // as stubs so audit always surfaces the import. (Compile phase catches
344
+ // the underlying TS error separately.)
345
+ it("flags imports when shim file is missing (defensive default)", () => {
346
+ const fs = makeFs({
347
+ "/site/src/sections/Foo.tsx": 'import { getSegment } from "~/lib/vtex-segment";\n',
348
+ });
349
+ const report = runAudit(SITE, fs);
350
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
351
+ expect(r.findings).toHaveLength(1);
352
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
353
+ "vtex-segment": ["getSegment"],
354
+ });
355
+ });
356
+
357
+ it("does not flag imports from src/lib itself", () => {
358
+ const fs = makeFs({
359
+ "/site/src/lib/vtex-segment.ts": 'import other from "~/lib/vtex-fetch";\n',
360
+ });
361
+ const report = runAudit(SITE, fs);
362
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
363
+ expect(r.findings).toEqual([]);
364
+ });
365
+
366
+ it("does NOT flag when imported symbols are all functional", () => {
367
+ const fs = makeFs({
368
+ "/site/src/lib/vtex-id.ts":
369
+ "export function parseCookie(s?: string): Record<string,string> {\n" +
370
+ " if (!s) return {};\n" +
371
+ " return Object.fromEntries(s.split(';').map(c => c.split('=') as [string,string]));\n" +
372
+ "}\n",
373
+ "/site/src/actions/x.ts": 'import { parseCookie } from "~/lib/vtex-id";\n',
374
+ });
375
+ const report = runAudit(SITE, fs);
376
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
377
+ // parseCookie has nested-block functional impl → not a stub → no warning.
378
+ expect(r.findings).toEqual([]);
379
+ });
380
+
381
+ it("flags only the stub symbols when import set is mixed", () => {
382
+ const fs = makeFs({
383
+ "/site/src/lib/vtex-segment.ts":
384
+ "export function getSegmentFromBag(_req?: any): null { return null; }\n" +
385
+ "export function withSegmentCookie(headers: Headers): Headers {\n" +
386
+ " headers.set('x', 'y');\n" +
387
+ " return headers;\n" +
388
+ "}\n",
389
+ "/site/src/loaders/x.ts":
390
+ 'import { getSegmentFromBag, withSegmentCookie } from "~/lib/vtex-segment";\n',
391
+ });
392
+ const report = runAudit(SITE, fs);
393
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
394
+ expect(r.findings).toHaveLength(1);
395
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
396
+ "vtex-segment": ["getSegmentFromBag"],
397
+ });
398
+ expect(r.findings[0].message).toContain("getSegmentFromBag");
399
+ expect(r.findings[0].message).not.toContain("withSegmentCookie");
400
+ });
401
+
402
+ it("flags identity-cast (toProduct) as a stub", () => {
403
+ const fs = makeFs({
404
+ "/site/src/lib/vtex-transform.ts":
405
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
406
+ "/site/src/loaders/search.ts":
407
+ 'import { toProduct } from "~/lib/vtex-transform";\n',
408
+ });
409
+ const report = runAudit(SITE, fs);
410
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
411
+ expect(r.findings).toHaveLength(1);
412
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
413
+ "vtex-transform": ["toProduct"],
414
+ });
415
+ });
416
+
417
+ it("does NOT flag `import type { X }` from a stub-having shim", () => {
418
+ const fs = makeFs({
419
+ "/site/src/lib/vtex-client.ts":
420
+ "export interface VTEXCommerceStable { account: string; }\n" +
421
+ "export function stub(): null { return null; }\n",
422
+ "/site/src/loaders/x.ts":
423
+ 'import type { VTEXCommerceStable } from "~/lib/vtex-client";\n',
424
+ });
425
+ const report = runAudit(SITE, fs);
426
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
427
+ // Type-only imports have no runtime → never a regression.
428
+ expect(r.findings).toEqual([]);
429
+ });
430
+
431
+ it("ignores per-symbol `type` modifier and only flags runtime imports", () => {
432
+ const fs = makeFs({
433
+ "/site/src/lib/vtex-mixed.ts":
434
+ "export interface Cfg { a: string; }\n" +
435
+ "export function stub(): null { return null; }\n" +
436
+ "export function ok(): boolean { return true; }\n",
437
+ "/site/src/loaders/x.ts":
438
+ 'import { type Cfg, stub, ok } from "~/lib/vtex-mixed";\n',
439
+ });
440
+ const report = runAudit(SITE, fs);
441
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
442
+ expect(r.findings).toHaveLength(1);
443
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
444
+ "vtex-mixed": ["stub"],
445
+ });
446
+ });
447
+
448
+ it("aggregates findings per file across multiple shims", () => {
449
+ const fs = makeFs({
450
+ "/site/src/lib/vtex-segment.ts":
451
+ "export function getSegmentFromBag(): null { return null; }\n",
452
+ "/site/src/lib/vtex-transform.ts":
453
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
454
+ "/site/src/loaders/search.ts":
455
+ 'import { getSegmentFromBag } from "~/lib/vtex-segment";\n' +
456
+ 'import { toProduct } from "~/lib/vtex-transform";\n',
457
+ });
458
+ const report = runAudit(SITE, fs);
459
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
460
+ expect(r.findings).toHaveLength(1);
461
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
462
+ "vtex-segment": ["getSegmentFromBag"],
463
+ "vtex-transform": ["toProduct"],
464
+ });
465
+ });
466
+
467
+ it("supports `as`-renamed imports (resolves to source name)", () => {
468
+ const fs = makeFs({
469
+ "/site/src/lib/vtex-segment.ts":
470
+ "export function getSegmentFromBag(): null { return null; }\n",
471
+ "/site/src/loaders/x.ts":
472
+ 'import { getSegmentFromBag as getSeg } from "~/lib/vtex-segment";\n',
473
+ });
474
+ const report = runAudit(SITE, fs);
475
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
476
+ expect(r.findings).toHaveLength(1);
477
+ expect(r.findings[0].meta?.stubsBySim).toEqual({
478
+ "vtex-segment": ["getSegmentFromBag"],
479
+ });
480
+ });
481
+ });
482
+
483
+ describe("rule: vtex-shim-regression — per-symbol fix hints", () => {
484
+ it("emits 1:1 swap hint for `toProduct`", () => {
485
+ const fs = makeFs({
486
+ "/site/src/lib/vtex-transform.ts":
487
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
488
+ "/site/src/loaders/x.ts":
489
+ 'import { toProduct } from "~/lib/vtex-transform";\n',
490
+ });
491
+ const report = runAudit(SITE, fs);
492
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
493
+ expect(r.findings).toHaveLength(1);
494
+ const f = r.findings[0];
495
+ expect(f.fix).toContain("toProduct → @decocms/apps/vtex/utils/transform");
496
+ expect(f.fix).toContain("1:1 import swap");
497
+ expect(f.meta?.fixHints).toEqual({
498
+ toProduct: {
499
+ kind: "swap",
500
+ canonical: "@decocms/apps/vtex/utils/transform",
501
+ note: expect.stringContaining("canonical signature"),
502
+ },
503
+ });
504
+ });
505
+
506
+ it("emits refactor hint for `getSegmentFromBag`", () => {
507
+ const fs = makeFs({
508
+ "/site/src/lib/vtex-segment.ts":
509
+ "export function getSegmentFromBag(): null { return null; }\n",
510
+ "/site/src/loaders/x.ts":
511
+ 'import { getSegmentFromBag } from "~/lib/vtex-segment";\n',
512
+ });
513
+ const report = runAudit(SITE, fs);
514
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
515
+ const f = r.findings[0];
516
+ expect(f.fix).toContain("getSegmentFromBag → call-site refactor");
517
+ expect(f.fix).toContain("buildSegmentFromCookies");
518
+ expect(f.meta?.fixHints).toEqual({
519
+ getSegmentFromBag: {
520
+ kind: "refactor",
521
+ note: expect.stringContaining("buildSegmentFromCookies"),
522
+ },
523
+ });
524
+ });
525
+
526
+ it("composes hints for files with multiple stubs", () => {
527
+ const fs = makeFs({
528
+ "/site/src/lib/vtex-segment.ts":
529
+ "export function getSegmentFromBag(): null { return null; }\n",
530
+ "/site/src/lib/vtex-transform.ts":
531
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
532
+ "/site/src/loaders/x.ts":
533
+ 'import { getSegmentFromBag } from "~/lib/vtex-segment";\n' +
534
+ 'import { toProduct } from "~/lib/vtex-transform";\n',
535
+ });
536
+ const report = runAudit(SITE, fs);
537
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
538
+ const f = r.findings[0];
539
+ expect(f.fix).toContain("getSegmentFromBag → call-site refactor");
540
+ expect(f.fix).toContain("toProduct → @decocms/apps/vtex/utils/transform");
541
+ // Joined with " | " for visual separation.
542
+ expect(f.fix).toContain(" | ");
543
+ expect(Object.keys(f.meta?.fixHints as object)).toEqual(
544
+ expect.arrayContaining(["toProduct", "getSegmentFromBag"]),
545
+ );
546
+ });
547
+
548
+ it("falls back to generic hint for symbols without entries", () => {
549
+ const fs = makeFs({
550
+ "/site/src/lib/vtex-mystery.ts":
551
+ "export function unknownStub(): null { return null; }\n",
552
+ "/site/src/loaders/x.ts":
553
+ 'import { unknownStub } from "~/lib/vtex-mystery";\n',
554
+ });
555
+ const report = runAudit(SITE, fs);
556
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
557
+ const f = r.findings[0];
558
+ expect(f.fix).toContain("unknownStub → repoint to '@decocms/apps/vtex/...");
559
+ // No fixHints in meta when no symbols match the table.
560
+ expect(f.meta?.fixHints).toBeUndefined();
561
+ });
562
+
563
+ it("mixes specific hints and generic fallback in one message", () => {
564
+ const fs = makeFs({
565
+ "/site/src/lib/vtex-transform.ts":
566
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
567
+ "/site/src/lib/vtex-mystery.ts":
568
+ "export function unknownStub(): null { return null; }\n",
569
+ "/site/src/loaders/x.ts":
570
+ 'import { toProduct } from "~/lib/vtex-transform";\n' +
571
+ 'import { unknownStub } from "~/lib/vtex-mystery";\n',
572
+ });
573
+ const report = runAudit(SITE, fs);
574
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
575
+ const f = r.findings[0];
576
+ expect(f.fix).toContain("toProduct → @decocms/apps/vtex/utils/transform");
577
+ expect(f.fix).toContain("unknownStub → repoint");
578
+ // Only the known symbol shows up in fixHints.
579
+ expect(f.meta?.fixHints).toEqual({
580
+ toProduct: expect.objectContaining({ kind: "swap" }),
581
+ });
582
+ });
583
+ });
584
+
585
+ describe("rule: local-widgets-types", () => {
586
+ it("flags presence of src/types/widgets.ts and counts imports", () => {
587
+ const fs = makeFs({
588
+ "/site/src/types/widgets.ts": "export type ImageWidget = string;\n",
589
+ "/site/src/sections/A.tsx": 'import type { ImageWidget } from "~/types/widgets";\n',
590
+ "/site/src/sections/B.tsx": 'import type { ImageWidget } from "~/types/widgets";\n',
591
+ "/site/src/sections/C.tsx": "export const x = 1;\n",
592
+ });
593
+ const report = runAudit(SITE, fs);
594
+ const r = report.rules.find((r) => r.rule === "local-widgets-types")!;
595
+ expect(r.findings).toHaveLength(1);
596
+ expect(r.findings[0].meta?.importCount).toBe(2);
597
+ });
598
+
599
+ it("returns zero findings when the file does not exist", () => {
600
+ const fs = makeFs({
601
+ "/site/src/sections/A.tsx": "export const x = 1;\n",
602
+ });
603
+ const report = runAudit(SITE, fs);
604
+ const r = report.rules.find((r) => r.rule === "local-widgets-types")!;
605
+ expect(r.findings).toEqual([]);
606
+ });
607
+ });
608
+
609
+ describe("rule: framework-todos", () => {
610
+ it("flags TODOs that mention deco/framework/move into", () => {
611
+ const fs = makeFs({
612
+ "/site/src/sections/Foo.tsx":
613
+ "// TODO: move into decoVitePlugin in next release\nexport const x = 1;\n",
614
+ });
615
+ const report = runAudit(SITE, fs);
616
+ const r = report.rules.find((r) => r.rule === "framework-todos")!;
617
+ expect(r.findings).toHaveLength(1);
618
+ expect(r.findings[0].file).toContain(":1");
619
+ });
620
+
621
+ it("does not flag unrelated TODOs", () => {
622
+ const fs = makeFs({
623
+ "/site/src/sections/Foo.tsx": "// TODO: i18n strings\nexport const x = 1;\n",
624
+ });
625
+ const report = runAudit(SITE, fs);
626
+ const r = report.rules.find((r) => r.rule === "framework-todos")!;
627
+ expect(r.findings).toEqual([]);
628
+ });
629
+ });
630
+
631
+ describe("rule: vtex-proxy-handler-missing", () => {
632
+ // Canonical wiring matching scripts/migrate/templates/server-entry.ts (generateVtexWorkerEntry):
633
+ // imports from @decocms/apps/vtex/utils/proxy and passes proxyHandler to createDecoWorkerEntry.
634
+ const okWorkerEntry = `
635
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
636
+ import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy";
637
+ const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" });
638
+ export default createDecoWorkerEntry(serverEntry, {
639
+ proxyHandler: async (req, url) => {
640
+ if (!shouldProxyToVtex(url.pathname)) return null;
641
+ return proxy(req, url);
642
+ },
643
+ });
644
+ `;
645
+
646
+ it("does not flag non-VTEX sites", () => {
647
+ const fs = makeFs({
648
+ "/site/src/index.ts": "export const x = 1;",
649
+ // Worker entry intentionally without the proxy import — non-VTEX
650
+ // sites don't need it and shouldn't be flagged.
651
+ "/site/src/worker-entry.ts": "export default {};",
652
+ });
653
+ const report = runAudit(SITE, fs);
654
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
655
+ expect(r.findings).toEqual([]);
656
+ });
657
+
658
+ it("does not flag VTEX site whose worker-entry has the proxyHandler wired", () => {
659
+ const fs = makeFs({
660
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
661
+ "/site/src/worker-entry.ts": okWorkerEntry,
662
+ });
663
+ const report = runAudit(SITE, fs);
664
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
665
+ expect(r.findings).toEqual([]);
666
+ });
667
+
668
+ it("flags VTEX site missing src/worker-entry.ts entirely", () => {
669
+ const fs = makeFs({
670
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
671
+ });
672
+ const report = runAudit(SITE, fs);
673
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
674
+ expect(r.findings).toHaveLength(1);
675
+ expect(r.findings[0].severity).toBe("info");
676
+ expect(r.findings[0].message).toContain("no src/worker-entry.ts");
677
+ });
678
+
679
+ it("flags VTEX site whose worker-entry omits the proxy import", () => {
680
+ const fs = makeFs({
681
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
682
+ "/site/src/worker-entry.ts": `
683
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
684
+ export default createDecoWorkerEntry(serverEntry, { admin: {} });
685
+ `,
686
+ });
687
+ const report = runAudit(SITE, fs);
688
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
689
+ expect(r.findings).toHaveLength(1);
690
+ expect(r.findings[0].message).toContain("no `@decocms/apps/vtex/utils/proxy` import");
691
+ });
692
+
693
+ it("does not flag VTEX site using object-shorthand proxyHandler wiring", () => {
694
+ // Hand-written worker entries commonly hoist proxyHandler to a top-level
695
+ // const and pass it via shorthand. The detector must treat
696
+ // `{ proxyHandler }`, `{ proxyHandler, admin }`, and `{ admin, proxyHandler }`
697
+ // the same as `proxyHandler: ...` — otherwise the audit cries wolf.
698
+ const shorthandWorkerEntry = `
699
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
700
+ import { shouldProxyToVtex, createVtexCheckoutProxy } from "@decocms/apps/vtex/utils/proxy";
701
+ const proxy = createVtexCheckoutProxy({ account: "x", checkoutOrigin: "x.vtexcommercestable.com.br" });
702
+ const proxyHandler = async (req: Request, url: URL) => {
703
+ if (!shouldProxyToVtex(url.pathname)) return null;
704
+ return proxy(req, url);
705
+ };
706
+ export default createDecoWorkerEntry(serverEntry, { admin: {}, proxyHandler });
707
+ `;
708
+ const fs = makeFs({
709
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
710
+ "/site/src/worker-entry.ts": shorthandWorkerEntry,
711
+ });
712
+ const report = runAudit(SITE, fs);
713
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
714
+ expect(r.findings).toEqual([]);
715
+ });
716
+
717
+ it("flags VTEX site that imports proxy helpers but never wires proxyHandler", () => {
718
+ const fs = makeFs({
719
+ "/site/src/commerceLoaders.ts": "import {} from \"@decocms/apps/vtex/mod\";",
720
+ "/site/src/worker-entry.ts": `
721
+ import { createDecoWorkerEntry } from "@decocms/start/sdk/workerEntry";
722
+ import { shouldProxyToVtex } from "@decocms/apps/vtex/utils/proxy";
723
+ // note: shouldProxyToVtex imported but not used in a proxyHandler.
724
+ export default createDecoWorkerEntry(serverEntry, { admin: {} });
725
+ `,
726
+ });
727
+ const report = runAudit(SITE, fs);
728
+ const r = report.rules.find((r) => r.rule === "vtex-proxy-handler-missing")!;
729
+ expect(r.findings).toHaveLength(1);
730
+ expect(r.findings[0].message).toContain("no `proxyHandler:` is wired");
731
+ });
732
+ });
733
+
734
+ describe("internals", () => {
735
+ it("extractExports parses common forms (top-level, unindented)", () => {
736
+ const code = [
737
+ "export const a = 1;",
738
+ "export function b() {}",
739
+ "export interface C {}",
740
+ "export type D = string;",
741
+ "export class E {}",
742
+ "const private_ = 1;",
743
+ ].join("\n");
744
+ expect(_internals.extractExports(code).sort()).toEqual(["C", "D", "E", "a", "b"]);
745
+ });
746
+ });
747
+
748
+ describe("runAudit — totals", () => {
749
+ it("totalFindings sums across all rules", () => {
750
+ const fs = makeFs({
751
+ "/site/src/lib/dead.ts": "export const x = 1;\n",
752
+ "/site/vite.config.ts": 'plugins: [{ name: "site-manual-chunks", config() {} }]',
753
+ "/site/src/sections/Foo.tsx":
754
+ "// TODO: deco framework should own this\nexport const y = 2;\n",
755
+ });
756
+ const report = runAudit(SITE, fs);
757
+ expect(report.totalFindings).toBe(3);
758
+ expect(report.totalFixActions).toBe(0);
759
+ });
760
+
761
+ it("supportsAutoFix flag reflects rule capability", () => {
762
+ const fs = makeFs({});
763
+ const report = runAudit(SITE, fs);
764
+ const supported = report.rules
765
+ .filter((r) => r.supportsAutoFix)
766
+ .map((r) => r.rule)
767
+ .sort();
768
+ expect(supported).toEqual(
769
+ [
770
+ "dead-lib-shims",
771
+ "dead-runtime-shim",
772
+ "local-framework-duplicate",
773
+ "local-widgets-types",
774
+ "lockfile-multiple",
775
+ "obsolete-vite-plugins",
776
+ "package-manager-missing",
777
+ "vtex-shim-regression",
778
+ ].sort(),
779
+ );
780
+ });
781
+ });
782
+
783
+ describe("runAudit — fix mode", () => {
784
+ it("does not mutate when no writer is provided (default audit-only)", () => {
785
+ const { fs, store } = makeMutableFs({
786
+ "/site/src/lib/dead.ts": "export const foo = 1;\n",
787
+ });
788
+ const before = { ...store };
789
+ runAudit(SITE, fs);
790
+ expect(store).toEqual(before);
791
+ });
792
+
793
+ it("fix mode deletes a dead-lib shim and reports the action", () => {
794
+ const { fs, writer, store } = makeMutableFs({
795
+ "/site/src/lib/dead.ts": "export const foo = 1;\n",
796
+ "/site/src/sections/Other.tsx": 'export const x = "y";\n',
797
+ });
798
+ const report = runAudit(SITE, fs, { writer });
799
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
800
+ expect(r.findings).toHaveLength(1);
801
+ expect(r.fixes).toHaveLength(1);
802
+ expect(r.fixes![0].kind).toBe("delete");
803
+ expect(r.fixes![0].file).toBe("src/lib/dead.ts");
804
+ expect("/site/src/lib/dead.ts" in store).toBe(false);
805
+ expect("/site/src/sections/Other.tsx" in store).toBe(true);
806
+ expect(report.totalFixActions).toBe(1);
807
+ });
808
+
809
+ it("fix mode rewrites runtime imports + deletes runtime.ts", () => {
810
+ const { fs, writer, store, log } = makeMutableFs({
811
+ "/site/src/runtime.ts":
812
+ "export const invoke = createNestedInvokeProxy();\nexport function createNestedInvokeProxy() { return {}; }\n",
813
+ "/site/src/sections/A.tsx": 'import { invoke } from "~/runtime";\nconsole.log(invoke);\n',
814
+ "/site/src/sections/B.tsx": "import { invoke } from '~/runtime';\nconsole.log(invoke);\n",
815
+ "/site/src/sections/C.tsx":
816
+ 'import { other } from "~/something-else";\nconsole.log(other);\n',
817
+ });
818
+ const report = runAudit(SITE, fs, { writer });
819
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
820
+ expect(r.findings).toHaveLength(1);
821
+ expect(r.fixes).toHaveLength(1);
822
+ expect(r.fixes![0].detail).toMatch(/rewrote 2 import/);
823
+ expect("/site/src/runtime.ts" in store).toBe(false);
824
+ expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/sdk"');
825
+ expect(store["/site/src/sections/B.tsx"]).toContain("'@decocms/start/sdk'");
826
+ expect(store["/site/src/sections/C.tsx"]).toContain('"~/something-else"');
827
+ expect(log.filter((e) => e.kind === "delete")).toHaveLength(1);
828
+ expect(log.filter((e) => e.kind === "write")).toHaveLength(2);
829
+ });
830
+
831
+ it("fix mode rewrites widgets imports + deletes widgets.ts", () => {
832
+ const { fs, writer, store } = makeMutableFs({
833
+ "/site/src/types/widgets.ts": "export type ImageWidget = string;\n",
834
+ "/site/src/sections/A.tsx":
835
+ 'import type { ImageWidget } from "~/types/widgets";\nexport const x: ImageWidget = "y";\n',
836
+ "/site/src/sections/B.tsx":
837
+ "import type { ImageWidget } from '~/types/widgets';\nexport const y: ImageWidget = 'z';\n",
838
+ });
839
+ const report = runAudit(SITE, fs, { writer });
840
+ const r = report.rules.find((r) => r.rule === "local-widgets-types")!;
841
+ expect(r.fixes).toHaveLength(1);
842
+ expect(r.fixes![0].detail).toMatch(/rewrote 2 import/);
843
+ expect("/site/src/types/widgets.ts" in store).toBe(false);
844
+ expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/types/widgets"');
845
+ expect(store["/site/src/sections/B.tsx"]).toContain("'@decocms/start/types/widgets'");
846
+ });
847
+
848
+ it("fix mode is a no-op for rules without applyFix (e.g. framework-todos)", () => {
849
+ const { fs, writer, store } = makeMutableFs({
850
+ "/site/src/sections/Foo.tsx": "// TODO: move into decoVitePlugin\nexport const x = 1;\n",
851
+ });
852
+ const beforeStore = { ...store };
853
+ const report = runAudit(SITE, fs, { writer });
854
+ const r = report.rules.find((r) => r.rule === "framework-todos")!;
855
+ expect(r.findings).toHaveLength(1);
856
+ expect(r.fixes).toBeUndefined();
857
+ expect(r.supportsAutoFix).toBe(false);
858
+ expect(store).toEqual(beforeStore);
859
+ });
860
+
861
+ it("fix mode rewrites only exact matches, not prefix collisions", () => {
862
+ const { fs, writer, store } = makeMutableFs({
863
+ "/site/src/types/widgets.ts": "export type ImageWidget = string;\n",
864
+ "/site/src/sections/A.tsx":
865
+ 'import type { ImageWidget } from "~/types/widgets";\nimport thing from "~/types/widgets-extra";\n',
866
+ });
867
+ runAudit(SITE, fs, { writer });
868
+ expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/types/widgets"');
869
+ expect(store["/site/src/sections/A.tsx"]).toContain('"~/types/widgets-extra"');
870
+ });
871
+ });
872
+
873
+ /* ------------------------------------------------------------------ */
874
+ /* W12-D / W12-E — vtex-shim-regression --fix for swap-able cases */
875
+ /* ------------------------------------------------------------------ */
876
+
877
+ describe("runAudit — fix mode — vtex-shim-regression swap cases", () => {
878
+ it("rewrites `toProduct` import to canonical when it is the only stub imported", () => {
879
+ const { fs, writer, store } = makeMutableFs({
880
+ "/site/src/lib/vtex-transform.ts":
881
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
882
+ "/site/src/loaders/search.ts":
883
+ 'import { toProduct } from "~/lib/vtex-transform";\nconsole.log(toProduct);\n',
884
+ });
885
+ const report = runAudit(SITE, fs, { writer });
886
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
887
+ expect(r.fixes).toHaveLength(1);
888
+ expect(r.fixes![0].file).toBe("src/loaders/search.ts");
889
+ expect(r.fixes![0].kind).toBe("rewrite-imports");
890
+ expect(r.fixes![0].detail).toContain("@decocms/apps/vtex/utils/transform");
891
+ expect(r.fixes![0].detail).toContain("toProduct");
892
+ expect(store["/site/src/loaders/search.ts"]).toContain(
893
+ '"@decocms/apps/vtex/utils/transform"',
894
+ );
895
+ expect(store["/site/src/loaders/search.ts"]).not.toContain('"~/lib/vtex-transform"');
896
+ });
897
+
898
+ it("rewrites `withSegmentCookie` import to canonical when it is the only stub imported", () => {
899
+ const { fs, writer, store } = makeMutableFs({
900
+ // Mirrors the post-#123 throwing-stub body — `throw new Error(...)`
901
+ // is recognised by the shim classifier as `unconditional throw`.
902
+ "/site/src/lib/vtex-segment.ts":
903
+ 'export function withSegmentCookie(..._args: any[]): any { throw new Error("stub"); }\n',
904
+ "/site/src/loaders/x.ts":
905
+ 'import { withSegmentCookie } from "~/lib/vtex-segment";\nconsole.log(withSegmentCookie);\n',
906
+ });
907
+ const report = runAudit(SITE, fs, { writer });
908
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
909
+ expect(r.fixes).toHaveLength(1);
910
+ expect(r.fixes![0].detail).toContain("@decocms/apps/vtex/utils/segment");
911
+ expect(store["/site/src/loaders/x.ts"]).toContain(
912
+ '"@decocms/apps/vtex/utils/segment"',
913
+ );
914
+ });
915
+
916
+ it("does NOT rewrite mixed swap+refactor surface (getSegmentFromBag is refactor-only)", () => {
917
+ const { fs, writer, store } = makeMutableFs({
918
+ "/site/src/lib/vtex-segment.ts":
919
+ "export function getSegmentFromBag(_req?: any): null { return null; }\n" +
920
+ 'export function withSegmentCookie(..._args: any[]): any { throw new Error("stub"); }\n',
921
+ "/site/src/loaders/x.ts":
922
+ 'import { getSegmentFromBag, withSegmentCookie } from "~/lib/vtex-segment";\n',
923
+ });
924
+ const before = store["/site/src/loaders/x.ts"];
925
+ const report = runAudit(SITE, fs, { writer });
926
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
927
+ // Finding still emitted (audit), but no fix applied (mixed surface).
928
+ expect(r.findings).toHaveLength(1);
929
+ expect(r.fixes).toEqual([]);
930
+ expect(store["/site/src/loaders/x.ts"]).toBe(before);
931
+ });
932
+
933
+ it("does NOT rewrite when the file imports a real impl from the same shim", () => {
934
+ // vtex-intelligent-search exports both `getISCookiesFromBag` (stub →
935
+ // refactor) and `isFilterParam` (real impl, not in STUB_FIX_HINTS).
936
+ // Even if only one symbol is imported, the rule's classifier already
937
+ // skips real impls. But if a file imports BOTH, our --fix must not
938
+ // rewrite — the canonical doesn't expose isFilterParam.
939
+ const { fs, writer, store } = makeMutableFs({
940
+ "/site/src/lib/vtex-intelligent-search.ts":
941
+ "export function getISCookiesFromBag(_r?: any): Record<string,string> { return {}; }\n" +
942
+ "export function isFilterParam(k: string): boolean { return k.startsWith('filter.'); }\n",
943
+ "/site/src/loaders/x.ts":
944
+ 'import { getISCookiesFromBag, isFilterParam } from "~/lib/vtex-intelligent-search";\n',
945
+ });
946
+ const before = store["/site/src/loaders/x.ts"];
947
+ const report = runAudit(SITE, fs, { writer });
948
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
949
+ expect(r.findings).toHaveLength(1);
950
+ expect(r.fixes).toEqual([]);
951
+ expect(store["/site/src/loaders/x.ts"]).toBe(before);
952
+ });
953
+
954
+ it("rewrites multiple files using the same swap-able shim in one pass", () => {
955
+ const { fs, writer, store } = makeMutableFs({
956
+ "/site/src/lib/vtex-transform.ts":
957
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
958
+ "/site/src/loaders/A.ts":
959
+ 'import { toProduct } from "~/lib/vtex-transform";\n',
960
+ "/site/src/loaders/B.ts":
961
+ "import { toProduct } from '~/lib/vtex-transform';\n",
962
+ });
963
+ const report = runAudit(SITE, fs, { writer });
964
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
965
+ expect(r.fixes!.length).toBe(2);
966
+ expect(store["/site/src/loaders/A.ts"]).toContain(
967
+ '"@decocms/apps/vtex/utils/transform"',
968
+ );
969
+ expect(store["/site/src/loaders/B.ts"]).toContain(
970
+ "'@decocms/apps/vtex/utils/transform'",
971
+ );
972
+ });
973
+
974
+ it("preserves the named-imports list verbatim when swapping", () => {
975
+ // The fix only rewrites the FROM clause, not the imported names. Keeps
976
+ // local aliases (`as`) intact.
977
+ const { fs, writer, store } = makeMutableFs({
978
+ "/site/src/lib/vtex-transform.ts":
979
+ "export function toProduct(p: any): unknown { return p as unknown; }\n",
980
+ "/site/src/loaders/x.ts":
981
+ 'import { toProduct as toP } from "~/lib/vtex-transform";\n' +
982
+ "console.log(toP);\n",
983
+ });
984
+ runAudit(SITE, fs, { writer });
985
+ expect(store["/site/src/loaders/x.ts"]).toContain(
986
+ 'import { toProduct as toP } from "@decocms/apps/vtex/utils/transform"',
987
+ );
988
+ });
989
+ });
990
+
991
+ /* ------------------------------------------------------------------ */
992
+ /* W12-F — obsolete-vite-plugins --fix */
993
+ /* ------------------------------------------------------------------ */
994
+
995
+ describe("runAudit — fix mode — obsolete-vite-plugins", () => {
996
+ it("removes a single inline obsolete plugin literal cleanly", () => {
997
+ const before = `import { defineConfig } from "vite";
998
+ export default defineConfig({
999
+ plugins: [
1000
+ react(),
1001
+ {
1002
+ name: "site-manual-chunks",
1003
+ config(_cfg, { command }) {
1004
+ if (command !== "build") return;
1005
+ return { build: { rollupOptions: { output: { manualChunks(_id: string) {} } } } };
1006
+ },
1007
+ },
1008
+ tailwindcss(),
1009
+ ],
1010
+ });
1011
+ `;
1012
+ const { fs, writer, store } = makeMutableFs({
1013
+ "/site/vite.config.ts": before,
1014
+ });
1015
+ const report = runAudit(SITE, fs, { writer });
1016
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
1017
+ expect(r.findings).toHaveLength(1);
1018
+ expect(r.fixes).toHaveLength(1);
1019
+ expect(r.fixes![0].kind).toBe("rewrite-vite-config");
1020
+ expect(r.fixes![0].detail).toContain("site-manual-chunks");
1021
+ const after = store["/site/vite.config.ts"];
1022
+ // Plugin literal is gone; siblings remain intact.
1023
+ expect(after).not.toContain('name: "site-manual-chunks"');
1024
+ expect(after).toContain("react()");
1025
+ expect(after).toContain("tailwindcss()");
1026
+ // The literal's body has been fully unindented out — no dangling braces.
1027
+ expect(after).not.toContain("manualChunks(_id");
1028
+ });
1029
+
1030
+ it("removes the leading `//` comment block attached to the plugin literal", () => {
1031
+ const before = `export default defineConfig({
1032
+ plugins: [
1033
+ decoVitePlugin(),
1034
+ // Override decoVitePlugin's default manualChunks — circular deps
1035
+ // would crash if split.
1036
+ // (mirrors the framework's improved splitting)
1037
+ {
1038
+ name: "site-manual-chunks",
1039
+ config() { return {}; },
1040
+ },
1041
+ tailwindcss(),
1042
+ ],
1043
+ });
1044
+ `;
1045
+ const { fs, writer, store } = makeMutableFs({
1046
+ "/site/vite.config.ts": before,
1047
+ });
1048
+ runAudit(SITE, fs, { writer });
1049
+ const after = store["/site/vite.config.ts"];
1050
+ expect(after).not.toContain("Override decoVitePlugin");
1051
+ expect(after).not.toContain("circular deps");
1052
+ expect(after).not.toContain("framework's improved splitting");
1053
+ expect(after).not.toContain("site-manual-chunks");
1054
+ // decoVitePlugin call still there, no orphan separator left behind.
1055
+ expect(after).toContain("decoVitePlugin(),");
1056
+ expect(after).toContain("tailwindcss()");
1057
+ expect(after).not.toMatch(/,\s*,/);
1058
+ });
1059
+
1060
+ it("removes BOTH obsolete plugins in one pass without disturbing each other", () => {
1061
+ const before = `export default defineConfig({
1062
+ plugins: [
1063
+ decoVitePlugin(),
1064
+ // chunks override
1065
+ {
1066
+ name: "site-manual-chunks",
1067
+ config() { return {}; },
1068
+ },
1069
+ // meta.gen client stub
1070
+ {
1071
+ name: "deco-stub-meta-gen",
1072
+ enforce: "pre" as const,
1073
+ load(id: string) { if (id === "x") return "export default {}"; },
1074
+ },
1075
+ tailwindcss(),
1076
+ ],
1077
+ });
1078
+ `;
1079
+ const { fs, writer, store } = makeMutableFs({
1080
+ "/site/vite.config.ts": before,
1081
+ });
1082
+ const report = runAudit(SITE, fs, { writer });
1083
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
1084
+ expect(r.findings).toHaveLength(2);
1085
+ expect(r.fixes).toHaveLength(1);
1086
+ expect(r.fixes![0].detail).toContain("site-manual-chunks");
1087
+ expect(r.fixes![0].detail).toContain("deco-stub-meta-gen");
1088
+ const after = store["/site/vite.config.ts"];
1089
+ expect(after).not.toContain("site-manual-chunks");
1090
+ expect(after).not.toContain("deco-stub-meta-gen");
1091
+ expect(after).not.toContain("chunks override");
1092
+ expect(after).not.toContain("meta.gen client stub");
1093
+ expect(after).toContain("decoVitePlugin(),");
1094
+ expect(after).toContain("tailwindcss()");
1095
+ });
1096
+
1097
+ it("does not miscount braces inside template literals or strings inside the plugin body", () => {
1098
+ // Template literal containing `${'}'}` and string with `}` must not
1099
+ // throw off the brace walker.
1100
+ const before = `export default defineConfig({
1101
+ plugins: [
1102
+ {
1103
+ name: "site-manual-chunks",
1104
+ load(id: string) {
1105
+ const a = \`a\${'}'}b\`;
1106
+ const b = "}";
1107
+ return id + a + b;
1108
+ },
1109
+ },
1110
+ tailwindcss(),
1111
+ ],
1112
+ });
1113
+ `;
1114
+ const { fs, writer, store } = makeMutableFs({
1115
+ "/site/vite.config.ts": before,
1116
+ });
1117
+ runAudit(SITE, fs, { writer });
1118
+ const after = store["/site/vite.config.ts"];
1119
+ expect(after).not.toContain("site-manual-chunks");
1120
+ expect(after).toContain("tailwindcss()");
1121
+ // Sanity: closing brackets of defineConfig + plugins array preserved.
1122
+ expect(after).toContain("],");
1123
+ expect(after.trim().endsWith("});")).toBe(true);
1124
+ });
1125
+
1126
+ it("is a no-op (no fixes) when no obsolete plugins are present", () => {
1127
+ const before = `export default defineConfig({
1128
+ plugins: [react(), tailwindcss()],
1129
+ });
1130
+ `;
1131
+ const { fs, writer, store } = makeMutableFs({
1132
+ "/site/vite.config.ts": before,
1133
+ });
1134
+ const report = runAudit(SITE, fs, { writer });
1135
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
1136
+ expect(r.findings).toEqual([]);
1137
+ expect(r.fixes).toBeUndefined();
1138
+ expect(store["/site/vite.config.ts"]).toBe(before);
1139
+ });
1140
+
1141
+ it("is idempotent — running --fix twice is a no-op the second time", () => {
1142
+ const before = `export default defineConfig({
1143
+ plugins: [
1144
+ decoVitePlugin(),
1145
+ {
1146
+ name: "deco-stub-meta-gen",
1147
+ load() { return "export default {};"; },
1148
+ },
1149
+ tailwindcss(),
1150
+ ],
1151
+ });
1152
+ `;
1153
+ const { fs, writer, store } = makeMutableFs({
1154
+ "/site/vite.config.ts": before,
1155
+ });
1156
+ runAudit(SITE, fs, { writer });
1157
+ const afterFirst = store["/site/vite.config.ts"];
1158
+ runAudit(SITE, fs, { writer });
1159
+ expect(store["/site/vite.config.ts"]).toBe(afterFirst);
1160
+ });
1161
+
1162
+ it("includes obsolete-vite-plugins in supportsAutoFix", () => {
1163
+ const fs = makeFs({});
1164
+ const report = runAudit(SITE, fs);
1165
+ const supported = report.rules
1166
+ .filter((r) => r.supportsAutoFix)
1167
+ .map((r) => r.rule);
1168
+ expect(supported).toContain("obsolete-vite-plugins");
1169
+ });
1170
+ });
1171
+
1172
+ /* ------------------------------------------------------------------ */
1173
+ /* W13-C — htmx-residue rule */
1174
+ /* ------------------------------------------------------------------ */
1175
+
1176
+ describe("rule: htmx-residue", () => {
1177
+ it("flags any leftover hx-* element in src/ with category breakdown", () => {
1178
+ const fs = makeFs({
1179
+ "/site/src/components/AddToBag.tsx":
1180
+ '<button hx-on:click={() => {}}>buy</button>\n',
1181
+ "/site/src/components/Search.tsx":
1182
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1183
+ });
1184
+ const report = runAudit(SITE, fs);
1185
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1186
+ expect(r.findings).toHaveLength(2);
1187
+ const summary = r.findings.map((f) => f.message).join(" | ");
1188
+ expect(summary).toContain("event-handler=1");
1189
+ expect(summary).toContain("form-swap=1");
1190
+ expect(r.findings[0].fix).toContain("htmx-rewrite.md");
1191
+ });
1192
+
1193
+ it("aggregates multiple occurrences in one file as a single finding", () => {
1194
+ const fs = makeFs({
1195
+ "/site/src/components/Big.tsx":
1196
+ '<button hx-on:click={() => {}}>1</button>\n' +
1197
+ '<button hx-on:click={() => {}}>2</button>\n' +
1198
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1199
+ });
1200
+ const report = runAudit(SITE, fs);
1201
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1202
+ expect(r.findings).toHaveLength(1);
1203
+ expect(r.findings[0].message).toContain("3 hx-* element(s)");
1204
+ expect(r.findings[0].message).toContain("event-handler=2");
1205
+ expect(r.findings[0].message).toContain("form-swap=1");
1206
+ expect(r.findings[0].meta?.total).toBe(3);
1207
+ });
1208
+
1209
+ it("emits warning severity (so --strict exits 2)", () => {
1210
+ const fs = makeFs({
1211
+ "/site/src/x.tsx": '<button hx-on:click={() => {}}>x</button>\n',
1212
+ });
1213
+ const report = runAudit(SITE, fs);
1214
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1215
+ expect(r.findings[0].severity).toBe("warning");
1216
+ });
1217
+
1218
+ it("excludes test files (*.test.tsx, *.spec.ts, __tests__/) — they may legitimately reference hx-*", () => {
1219
+ const fs = makeFs({
1220
+ "/site/src/components/x.test.tsx":
1221
+ '<button hx-on:click={() => {}}>x</button>\n',
1222
+ "/site/src/components/y.spec.ts":
1223
+ 'expect(html).toContain("hx-post=\\"/x\\""); /* doesn\'t hit our regex */\n',
1224
+ "/site/src/__tests__/csrf.tsx":
1225
+ '<form hx-post="/x" hx-target="#r" hx-swap="innerHTML"><input/></form>\n',
1226
+ });
1227
+ const report = runAudit(SITE, fs);
1228
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1229
+ expect(r.findings).toEqual([]);
1230
+ });
1231
+
1232
+ it("does NOT flag files outside src/ (the rule is scoped to migrated React tree)", () => {
1233
+ const fs = makeFs({
1234
+ // A pre-migration site might still have ./components/ at root.
1235
+ // After migration that's gone; if the engineer left some stragglers
1236
+ // in /scripts or /docs they don't block "rewrite-complete" gate.
1237
+ "/site/scripts/legacy.tsx":
1238
+ '<button hx-on:click={() => {}}>x</button>\n',
1239
+ "/site/docs/example.tsx":
1240
+ '<button hx-on:click={() => {}}>x</button>\n',
1241
+ });
1242
+ const report = runAudit(SITE, fs);
1243
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1244
+ expect(r.findings).toEqual([]);
1245
+ });
1246
+
1247
+ it("returns zero findings on a clean migrated tree (the rewrite-complete gate)", () => {
1248
+ const fs = makeFs({
1249
+ "/site/src/components/Real.tsx":
1250
+ '<button onClick={() => {}}>x</button>\n',
1251
+ "/site/src/routes/index.tsx":
1252
+ 'export const Route = createFileRoute("/")({ component: () => <div/> });\n',
1253
+ });
1254
+ const report = runAudit(SITE, fs);
1255
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1256
+ expect(r.findings).toEqual([]);
1257
+ });
1258
+
1259
+ it("reports the line number of the FIRST hx-* element in the file", () => {
1260
+ const fs = makeFs({
1261
+ "/site/src/x.tsx":
1262
+ "import x from 'y';\n" +
1263
+ "// header\n" +
1264
+ '<button hx-on:click={() => {}}>x</button>\n',
1265
+ });
1266
+ const report = runAudit(SITE, fs);
1267
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1268
+ expect(r.findings[0].file).toBe("src/x.tsx:3");
1269
+ expect(r.findings[0].meta?.firstLine).toBe(3);
1270
+ });
1271
+
1272
+ it("does NOT support auto-fix (rewrites are non-mechanical)", () => {
1273
+ const fs = makeFs({});
1274
+ const report = runAudit(SITE, fs);
1275
+ const r = report.rules.find((r) => r.rule === "htmx-residue")!;
1276
+ expect(r.supportsAutoFix).toBe(false);
1277
+ });
1278
+ });
1279
+
1280
+ /* ------------------------------------------------------------------ */
1281
+ /* W15-B-1 — local-framework-duplicate rule */
1282
+ /* ------------------------------------------------------------------ */
1283
+
1284
+ describe("rule: local-framework-duplicate", () => {
1285
+ it("flags src/sdk/clx.ts when content matches the framework export (auto-fixable)", () => {
1286
+ const fs = makeFs({
1287
+ "/site/src/sdk/clx.ts":
1288
+ "export const clx = (...args: (string | null | undefined | false)[]) =>\n" +
1289
+ ' args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n' +
1290
+ "export default clx;\n",
1291
+ });
1292
+ const report = runAudit(SITE, fs);
1293
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1294
+ expect(r.findings).toHaveLength(1);
1295
+ expect(r.findings[0].file).toBe("src/sdk/clx.ts");
1296
+ expect(r.findings[0].message).toContain("pure dup");
1297
+ expect(r.findings[0].meta?.id).toBe("clx");
1298
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(true);
1299
+ expect(r.findings[0].meta?.canonicalImport).toBe("@decocms/start/sdk/clx");
1300
+ });
1301
+
1302
+ it("flags src/sdk/clx.ts when site adds a clsx alias (signature still matches)", () => {
1303
+ const fs = makeFs({
1304
+ "/site/src/sdk/clx.ts":
1305
+ "export const clx = (...args: (string | null | undefined | false)[]) =>\n" +
1306
+ ' args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n' +
1307
+ "export const clsx = clx;\n" +
1308
+ "export default clx;\n",
1309
+ });
1310
+ const report = runAudit(SITE, fs);
1311
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1312
+ expect(r.findings).toHaveLength(1);
1313
+ expect(r.findings[0].meta?.id).toBe("clx");
1314
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(true);
1315
+ });
1316
+
1317
+ it("does NOT flag a clx.ts that has been forked (signature mismatch)", () => {
1318
+ const fs = makeFs({
1319
+ // Realistic fork: uses lodash-style cn from a different package.
1320
+ "/site/src/sdk/clx.ts":
1321
+ 'import { cn } from "lodash";\nexport const clx = cn;\nexport default clx;\n',
1322
+ });
1323
+ const report = runAudit(SITE, fs);
1324
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1325
+ expect(r.findings).toEqual([]);
1326
+ });
1327
+
1328
+ it("flags src/sdk/useSendEvent.ts as warn-only (typing regression risk)", () => {
1329
+ const fs = makeFs({
1330
+ "/site/src/sdk/useSendEvent.ts":
1331
+ 'import { AnalyticsEvent } from "@decocms/apps/commerce/types";\n' +
1332
+ "export const useSendEvent = <E extends AnalyticsEvent>(\n" +
1333
+ " { event, on }: { event: E; on: 'click' | 'view' | 'change' },\n" +
1334
+ ") => ({\n" +
1335
+ ' "data-event": encodeURIComponent(JSON.stringify(event)),\n' +
1336
+ ' "data-event-trigger": on,\n' +
1337
+ "});\n",
1338
+ });
1339
+ const report = runAudit(SITE, fs);
1340
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1341
+ expect(r.findings).toHaveLength(1);
1342
+ expect(r.findings[0].file).toBe("src/sdk/useSendEvent.ts");
1343
+ expect(r.findings[0].message).toContain("partial overlap");
1344
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
1345
+ expect(r.findings[0].fix).toContain("typed AnalyticsEvent generic");
1346
+ });
1347
+
1348
+ it("flags src/sdk/url.ts as warn-only (positional bool → options object call-site rewrite)", () => {
1349
+ const fs = makeFs({
1350
+ "/site/src/sdk/url.ts":
1351
+ "export const relative = (\n" +
1352
+ " link?: string | undefined,\n" +
1353
+ " removeIdSku?: boolean,\n" +
1354
+ ") => {\n" +
1355
+ ' const linkUrl = link ? new URL(link, "http://localhost") : undefined;\n' +
1356
+ " if (linkUrl && removeIdSku) {\n" +
1357
+ ' linkUrl.searchParams.delete("idsku");\n' +
1358
+ ' linkUrl.searchParams.delete("skuId");\n' +
1359
+ " }\n" +
1360
+ " return linkUrl ? `${linkUrl.pathname}` : undefined;\n" +
1361
+ "};\n",
1362
+ });
1363
+ const report = runAudit(SITE, fs);
1364
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1365
+ expect(r.findings).toHaveLength(1);
1366
+ expect(r.findings[0].file).toBe("src/sdk/url.ts");
1367
+ expect(r.findings[0].meta?.id).toBe("url-relative");
1368
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
1369
+ expect(r.findings[0].meta?.canonicalImport).toBe(
1370
+ "@decocms/apps/commerce/sdk/url",
1371
+ );
1372
+ expect(r.findings[0].fix).toContain("stripSearchParams");
1373
+ });
1374
+
1375
+ it("flags src/sdk/useSuggestions.ts as warn-only (factory rewrite needed)", () => {
1376
+ const fs = makeFs({
1377
+ "/site/src/sdk/useSuggestions.ts":
1378
+ 'import { signal } from "~/sdk/signal";\n' +
1379
+ "let queue = Promise.resolve();\n" +
1380
+ 'let latestQuery = "";\n' +
1381
+ "export const useSuggestions = (loader) => {\n" +
1382
+ " const setQuery = (query) => {\n" +
1383
+ " latestQuery = query;\n" +
1384
+ ' queue = queue.then(() => fetch(`/deco/invoke/${loader.__resolveType}`));\n' +
1385
+ " };\n" +
1386
+ " return { setQuery };\n" +
1387
+ "};\n",
1388
+ });
1389
+ const report = runAudit(SITE, fs);
1390
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1391
+ expect(r.findings).toHaveLength(1);
1392
+ expect(r.findings[0].file).toBe("src/sdk/useSuggestions.ts");
1393
+ expect(r.findings[0].meta?.id).toBe("use-suggestions");
1394
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
1395
+ expect(r.findings[0].meta?.canonicalImport).toBe(
1396
+ "@decocms/start/sdk/useSuggestions",
1397
+ );
1398
+ expect(r.findings[0].fix).toContain("createUseSuggestions");
1399
+ });
1400
+
1401
+ it("does NOT flag a useSuggestions.ts that already adopted the factory shim", () => {
1402
+ // Sites that completed the W15-B-2 migration end up with a
1403
+ // 5-line factory shim — no `latestQuery` or `/deco/invoke/`
1404
+ // strings. The rule's signature must NOT fire on the shim.
1405
+ const fs = makeFs({
1406
+ "/site/src/sdk/useSuggestions.ts":
1407
+ 'import { createUseSuggestions } from "@decocms/start/sdk/useSuggestions";\n' +
1408
+ 'import * as Sentry from "@sentry/react";\n' +
1409
+ "export const { useSuggestions } = createUseSuggestions({\n" +
1410
+ " onError: (err) => Sentry.captureException(err),\n" +
1411
+ "});\n",
1412
+ });
1413
+ const report = runAudit(SITE, fs);
1414
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1415
+ const finding = r.findings.find((f) => f.meta?.id === "use-suggestions");
1416
+ expect(finding).toBeUndefined();
1417
+ });
1418
+
1419
+ it("does NOT flag a forked url.ts that no longer carries the removeIdSku flag", () => {
1420
+ // A site that already adopted an options-object-shaped local helper
1421
+ // should not be flagged — the rule's signature is anchored on the
1422
+ // legacy positional-boolean shape.
1423
+ const fs = makeFs({
1424
+ "/site/src/sdk/url.ts":
1425
+ "export const relative = (link?: string, options?: { stripSearchParams?: string[] }) => {\n" +
1426
+ ' if (!link) return undefined;\n' +
1427
+ ' return new URL(link, "https://localhost").pathname;\n' +
1428
+ "};\n",
1429
+ });
1430
+ const report = runAudit(SITE, fs);
1431
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1432
+ // url-relative entry must NOT fire on the canonical-shaped local fork.
1433
+ const urlFinding = r.findings.find((f) => f.meta?.id === "url-relative");
1434
+ expect(urlFinding).toBeUndefined();
1435
+ });
1436
+
1437
+ it("flags src/matchers/location.ts as warn-only (behaviour-superset opportunity)", () => {
1438
+ const fs = makeFs({
1439
+ "/site/src/matchers/location.ts":
1440
+ 'import { registerMatcher } from "@decocms/start/cms";\n' +
1441
+ "export function registerLocationMatcher(): void {\n" +
1442
+ ' registerMatcher("website/matchers/location.ts", (rule, ctx) => {\n' +
1443
+ " const cookies = ctx.cookies ?? {};\n" +
1444
+ " const country = cookies.__cf_geo_country ?? '';\n" +
1445
+ " return Boolean(country);\n" +
1446
+ " });\n" +
1447
+ "}\n",
1448
+ });
1449
+ const report = runAudit(SITE, fs);
1450
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1451
+ expect(r.findings).toHaveLength(1);
1452
+ expect(r.findings[0].file).toBe("src/matchers/location.ts");
1453
+ expect(r.findings[0].meta?.safeToAutoFix).toBe(false);
1454
+ expect(r.findings[0].fix).toContain("registerBuiltinMatchers");
1455
+ });
1456
+
1457
+ it("emits zero findings on a clean tree (no duplicates present)", () => {
1458
+ const fs = makeFs({
1459
+ "/site/src/sections/Hello.tsx":
1460
+ 'import { clx } from "@decocms/start/sdk/clx";\nexport default () => <div className={clx("a")}>x</div>;\n',
1461
+ });
1462
+ const report = runAudit(SITE, fs);
1463
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1464
+ expect(r.findings).toEqual([]);
1465
+ });
1466
+
1467
+ it("emits warning severity for both auto-fixable AND warn-only entries (--strict gates everything)", () => {
1468
+ const fs = makeFs({
1469
+ "/site/src/sdk/clx.ts":
1470
+ 'export const clx = (...args: any[]) => args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n',
1471
+ "/site/src/sdk/useSendEvent.ts":
1472
+ 'export const useSendEvent = (e: any) => ({ "data-event": encodeURIComponent(JSON.stringify(e)) });\n',
1473
+ });
1474
+ const report = runAudit(SITE, fs);
1475
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1476
+ for (const f of r.findings) expect(f.severity).toBe("warning");
1477
+ });
1478
+
1479
+ it("auto-fix rewrites importers using ~/sdk/clx and deletes the file", () => {
1480
+ const { fs, writer, store } = makeMutableFs({
1481
+ "/site/src/sdk/clx.ts":
1482
+ 'export const clx = (...args: any[]) => args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n',
1483
+ "/site/src/components/A.tsx":
1484
+ 'import { clx } from "~/sdk/clx";\nexport default () => clx("x");\n',
1485
+ "/site/src/components/B.tsx":
1486
+ 'import { clx } from "~/sdk/clx";\nimport React from "react";\nexport default () => clx("y");\n',
1487
+ "/site/src/components/Unrelated.tsx":
1488
+ 'import { clx } from "@decocms/start/sdk/clx";\nexport default () => clx("z");\n',
1489
+ });
1490
+ const report = runAudit(SITE, fs, { writer });
1491
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1492
+ expect(r.fixes).toBeDefined();
1493
+ expect(r.fixes!.length).toBe(1);
1494
+ expect(r.fixes![0].kind).toBe("rewrite-imports+delete");
1495
+ expect(r.fixes![0].detail).toContain("rewrote 2 import(s)");
1496
+ // File deleted from the in-memory store
1497
+ expect(store["/site/src/sdk/clx.ts"]).toBeUndefined();
1498
+ // Importers rewritten
1499
+ expect(store["/site/src/components/A.tsx"]).toContain(
1500
+ 'from "@decocms/start/sdk/clx"',
1501
+ );
1502
+ expect(store["/site/src/components/B.tsx"]).toContain(
1503
+ 'from "@decocms/start/sdk/clx"',
1504
+ );
1505
+ // Already-canonical import untouched
1506
+ expect(store["/site/src/components/Unrelated.tsx"]).toContain(
1507
+ 'from "@decocms/start/sdk/clx"',
1508
+ );
1509
+ expect(store["/site/src/components/Unrelated.tsx"]).not.toMatch(/~\/sdk\/clx/);
1510
+ });
1511
+
1512
+ it("auto-fix is a no-op for warn-only entries (does NOT delete partial-overlap files)", () => {
1513
+ const { fs, writer, store } = makeMutableFs({
1514
+ "/site/src/sdk/useSendEvent.ts":
1515
+ 'import { AnalyticsEvent } from "@decocms/apps/commerce/types";\n' +
1516
+ "export const useSendEvent = <E extends AnalyticsEvent>() => ({\n" +
1517
+ ' "data-event": encodeURIComponent("x"),\n' +
1518
+ "});\n",
1519
+ "/site/src/matchers/location.ts":
1520
+ 'import { registerMatcher } from "@decocms/start/cms";\n' +
1521
+ 'registerMatcher("website/matchers/location.ts", () => Boolean(__cf_geo_country));\n',
1522
+ });
1523
+ const report = runAudit(SITE, fs, { writer });
1524
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1525
+ expect(r.findings.length).toBe(2);
1526
+ // Both fixes are no-ops because safeToAutoFix === false.
1527
+ expect(r.fixes ?? []).toEqual([]);
1528
+ // Files preserved.
1529
+ expect(store["/site/src/sdk/useSendEvent.ts"]).toBeDefined();
1530
+ expect(store["/site/src/matchers/location.ts"]).toBeDefined();
1531
+ });
1532
+
1533
+ it("auto-fix runs only on auto-fixable entries when both kinds coexist", () => {
1534
+ const { fs, writer, store } = makeMutableFs({
1535
+ "/site/src/sdk/clx.ts":
1536
+ 'export const clx = (...args: any[]) => args.filter(Boolean).join(" ").replace(/\\s\\s+/g, " ");\n',
1537
+ "/site/src/sdk/useSendEvent.ts":
1538
+ 'export const useSendEvent = (e: any) => ({ "data-event": encodeURIComponent(JSON.stringify(e)) });\n',
1539
+ "/site/src/components/A.tsx":
1540
+ 'import { clx } from "~/sdk/clx";\nexport default () => clx("x");\n',
1541
+ });
1542
+ const report = runAudit(SITE, fs, { writer });
1543
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1544
+ expect(r.findings.length).toBe(2);
1545
+ expect(r.fixes!.length).toBe(1); // only clx auto-fixed
1546
+ expect(r.fixes![0].file).toBe("src/sdk/clx.ts");
1547
+ expect(store["/site/src/sdk/clx.ts"]).toBeUndefined();
1548
+ expect(store["/site/src/sdk/useSendEvent.ts"]).toBeDefined();
1549
+ });
1550
+
1551
+ it("supportsAutoFix is true (the rule has applyFix even though some entries are warn-only)", () => {
1552
+ const fs = makeFs({});
1553
+ const report = runAudit(SITE, fs);
1554
+ const r = report.rules.find((r) => r.rule === "local-framework-duplicate")!;
1555
+ expect(r.supportsAutoFix).toBe(true);
1556
+ });
1557
+ });
1558
+
1559
+ describe("rule: lockfile-multiple", () => {
1560
+ it("flags package-lock.json and yarn.lock when bun.lock exists", () => {
1561
+ const fs = makeFs({
1562
+ "/site/package.json": "{}",
1563
+ "/site/bun.lock": "{}",
1564
+ "/site/package-lock.json": "{}",
1565
+ "/site/yarn.lock": "",
1566
+ });
1567
+ const report = runAudit(SITE, fs);
1568
+ const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
1569
+ expect(r.findings.map((f) => f.file).sort()).toEqual(["package-lock.json", "yarn.lock"]);
1570
+ });
1571
+
1572
+ it("does not flag when only bun.lock is present", () => {
1573
+ const fs = makeFs({
1574
+ "/site/package.json": "{}",
1575
+ "/site/bun.lock": "{}",
1576
+ });
1577
+ const report = runAudit(SITE, fs);
1578
+ const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
1579
+ expect(r.findings).toEqual([]);
1580
+ });
1581
+
1582
+ it("does not fire when bun.lock is absent (lockfile-missing handles that case)", () => {
1583
+ const fs = makeFs({
1584
+ "/site/package.json": "{}",
1585
+ "/site/package-lock.json": "{}",
1586
+ });
1587
+ const report = runAudit(SITE, fs);
1588
+ const r = report.rules.find((r) => r.rule === "lockfile-multiple")!;
1589
+ expect(r.findings).toEqual([]);
1590
+ });
1591
+
1592
+ it("--fix deletes the offending lockfiles", () => {
1593
+ const { fs, writer, store } = makeMutableFs({
1594
+ "/site/package.json": "{}",
1595
+ "/site/bun.lock": "{}",
1596
+ "/site/package-lock.json": "{}",
1597
+ "/site/yarn.lock": "",
1598
+ });
1599
+ runAudit(SITE, fs, { writer });
1600
+ expect(store["/site/package-lock.json"]).toBeUndefined();
1601
+ expect(store["/site/yarn.lock"]).toBeUndefined();
1602
+ expect(store["/site/bun.lock"]).toBeDefined();
1603
+ });
1604
+ });
1605
+
1606
+ describe("rule: lockfile-missing", () => {
1607
+ it("fires when package.json exists without bun.lock", () => {
1608
+ const fs = makeFs({ "/site/package.json": "{}" });
1609
+ const report = runAudit(SITE, fs);
1610
+ const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
1611
+ expect(r.findings).toHaveLength(1);
1612
+ expect(r.findings[0].file).toBe("bun.lock");
1613
+ expect(r.findings[0].fix).toContain("bun install");
1614
+ });
1615
+
1616
+ it("does not fire when bun.lock exists", () => {
1617
+ const fs = makeFs({ "/site/package.json": "{}", "/site/bun.lock": "{}" });
1618
+ const report = runAudit(SITE, fs);
1619
+ const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
1620
+ expect(r.findings).toEqual([]);
1621
+ });
1622
+
1623
+ it("does not fire on a directory without package.json", () => {
1624
+ const fs = makeFs({});
1625
+ const report = runAudit(SITE, fs);
1626
+ const r = report.rules.find((r) => r.rule === "lockfile-missing")!;
1627
+ expect(r.findings).toEqual([]);
1628
+ });
1629
+ });
1630
+
1631
+ describe("rule: lockfile-drift", () => {
1632
+ it("flags a caret range whose locked version is below the floor", () => {
1633
+ const pkg = JSON.stringify({
1634
+ dependencies: { "@decocms/apps": "^1.11.0" },
1635
+ });
1636
+ const lock = `"@decocms/apps@1.6.2"\n`;
1637
+ const fs = makeFs({
1638
+ "/site/package.json": pkg,
1639
+ "/site/bun.lock": lock,
1640
+ });
1641
+ const report = runAudit(SITE, fs);
1642
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1643
+ expect(r.findings).toHaveLength(1);
1644
+ expect(r.findings[0].fix).toContain("@decocms/apps");
1645
+ });
1646
+
1647
+ it("does not flag when locked version satisfies the caret range", () => {
1648
+ const pkg = JSON.stringify({
1649
+ dependencies: { "@decocms/apps": "^1.11.0" },
1650
+ });
1651
+ const lock = `"@decocms/apps@1.13.2"\n`;
1652
+ const fs = makeFs({
1653
+ "/site/package.json": pkg,
1654
+ "/site/bun.lock": lock,
1655
+ });
1656
+ const report = runAudit(SITE, fs);
1657
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1658
+ expect(r.findings).toEqual([]);
1659
+ });
1660
+
1661
+ it("flags a dep that's missing from bun.lock entirely", () => {
1662
+ const pkg = JSON.stringify({
1663
+ dependencies: { "missing-pkg": "^1.0.0" },
1664
+ });
1665
+ const fs = makeFs({
1666
+ "/site/package.json": pkg,
1667
+ "/site/bun.lock": `"other-pkg@1.0.0"\n`,
1668
+ });
1669
+ const report = runAudit(SITE, fs);
1670
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1671
+ expect(r.findings).toHaveLength(1);
1672
+ });
1673
+
1674
+ it("treats * / latest / git specs as satisfied by mere presence", () => {
1675
+ const pkg = JSON.stringify({
1676
+ dependencies: {
1677
+ "loose-dep": "*",
1678
+ "latest-dep": "latest",
1679
+ },
1680
+ });
1681
+ const lock = `"loose-dep@9.9.9"\n"latest-dep@0.0.1"\n`;
1682
+ const fs = makeFs({
1683
+ "/site/package.json": pkg,
1684
+ "/site/bun.lock": lock,
1685
+ });
1686
+ const report = runAudit(SITE, fs);
1687
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1688
+ expect(r.findings).toEqual([]);
1689
+ });
1690
+
1691
+ it("considers devDependencies too", () => {
1692
+ const pkg = JSON.stringify({
1693
+ devDependencies: { typescript: "~5.9.0" },
1694
+ });
1695
+ const lock = `"typescript@5.8.0"\n`;
1696
+ const fs = makeFs({
1697
+ "/site/package.json": pkg,
1698
+ "/site/bun.lock": lock,
1699
+ });
1700
+ const report = runAudit(SITE, fs);
1701
+ const r = report.rules.find((r) => r.rule === "lockfile-drift")!;
1702
+ expect(r.findings).toHaveLength(1);
1703
+ });
1704
+
1705
+ it("internals: satisfiesRange handles caret, tilde, exact, and sentinels", () => {
1706
+ const { satisfiesRange } = _internals;
1707
+ expect(satisfiesRange("1.13.2", "^1.11.0")).toBe(true);
1708
+ expect(satisfiesRange("1.6.2", "^1.11.0")).toBe(false);
1709
+ expect(satisfiesRange("2.0.0", "^1.11.0")).toBe(false);
1710
+ expect(satisfiesRange("0.5.3", "^0.5.0")).toBe(true);
1711
+ expect(satisfiesRange("0.6.0", "^0.5.0")).toBe(false);
1712
+ expect(satisfiesRange("5.9.5", "~5.9.0")).toBe(true);
1713
+ expect(satisfiesRange("5.10.0", "~5.9.0")).toBe(false);
1714
+ expect(satisfiesRange("1.2.3", "1.2.3")).toBe(true);
1715
+ expect(satisfiesRange("1.2.4", "1.2.3")).toBe(false);
1716
+ expect(satisfiesRange("1.2.3", "*")).toBeNull();
1717
+ expect(satisfiesRange("1.2.3", "latest")).toBeNull();
1718
+ expect(satisfiesRange("1.2.3", "git+https://...")).toBeNull();
1719
+ });
1720
+ });
1721
+
1722
+ describe("rule: package-manager-missing", () => {
1723
+ it("fires when packageManager is absent", () => {
1724
+ const fs = makeFs({
1725
+ "/site/package.json": JSON.stringify({ name: "x", license: "MIT" }),
1726
+ });
1727
+ const report = runAudit(SITE, fs);
1728
+ const r = report.rules.find((r) => r.rule === "package-manager-missing")!;
1729
+ expect(r.findings).toHaveLength(1);
1730
+ expect(r.findings[0].fix).toContain("bun@");
1731
+ });
1732
+
1733
+ it("does not fire when packageManager is set", () => {
1734
+ const fs = makeFs({
1735
+ "/site/package.json": JSON.stringify({
1736
+ name: "x",
1737
+ packageManager: "bun@1.3.5",
1738
+ }),
1739
+ });
1740
+ const report = runAudit(SITE, fs);
1741
+ const r = report.rules.find((r) => r.rule === "package-manager-missing")!;
1742
+ expect(r.findings).toEqual([]);
1743
+ });
1744
+
1745
+ it("--fix writes packageManager directly after license, preserving other keys", () => {
1746
+ const initial = JSON.stringify({
1747
+ name: "x",
1748
+ version: "1.0.0",
1749
+ license: "MIT",
1750
+ dependencies: { foo: "^1.0.0" },
1751
+ });
1752
+ const { fs, writer, store } = makeMutableFs({
1753
+ "/site/package.json": initial,
1754
+ });
1755
+ runAudit(SITE, fs, { writer });
1756
+ const updated = JSON.parse(store["/site/package.json"]);
1757
+ expect(updated.packageManager).toMatch(/^bun@/);
1758
+ const keys = Object.keys(updated);
1759
+ expect(keys.indexOf("packageManager")).toBe(keys.indexOf("license") + 1);
1760
+ expect(updated.dependencies).toEqual({ foo: "^1.0.0" });
1761
+ });
1762
+
1763
+ it("--fix appends packageManager when license is absent", () => {
1764
+ const { fs, writer, store } = makeMutableFs({
1765
+ "/site/package.json": JSON.stringify({ name: "x" }),
1766
+ });
1767
+ runAudit(SITE, fs, { writer });
1768
+ const updated = JSON.parse(store["/site/package.json"]);
1769
+ expect(updated.packageManager).toMatch(/^bun@/);
1770
+ });
1771
+ });