@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,237 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Scans site section files and extracts convention-based metadata:
4
+ * - export const eager = true → alwaysEager
5
+ * - export const cache = "listing" → registerCacheableSections
6
+ * - export const layout = true → registerLayoutSections
7
+ * - export const sync = true → registerSectionsSync (bundled, not lazy)
8
+ * - export const clientOnly = true → registerSection with clientOnly
9
+ * - export const seo = true → registerSeoSections
10
+ * - export function LoadingFallback → registerSection with loadingFallback
11
+ *
12
+ * Emits sections.gen.ts with metadata + sync imports for sections marked sync=true.
13
+ *
14
+ * Usage (from site root):
15
+ * npx tsx node_modules/@decocms/blocks-cli/scripts/generate-sections.ts
16
+ *
17
+ * CLI:
18
+ * --sections-dir override input (default: src/sections)
19
+ * --out-file override output (default: src/server/cms/sections.gen.ts)
20
+ */
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+
24
+ const args = process.argv.slice(2);
25
+ function arg(name: string, fallback: string): string {
26
+ const idx = args.indexOf(`--${name}`);
27
+ return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback;
28
+ }
29
+
30
+ const sectionsDir = path.resolve(process.cwd(), arg("sections-dir", "src/sections"));
31
+ const outFile = path.resolve(process.cwd(), arg("out-file", "src/server/cms/sections.gen.ts"));
32
+
33
+ interface SectionMeta {
34
+ eager?: boolean;
35
+ neverDefer?: boolean;
36
+ cache?: string;
37
+ layout?: boolean;
38
+ sync?: boolean;
39
+ clientOnly?: boolean;
40
+ seo?: boolean;
41
+ hasLoadingFallback?: boolean;
42
+ }
43
+
44
+ const EXPORT_CONST_RE = /export\s+const\s+(eager|neverDefer|cache|layout|sync|clientOnly|seo)\s*=\s*(.+?)(?:;|\n)/g;
45
+ // Detects `export function LoadingFallback(...)`, `export const LoadingFallback = ...`, etc.
46
+ const LOADING_FALLBACK_INLINE_RE = /export\s+(?:function|const|let|var)\s+LoadingFallback\b/;
47
+ // Detects re-exports like:
48
+ // export { LoadingFallback } from "..."
49
+ // export { default as LoadingFallback } from "..."
50
+ // export { Foo as LoadingFallback } from "..."
51
+ // False-positive for `export { LoadingFallback as Foo }` (exports `Foo`, not
52
+ // `LoadingFallback`), but that's unrealistic in section files and would surface
53
+ // as a loud build error rather than silent CLS.
54
+ const LOADING_FALLBACK_REEXPORT_RE = /export\s*\{[^}]*\bLoadingFallback\b[^}]*\}/;
55
+
56
+ function hasLoadingFallbackExport(content: string): boolean {
57
+ return (
58
+ LOADING_FALLBACK_INLINE_RE.test(content) ||
59
+ LOADING_FALLBACK_REEXPORT_RE.test(content)
60
+ );
61
+ }
62
+
63
+ function extractMeta(content: string): SectionMeta | null {
64
+ const meta: SectionMeta = {};
65
+ let found = false;
66
+
67
+ for (const match of content.matchAll(EXPORT_CONST_RE)) {
68
+ const key = match[1] as keyof SectionMeta;
69
+ const rawValue = match[2].trim().replace(/['"]/g, "");
70
+ found = true;
71
+
72
+ if (key === "cache") {
73
+ meta.cache = rawValue;
74
+ } else if (rawValue === "true") {
75
+ (meta as any)[key] = true;
76
+ }
77
+ }
78
+
79
+ if (hasLoadingFallbackExport(content)) {
80
+ meta.hasLoadingFallback = true;
81
+ found = true;
82
+ }
83
+
84
+ return found ? meta : null;
85
+ }
86
+
87
+ function walkDir(dir: string, base: string = dir): string[] {
88
+ const results: string[] = [];
89
+ if (!fs.existsSync(dir)) return results;
90
+
91
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
92
+ const fullPath = path.join(dir, entry.name);
93
+ if (entry.isDirectory()) {
94
+ results.push(...walkDir(fullPath, base));
95
+ } else if (entry.name.endsWith(".tsx") || entry.name.endsWith(".ts")) {
96
+ results.push(fullPath);
97
+ }
98
+ }
99
+ return results;
100
+ }
101
+
102
+ function fileToSectionKey(filePath: string, _sectionsDir: string): string {
103
+ const rel = path.relative(_sectionsDir, filePath).replace(/\\/g, "/");
104
+ return `site/sections/${rel}`;
105
+ }
106
+
107
+ function relativeImportPath(from: string, to: string): string {
108
+ let rel = path.relative(path.dirname(from), to).replace(/\\/g, "/");
109
+ if (!rel.startsWith(".")) rel = `./${rel}`;
110
+ return rel.replace(/\.tsx?$/, "");
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+
115
+ if (!fs.existsSync(sectionsDir)) {
116
+ console.warn(`Sections directory not found: ${sectionsDir} — generating empty output.`);
117
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
118
+ fs.writeFileSync(outFile, [
119
+ "// Auto-generated — no sections found.",
120
+ "export const sectionMeta = {};",
121
+ "",
122
+ ].join("\n"));
123
+ process.exit(0);
124
+ }
125
+
126
+ const sectionFiles = walkDir(sectionsDir);
127
+ const entries: Array<{ key: string; meta: SectionMeta; filePath: string }> = [];
128
+
129
+ for (const filePath of sectionFiles) {
130
+ const content = fs.readFileSync(filePath, "utf-8");
131
+ const meta = extractMeta(content);
132
+ if (!meta) continue;
133
+ const key = fileToSectionKey(filePath, sectionsDir);
134
+ entries.push({ key, meta, filePath });
135
+ }
136
+
137
+ const syncEntries = entries.filter((e) => e.meta.sync);
138
+ const fallbackEntries = entries.filter((e) => e.meta.hasLoadingFallback);
139
+
140
+ const lines: string[] = [
141
+ "// Auto-generated by @decocms/blocks-cli/scripts/generate-sections.ts",
142
+ "// Do not edit manually. Add convention exports to your section files instead.",
143
+ "//",
144
+ "// Supported conventions:",
145
+ "// export const eager = true → prefer eager (defers past fold threshold)",
146
+ "// export const neverDefer = true → ALWAYS eager, ignores fold threshold",
147
+ "// export const cache = \"listing\" → SWR-cached section loader results",
148
+ "// export const layout = true → cached as layout (Header, Footer, Theme)",
149
+ "// export const sync = true → bundled synchronously (not lazy-loaded)",
150
+ "// export const clientOnly = true → skip SSR (client-only rendering)",
151
+ "// export const seo = true → SEO section (provides page head data)",
152
+ "// export function LoadingFallback → skeleton shown while section loads",
153
+ "",
154
+ ];
155
+
156
+ // Sync imports — sections marked sync=true get static imports for registerSectionsSync
157
+ for (let i = 0; i < syncEntries.length; i++) {
158
+ const e = syncEntries[i];
159
+ const importPath = relativeImportPath(outFile, e.filePath);
160
+ const varName = `_sync${i}`;
161
+ lines.push(`import * as ${varName} from "${importPath}";`);
162
+ }
163
+
164
+ // LoadingFallback imports — sections with LoadingFallback that aren't sync-imported
165
+ const nonSyncFallbacks = fallbackEntries.filter((e) => !e.meta.sync);
166
+ for (let i = 0; i < nonSyncFallbacks.length; i++) {
167
+ const e = nonSyncFallbacks[i];
168
+ const importPath = relativeImportPath(outFile, e.filePath);
169
+ lines.push(`import { LoadingFallback as _fb${i} } from "${importPath}";`);
170
+ }
171
+
172
+ lines.push("");
173
+
174
+ // Metadata map
175
+ lines.push("export interface SectionMetaEntry {");
176
+ lines.push(" eager?: boolean;");
177
+ lines.push(" cache?: string;");
178
+ lines.push(" layout?: boolean;");
179
+ lines.push(" sync?: boolean;");
180
+ lines.push(" clientOnly?: boolean;");
181
+ lines.push(" seo?: boolean;");
182
+ lines.push(" hasLoadingFallback?: boolean;");
183
+ lines.push("}");
184
+ lines.push("");
185
+ lines.push("export const sectionMeta: Record<string, SectionMetaEntry> = {");
186
+ for (const e of entries) {
187
+ const props = Object.entries(e.meta)
188
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? `"${v}"` : v}`)
189
+ .join(", ");
190
+ lines.push(` "${e.key}": { ${props} },`);
191
+ }
192
+ lines.push("};");
193
+ lines.push("");
194
+
195
+ // Sync components map
196
+ if (syncEntries.length > 0) {
197
+ lines.push("export const syncComponents: Record<string, any> = {");
198
+ for (let i = 0; i < syncEntries.length; i++) {
199
+ lines.push(` "${syncEntries[i].key}": _sync${i},`);
200
+ }
201
+ lines.push("};");
202
+ } else {
203
+ lines.push("export const syncComponents: Record<string, any> = {};");
204
+ }
205
+ lines.push("");
206
+
207
+ // LoadingFallback components map
208
+ const allFallbacks = entries.filter((e) => e.meta.hasLoadingFallback);
209
+ if (allFallbacks.length > 0) {
210
+ lines.push("export const loadingFallbacks: Record<string, React.ComponentType<any>> = {");
211
+ for (const e of allFallbacks) {
212
+ if (e.meta.sync) {
213
+ const syncIdx = syncEntries.indexOf(e);
214
+ lines.push(` "${e.key}": _sync${syncIdx}.LoadingFallback,`);
215
+ } else {
216
+ const fbIdx = nonSyncFallbacks.indexOf(e);
217
+ lines.push(` "${e.key}": _fb${fbIdx},`);
218
+ }
219
+ }
220
+ lines.push("};");
221
+ } else {
222
+ lines.push("export const loadingFallbacks: Record<string, React.ComponentType<any>> = {};");
223
+ }
224
+ lines.push("");
225
+
226
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
227
+ fs.writeFileSync(outFile, lines.join("\n"));
228
+
229
+ console.log(
230
+ `Generated section metadata for ${entries.length} sections → ${path.relative(process.cwd(), outFile)}`,
231
+ );
232
+ console.log(
233
+ ` ${syncEntries.length} sync, ${allFallbacks.length} with LoadingFallback, ` +
234
+ `${entries.filter((e) => e.meta.eager).length} eager, ` +
235
+ `${entries.filter((e) => e.meta.layout).length} layout, ` +
236
+ `${entries.filter((e) => e.meta.cache).length} cached`,
237
+ );
@@ -0,0 +1,226 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * HTMX surface analyzer — CLI entry point.
4
+ *
5
+ * Inventories the `hx-*` surface of a Deco storefront so the engineer
6
+ * (or the next codemod wave) knows exactly what shapes are out there
7
+ * before starting the rewrite to React. Per D2 in the migration
8
+ * tooling policy, all htmx is rewritten on migration; no runtime is
9
+ * shipped in `@decocms/start`.
10
+ *
11
+ * Usage (from a site directory):
12
+ * npx -p @decocms/start deco-htmx-analyze
13
+ * npx -p @decocms/start deco-htmx-analyze --source /path/to/site
14
+ * npx -p @decocms/start deco-htmx-analyze --json
15
+ *
16
+ * Options:
17
+ * --source <dir> Site directory to analyze (default: current directory)
18
+ * --json Emit machine-readable JSON instead of pretty text
19
+ * --top <n> Show top N files by occurrence count (default: 20)
20
+ * --help, -h Show this help
21
+ *
22
+ * Wave 13-A. Read-only. Codemods land in Wave 14.
23
+ */
24
+
25
+ import * as path from "node:path";
26
+ import { realFsAdapter } from "./migrate/post-cleanup/runner";
27
+ import {
28
+ analyzeHtmx,
29
+ type HtmxCategory,
30
+ type HtmxInventory,
31
+ } from "./migrate/analyzers/htmx-analyze";
32
+ import { banner, bold, cyan, gray, green, red, yellow } from "./migrate/colors";
33
+
34
+ interface CliOpts {
35
+ source: string;
36
+ json: boolean;
37
+ top: number;
38
+ help: boolean;
39
+ }
40
+
41
+ function parseArgs(args: string[]): CliOpts {
42
+ let source = ".";
43
+ let json = false;
44
+ let top = 20;
45
+ let help = false;
46
+ for (let i = 0; i < args.length; i++) {
47
+ switch (args[i]) {
48
+ case "--source":
49
+ source = args[++i];
50
+ break;
51
+ case "--json":
52
+ json = true;
53
+ break;
54
+ case "--top":
55
+ top = Number.parseInt(args[++i] ?? "20", 10);
56
+ if (Number.isNaN(top) || top < 0) top = 20;
57
+ break;
58
+ case "--help":
59
+ case "-h":
60
+ help = true;
61
+ break;
62
+ default:
63
+ console.error(`Unknown argument: ${args[i]}`);
64
+ process.exit(1);
65
+ }
66
+ }
67
+ return { source, json, top, help };
68
+ }
69
+
70
+ function printHelp(): void {
71
+ console.log(`deco-htmx-analyze
72
+
73
+ Inventory the htmx surface (hx-* attributes) of a Deco storefront.
74
+
75
+ Usage:
76
+ npx -p @decocms/start deco-htmx-analyze [options]
77
+
78
+ Options:
79
+ --source <dir> Site directory to analyze (default: cwd)
80
+ --json Emit machine-readable JSON
81
+ --top <n> Top N files by occurrence count (default: 20)
82
+ --help, -h Show this help
83
+
84
+ The output is read-only. Codemods that rewrite htmx to React are a
85
+ planned follow-up — see the deco-to-tanstack-migration skill for the
86
+ per-pattern rewrite recipes.
87
+ `);
88
+ }
89
+
90
+ const CATEGORY_DESCRIPTIONS: Record<HtmxCategory, string> = {
91
+ "event-handler": "hx-on:* with no fetch attr — pure client-side handler",
92
+ "form-swap": "hx-post on a <form> with hx-target/hx-swap",
93
+ "click-swap": "hx-get/hx-post on a button with hx-target",
94
+ "auto-fetch": "hx-trigger=keyup/intersect/etc on input or auto-fired element",
95
+ "oob-swap": "hx-swap-oob / hx-select-oob — out-of-band patches",
96
+ boost: "hx-boost=true — link prefetch, already-SPA in TanStack Start",
97
+ unmatched: "hx-* attribute set that didn't match a known pattern",
98
+ };
99
+
100
+ const CATEGORY_RECIPES: Record<HtmxCategory, string> = {
101
+ "event-handler":
102
+ "replace hx-on:click={useScript(...)} with onClick={() => { ... }}",
103
+ "form-swap":
104
+ "<form onSubmit> + useMutation/server function; render result with state",
105
+ "click-swap":
106
+ "setState/setView + conditional render, or sub-route via TanStack Router",
107
+ "auto-fetch":
108
+ "debounced state + useQuery; for intersect use IntersectionObserver",
109
+ "oob-swap":
110
+ "manual: out-of-band has no 1:1; refactor to broadcast event + listener",
111
+ boost: "replace <a hx-boost> with TanStack Router <Link> (already SPA)",
112
+ unmatched: "manual review",
113
+ };
114
+
115
+ const CATEGORY_ORDER: HtmxCategory[] = [
116
+ "event-handler",
117
+ "click-swap",
118
+ "form-swap",
119
+ "auto-fetch",
120
+ "boost",
121
+ "oob-swap",
122
+ "unmatched",
123
+ ];
124
+
125
+ function printText(inv: HtmxInventory, top: number): void {
126
+ banner("HTMX surface analysis");
127
+
128
+ if (inv.totalOccurrences === 0) {
129
+ console.log(green("✓ No hx-* attributes found.\n"));
130
+ return;
131
+ }
132
+
133
+ console.log(`${bold("Files with hx-* usage:")} ${inv.totalFiles}`);
134
+ console.log(`${bold("Total occurrences:")} ${inv.totalOccurrences}\n`);
135
+
136
+ console.log(bold("By category:"));
137
+ for (const cat of CATEGORY_ORDER) {
138
+ const count = inv.byCategory[cat];
139
+ if (count === 0) continue;
140
+ const label = `${cat.padEnd(15)}`;
141
+ const desc = gray(CATEGORY_DESCRIPTIONS[cat]);
142
+ console.log(` ${cyan(label)} ${String(count).padStart(4)} ${desc}`);
143
+ }
144
+
145
+ console.log(`\n${bold("Migration recipes:")}`);
146
+ for (const cat of CATEGORY_ORDER) {
147
+ if (inv.byCategory[cat] === 0) continue;
148
+ console.log(` ${cyan(cat.padEnd(15))} ${gray(CATEGORY_RECIPES[cat])}`);
149
+ }
150
+
151
+ console.log(`\n${bold(`Top ${top} files by occurrence:`)}`);
152
+ const slice = inv.files.slice(0, top);
153
+ const widest = Math.max(...slice.map((f) => f.file.length), 30);
154
+ for (const f of slice) {
155
+ const detail = CATEGORY_ORDER.filter((c) => f.byCategory[c] > 0)
156
+ .map((c) => `${c}=${f.byCategory[c]}`)
157
+ .join(", ");
158
+ console.log(
159
+ ` ${f.file.padEnd(widest)} ${String(f.total).padStart(3)} ${gray(detail)}`,
160
+ );
161
+ }
162
+
163
+ if (inv.files.length > top) {
164
+ console.log(gray(` …and ${inv.files.length - top} more file(s)`));
165
+ }
166
+
167
+ console.log(`\n${bold("Sample call sites:")}`);
168
+ for (const cat of CATEGORY_ORDER) {
169
+ const samples = inv.samples[cat];
170
+ if (samples.length === 0) continue;
171
+ console.log(` ${cyan(cat)}`);
172
+ for (const s of samples) {
173
+ const attrs = s.attrs.join(", ");
174
+ console.log(
175
+ ` ${gray(`${s.file}:${s.line}`)} <${s.tag}> [${attrs}]`,
176
+ );
177
+ }
178
+ }
179
+
180
+ const hasOob = inv.byCategory["oob-swap"] > 0;
181
+ const hasUnmatched = inv.byCategory.unmatched > 0;
182
+ if (hasOob || hasUnmatched) {
183
+ console.log();
184
+ if (hasOob) {
185
+ console.log(
186
+ yellow(
187
+ "⚠ oob-swap occurrences require manual rewrite — no 1:1 React equivalent.",
188
+ ),
189
+ );
190
+ }
191
+ if (hasUnmatched) {
192
+ console.log(
193
+ yellow(
194
+ "⚠ unmatched occurrences require manual review — see Sample call sites.",
195
+ ),
196
+ );
197
+ }
198
+ }
199
+ console.log();
200
+ }
201
+
202
+ function main(argv: string[]): number {
203
+ const opts = parseArgs(argv);
204
+ if (opts.help) {
205
+ printHelp();
206
+ return 0;
207
+ }
208
+
209
+ const sourceDir = path.resolve(opts.source);
210
+ const inv = analyzeHtmx(sourceDir, realFsAdapter);
211
+
212
+ if (opts.json) {
213
+ console.log(JSON.stringify(inv, null, 2));
214
+ } else {
215
+ printText(inv, opts.top);
216
+ }
217
+ return 0;
218
+ }
219
+
220
+ try {
221
+ process.exit(main(process.argv.slice(2)));
222
+ } catch (err) {
223
+ console.error(red(`✗ deco-htmx-analyze failed: ${(err as Error).message}`));
224
+ if (process.env.DEBUG) console.error((err as Error).stack);
225
+ process.exit(1);
226
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ blockHasPath,
4
+ type Candidate,
5
+ decodeBlockName,
6
+ decodeBlockNameWithPasses,
7
+ mergeCandidates,
8
+ pickWinner,
9
+ } from "./blocks-dedupe";
10
+
11
+ const cand = (overrides: Partial<Candidate> & { file: string }): Candidate => ({
12
+ passes: 0,
13
+ mtimeMs: 0,
14
+ hasPath: true,
15
+ parsed: { path: "/" },
16
+ ...overrides,
17
+ });
18
+
19
+ describe("decodeBlockNameWithPasses", () => {
20
+ it("returns the literal key with a 0 pass count when filename has no encoding", () => {
21
+ expect(decodeBlockNameWithPasses("Header.json")).toEqual({ name: "Header", passes: 0 });
22
+ });
23
+
24
+ it("decodes a single layer of URL encoding", () => {
25
+ expect(decodeBlockNameWithPasses("pages-Home%20-%20LB-618509.json")).toEqual({
26
+ name: "pages-Home - LB-618509",
27
+ passes: 1,
28
+ });
29
+ });
30
+
31
+ it("decodes the bot's double-encoded scheme through to the literal key", () => {
32
+ // Bot encodes the raw prod URL-encoded key once: `encodeURIComponent("pages-Home%20-%20LB-618509")`.
33
+ expect(decodeBlockNameWithPasses("pages-Home%2520-%2520LB-618509.json")).toEqual({
34
+ name: "pages-Home - LB-618509",
35
+ passes: 2,
36
+ });
37
+ });
38
+
39
+ it("stops decoding when a literal % survives a round", () => {
40
+ // `%` alone isn't valid encoding — the loop catches the throw and stops.
41
+ expect(decodeBlockNameWithPasses("weird%percent.json")).toEqual({
42
+ name: "weird%percent",
43
+ passes: 0,
44
+ });
45
+ });
46
+ });
47
+
48
+ describe("decodeBlockName", () => {
49
+ it("matches decodeBlockNameWithPasses on the name", () => {
50
+ expect(decodeBlockName("pages-Home%2520-%2520LB-618509.json")).toBe("pages-Home - LB-618509");
51
+ });
52
+ });
53
+
54
+ describe("blockHasPath", () => {
55
+ it("returns true for live page blocks", () => {
56
+ expect(blockHasPath({ path: "/", sections: [] })).toBe(true);
57
+ });
58
+
59
+ it("returns false when path is null (zombie entry)", () => {
60
+ expect(blockHasPath({ path: null, sections: [] })).toBe(false);
61
+ });
62
+
63
+ it("returns false when path is missing", () => {
64
+ expect(blockHasPath({ sections: [] })).toBe(false);
65
+ });
66
+
67
+ it("returns false for empty path strings", () => {
68
+ expect(blockHasPath({ path: "" })).toBe(false);
69
+ });
70
+
71
+ it("returns false for non-objects", () => {
72
+ expect(blockHasPath(null)).toBe(false);
73
+ expect(blockHasPath("/")).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("pickWinner", () => {
78
+ it("prefers a candidate with a real path over a zombie", () => {
79
+ const live = cand({ file: "live.json", hasPath: true });
80
+ const zombie = cand({ file: "zombie.json", hasPath: false, parsed: { path: null } });
81
+ expect(pickWinner(live, zombie)).toBe(live);
82
+ expect(pickWinner(zombie, live)).toBe(live);
83
+ });
84
+
85
+ it("prefers higher decode-pass count when path-status matches", () => {
86
+ // The lebiscuit reproduction case: a stale single-encoded leftover with a
87
+ // newer mtime and larger size loses to the bot's double-encoded fresh file.
88
+ const stale = cand({
89
+ file: "pages-Home%20-%20LB-618509.json",
90
+ passes: 1,
91
+ mtimeMs: 2_000_000,
92
+ });
93
+ const fresh = cand({
94
+ file: "pages-Home%2520-%2520LB-618509.json",
95
+ passes: 2,
96
+ mtimeMs: 1_000_000,
97
+ });
98
+ expect(pickWinner(stale, fresh)).toBe(fresh);
99
+ });
100
+
101
+ it("falls through to mtime when pass count ties", () => {
102
+ const older = cand({ file: "a.json", passes: 1, mtimeMs: 1 });
103
+ const newer = cand({ file: "b.json", passes: 1, mtimeMs: 2 });
104
+ expect(pickWinner(older, newer)).toBe(newer);
105
+ });
106
+
107
+ it("falls through to lex filename when everything else ties", () => {
108
+ const a = cand({ file: "a.json", passes: 0, mtimeMs: 5 });
109
+ const b = cand({ file: "b.json", passes: 0, mtimeMs: 5 });
110
+ expect(pickWinner(a, b)).toBe(a);
111
+ expect(pickWinner(b, a)).toBe(a);
112
+ });
113
+ });
114
+
115
+ describe("mergeCandidates", () => {
116
+ it("returns each candidate unchanged when there are no collisions", () => {
117
+ const a = cand({ file: "Header.json" });
118
+ const b = cand({ file: "Footer.json" });
119
+ const result = mergeCandidates([
120
+ { candidate: a, key: "Header" },
121
+ { candidate: b, key: "Footer" },
122
+ ]);
123
+ expect(result.collisions).toEqual([]);
124
+ expect(result.winners).toEqual({ Header: a, Footer: b });
125
+ });
126
+
127
+ it("records a collision and picks the winner", () => {
128
+ const stale = cand({ file: "pages-Home%20-%20LB-618509.json", passes: 1, mtimeMs: 5 });
129
+ const fresh = cand({ file: "pages-Home%2520-%2520LB-618509.json", passes: 2, mtimeMs: 1 });
130
+ const result = mergeCandidates([
131
+ { candidate: stale, key: "pages-Home - LB-618509" },
132
+ { candidate: fresh, key: "pages-Home - LB-618509" },
133
+ ]);
134
+ expect(result.winners["pages-Home - LB-618509"]).toBe(fresh);
135
+ expect(result.collisions).toEqual([
136
+ {
137
+ key: "pages-Home - LB-618509",
138
+ files: ["pages-Home%20-%20LB-618509.json", "pages-Home%2520-%2520LB-618509.json"],
139
+ winner: "pages-Home%2520-%2520LB-618509.json",
140
+ },
141
+ ]);
142
+ });
143
+
144
+ it("collapses three-way collisions into one record without dropping the winner", () => {
145
+ const a = cand({ file: "a.json", passes: 0, mtimeMs: 1 });
146
+ const b = cand({ file: "b.json", passes: 1, mtimeMs: 2 });
147
+ const c = cand({ file: "c.json", passes: 2, mtimeMs: 3 });
148
+ const result = mergeCandidates([
149
+ { candidate: a, key: "k" },
150
+ { candidate: b, key: "k" },
151
+ { candidate: c, key: "k" },
152
+ ]);
153
+ expect(result.winners.k).toBe(c);
154
+ expect(result.collisions).toHaveLength(1);
155
+ expect(result.collisions[0].winner).toBe("c.json");
156
+ // a, b, and c should all be tracked (a and b as ignored, c as winner).
157
+ expect(new Set(result.collisions[0].files)).toEqual(new Set(["a.json", "b.json", "c.json"]));
158
+ });
159
+
160
+ it("prefers the live page over a zombie even when zombie has more passes", () => {
161
+ const livePlain = cand({
162
+ file: "Home.json",
163
+ passes: 0,
164
+ hasPath: true,
165
+ parsed: { path: "/" },
166
+ });
167
+ const zombieEncoded = cand({
168
+ file: "Home%2520.json",
169
+ passes: 2,
170
+ hasPath: false,
171
+ parsed: { path: null },
172
+ });
173
+ const result = mergeCandidates([
174
+ { candidate: zombieEncoded, key: "Home" },
175
+ { candidate: livePlain, key: "Home" },
176
+ ]);
177
+ expect(result.winners.Home).toBe(livePlain);
178
+ });
179
+ });