@decocms/blocks-cli 8.0.0 → 8.1.0-next.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 (100) hide show
  1. package/package.json +5 -3
  2. package/scripts/analyze-traces.mjs +1 -0
  3. package/scripts/cdn-rules.test.ts +105 -0
  4. package/scripts/cdn-rules.ts +192 -0
  5. package/scripts/deco-migrate-cli.ts +6 -7
  6. package/scripts/fast-deploy-kv.test.ts +172 -0
  7. package/scripts/generate-blocks.test.ts +11 -0
  8. package/scripts/generate-blocks.ts +0 -10
  9. package/scripts/generate-invoke.test.ts +2 -2
  10. package/scripts/generate-invoke.ts +9 -21
  11. package/scripts/generate-loaders.test.ts +2 -2
  12. package/scripts/generate-loaders.ts +0 -10
  13. package/scripts/generate-schema-loader-aliases.test.ts +186 -0
  14. package/scripts/generate-schema-workspaces.test.ts +180 -0
  15. package/scripts/generate-schema.test.ts +68 -29
  16. package/scripts/generate-schema.ts +101 -89
  17. package/scripts/generate-sections.test.ts +104 -35
  18. package/scripts/generate-sections.ts +57 -11
  19. package/scripts/generate-workspaces.test.ts +179 -0
  20. package/scripts/generate.test.ts +1 -2
  21. package/scripts/generate.ts +43 -45
  22. package/scripts/lib/blocks-dedupe.test.ts +1 -1
  23. package/scripts/lib/cf-kv-rest.ts +49 -1
  24. package/scripts/lib/installedPackages.ts +42 -0
  25. package/scripts/lib/invokeSource.ts +18 -0
  26. package/scripts/lib/kv-snapshot.ts +44 -0
  27. package/scripts/lib/read-decofile.ts +13 -2
  28. package/scripts/lib/upgrade-6-to-7.ts +1 -1
  29. package/scripts/lib/wrangler-config.test.ts +49 -0
  30. package/scripts/lib/wrangler-config.ts +31 -0
  31. package/scripts/migrate/analyzers/htmx-analyze.test.ts +6 -6
  32. package/scripts/migrate/analyzers/tailwind-config.ts +95 -1
  33. package/scripts/migrate/config.ts +2 -2
  34. package/scripts/migrate/delete-sets.ts +75 -0
  35. package/scripts/migrate/fast-deploy-scaffold.test.ts +130 -0
  36. package/scripts/migrate/icon-casing.test.ts +63 -0
  37. package/scripts/migrate/phase-analyze.test.ts +66 -4
  38. package/scripts/migrate/phase-analyze.ts +79 -60
  39. package/scripts/migrate/phase-cleanup.test.ts +3 -1
  40. package/scripts/migrate/phase-cleanup.ts +1 -1
  41. package/scripts/migrate/phase-compile.test.ts +3 -1
  42. package/scripts/migrate/phase-report.test.ts +2 -0
  43. package/scripts/migrate/phase-report.ts +3 -4
  44. package/scripts/migrate/phase-scaffold.ts +150 -45
  45. package/scripts/migrate/phase-transform.ts +107 -2
  46. package/scripts/migrate/phase-verify-legacy-specifiers.test.ts +13 -0
  47. package/scripts/migrate/phase-verify.test.ts +3 -1
  48. package/scripts/migrate/phase-verify.ts +87 -33
  49. package/scripts/migrate/post-cleanup/rules.ts +29 -11
  50. package/scripts/migrate/post-cleanup/shim-classify.test.ts +1 -1
  51. package/scripts/migrate/post-cleanup/shim-classify.ts +2 -2
  52. package/scripts/migrate/source-layout.test.ts +43 -0
  53. package/scripts/migrate/source-layout.ts +32 -5
  54. package/scripts/migrate/templates/app-css.test.ts +24 -0
  55. package/scripts/migrate/templates/app-css.ts +27 -0
  56. package/scripts/migrate/templates/ci-workflows.test.ts +179 -0
  57. package/scripts/migrate/templates/ci-yml.ts +182 -0
  58. package/scripts/migrate/templates/cursor-rules.test.ts +3 -3
  59. package/scripts/migrate/templates/hooks.test.ts +3 -1
  60. package/scripts/migrate/templates/lib-utils.ts +3 -3
  61. package/scripts/migrate/templates/main-push-guard-yml.ts +45 -0
  62. package/scripts/migrate/templates/package-json.ts +10 -0
  63. package/scripts/migrate/templates/parity-yml.ts +136 -0
  64. package/scripts/migrate/templates/perf-yml.ts +449 -0
  65. package/scripts/migrate/templates/playwright-yml.ts +127 -0
  66. package/scripts/migrate/templates/react-doctor-yml.ts +46 -0
  67. package/scripts/migrate/templates/routes.test.ts +31 -0
  68. package/scripts/migrate/templates/routes.ts +19 -9
  69. package/scripts/migrate/templates/section-loaders.ts +3 -3
  70. package/scripts/migrate/templates/server-entry-vtex-invoke.test.ts +19 -0
  71. package/scripts/migrate/templates/server-entry.ts +19 -14
  72. package/scripts/migrate/templates/setup.ts +11 -3
  73. package/scripts/migrate/templates/sync-blocks-bot-yml.ts +201 -0
  74. package/scripts/migrate/templates/ui-components.ts +3 -0
  75. package/scripts/migrate/transforms/ctx-compat.test.ts +37 -0
  76. package/scripts/migrate/transforms/ctx-compat.ts +93 -1
  77. package/scripts/migrate/transforms/htmx-on-events.test.ts +1 -1
  78. package/scripts/migrate/transforms/htmx-on-events.ts +2 -2
  79. package/scripts/migrate/transforms/jsx-class-type.test.ts +28 -0
  80. package/scripts/migrate/transforms/jsx.ts +10 -0
  81. package/scripts/migrate/transforms/picture.test.ts +56 -0
  82. package/scripts/migrate/transforms/picture.ts +95 -0
  83. package/scripts/migrate/transforms/tailwind-renames.test.ts +29 -0
  84. package/scripts/migrate/transforms/tailwind-renames.ts +34 -0
  85. package/scripts/migrate/transforms/timer-types.test.ts +30 -0
  86. package/scripts/migrate/transforms/timer-types.ts +48 -0
  87. package/scripts/migrate/transforms/use-script-handlers.test.ts +37 -0
  88. package/scripts/migrate/transforms/use-script-handlers.ts +36 -0
  89. package/scripts/migrate/types.ts +13 -1
  90. package/scripts/migrate-blocks-to-kv.ts +16 -2
  91. package/scripts/migrate-to-cf-observability.test.ts +5 -5
  92. package/scripts/migrate-to-cf-observability.ts +1 -1
  93. package/scripts/migrate.ts +244 -6
  94. package/scripts/reconcile.test.ts +162 -0
  95. package/scripts/reconcile.ts +303 -0
  96. package/scripts/sync-blocks-bot.test.ts +276 -0
  97. package/scripts/sync-blocks-bot.ts +567 -0
  98. package/scripts/sync-blocks-to-kv.ts +20 -2
  99. package/scripts/upgrade-6-to-7.ts +0 -0
  100. package/scripts/lib/legacyArtifact.ts +0 -20
@@ -5,12 +5,15 @@ import type { MigrationContext, TransformResult, SectionMeta } from "./types";
5
5
  import { log, logPhase } from "./types";
6
6
  import { transformImports } from "./transforms/imports";
7
7
  import { transformJsx } from "./transforms/jsx";
8
+ import { transformPicture } from "./transforms/picture";
9
+ import { transformTimerTypes } from "./transforms/timer-types";
8
10
  import { transformFreshApis } from "./transforms/fresh-apis";
9
11
  import { transformCtxCompat } from "./transforms/ctx-compat";
10
12
  import { transformDenoIsms } from "./transforms/deno-isms";
11
13
  import { transformTailwind } from "./transforms/tailwind";
12
14
  import { transformDeadCode } from "./transforms/dead-code";
13
15
  import { transformHtmxOnEvents } from "./transforms/htmx-on-events";
16
+ import { transformUseScriptHandlers } from "./transforms/use-script-handlers";
14
17
  import { createSectionConventionsTransform } from "./transforms/section-conventions";
15
18
 
16
19
  /** Map of section path → metadata, populated per-run */
@@ -28,7 +31,7 @@ function getSectionMeta(ctx: MigrationContext, relPath: string): SectionMeta | u
28
31
 
29
32
  /**
30
33
  * Cached per-run section-conventions closure. Built once from the
31
- * resolved config sets (`ctx.config.sectionConventions`), so casaevideo
34
+ * resolved config sets (`ctx.config.sectionConventions`), so the built-in
32
35
  * defaults still apply when no config file exists.
33
36
  */
34
37
  let cachedSectionTransform:
@@ -67,10 +70,13 @@ function applyTransforms(content: string, filePath: string, ctx?: MigrationConte
67
70
  { name: "imports", fn: (c) => transformImports(c, ctx?.islandWrapperTargets) },
68
71
  { name: "jsx", fn: transformJsx },
69
72
  { name: "htmx-on-events", fn: transformHtmxOnEvents },
73
+ { name: "use-script-handlers", fn: transformUseScriptHandlers },
70
74
  { name: "fresh-apis", fn: transformFreshApis },
71
75
  { name: "ctx-compat", fn: transformCtxCompat },
72
76
  { name: "dead-code", fn: (c) => transformDeadCode(c, ctx?.platform) },
73
77
  { name: "deno-isms", fn: transformDenoIsms },
78
+ { name: "timer-types", fn: transformTimerTypes },
79
+ { name: "picture", fn: transformPicture },
74
80
  { name: "tailwind", fn: transformTailwind },
75
81
  ];
76
82
 
@@ -146,6 +152,37 @@ export function transform(ctx: MigrationContext): void {
146
152
  });
147
153
  }
148
154
 
155
+ // Flag usePartialSection (Fresh load-more / "Ver mais" pattern) with an
156
+ // actionable useLoadMore recipe instead of a generic HTMX warning.
157
+ if (/usePartialSection/.test(result.content)) {
158
+ const hasAppend = /mode\s*:\s*["']append["']/.test(result.content);
159
+ ctx.manualReviewItems.push({
160
+ file: targetPath,
161
+ reason:
162
+ `usePartialSection${hasAppend ? " (mode:append)" : ""} detected — ` +
163
+ "this is the Fresh load-more / \"Ver mais\" pagination pattern. " +
164
+ "In TanStack it is a no-op stub; the button navigates replacing all products instead of appending. " +
165
+ "Convert to useLoadMore from @decocms/blocks/hooks:\n\n" +
166
+ ' import { useLoadMore } from "@decocms/blocks/hooks"\n\n' +
167
+ ' // At the top of your component (must be "use client"):\n' +
168
+ " const { pages, loadMore, loading, hasMore } = useLoadMore(\n" +
169
+ " props.page ?? { products: [], pageInfo: {} },\n" +
170
+ ' "LOADER_KEY" // replace with your loader path, e.g.\n' +
171
+ ' // "apps/vtex.ts/loaders/intelligentSearch/productListingPage.ts"\n' +
172
+ " )\n" +
173
+ " const allProducts = pages.flatMap(p => p.products ?? [])\n\n" +
174
+ " // Replace the usePartialSection anchor/button with:\n" +
175
+ " {hasMore && (\n" +
176
+ ' <button onClick={loadMore} disabled={loading}>\n' +
177
+ ' {loading ? "Carregando..." : "Ver mais"}\n' +
178
+ " </button>\n" +
179
+ " )}\n\n" +
180
+ "Add \"use client\" to the top of the file. " +
181
+ "See deco-to-tanstack-migration skill, 'Ver mais / Load More' section.",
182
+ severity: "warning",
183
+ });
184
+ }
185
+
149
186
  // Flag files with hx-on:click that use useScript (simpler pattern)
150
187
  if (/hx-on:click=\{useScript/.test(result.content)) {
151
188
  ctx.manualReviewItems.push({
@@ -190,7 +227,7 @@ export function transform(ctx: MigrationContext): void {
190
227
  "that ships HTML fragments and swaps them client-side. It does not work on TanStack Start. " +
191
228
  "Recipes: " +
192
229
  "(1) Self-contained UI toggles → keep state in React (`useState` + event handlers); " +
193
- "(2) Form submissions / mutations → `createServerFn` + `useMutation` (see casaevideo-storefront for canonical examples); " +
230
+ "(2) Form submissions / mutations → `createServerFn` + `useMutation` (see a production storefront for canonical examples); " +
194
231
  "(3) Ad-hoc data fetches → call the loader/action via `~/server/invoke` and store results in `useState`. " +
195
232
  "Remove the import after refactoring, then delete `src/sections/Component.tsx`.",
196
233
  severity: "error",
@@ -233,6 +270,74 @@ export function transform(ctx: MigrationContext): void {
233
270
  // the actual file and rewrite the import.
234
271
  if (!ctx.dryRun) {
235
272
  fixIslandImports(ctx);
273
+ reconcileIconCasing(ctx);
274
+ }
275
+ }
276
+
277
+ /**
278
+ * `<Icon id="close">` → `<Icon id="Close">`. Deco-fresh (Deno) didn't strictly
279
+ * typecheck, so sites often used lowercase icon ids that don't match their own
280
+ * PascalCase `AvailableIcons` union — the stricter TanStack typecheck then fails
281
+ * ("Type '\"close\"' is not assignable to type 'AvailableIcons'"). This
282
+ * case-corrects any `<Icon id="X">` whose name matches an AvailableIcons entry
283
+ * case-insensitively. Names with NO match (a genuinely missing icon) are left
284
+ * as-is and flagged for manual review — adding them to the type + sprite is a
285
+ * design decision, not a rename.
286
+ */
287
+ export function reconcileIconCasing(ctx: MigrationContext): void {
288
+ const srcDir = path.join(ctx.sourceDir, "src");
289
+ if (!fs.existsSync(srcDir)) return;
290
+
291
+ const walk = (dir: string, visit: (file: string, content: string) => void) => {
292
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
293
+ if (entry.isDirectory()) {
294
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
295
+ walk(path.join(dir, entry.name), visit);
296
+ } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
297
+ visit(path.join(dir, entry.name), fs.readFileSync(path.join(dir, entry.name), "utf-8"));
298
+ }
299
+ }
300
+ };
301
+
302
+ // 1. Canonical icon names from the declared `AvailableIcons` union (if any).
303
+ const canonical = new Map<string, string>(); // lowercased → canonical casing
304
+ walk(srcDir, (_file, content) => {
305
+ const m = content.match(/export type AvailableIcons\s*=\s*([\s\S]*?);/);
306
+ if (m) for (const n of m[1].matchAll(/["']([A-Za-z0-9_-]+)["']/g)) canonical.set(n[1].toLowerCase(), n[1]);
307
+ });
308
+ if (canonical.size === 0) return;
309
+
310
+ // 2. Case-correct `<Icon id="X">` usages; flag unmatched names once per file.
311
+ const iconIdRe = /(<Icon\b[^>]*?\bid=["'])([A-Za-z0-9_-]+)(["'])/g;
312
+ let corrected = 0;
313
+ walk(srcDir, (file, content) => {
314
+ let modified = false;
315
+ const flagged = new Set<string>();
316
+ const next = content.replace(iconIdRe, (whole, pre, name, post) => {
317
+ const canon = canonical.get(name.toLowerCase());
318
+ if (canon) {
319
+ if (canon !== name) {
320
+ modified = true;
321
+ corrected++;
322
+ return `${pre}${canon}${post}`;
323
+ }
324
+ return whole;
325
+ }
326
+ if (!flagged.has(name)) {
327
+ flagged.add(name);
328
+ ctx.manualReviewItems.push({
329
+ file: path.relative(ctx.sourceDir, file).replace(/\\/g, "/"),
330
+ reason: `<Icon id="${name}"> is not in AvailableIcons — add it to the type in Icon.tsx and the sprite, or the icon renders blank.`,
331
+ severity: "warning",
332
+ });
333
+ }
334
+ return whole;
335
+ });
336
+ if (modified) fs.writeFileSync(file, next, "utf-8");
337
+ });
338
+
339
+ if (corrected > 0) {
340
+ console.log(` Icon casing: corrected ${corrected} <Icon id> to match AvailableIcons`);
236
341
  }
237
342
  }
238
343
 
@@ -17,6 +17,7 @@ import type { MigrationContext } from "./types";
17
17
  function makeCtx(sourceDir: string): MigrationContext {
18
18
  return {
19
19
  sourceDir,
20
+ layout: "classic",
20
21
  siteName: "test-site",
21
22
  platform: "custom",
22
23
  vtexAccount: null,
@@ -25,6 +26,18 @@ function makeCtx(sourceDir: string): MigrationContext {
25
26
  discoveredNpmDeps: {},
26
27
  themeColors: {},
27
28
  fontFamily: null,
29
+ googleFonts: { preconnects: [], stylesheets: [] },
30
+ tailwindConfig: {
31
+ colors: {},
32
+ fontFamily: {},
33
+ screens: {},
34
+ animations: {},
35
+ keyframes: {},
36
+ safelist: [],
37
+ safelistPatterns: [],
38
+ plugins: [],
39
+ reviewItems: [],
40
+ },
28
41
  files: [],
29
42
  sectionMetas: [],
30
43
  islandClassifications: [],
@@ -28,7 +28,9 @@ function makeCtx(sourceDir: string): MigrationContext {
28
28
  importMap: {},
29
29
  discoveredNpmDeps: {},
30
30
  themeColors: {},
31
- tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
31
+ tailwindConfig: { colors: {}, fontFamily: {}, screens: {}, animations: {}, keyframes: {}, safelist: [], safelistPatterns: [], plugins: [], reviewItems: [] },
32
+ layout: "classic",
33
+ googleFonts: { preconnects: [], stylesheets: [] },
32
34
  fontFamily: null,
33
35
  files: [],
34
36
  sectionMetas: [],
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { parseJsonc } from "../lib/jsonc";
3
4
  import type { MigrationContext } from "./types";
4
5
  import { logPhase } from "./types";
5
6
 
@@ -20,8 +21,15 @@ const REQUIRED_FILES = [
20
21
  // Deploy / preview / sync-secrets pipelines are owned by Cloudflare
21
22
  // Workers Builds (D6.3) -- configured in the CF dashboard, not via
22
23
  // GitHub workflow files in the site repo.
23
- ".github/workflows/regen-blocks.yml",
24
24
  ".github/workflows/lockfile-check.yml",
25
+ ".github/workflows/ci.yml",
26
+ ".github/workflows/main-push-guard.yml",
27
+ ".github/workflows/playwright.yml",
28
+ ".github/workflows/react-doctor.yml",
29
+ ".github/workflows/parity.yml",
30
+ ".github/workflows/sync-blocks-bot.yml",
31
+ "tools/gates/no-suppressions.sh",
32
+ "playwright.config.ts",
25
33
  "knip.config.ts",
26
34
  ".prettierrc",
27
35
  "src/server.ts",
@@ -69,9 +77,7 @@ export const checks: Check[] = [
69
77
  name: "All scaffolded files exist",
70
78
  severity: "error",
71
79
  fn: (ctx) => {
72
- const missing = REQUIRED_FILES.filter(
73
- (f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
74
- );
80
+ const missing = REQUIRED_FILES.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
75
81
  if (missing.length > 0) {
76
82
  console.log(` Missing: ${missing.join(", ")}`);
77
83
  return false;
@@ -79,13 +85,50 @@ export const checks: Check[] = [
79
85
  return true;
80
86
  },
81
87
  },
88
+ {
89
+ // Fast Deploy needs THREE things and is inert unless all three are present:
90
+ // the DECO_KV binding, DECO_FAST_DEPLOY=1, and setup.ts handing the KV
91
+ // resolver to blocks-admin. Two of three is the worst state — it looks
92
+ // configured, the Studio publish reports success, and nothing reaches KV.
93
+ name: "Fast Deploy wired end to end",
94
+ severity: "error",
95
+ fn: (ctx) => {
96
+ const missing: string[] = [];
97
+ const wranglerPath = path.join(ctx.sourceDir, "wrangler.jsonc");
98
+ try {
99
+ const cfg = parseJsonc(fs.readFileSync(wranglerPath, "utf-8")) as {
100
+ kv_namespaces?: Array<{ binding?: string }>;
101
+ vars?: Record<string, unknown>;
102
+ };
103
+ const flag = cfg.vars?.DECO_FAST_DEPLOY;
104
+ if (!(cfg.kv_namespaces ?? []).some((n) => n?.binding === "DECO_KV")) {
105
+ missing.push("wrangler.jsonc kv_namespaces is missing the DECO_KV binding");
106
+ }
107
+ if (flag !== "1" && flag !== "true") {
108
+ missing.push('wrangler.jsonc vars.DECO_FAST_DEPLOY is not "1"');
109
+ }
110
+ } catch (e) {
111
+ missing.push(`wrangler.jsonc could not be parsed: ${(e as Error).message}`);
112
+ }
113
+
114
+ const setupPath = path.join(ctx.sourceDir, "src", "setup.ts");
115
+ const setup = fs.existsSync(setupPath) ? fs.readFileSync(setupPath, "utf-8") : "";
116
+ if (!setup.includes("setupTanstackFastDeploy()")) {
117
+ missing.push("src/setup.ts does not call setupTanstackFastDeploy()");
118
+ }
119
+
120
+ if (missing.length > 0) {
121
+ for (const m of missing) console.log(` ${m}`);
122
+ return false;
123
+ }
124
+ return true;
125
+ },
126
+ },
82
127
  {
83
128
  name: "Old artifacts removed",
84
129
  severity: "error",
85
130
  fn: (ctx) => {
86
- const remaining = MUST_NOT_EXIST.filter(
87
- (f) => fs.existsSync(path.join(ctx.sourceDir, f)),
88
- );
131
+ const remaining = MUST_NOT_EXIST.filter((f) => fs.existsSync(path.join(ctx.sourceDir, f)));
89
132
  if (remaining.length > 0) {
90
133
  console.log(` Still exists: ${remaining.join(", ")}`);
91
134
  return false;
@@ -136,14 +179,16 @@ export const checks: Check[] = [
136
179
  },
137
180
  },
138
181
  {
139
- name: 'No class= in JSX (should be className=)',
182
+ name: "No class= in JSX (should be className=)",
140
183
  severity: "warning",
141
184
  fn: (ctx) => {
142
185
  const srcDir = path.join(ctx.sourceDir, "src");
143
186
  if (!fs.existsSync(srcDir)) return true;
144
187
  const bad = findFilesWithPattern(srcDir, /<[a-zA-Z][^>]*\sclass\s*=/);
145
188
  if (bad.length > 0) {
146
- console.log(` Still has class= in JSX: ${bad.slice(0, 5).join(", ")}${bad.length > 5 ? ` (+${bad.length - 5} more)` : ""}`);
189
+ console.log(
190
+ ` Still has class= in JSX: ${bad.slice(0, 5).join(", ")}${bad.length > 5 ? ` (+${bad.length - 5} more)` : ""}`,
191
+ );
147
192
  return false;
148
193
  }
149
194
  return true;
@@ -158,12 +203,8 @@ export const checks: Check[] = [
158
203
  console.log(" public/ directory missing");
159
204
  return false;
160
205
  }
161
- const hasSprites = fs.existsSync(
162
- path.join(publicDir, "sprites.svg"),
163
- );
164
- const hasFavicon = fs.existsSync(
165
- path.join(publicDir, "favicon.ico"),
166
- );
206
+ const hasSprites = fs.existsSync(path.join(publicDir, "sprites.svg"));
207
+ const hasFavicon = fs.existsSync(path.join(publicDir, "favicon.ico"));
167
208
  if (!hasSprites) console.log(" Missing: public/sprites.svg");
168
209
  if (!hasFavicon) console.log(" Missing: public/favicon.ico");
169
210
  return hasSprites && hasFavicon;
@@ -272,7 +313,9 @@ export const checks: Check[] = [
272
313
  const bad = findFilesWithPattern(srcDir, /(?<!<(?:img|Image)[^>]*)-z-\d+/);
273
314
  if (bad.length > 0) {
274
315
  console.log(` Negative z-index on non-image elements: ${bad.join(", ")}`);
275
- console.log(` These may be invisible due to stacking contexts. Replace with z-0 or positive z-index.`);
316
+ console.log(
317
+ ` These may be invisible due to stacking contexts. Replace with z-0 or positive z-index.`,
318
+ );
276
319
  return false;
277
320
  }
278
321
  return true;
@@ -319,7 +362,9 @@ export const checks: Check[] = [
319
362
  // Check that runtime.ts exists and has the invoke proxy
320
363
  const runtimePath = path.join(srcDir, "runtime.ts");
321
364
  if (!fs.existsSync(runtimePath)) {
322
- console.log(` Files use invoke.* but src/runtime.ts is missing: ${hasInvoke.join(", ")}`);
365
+ console.log(
366
+ ` Files use invoke.* but src/runtime.ts is missing: ${hasInvoke.join(", ")}`,
367
+ );
323
368
  return false;
324
369
  }
325
370
  }
@@ -374,7 +419,10 @@ export const checks: Check[] = [
374
419
  fn: (ctx) => {
375
420
  const srcDir = path.join(ctx.sourceDir, "src");
376
421
  if (!fs.existsSync(srcDir)) return true;
377
- const bad = findFilesWithPattern(srcDir, /\bhx-(?:get|post|put|delete|patch|trigger|target|swap|on|indicator|sync|select)\b/);
422
+ const bad = findFilesWithPattern(
423
+ srcDir,
424
+ /\bhx-(?:get|post|put|delete|patch|trigger|target|swap|on|indicator|sync|select)\b/,
425
+ );
378
426
  if (bad.length > 0) {
379
427
  console.log(` HTMX attributes found (needs manual React migration): ${bad.join(", ")}`);
380
428
  return false;
@@ -428,7 +476,9 @@ export const checks: Check[] = [
428
476
  ];
429
477
  const unique = [...new Set(bad)];
430
478
  if (unique.length > 0) {
431
- console.log(` Still references frozen @decocms/start or @decocms/apps/* specifiers: ${unique.join(", ")}`);
479
+ console.log(
480
+ ` Still references frozen @decocms/start or @decocms/apps/* specifiers: ${unique.join(", ")}`,
481
+ );
432
482
  return false;
433
483
  }
434
484
  return true;
@@ -445,9 +495,7 @@ export const checks: Check[] = [
445
495
  "src/setup/commerce-init.ts",
446
496
  "src/setup/section-loaders.ts",
447
497
  ];
448
- const missing = setupFiles.filter(
449
- (f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
450
- );
498
+ const missing = setupFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
451
499
  if (missing.length > 0) {
452
500
  console.log(` Missing setup infrastructure: ${missing.join(", ")}`);
453
501
  return false;
@@ -463,9 +511,13 @@ export const checks: Check[] = [
463
511
  if (fs.existsSync(islandsDir)) {
464
512
  try {
465
513
  const files = fs.readdirSync(islandsDir, { recursive: true });
466
- const tsxFiles = (files as string[]).filter((f: string) => f.endsWith(".tsx") || f.endsWith(".ts"));
514
+ const tsxFiles = (files as string[]).filter(
515
+ (f: string) => f.endsWith(".tsx") || f.endsWith(".ts"),
516
+ );
467
517
  if (tsxFiles.length > 0) {
468
- console.log(` src/islands/ still has ${tsxFiles.length} files — should be moved to components/`);
518
+ console.log(
519
+ ` src/islands/ still has ${tsxFiles.length} files — should be moved to components/`,
520
+ );
469
521
  return false;
470
522
  }
471
523
  } catch {}
@@ -482,9 +534,7 @@ export const checks: Check[] = [
482
534
  "src/hooks/useUser.ts",
483
535
  "src/hooks/useWishlist.ts",
484
536
  ];
485
- const missing = hookFiles.filter(
486
- (f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
487
- );
537
+ const missing = hookFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
488
538
  if (missing.length > 0) {
489
539
  console.log(` Missing hooks: ${missing.join(", ")}`);
490
540
  return false;
@@ -502,9 +552,7 @@ export const checks: Check[] = [
502
552
  "src/types/deco.ts",
503
553
  "src/types/commerce-app.ts",
504
554
  ];
505
- const missing = typeFiles.filter(
506
- (f) => !fs.existsSync(path.join(ctx.sourceDir, f)),
507
- );
555
+ const missing = typeFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f)));
508
556
  if (missing.length > 0) {
509
557
  console.log(` Missing type files: ${missing.join(", ")}`);
510
558
  return false;
@@ -525,7 +573,8 @@ function findFilesWithPattern(
525
573
  for (const entry of entries) {
526
574
  const fullPath = path.join(dir, entry.name);
527
575
  if (entry.isDirectory()) {
528
- if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server") continue;
576
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server")
577
+ continue;
529
578
  findFilesWithPattern(fullPath, pattern, results, root);
530
579
  } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
531
580
  const content = fs.readFileSync(fullPath, "utf-8");
@@ -558,7 +607,8 @@ function findMatchesWithPattern(
558
607
  for (const entry of entries) {
559
608
  const fullPath = path.join(dir, entry.name);
560
609
  if (entry.isDirectory()) {
561
- if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server") continue;
610
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server")
611
+ continue;
562
612
  findMatchesWithPattern(fullPath, pattern, results, root);
563
613
  } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
564
614
  const content = fs.readFileSync(fullPath, "utf-8");
@@ -586,7 +636,11 @@ export function verify(ctx: MigrationContext): boolean {
586
636
 
587
637
  for (const check of checks) {
588
638
  const pass = check.fn(ctx);
589
- const icon = pass ? "\x1b[32m✓\x1b[0m" : check.severity === "error" ? "\x1b[31m✗\x1b[0m" : "\x1b[33m⚠\x1b[0m";
639
+ const icon = pass
640
+ ? "\x1b[32m✓\x1b[0m"
641
+ : check.severity === "error"
642
+ ? "\x1b[31m✗\x1b[0m"
643
+ : "\x1b[33m⚠\x1b[0m";
590
644
  console.log(` ${icon} ${check.name}`);
591
645
  if (!pass) {
592
646
  if (check.severity === "error") errors++;
@@ -1090,8 +1090,8 @@ export const FRAMEWORK_DUPLICATES: FrameworkDuplicate[] = [
1090
1090
  sitePath: "src/sdk/useSuggestions.ts",
1091
1091
  canonicalImport: "@decocms/blocks/sdk/useSuggestions",
1092
1092
  // Fingerprint: hand-rolled hook with the module-level signal +
1093
- // serial-queue + latestQuery cancel pattern. Both casaevideo and
1094
- // baggagio independently invented this exact shape. Sites that
1093
+ // serial-queue + latestQuery cancel pattern. Multiple production sites
1094
+ // independently invented this exact shape. Sites that
1095
1095
  // already adopted `createUseSuggestions(…)` factory calls won't
1096
1096
  // match this signature.
1097
1097
  contentSignature: [
@@ -1702,9 +1702,21 @@ const ruleVtexProxyHandlerMissing: Rule = {
1702
1702
  const MODULE_SIGNAL_RE =
1703
1703
  /^(?:export\s+)?const\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:\w+\.)?signal\s*\(/m;
1704
1704
 
1705
+ // Well-known signals returned by useUI(). These are never declared with
1706
+ // `const x = signal(...)` at module scope — they come from a hook — so
1707
+ // MODULE_SIGNAL_RE won't catch them. The list is stable: it's the public
1708
+ // API surface of useUI() across all deco storefronts.
1709
+ const WELL_KNOWN_UI_SIGNALS = new Set([
1710
+ "displayCart",
1711
+ "displayMenu",
1712
+ "displayNav",
1713
+ "displaySearchbar",
1714
+ "displaySearchPopup",
1715
+ ]);
1716
+
1705
1717
  const ruleSignalValueReads: Rule = {
1706
1718
  id: "signal-value-reads",
1707
- title: "Module-level signal .value reads without useStore()",
1719
+ title: "Signal .value reads without subscription (freezes UI)",
1708
1720
  run({ siteDir, fs }: RuleContext): Finding[] {
1709
1721
  const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
1710
1722
 
@@ -1724,7 +1736,13 @@ const ruleSignalValueReads: Rule = {
1724
1736
  }
1725
1737
  }
1726
1738
 
1727
- if (moduleSignals.size === 0) return [];
1739
+ // Also check well-known useUI() signals — they never appear as module-level
1740
+ // declarations, so MODULE_SIGNAL_RE never picks them up.
1741
+ for (const name of WELL_KNOWN_UI_SIGNALS) {
1742
+ if (!moduleSignals.has(name)) {
1743
+ moduleSignals.set(name, "useUI()");
1744
+ }
1745
+ }
1728
1746
 
1729
1747
  // Pre-compile per-signal regexes once — avoids N×M compilations in Pass 2.
1730
1748
  const valueReMap = new Map<string, RegExp>(
@@ -1732,7 +1750,8 @@ const ruleSignalValueReads: Rule = {
1732
1750
  );
1733
1751
 
1734
1752
  // Pass 2: for each .tsx file, check per-signal whether .value is read
1735
- // without a matching useStore(name.store call in the same file.
1753
+ // without a matching subscription in the same file.
1754
+ // Accepts either useStore(name.store or useSignalValue(name as subscriptions.
1736
1755
  const tsxFiles = tsFiles.filter((f) => f.endsWith(".tsx"));
1737
1756
  const findings: Finding[] = [];
1738
1757
 
@@ -1743,18 +1762,17 @@ const ruleSignalValueReads: Rule = {
1743
1762
  for (const [name, declaredIn] of moduleSignals) {
1744
1763
  const valueRe = valueReMap.get(name)!;
1745
1764
  if (!valueRe.test(content)) continue;
1746
- // Per-signal subscription check: useStore(name.store is the canonical
1747
- // subscription pattern. A file that subscribes to a different signal
1748
- // is not considered subscribed to this one.
1749
- const subscribedRe = new RegExp(`\\buseStore\\s*\\(\\s*${name}\\.store\\b`);
1765
+ const subscribedRe = new RegExp(
1766
+ `\\buseStore\\s*\\(\\s*${name}\\.store\\b|\\buseSignalValue\\s*\\(\\s*${name}\\b`,
1767
+ );
1750
1768
  if (subscribedRe.test(content)) continue;
1751
1769
 
1752
1770
  findings.push({
1753
1771
  rule: "signal-value-reads",
1754
1772
  severity: "warning",
1755
1773
  file: rel,
1756
- message: `'${name}.value' read without useStore() — component will not re-render when the signal changes`,
1757
- fix: `Use useStore(${name}.store, (s) => s) to subscribe, or replace with useQuery / useMutation`,
1774
+ message: `'${name}.value' read without subscription — component will not re-render when the signal changes`,
1775
+ fix: `Replace with \`const ${name}Value = useSignalValue(${name})\` (from ~/sdk/signal) or \`useStore(${name}.store)\``,
1758
1776
  meta: { signalName: name, declaredIn },
1759
1777
  });
1760
1778
  }
@@ -183,7 +183,7 @@ describe("classifyShimExports — type/interface declarations", () => {
183
183
  });
184
184
  });
185
185
 
186
- describe("classifyShimExports — real casaevideo-storefront fixtures", () => {
186
+ describe("classifyShimExports — real production storefront fixtures", () => {
187
187
  it("vtex-segment.ts (mixed: stub + functional)", () => {
188
188
  const code = `
189
189
  export function getSegmentFromBag(_req?: any): Record<string, unknown> | null {
@@ -26,11 +26,11 @@
26
26
  * would need real semantic analysis. False negatives are tolerable; the
27
27
  * rule still warns when *any* imported symbol from the shim is a clear
28
28
  * stub, which is enough to surface the real-world regressions we've
29
- * actually seen on production sites (casaevideo: `getSegmentFromBag`,
29
+ * actually seen on production sites (e.g. `getSegmentFromBag`,
30
30
  * `getISCookiesFromBag`, `toProduct`).
31
31
  *
32
32
  * Implementation note — string parsing, not a real TypeScript AST. The
33
- * shim files are tiny by design (the casaevideo ones are 1-39 lines).
33
+ * shim files are tiny by design (the real ones are 1-39 lines).
34
34
  * A balanced-brace body extractor + small set of stub patterns covers
35
35
  * every case observed on real sites. If this ever needs to handle
36
36
  * decorators, generics on consts, or weirder JSX forms, the right move
@@ -19,6 +19,26 @@ function makeFs(paths: string[]): FsLike {
19
19
  };
20
20
  }
21
21
 
22
+ /**
23
+ * FsLike where each dir path is tagged as populated (has files) or empty.
24
+ * `dirsWithFiles` exist AND report `hasFiles: true`; `emptyDirs` exist but
25
+ * report `hasFiles: false` (leftover stubs).
26
+ */
27
+ function makeFsWithFiles(dirsWithFiles: string[], emptyDirs: string[] = []): FsLike {
28
+ const norm = (p: string) => p.replace(/\\/g, "/");
29
+ const populated = new Set(dirsWithFiles.map(norm));
30
+ const empty = new Set(emptyDirs.map(norm));
31
+ return {
32
+ existsSync(p) {
33
+ const n = norm(p);
34
+ return populated.has(n) || empty.has(n);
35
+ },
36
+ hasFiles(p) {
37
+ return populated.has(norm(p));
38
+ },
39
+ };
40
+ }
41
+
22
42
  const SITE = "/site";
23
43
 
24
44
  describe("detectSourceLayout — classic layout", () => {
@@ -64,6 +84,29 @@ describe("detectSourceLayout — mixed layout", () => {
64
84
  });
65
85
  });
66
86
 
87
+ describe("detectSourceLayout — empty leftover dirs (hasFiles)", () => {
88
+ it("ignores an EMPTY root sections/ next to a populated src/sections/ → modern", () => {
89
+ // The real portal-davinci case: leftover empty `sections/` + real `src/sections/`.
90
+ const fs = makeFsWithFiles(["/site/src/sections"], ["/site/sections"]);
91
+ expect(detectSourceLayout(SITE, fs)).toBe("modern");
92
+ });
93
+
94
+ it("ignores an EMPTY src/sections/ next to a populated root sections/ → classic", () => {
95
+ const fs = makeFsWithFiles(["/site/sections"], ["/site/src/sections"]);
96
+ expect(detectSourceLayout(SITE, fs)).toBe("classic");
97
+ });
98
+
99
+ it("still flags genuinely mixed (both populated) as mixed", () => {
100
+ const fs = makeFsWithFiles(["/site/sections", "/site/src/sections"]);
101
+ expect(detectSourceLayout(SITE, fs)).toBe("mixed");
102
+ });
103
+
104
+ it("empty dirs on both sides → empty", () => {
105
+ const fs = makeFsWithFiles([], ["/site/sections", "/site/src/sections"]);
106
+ expect(detectSourceLayout(SITE, fs)).toBe("empty");
107
+ });
108
+ });
109
+
67
110
  describe("detectSourceLayout — empty layout", () => {
68
111
  it("returns empty when neither root nor src/ has recognised dirs", () => {
69
112
  const fs = makeFs(["/site/package.json", "/site/README.md"]);
@@ -31,19 +31,46 @@ const RECOGNISED_DIRS = ["sections", "islands", "components", "loaders", "action
31
31
 
32
32
  export interface FsLike {
33
33
  existsSync(p: string): boolean;
34
+ /**
35
+ * True when `p` is a directory containing at least one FILE (recursively).
36
+ * An empty leftover dir (e.g. a `sections/` stub next to a populated
37
+ * `src/sections/`) must NOT count as a layout, or it triggers a false
38
+ * "mixed layout" abort. Optional so existing `{ existsSync }` stubs keep
39
+ * working — when absent, mere existence counts (previous behaviour).
40
+ */
41
+ hasFiles?(p: string): boolean;
34
42
  }
35
43
 
36
- const realFs: FsLike = { existsSync: fs.existsSync };
44
+ /** Recursively: does this dir hold at least one file? Empty dirs → false. */
45
+ function realHasFiles(p: string): boolean {
46
+ let entries: fs.Dirent[];
47
+ try {
48
+ entries = fs.readdirSync(p, { withFileTypes: true });
49
+ } catch {
50
+ return false;
51
+ }
52
+ for (const e of entries) {
53
+ if (e.isFile()) return true;
54
+ if (e.isDirectory() && realHasFiles(path.join(p, e.name))) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ const realFs: FsLike = { existsSync: fs.existsSync, hasFiles: realHasFiles };
37
60
 
38
61
  /**
39
62
  * Classify the source directory's layout. Pure function — accepts
40
63
  * a `FsLike` so unit tests can stub the disk without mocking node:fs.
41
64
  */
42
65
  export function detectSourceLayout(sourceDir: string, fsAdapter: FsLike = realFs): SourceLayout {
43
- const hasRootDir = RECOGNISED_DIRS.some((d) => fsAdapter.existsSync(path.join(sourceDir, d)));
44
- const hasSrcDir = RECOGNISED_DIRS.some((d) =>
45
- fsAdapter.existsSync(path.join(sourceDir, "src", d)),
46
- );
66
+ // A recognised dir only "counts" if it exists AND holds files — an empty
67
+ // leftover `sections/` next to a populated `src/sections/` is not a real
68
+ // second layout and must not trigger a "mixed" abort. Falls back to mere
69
+ // existence when the adapter doesn't implement hasFiles (old test stubs).
70
+ const counts = (p: string): boolean =>
71
+ fsAdapter.existsSync(p) && (fsAdapter.hasFiles ? fsAdapter.hasFiles(p) : true);
72
+ const hasRootDir = RECOGNISED_DIRS.some((d) => counts(path.join(sourceDir, d)));
73
+ const hasSrcDir = RECOGNISED_DIRS.some((d) => counts(path.join(sourceDir, "src", d)));
47
74
 
48
75
  if (hasRootDir && hasSrcDir) return "mixed";
49
76
  if (hasSrcDir) return "modern";