@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,246 @@
1
+ /**
2
+ * Per-export classifier for `~/lib/vtex-*` shim files.
3
+ *
4
+ * The post-migration audit's `vtex-shim-regression` rule used to flag
5
+ * any import from these shims. That was overconfident — some shims have
6
+ * functional implementations of utility code (cookie parsing, fetch
7
+ * wrapping), while others are silent stubs (`return null`, `return {}`,
8
+ * identity casts) that drop runtime data and cause hard-to-trace bugs.
9
+ *
10
+ * This classifier inspects each export of a shim and labels it as:
11
+ *
12
+ * - **stub**: definitely a silent regression — body is `return null`,
13
+ * `return {}`, `return []`, an identity cast `return x as T`, or an
14
+ * unconditional `throw`. Importing this symbol means the shim is
15
+ * pretending to do work but isn't.
16
+ * - **type-only**: `interface` or `type` declaration — no runtime impact,
17
+ * never a regression.
18
+ * - **functional**: anything else (real implementations, even trivial
19
+ * ones like `return key.startsWith("filter.")`). Default-safe label —
20
+ * if we can't prove it's a stub, treat it as functional and don't flag.
21
+ *
22
+ * Trade-off — by design, we err toward "functional" when uncertain.
23
+ * That means some lossy implementations (e.g. a `withSegmentCookie`
24
+ * that returns `new Headers()` instead of attaching the cookie) classify
25
+ * as functional even though they're effectively stubs. Catching those
26
+ * would need real semantic analysis. False negatives are tolerable; the
27
+ * rule still warns when *any* imported symbol from the shim is a clear
28
+ * stub, which is enough to surface the real-world regressions we've
29
+ * actually seen on production sites (casaevideo: `getSegmentFromBag`,
30
+ * `getISCookiesFromBag`, `toProduct`).
31
+ *
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).
34
+ * A balanced-brace body extractor + small set of stub patterns covers
35
+ * every case observed on real sites. If this ever needs to handle
36
+ * decorators, generics on consts, or weirder JSX forms, the right move
37
+ * is to swap in `typescript`'s `createSourceFile` — but we don't pay
38
+ * that dependency cost until it's clearly needed.
39
+ */
40
+
41
+ export type ExportClass = "stub" | "type-only" | "functional";
42
+
43
+ export interface ClassifiedExport {
44
+ name: string;
45
+ class: ExportClass;
46
+ /** Short, human-readable reason for the classification. */
47
+ reason?: string;
48
+ }
49
+
50
+ /**
51
+ * Strip line/block comments from a string before regex-matching the
52
+ * body. Keeps newlines so positions stay roughly aligned for debug.
53
+ */
54
+ function stripComments(s: string): string {
55
+ return s
56
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
57
+ .replace(/\/\/[^\n]*/g, "");
58
+ }
59
+
60
+ /**
61
+ * Find the matching `}` for the `{` at `openIdx`. Returns the index of
62
+ * the closing brace, or -1 if unbalanced. Tolerates strings and template
63
+ * literals that may contain stray braces — we honor the basic quoting
64
+ * rules, not full TS lexer fidelity.
65
+ */
66
+ function findMatchingBrace(content: string, openIdx: number): number {
67
+ let depth = 0;
68
+ let i = openIdx;
69
+ let inStr: string | null = null;
70
+ let esc = false;
71
+ while (i < content.length) {
72
+ const c = content[i];
73
+ if (inStr) {
74
+ if (esc) {
75
+ esc = false;
76
+ } else if (c === "\\") {
77
+ esc = true;
78
+ } else if (c === inStr) {
79
+ inStr = null;
80
+ }
81
+ } else if (c === '"' || c === "'" || c === "`") {
82
+ inStr = c;
83
+ } else if (c === "{") {
84
+ depth++;
85
+ } else if (c === "}") {
86
+ depth--;
87
+ if (depth === 0) return i;
88
+ }
89
+ i++;
90
+ }
91
+ return -1;
92
+ }
93
+
94
+ /**
95
+ * Match `return null` / `return {}` / `return []` / `return X as Y` /
96
+ * `throw new Error(...)` as the only meaningful statement in the body.
97
+ * Comments and whitespace are tolerated.
98
+ */
99
+ function classifyBodyText(body: string): { class: ExportClass; reason?: string } | null {
100
+ const cleaned = stripComments(body).trim();
101
+ // Allow a trailing semicolon, but not multiple statements.
102
+ const single = cleaned.replace(/;\s*$/, "").trim();
103
+ if (single === "") return null;
104
+ // Reject anything that looks like multiple statements.
105
+ if (/[;\n]/.test(single.replace(/\s+/g, " ").trim()) && !/^return\s/.test(single)) {
106
+ return null;
107
+ }
108
+ if (/^return\s+null$/.test(single)) {
109
+ return { class: "stub", reason: "returns null" };
110
+ }
111
+ if (/^return\s+\{\s*\}$/.test(single)) {
112
+ return { class: "stub", reason: "returns empty object" };
113
+ }
114
+ if (/^return\s+\[\s*\]$/.test(single)) {
115
+ return { class: "stub", reason: "returns empty array" };
116
+ }
117
+ if (/^return\s+""$/.test(single) || /^return\s+''$/.test(single)) {
118
+ return { class: "stub", reason: "returns empty string" };
119
+ }
120
+ // Identity cast: `return ident as Type`. The right-hand side must be a
121
+ // bare identifier (or nested member like `obj.prop`) — anything else is
122
+ // probably real work.
123
+ const identityMatch = single.match(
124
+ /^return\s+([A-Za-z_][A-Za-z0-9_.]*)\s+as\s+[A-Za-z_][A-Za-z0-9_<>,\s.|&]*$/,
125
+ );
126
+ if (identityMatch) {
127
+ return { class: "stub", reason: `identity cast (return ${identityMatch[1]} as …)` };
128
+ }
129
+ if (/^throw\s+new\s+\w+\s*\(/.test(single)) {
130
+ return { class: "stub", reason: "unconditional throw" };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Locate `export function NAME(args): RT { body }` and `export async`
137
+ * variants. Returns each export with its body classified.
138
+ */
139
+ function classifyFunctionDecls(content: string): ClassifiedExport[] {
140
+ const out: ClassifiedExport[] = [];
141
+ // Anchored at start-of-line + optional indent — avoids catching
142
+ // `export default function` (which has no name immediately after).
143
+ const re = /(?:^|\n)[ \t]*export\s+(?:async\s+)?function\s+([A-Za-z_][A-Za-z0-9_]*)\b/g;
144
+ for (const m of content.matchAll(re)) {
145
+ const name = m[1];
146
+ const startSearchAt = (m.index ?? 0) + m[0].length;
147
+ const openIdx = content.indexOf("{", startSearchAt);
148
+ if (openIdx === -1) {
149
+ out.push({ name, class: "functional", reason: "no body found (declaration only)" });
150
+ continue;
151
+ }
152
+ const closeIdx = findMatchingBrace(content, openIdx);
153
+ if (closeIdx === -1) {
154
+ out.push({ name, class: "functional", reason: "unbalanced braces" });
155
+ continue;
156
+ }
157
+ const body = content.slice(openIdx + 1, closeIdx);
158
+ const verdict = classifyBodyText(body);
159
+ out.push(
160
+ verdict
161
+ ? { name, class: verdict.class, reason: verdict.reason }
162
+ : { name, class: "functional" },
163
+ );
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * Locate `export const NAME = (args) => …` and classify the arrow body.
170
+ * Block-bodied arrows reuse the function-body classifier; expression
171
+ * bodies are matched directly.
172
+ */
173
+ function classifyConstArrowDecls(content: string): ClassifiedExport[] {
174
+ const out: ClassifiedExport[] = [];
175
+ const re = /(?:^|\n)[ \t]*export\s+const\s+([A-Za-z_][A-Za-z0-9_]*)\s*=/g;
176
+ for (const m of content.matchAll(re)) {
177
+ const name = m[1];
178
+ const startIdx = (m.index ?? 0) + m[0].length;
179
+ const after = content.slice(startIdx);
180
+ // Look for: optional `async`, `(args)`, optional `: ReturnType`, `=>`.
181
+ const arrowHead = after.match(/^\s*(?:async\s+)?\([^)]*\)\s*(?::[^=]+)?=>\s*/);
182
+ if (!arrowHead) {
183
+ out.push({ name, class: "functional" });
184
+ continue;
185
+ }
186
+ const bodyStart = startIdx + arrowHead[0].length;
187
+ // Block body: classify the inside via the shared body classifier.
188
+ if (content[bodyStart] === "{") {
189
+ const closeIdx = findMatchingBrace(content, bodyStart);
190
+ if (closeIdx === -1) {
191
+ out.push({ name, class: "functional", reason: "unbalanced braces" });
192
+ continue;
193
+ }
194
+ const body = content.slice(bodyStart + 1, closeIdx);
195
+ const verdict = classifyBodyText(body);
196
+ out.push(
197
+ verdict
198
+ ? { name, class: verdict.class, reason: verdict.reason }
199
+ : { name, class: "functional" },
200
+ );
201
+ continue;
202
+ }
203
+ // Expression body: read until line break / semicolon.
204
+ const exprMatch = content.slice(bodyStart).match(/^([^;\n]+?)\s*;?\s*(?:\n|$)/);
205
+ if (!exprMatch) {
206
+ out.push({ name, class: "functional" });
207
+ continue;
208
+ }
209
+ const expr = exprMatch[1].trim();
210
+ if (expr === "null") {
211
+ out.push({ name, class: "stub", reason: "arrow returns null" });
212
+ } else if (/^\(\s*\{\s*\}\s*\)$/.test(expr) || expr === "{}") {
213
+ out.push({ name, class: "stub", reason: "arrow returns empty object" });
214
+ } else if (/^\[\s*\]$/.test(expr)) {
215
+ out.push({ name, class: "stub", reason: "arrow returns empty array" });
216
+ } else {
217
+ out.push({ name, class: "functional" });
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * Locate `export interface NAME` / `export type NAME` declarations.
225
+ */
226
+ function classifyTypeDecls(content: string): ClassifiedExport[] {
227
+ const out: ClassifiedExport[] = [];
228
+ const re = /(?:^|\n)[ \t]*export\s+(?:interface|type)\s+([A-Za-z_][A-Za-z0-9_]*)\b/g;
229
+ for (const m of content.matchAll(re)) {
230
+ out.push({ name: m[1], class: "type-only" });
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * Classify every top-level export in a shim file. Returns one entry per
237
+ * export, in source order is not guaranteed (we run three passes over
238
+ * the content); callers should look up by `name`.
239
+ */
240
+ export function classifyShimExports(content: string): ClassifiedExport[] {
241
+ return [
242
+ ...classifyFunctionDecls(content),
243
+ ...classifyConstArrowDecls(content),
244
+ ...classifyTypeDecls(content),
245
+ ];
246
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Post-migration cleanup audit — shared types.
3
+ *
4
+ * The audit runner is a thin orchestrator: it loads the site, runs each
5
+ * rule, and prints the findings. The interesting bits live in `rules.ts`.
6
+ *
7
+ * Rules are pure(ish) functions over an injected `FsAdapter`, which means
8
+ * they can be unit-tested with an in-memory file system and never touch
9
+ * the real disk in CI.
10
+ */
11
+
12
+ export type Severity = "info" | "warning";
13
+
14
+ export interface Finding {
15
+ /** Stable rule identifier (e.g. "dead-lib-shims"). */
16
+ rule: string;
17
+ severity: Severity;
18
+ /** Site-relative path of the file the finding refers to. */
19
+ file: string;
20
+ /** One-line message — shown in default text output. */
21
+ message: string;
22
+ /** Suggested human action, if any. */
23
+ fix?: string;
24
+ /** Free-form structured payload for JSON consumers. */
25
+ meta?: Record<string, unknown>;
26
+ }
27
+
28
+ export interface RuleSummary {
29
+ /** Stable rule identifier. */
30
+ rule: string;
31
+ /** Human-readable section title. */
32
+ title: string;
33
+ findings: Finding[];
34
+ /** Populated only when fix mode is on. */
35
+ fixes?: FixAction[];
36
+ /**
37
+ * True when the rule has an `applyFix` implementation. Lets the CLI
38
+ * tell users which findings would auto-fix vs require manual work.
39
+ */
40
+ supportsAutoFix: boolean;
41
+ }
42
+
43
+ export interface AuditReport {
44
+ site: string;
45
+ rules: RuleSummary[];
46
+ totalFindings: number;
47
+ /** Total fix actions across all rules (0 if not in fix mode). */
48
+ totalFixActions: number;
49
+ }
50
+
51
+ /**
52
+ * Minimal file-system adapter — read + glob. Keeping the surface tiny
53
+ * is what lets us pass an in-memory map in unit tests.
54
+ */
55
+ export interface FsAdapter {
56
+ exists(absPath: string): boolean;
57
+ readText(absPath: string): string;
58
+ /**
59
+ * Return absolute paths matching the glob, ordered by path. Globs are
60
+ * relative to `siteDir`. Implementations must respect `excludeDirs` and
61
+ * skip them entirely.
62
+ */
63
+ glob(siteDir: string, pattern: string, excludeDirs?: string[]): string[];
64
+ }
65
+
66
+ export interface RuleContext {
67
+ siteDir: string;
68
+ fs: FsAdapter;
69
+ }
70
+
71
+ /**
72
+ * Mutating side of the FS adapter. Kept separate from `FsAdapter` so
73
+ * read-only audits (the default) cannot accidentally write. Tests
74
+ * substitute a recorder that captures actions without touching disk.
75
+ */
76
+ export interface FsWriter {
77
+ deleteFile(absPath: string): void;
78
+ writeText(absPath: string, content: string): void;
79
+ }
80
+
81
+ /**
82
+ * One concrete change applied (or that would have been applied) by a
83
+ * rule's `applyFix` implementation. Consumed by the CLI to render a
84
+ * summary, and by the JSON output for CI dashboards.
85
+ */
86
+ export interface FixAction {
87
+ /** Site-relative path the action targets. */
88
+ file: string;
89
+ /** "delete" | "rewrite-imports" | future: "edit" — kept open. */
90
+ kind: string;
91
+ /** Human-readable description, e.g. "deleted" or "rewrote 44 imports". */
92
+ detail: string;
93
+ }
94
+
95
+ export interface Rule {
96
+ id: string;
97
+ title: string;
98
+ run(ctx: RuleContext): Finding[];
99
+ /**
100
+ * Optional. Implement for rules whose findings can be safely
101
+ * auto-corrected. Called only when the runner is in fix mode.
102
+ * Must return one or more `FixAction`s describing what changed
103
+ * (used both for output and for tests with a stubbed writer).
104
+ */
105
+ applyFix?(ctx: RuleContext, findings: Finding[], writer: FsWriter): FixAction[];
106
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ detectSourceLayout,
4
+ explainNonClassicLayout,
5
+ type FsLike,
6
+ type SourceLayout,
7
+ } from "./source-layout";
8
+
9
+ /**
10
+ * In-memory FsLike for tests. Holds a Set of paths that "exist" — no
11
+ * content needed since `detectSourceLayout` only calls `existsSync`.
12
+ */
13
+ function makeFs(paths: string[]): FsLike {
14
+ const set = new Set(paths.map((p) => p.replace(/\\/g, "/")));
15
+ return {
16
+ existsSync(p: string) {
17
+ return set.has(p.replace(/\\/g, "/"));
18
+ },
19
+ };
20
+ }
21
+
22
+ const SITE = "/site";
23
+
24
+ describe("detectSourceLayout — classic layout", () => {
25
+ it("classifies a site with sections/ at root as classic", () => {
26
+ const fs = makeFs(["/site/sections"]);
27
+ expect(detectSourceLayout(SITE, fs)).toBe("classic");
28
+ });
29
+
30
+ it("classifies multi-dir root layout as classic", () => {
31
+ const fs = makeFs(["/site/sections", "/site/islands", "/site/components", "/site/loaders"]);
32
+ expect(detectSourceLayout(SITE, fs)).toBe("classic");
33
+ });
34
+
35
+ it("any single recognised root dir is enough", () => {
36
+ for (const d of ["sections", "islands", "components", "loaders", "actions"]) {
37
+ const fs = makeFs([`/site/${d}`]);
38
+ expect(detectSourceLayout(SITE, fs)).toBe("classic");
39
+ }
40
+ });
41
+ });
42
+
43
+ describe("detectSourceLayout — modern layout", () => {
44
+ it("classifies src/sections-only as modern", () => {
45
+ const fs = makeFs(["/site/src/sections"]);
46
+ expect(detectSourceLayout(SITE, fs)).toBe("modern");
47
+ });
48
+
49
+ it("classifies multi-dir src/ layout as modern", () => {
50
+ const fs = makeFs(["/site/src/sections", "/site/src/islands", "/site/src/components"]);
51
+ expect(detectSourceLayout(SITE, fs)).toBe("modern");
52
+ });
53
+ });
54
+
55
+ describe("detectSourceLayout — mixed layout", () => {
56
+ it("flags both root + src/ as mixed", () => {
57
+ const fs = makeFs(["/site/sections", "/site/src/sections"]);
58
+ expect(detectSourceLayout(SITE, fs)).toBe("mixed");
59
+ });
60
+
61
+ it("flags partial overlap as mixed (root islands + src sections)", () => {
62
+ const fs = makeFs(["/site/islands", "/site/src/sections"]);
63
+ expect(detectSourceLayout(SITE, fs)).toBe("mixed");
64
+ });
65
+ });
66
+
67
+ describe("detectSourceLayout — empty layout", () => {
68
+ it("returns empty when neither root nor src/ has recognised dirs", () => {
69
+ const fs = makeFs(["/site/package.json", "/site/README.md"]);
70
+ expect(detectSourceLayout(SITE, fs)).toBe("empty");
71
+ });
72
+
73
+ it("returns empty for an unrelated dir", () => {
74
+ const fs = makeFs(["/site/random-dir/x.txt"]);
75
+ expect(detectSourceLayout(SITE, fs)).toBe("empty");
76
+ });
77
+ });
78
+
79
+ describe("detectSourceLayout — works against the real disk", () => {
80
+ it("uses real fs by default", () => {
81
+ // Just call with no fsAdapter on a real path that doesn't exist —
82
+ // should return "empty" without throwing. Smoke check that the
83
+ // default-arg wiring works.
84
+ expect(detectSourceLayout("/this/does/not/exist")).toBe("empty");
85
+ });
86
+ });
87
+
88
+ describe("explainNonClassicLayout — message content", () => {
89
+ const cases: Array<[Exclude<SourceLayout, "classic">, string[]]> = [
90
+ ["modern", ['Modern Fresh "src/" layout', "Move src/sections", "File an issue"]],
91
+ ["mixed", ["Mixed layout", "pick one layout", "clean checkout"]],
92
+ [
93
+ "empty",
94
+ [
95
+ "No recognizable Deco layout",
96
+ "sections, islands, components, loaders, actions",
97
+ "--source",
98
+ ],
99
+ ],
100
+ ];
101
+
102
+ for (const [layout, fragments] of cases) {
103
+ it(`${layout}: includes site path + key guidance`, () => {
104
+ const msg = explainNonClassicLayout(layout, "/some/site");
105
+ expect(msg).toContain("/some/site");
106
+ for (const f of fragments) {
107
+ expect(msg).toContain(f);
108
+ }
109
+ });
110
+ }
111
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Source-layout detection.
3
+ *
4
+ * Classic Fresh sites place sections, islands, components etc. at the
5
+ * repo root. Modern Fresh (post 1.6) and several community starters
6
+ * use a `src/` layout where everything lives under `src/sections/`,
7
+ * `src/islands/`, etc. The migration analyzer's `SKIP_DIRS` includes
8
+ * `"src"` (because the OUTPUT site stores migrated code there), so
9
+ * a modern-layout source would be silently scanned as if it were
10
+ * empty — yielding a near-empty migration with no helpful errors.
11
+ *
12
+ * This module classifies a source directory into one of:
13
+ * - "classic": expected layout (sections/, islands/, …) at root
14
+ * - "modern": src/-layout (src/sections/, src/islands/, …)
15
+ * - "mixed": both root sections/ AND src/sections/ — usually a
16
+ * half-migrated repo
17
+ * - "empty": nothing recognizable; could be a fresh scaffold
18
+ *
19
+ * The migration script consumes this for an early-abort with an
20
+ * actionable error message. Eventually the analyzer can be extended
21
+ * to scan `src/` natively, at which point the "modern" branch can
22
+ * proceed instead of aborting.
23
+ */
24
+
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+
28
+ export type SourceLayout = "classic" | "modern" | "mixed" | "empty";
29
+
30
+ const RECOGNISED_DIRS = ["sections", "islands", "components", "loaders", "actions"];
31
+
32
+ export interface FsLike {
33
+ existsSync(p: string): boolean;
34
+ }
35
+
36
+ const realFs: FsLike = { existsSync: fs.existsSync };
37
+
38
+ /**
39
+ * Classify the source directory's layout. Pure function — accepts
40
+ * a `FsLike` so unit tests can stub the disk without mocking node:fs.
41
+ */
42
+ 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
+ );
47
+
48
+ if (hasRootDir && hasSrcDir) return "mixed";
49
+ if (hasSrcDir) return "modern";
50
+ if (hasRootDir) return "classic";
51
+ return "empty";
52
+ }
53
+
54
+ /**
55
+ * Build a human-readable, actionable message for a non-classic
56
+ * layout. Consumed by the CLI to print before exiting. Lives in
57
+ * this module so the test can pin the exact wording.
58
+ */
59
+ export function explainNonClassicLayout(
60
+ layout: Exclude<SourceLayout, "classic">,
61
+ sourceDir: string,
62
+ ): string {
63
+ switch (layout) {
64
+ case "modern":
65
+ return [
66
+ `Modern Fresh "src/" layout detected at ${sourceDir}/src.`,
67
+ "",
68
+ " This migration script currently scans only the classic root layout",
69
+ " (sections/, islands/, components/, loaders/, actions/ at the repo root).",
70
+ "",
71
+ " Workaround until native support lands:",
72
+ " 1. Move src/sections, src/islands, src/components, src/loaders, src/actions",
73
+ " up one level to the repo root (the script's expected layout).",
74
+ " 2. Re-run the migration.",
75
+ " 3. (If desired) Restructure to a src/ layout post-migration — the",
76
+ " TanStack Start scaffold uses src/ on the output side regardless.",
77
+ "",
78
+ " File an issue with your site URL so this can be supported natively.",
79
+ ].join("\n");
80
+ case "mixed":
81
+ return [
82
+ `Mixed layout detected at ${sourceDir}.`,
83
+ "",
84
+ " Both root sections/ and src/sections/ are present. This usually means",
85
+ " the migration was previously run partially against this directory, or",
86
+ " the source genuinely has parallel layouts (rare).",
87
+ "",
88
+ " Resolution: pick one layout and remove the other before re-running.",
89
+ " If this is a half-migrated repo, restore the original via git and",
90
+ " run the migration against a clean checkout (--source <fresh-path>).",
91
+ ].join("\n");
92
+ case "empty":
93
+ return [
94
+ `No recognizable Deco layout found at ${sourceDir}.`,
95
+ "",
96
+ " Expected one of these directories at the repo root or under src/:",
97
+ ` ${RECOGNISED_DIRS.join(", ")}`,
98
+ "",
99
+ " Did you point --source at the correct directory? It should be the",
100
+ " root of an existing Fresh-based Deco site, not the new TanStack site.",
101
+ ].join("\n");
102
+ }
103
+ }