@decocms/start 2.11.0 → 2.13.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.
@@ -485,3 +485,41 @@ This script handles **Phases 0-6** of the [migration playbook](../deco-to-tansta
485
485
  - Phase 7-12 — Section registry tuning, route customization, matchers, async rendering, search
486
486
 
487
487
  The script gets you from "raw Fresh site" to "builds with `npm run build` and has ~0 old imports". Human work starts at runtime debugging and feature wiring.
488
+
489
+ ## Post-Migration Audit (`deco-post-cleanup`)
490
+
491
+ After the migration script's compile phase passes, run the
492
+ **`deco-post-cleanup`** audit to catch the residual cleanup the
493
+ script leaves behind on existing-but-pre-framework-helpers sites:
494
+
495
+ ```bash
496
+ # Read-only audit (default)
497
+ npx -p @decocms/start deco-post-cleanup
498
+
499
+ # Auto-fix the safe rules (dead-lib-shims, dead-runtime-shim, local-widgets-types)
500
+ npx -p @decocms/start deco-post-cleanup --fix
501
+
502
+ # CI gate: auto-fix safe rules, exit 2 if any warnings remain
503
+ npx -p @decocms/start deco-post-cleanup --fix --strict
504
+ ```
505
+
506
+ The audit covers 7 rules (delete dead lib shims, drop obsolete inline
507
+ Vite plugins, delete dead `runtime.ts` invoke shim, delete site-local
508
+ `withSiteGlobals` wrapper, repoint `~/lib/vtex-*` shim regressions,
509
+ delete shadowed `widgets.ts`, surface orphan framework TODOs). The
510
+ detection logic mirrors the canonical checklist at
511
+ [`deco-to-tanstack-migration/references/post-migration-cleanup.md`](../deco-to-tanstack-migration/references/post-migration-cleanup.md).
512
+
513
+ **Why `compile` and `audit` are complementary:**
514
+
515
+ | Tool | Catches |
516
+ |------|---------|
517
+ | `phase-compile` (in this script) | TS5097, missing exports, type bugs — anything `tsc --noEmit` finds |
518
+ | `deco-post-cleanup` (separate CLI) | Silent runtime stubs (e.g. dead `~/lib/vtex-*` shims that typecheck cleanly but resolve to `{}` at runtime) |
519
+
520
+ `tsc` doesn't catch the silent-stub class of bug because the dead
521
+ shim files have valid TypeScript signatures. The audit's pattern
522
+ matches surface what compilation cannot.
523
+
524
+ Source: `scripts/migrate-post-cleanup.ts` + `scripts/migrate/post-cleanup/`.
525
+ Tests: `scripts/migrate/post-cleanup/runner.test.ts`.
@@ -8,25 +8,39 @@ after the site has been shipping for weeks.
8
8
  ## Run the audit first
9
9
 
10
10
  This whole checklist is now automated by the **`deco-post-cleanup`**
11
- audit script (added in `@decocms/start >= 2.11.0`). Run it from the
12
- site repo to get a structured report of which sections below actually
13
- apply to your codebase:
11
+ audit script (added in `@decocms/start >= 2.11.0`, `--fix` mode in
12
+ `>= 2.12.0`). Run it from the site repo to get a structured report
13
+ of which sections below actually apply to your codebase:
14
14
 
15
15
  ```bash
16
16
  # Pretty text output, exits 0 unless --strict is passed
17
17
  npx -p @decocms/start deco-post-cleanup
18
18
 
19
- # Machine-readable JSON for CI dashboards
20
- npx -p @decocms/start deco-post-cleanup --json
19
+ # Auto-apply mechanical fixes for the safe rules, then report what's left.
20
+ # Safe rules: dead-lib-shims, dead-runtime-shim, local-widgets-types.
21
+ # Other rules stay detect-only — they require human judgment.
22
+ npx -p @decocms/start deco-post-cleanup --fix
23
+
24
+ # Combine for CI: auto-fix safe rules, fail (exit 2) if warnings remain.
25
+ npx -p @decocms/start deco-post-cleanup --fix --strict
21
26
 
22
- # Fail the run (exit 2) if any warning-severity findings exist
23
- npx -p @decocms/start deco-post-cleanup --strict
27
+ # Machine-readable JSON for dashboards
28
+ npx -p @decocms/start deco-post-cleanup --json
24
29
  ```
25
30
 
26
31
  The audit covers all 7 rules below and prints the exact file path +
27
- suggested fix for each finding. It is **read-only** auto-fix support
28
- is a planned follow-up. Use the audit to scope this checklist to the
29
- real, current findings instead of triaging the full document by hand.
32
+ suggested fix for each finding. With `--fix`, the three safe rules
33
+ auto-apply (`rm` for dead files, regex-anchored import rewrites for
34
+ shadowed shims). The output explicitly tags rules that require manual
35
+ work as `(0 fixed, manual)`, so you always know what's left after
36
+ auto-fix runs.
37
+
38
+ Real-world signal: on baggagio, `--fix` produced a byte-identical
39
+ diff to the manual cleanup PR a human had just made (45 files,
40
+ +45/-53). On casaevideo-storefront (production), the audit caught
41
+ six silent VTEX shim regressions that no `tsc --noEmit` run can
42
+ detect — those still require manual cleanup until rule 5 gains a
43
+ per-shim mapping table.
30
44
 
31
45
  ## 1. Delete unused `src/lib/*` shims
32
46
 
@@ -5,6 +5,43 @@ recurring set of dead-code and boilerplate cleanup that every migrated
5
5
  site benefits from. Run this checklist before the first PR review, not
6
6
  after the site has been shipping for weeks.
7
7
 
8
+ ## Run the audit first
9
+
10
+ This whole checklist is now automated by the **`deco-post-cleanup`**
11
+ audit script (added in `@decocms/start >= 2.11.0`, `--fix` mode in
12
+ `>= 2.12.0`). Run it from the site repo to get a structured report
13
+ of which sections below actually apply to your codebase:
14
+
15
+ ```bash
16
+ # Pretty text output, exits 0 unless --strict is passed
17
+ npx -p @decocms/start deco-post-cleanup
18
+
19
+ # Auto-apply mechanical fixes for the safe rules, then report what's left.
20
+ # Safe rules: dead-lib-shims, dead-runtime-shim, local-widgets-types.
21
+ # Other rules stay detect-only — they require human judgment.
22
+ npx -p @decocms/start deco-post-cleanup --fix
23
+
24
+ # Combine for CI: auto-fix safe rules, fail (exit 2) if warnings remain.
25
+ npx -p @decocms/start deco-post-cleanup --fix --strict
26
+
27
+ # Machine-readable JSON for dashboards
28
+ npx -p @decocms/start deco-post-cleanup --json
29
+ ```
30
+
31
+ The audit covers all 7 rules below and prints the exact file path +
32
+ suggested fix for each finding. With `--fix`, the three safe rules
33
+ auto-apply (`rm` for dead files, regex-anchored import rewrites for
34
+ shadowed shims). The output explicitly tags rules that require manual
35
+ work as `(0 fixed, manual)`, so you always know what's left after
36
+ auto-fix runs.
37
+
38
+ Real-world signal: on baggagio, `--fix` produced a byte-identical
39
+ diff to the manual cleanup PR a human had just made (45 files,
40
+ +45/-53). On casaevideo-storefront (production), the audit caught
41
+ six silent VTEX shim regressions that no `tsc --noEmit` run can
42
+ detect — those still require manual cleanup until rule 5 gains a
43
+ per-shim mapping table.
44
+
8
45
  ## 1. Delete unused `src/lib/*` shims
9
46
 
10
47
  The migration script's `templates/lib-utils.ts` generates 11 shim files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.11.0",
3
+ "version": "2.13.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tests for Phase 9 (cleanup audit integration into migrate.ts).
3
+ *
4
+ * These exercise the wrapper logic — what it prints, when it fails,
5
+ * how it interacts with --strict and dry-run. The underlying audit
6
+ * rules are tested separately in post-cleanup/runner.test.ts; we
7
+ * stub the disk minimally here just to drive findings counts.
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { cleanupAudit } from "./phase-cleanup-audit";
15
+ import { createContext } from "./types";
16
+
17
+ let tmpDir: string;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deco-migrate-audit-"));
21
+ });
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(tmpDir, { recursive: true, force: true });
25
+ });
26
+
27
+ function makeCtx(overrides?: { dryRun?: boolean }) {
28
+ return createContext(tmpDir, {
29
+ dryRun: overrides?.dryRun ?? false,
30
+ verbose: false,
31
+ });
32
+ }
33
+
34
+ describe("cleanupAudit — dry-run", () => {
35
+ it("is a no-op in dry-run mode (returns false, no console output)", () => {
36
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
37
+ const ctx = makeCtx({ dryRun: true });
38
+ const failed = cleanupAudit(ctx);
39
+ expect(failed).toBe(false);
40
+ expect(spy).not.toHaveBeenCalled();
41
+ spy.mockRestore();
42
+ });
43
+ });
44
+
45
+ describe("cleanupAudit — empty site", () => {
46
+ it("prints success and returns false when there are no findings", () => {
47
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
48
+ const ctx = makeCtx();
49
+ const failed = cleanupAudit(ctx);
50
+ expect(failed).toBe(false);
51
+ const out = spy.mock.calls.map((c) => c.join(" ")).join("\n");
52
+ expect(out).toMatch(/No findings/);
53
+ spy.mockRestore();
54
+ });
55
+ });
56
+
57
+ describe("cleanupAudit — info-only findings (e.g. local widgets.ts)", () => {
58
+ beforeEach(() => {
59
+ // Set up a finding for rule 6 (local-widgets-types):
60
+ // need src/types/widgets.ts + at least one importer.
61
+ fs.mkdirSync(path.join(tmpDir, "src", "types"), { recursive: true });
62
+ fs.writeFileSync(
63
+ path.join(tmpDir, "src", "types", "widgets.ts"),
64
+ "export type ImageWidget = string;\n",
65
+ );
66
+ fs.mkdirSync(path.join(tmpDir, "src", "sections"), { recursive: true });
67
+ fs.writeFileSync(
68
+ path.join(tmpDir, "src", "sections", "Foo.tsx"),
69
+ 'import type { ImageWidget } from "~/types/widgets";\nexport const x: ImageWidget = "y";\n',
70
+ );
71
+ });
72
+
73
+ it("prints the finding and returns false (info doesn't fail strict)", () => {
74
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
75
+ const ctx = makeCtx();
76
+ const failed = cleanupAudit(ctx, { strict: true });
77
+ expect(failed).toBe(false);
78
+ const out = spy.mock.calls.map((c) => c.join(" ")).join("\n");
79
+ expect(out).toMatch(/Local src\/types\/widgets\.ts/);
80
+ expect(out).toMatch(/widgets\.ts/);
81
+ expect(out).toMatch(/deco-post-cleanup --fix/);
82
+ spy.mockRestore();
83
+ });
84
+ });
85
+
86
+ describe("cleanupAudit — warning findings (vtex-shim-regression)", () => {
87
+ beforeEach(() => {
88
+ // Trigger rule 5 (vtex-shim-regression, warning severity):
89
+ // any file outside src/lib/ that imports from ~/lib/vtex-*.
90
+ fs.mkdirSync(path.join(tmpDir, "src", "loaders"), { recursive: true });
91
+ fs.writeFileSync(
92
+ path.join(tmpDir, "src", "loaders", "Product.ts"),
93
+ 'import { fetchSafe } from "~/lib/vtex-fetch";\nexport default fetchSafe;\n',
94
+ );
95
+ });
96
+
97
+ it("returns false in non-strict mode even with warning findings", () => {
98
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
99
+ const ctx = makeCtx();
100
+ const failed = cleanupAudit(ctx, { strict: false });
101
+ expect(failed).toBe(false);
102
+ spy.mockRestore();
103
+ });
104
+
105
+ it("returns true in --strict mode when warning findings exist", () => {
106
+ const spyLog = vi.spyOn(console, "log").mockImplementation(() => {});
107
+ const ctx = makeCtx();
108
+ const failed = cleanupAudit(ctx, { strict: true });
109
+ expect(failed).toBe(true);
110
+ const out = spyLog.mock.calls.map((c) => c.join(" ")).join("\n");
111
+ expect(out).toMatch(/--strict/);
112
+ expect(out).toMatch(/failed the audit/);
113
+ spyLog.mockRestore();
114
+ });
115
+ });
116
+
117
+ describe("cleanupAudit — output truncation", () => {
118
+ beforeEach(() => {
119
+ // Fabricate >5 vtex-shim-regression findings to test the cap.
120
+ fs.mkdirSync(path.join(tmpDir, "src", "loaders"), { recursive: true });
121
+ for (let i = 0; i < 8; i++) {
122
+ fs.writeFileSync(
123
+ path.join(tmpDir, "src", "loaders", `Loader${i}.ts`),
124
+ 'import { fetchSafe } from "~/lib/vtex-fetch";\n',
125
+ );
126
+ }
127
+ });
128
+
129
+ it("caps per-rule output at 5 with a 'and N more' suffix", () => {
130
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
131
+ const ctx = makeCtx();
132
+ cleanupAudit(ctx);
133
+ const out = spy.mock.calls.map((c) => c.join(" ")).join("\n");
134
+ expect(out).toMatch(/and 3 more/);
135
+ spy.mockRestore();
136
+ });
137
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Phase 9: Post-Migration Cleanup Audit
3
+ *
4
+ * Runs the same `deco-post-cleanup` audit logic as the standalone CLI,
5
+ * but inline at the tail of `deco-migrate`. The goal is to surface any
6
+ * residual debt the migration script can't fix on its own (e.g.
7
+ * silent vtex shim regressions, orphan TODOs, manual-review items)
8
+ * the moment the migration completes — without making the user
9
+ * remember a separate command.
10
+ *
11
+ * Behaviour:
12
+ * - Always READ-ONLY. Auto-fix is opt-in via the standalone CLI's
13
+ * `--fix` flag — never invoked from inside the migration script
14
+ * to keep the migration's mutation surface predictable.
15
+ * - Skipped in dry-run (no migrated output to scan).
16
+ * - Skipped via `--no-cleanup-audit` when integrated runs are noisy.
17
+ * - In `--strict` mode, returns true (caller exits 2) if any
18
+ * warning-severity findings exist. Info findings never fail the
19
+ * build — they're just hints.
20
+ */
21
+
22
+ import { banner, bold, gray, green, red, yellow } from "./colors";
23
+ import { realFsAdapter, runAudit } from "./post-cleanup/runner";
24
+ import type { AuditReport, Severity } from "./post-cleanup/types";
25
+ import type { MigrationContext } from "./types";
26
+
27
+ export interface CleanupAuditOptions {
28
+ /** Promote warning findings to fatal (exit 2 from main). Default: false. */
29
+ strict?: boolean;
30
+ }
31
+
32
+ /**
33
+ * Returns `true` when the caller should exit with a non-zero code.
34
+ * Always false in normal mode — audit is informational by default.
35
+ */
36
+ export function cleanupAudit(ctx: MigrationContext, opts: CleanupAuditOptions = {}): boolean {
37
+ if (ctx.dryRun) {
38
+ return false;
39
+ }
40
+
41
+ banner("Phase 9: Post-Migration Cleanup Audit");
42
+
43
+ const report = runAudit(ctx.sourceDir, realFsAdapter);
44
+
45
+ if (report.totalFindings === 0) {
46
+ console.log(` ${green("✓")} No findings — migration output is clean.`);
47
+ return false;
48
+ }
49
+
50
+ printSummary(report);
51
+
52
+ const warnings = countSeverity(report, "warning");
53
+ const infos = countSeverity(report, "info");
54
+ const willFail = (opts.strict ?? false) && warnings > 0;
55
+
56
+ console.log("");
57
+ console.log(
58
+ ` ${bold("Audit:")} ${report.totalFindings} finding(s) — ${yellow(`${warnings} warning(s)`)}, ${gray(`${infos} info`)}`,
59
+ );
60
+ console.log(
61
+ ` ${gray("Run")} ${bold("deco-post-cleanup --fix")} ${gray("from this directory to auto-correct the safe rules,")}`,
62
+ );
63
+ console.log(` ${gray("or see post-migration-cleanup.md for the full per-rule playbook.")}`);
64
+
65
+ if (willFail) {
66
+ console.log(
67
+ `\n ${red("--strict:")} ${warnings} warning-severity finding(s) failed the audit.`,
68
+ );
69
+ return true;
70
+ }
71
+
72
+ return false;
73
+ }
74
+
75
+ function severityTag(sev: Severity, text: string): string {
76
+ if (sev === "warning") return yellow(text);
77
+ return gray(text);
78
+ }
79
+
80
+ function printSummary(report: AuditReport): void {
81
+ let idx = 0;
82
+ for (const summary of report.rules) {
83
+ idx++;
84
+ if (summary.findings.length === 0) continue;
85
+ const headColor = yellow;
86
+ console.log(
87
+ ` ${headColor(`[${idx}] ${summary.title}`)} ${gray(`(${summary.findings.length} found)`)}`,
88
+ );
89
+ // Cap the per-rule output so a noisy site doesn't drown the
90
+ // migration's own report. Standalone CLI shows everything.
91
+ const visible = summary.findings.slice(0, 5);
92
+ for (const f of visible) {
93
+ const tag = severityTag(f.severity, `[${f.severity.toUpperCase()}]`);
94
+ console.log(` ${tag} ${bold(f.file)} — ${f.message}`);
95
+ }
96
+ const hidden = summary.findings.length - visible.length;
97
+ if (hidden > 0) {
98
+ console.log(` ${gray(`...and ${hidden} more (run deco-post-cleanup for full list)`)}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ function countSeverity(report: AuditReport, sev: Severity): number {
104
+ return report.rules.flatMap((r) => r.findings).filter((f) => f.severity === sev).length;
105
+ }
@@ -9,10 +9,47 @@
9
9
  * Rules are intentionally read-only here — `--fix` is a follow-up.
10
10
  */
11
11
 
12
- import type { Finding, Rule, RuleContext } from "./types";
12
+ import type { Finding, FixAction, FsWriter, Rule, RuleContext } from "./types";
13
13
 
14
14
  const SRC_GLOB_EXCLUDES = ["node_modules", "dist", ".wrangler", ".vite", ".tanstack", "build"];
15
15
 
16
+ /**
17
+ * Rewrite all `from "<oldSpec>"` (or `from '<oldSpec>'`) imports in
18
+ * `src/**` to `from "<newSpec>"`. Returns the list of site-relative
19
+ * paths actually changed so fix-action summaries can quote a count.
20
+ * Uses the write side of the FS adapter — never touches disk in unit
21
+ * tests.
22
+ *
23
+ * Intentionally string-anchored on the exact spec; will not pick up
24
+ * partial-prefix matches like `~/types/widgets-extra`.
25
+ */
26
+ function rewriteImportSpec(
27
+ ctx: RuleContext,
28
+ writer: FsWriter,
29
+ oldSpec: string,
30
+ newSpec: string,
31
+ ): string[] {
32
+ const { siteDir, fs } = ctx;
33
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
34
+ const escaped = oldSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ const re = new RegExp(`from\\s+(['"])${escaped}\\1`, "g");
36
+ const updated: string[] = [];
37
+ for (const abs of tsFiles) {
38
+ const content = fs.readText(abs);
39
+ if (!re.test(content)) {
40
+ re.lastIndex = 0;
41
+ continue;
42
+ }
43
+ re.lastIndex = 0;
44
+ const next = content.replace(re, (_m, q) => `from ${q}${newSpec}${q}`);
45
+ if (next !== content) {
46
+ writer.writeText(abs, next);
47
+ updated.push(abs.slice(siteDir.length + 1));
48
+ }
49
+ }
50
+ return updated;
51
+ }
52
+
16
53
  /* ------------------------------------------------------------------ */
17
54
  /* Rule 1 — dead `src/lib/*` shims */
18
55
  /* ------------------------------------------------------------------ */
@@ -64,6 +101,18 @@ const ruleDeadLibShims: Rule = {
64
101
  }
65
102
  return findings;
66
103
  },
104
+ applyFix({ siteDir }, findings, writer): FixAction[] {
105
+ const actions: FixAction[] = [];
106
+ for (const f of findings) {
107
+ writer.deleteFile(`${siteDir}/${f.file}`);
108
+ actions.push({
109
+ file: f.file,
110
+ kind: "delete",
111
+ detail: "deleted (all exports verified unused)",
112
+ });
113
+ }
114
+ return actions;
115
+ },
67
116
  };
68
117
 
69
118
  /* ------------------------------------------------------------------ */
@@ -135,6 +184,18 @@ const ruleDeadRuntimeShim: Rule = {
135
184
  },
136
185
  ];
137
186
  },
187
+ applyFix(ctx, findings, writer): FixAction[] {
188
+ if (findings.length === 0) return [];
189
+ const updated = rewriteImportSpec(ctx, writer, "~/runtime", "@decocms/start/sdk");
190
+ writer.deleteFile(`${ctx.siteDir}/src/runtime.ts`);
191
+ return [
192
+ {
193
+ file: "src/runtime.ts",
194
+ kind: "rewrite-imports+delete",
195
+ detail: `rewrote ${updated.length} import(s) → @decocms/start/sdk and deleted src/runtime.ts`,
196
+ },
197
+ ];
198
+ },
138
199
  };
139
200
 
140
201
  /* ------------------------------------------------------------------ */
@@ -232,6 +293,23 @@ const ruleLocalWidgetsTypes: Rule = {
232
293
  },
233
294
  ];
234
295
  },
296
+ applyFix(ctx, findings, writer): FixAction[] {
297
+ if (findings.length === 0) return [];
298
+ const updated = rewriteImportSpec(
299
+ ctx,
300
+ writer,
301
+ "~/types/widgets",
302
+ "@decocms/start/types/widgets",
303
+ );
304
+ writer.deleteFile(`${ctx.siteDir}/src/types/widgets.ts`);
305
+ return [
306
+ {
307
+ file: "src/types/widgets.ts",
308
+ kind: "rewrite-imports+delete",
309
+ detail: `rewrote ${updated.length} import(s) → @decocms/start/types/widgets and deleted src/types/widgets.ts`,
310
+ },
311
+ ];
312
+ },
235
313
  };
236
314
 
237
315
  /* ------------------------------------------------------------------ */
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { _internals, ALL_RULES } from "./rules";
3
3
  import { runAudit } from "./runner";
4
- import type { FsAdapter } from "./types";
4
+ import type { FsAdapter, FsWriter } from "./types";
5
5
 
6
6
  /**
7
7
  * In-memory FsAdapter for tests. Maps absolute path → file content.
@@ -57,6 +57,75 @@ function makeFs(files: Record<string, string>): FsAdapter {
57
57
 
58
58
  const SITE = "/site";
59
59
 
60
+ /**
61
+ * Mutable in-memory FS — read AND write share one backing store. Used
62
+ * for fix-mode tests. The `store` is exposed so tests can assert what
63
+ * the writer left behind (deletions and content rewrites).
64
+ */
65
+ function makeMutableFs(initial: Record<string, string>): {
66
+ fs: FsAdapter;
67
+ writer: FsWriter;
68
+ store: Record<string, string>;
69
+ log: { kind: "delete" | "write"; absPath: string }[];
70
+ } {
71
+ const store = Object.fromEntries(
72
+ Object.entries(initial).map(([k, v]) => [k.replace(/\\/g, "/"), v]),
73
+ );
74
+ const log: { kind: "delete" | "write"; absPath: string }[] = [];
75
+ const fs: FsAdapter = {
76
+ exists(absPath) {
77
+ return absPath.replace(/\\/g, "/") in store;
78
+ },
79
+ readText(absPath) {
80
+ const k = absPath.replace(/\\/g, "/");
81
+ if (!(k in store)) throw new Error(`ENOENT: ${absPath}`);
82
+ return store[k];
83
+ },
84
+ glob(siteDir, pattern, excludeDirs = []) {
85
+ const root = siteDir.replace(/\\/g, "/");
86
+ const all = Object.keys(store).filter((p) => p.startsWith(`${root}/`));
87
+ const filtered = all.filter((p) => {
88
+ const rel = p.slice(root.length + 1);
89
+ return !excludeDirs.some((dir) => rel.startsWith(`${dir}/`));
90
+ });
91
+ const branches = pattern.includes("{")
92
+ ? pattern
93
+ .match(/\{([^{}]+)\}/)![1]
94
+ .split(",")
95
+ .map((b) => pattern.replace(/\{[^{}]+\}/, b.trim()))
96
+ : [pattern];
97
+ const regexes = branches.map((p) => {
98
+ const re = p
99
+ .replace(/[.+^$()|]/g, "\\$&")
100
+ .replace(/\*\*\//g, "<<DBL>>")
101
+ .replace(/\*\*/g, "<<DBL>>")
102
+ .replace(/\*/g, "[^/]*")
103
+ .replace(/<<DBL>>/g, "(?:.*/)?");
104
+ return new RegExp(`^${re}$`);
105
+ });
106
+ return filtered
107
+ .filter((p) => {
108
+ const rel = p.slice(root.length + 1);
109
+ return regexes.some((re) => re.test(rel));
110
+ })
111
+ .sort();
112
+ },
113
+ };
114
+ const writer: FsWriter = {
115
+ deleteFile(absPath) {
116
+ const k = absPath.replace(/\\/g, "/");
117
+ delete store[k];
118
+ log.push({ kind: "delete", absPath: k });
119
+ },
120
+ writeText(absPath, content) {
121
+ const k = absPath.replace(/\\/g, "/");
122
+ store[k] = content;
123
+ log.push({ kind: "write", absPath: k });
124
+ },
125
+ };
126
+ return { fs, writer, store, log };
127
+ }
128
+
60
129
  describe("runAudit — empty site", () => {
61
130
  it("returns zero findings on an empty tree", () => {
62
131
  const fs = makeFs({});
@@ -284,5 +353,108 @@ describe("runAudit — totals", () => {
284
353
  });
285
354
  const report = runAudit(SITE, fs);
286
355
  expect(report.totalFindings).toBe(3);
356
+ expect(report.totalFixActions).toBe(0);
357
+ });
358
+
359
+ it("supportsAutoFix flag reflects rule capability", () => {
360
+ const fs = makeFs({});
361
+ const report = runAudit(SITE, fs);
362
+ const supported = report.rules
363
+ .filter((r) => r.supportsAutoFix)
364
+ .map((r) => r.rule)
365
+ .sort();
366
+ expect(supported).toEqual(
367
+ ["dead-lib-shims", "dead-runtime-shim", "local-widgets-types"].sort(),
368
+ );
369
+ });
370
+ });
371
+
372
+ describe("runAudit — fix mode", () => {
373
+ it("does not mutate when no writer is provided (default audit-only)", () => {
374
+ const { fs, store } = makeMutableFs({
375
+ "/site/src/lib/dead.ts": "export const foo = 1;\n",
376
+ });
377
+ const before = { ...store };
378
+ runAudit(SITE, fs);
379
+ expect(store).toEqual(before);
380
+ });
381
+
382
+ it("fix mode deletes a dead-lib shim and reports the action", () => {
383
+ const { fs, writer, store } = makeMutableFs({
384
+ "/site/src/lib/dead.ts": "export const foo = 1;\n",
385
+ "/site/src/sections/Other.tsx": 'export const x = "y";\n',
386
+ });
387
+ const report = runAudit(SITE, fs, { writer });
388
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
389
+ expect(r.findings).toHaveLength(1);
390
+ expect(r.fixes).toHaveLength(1);
391
+ expect(r.fixes![0].kind).toBe("delete");
392
+ expect(r.fixes![0].file).toBe("src/lib/dead.ts");
393
+ expect("/site/src/lib/dead.ts" in store).toBe(false);
394
+ expect("/site/src/sections/Other.tsx" in store).toBe(true);
395
+ expect(report.totalFixActions).toBe(1);
396
+ });
397
+
398
+ it("fix mode rewrites runtime imports + deletes runtime.ts", () => {
399
+ const { fs, writer, store, log } = makeMutableFs({
400
+ "/site/src/runtime.ts":
401
+ "export const invoke = createNestedInvokeProxy();\nexport function createNestedInvokeProxy() { return {}; }\n",
402
+ "/site/src/sections/A.tsx": 'import { invoke } from "~/runtime";\nconsole.log(invoke);\n',
403
+ "/site/src/sections/B.tsx": "import { invoke } from '~/runtime';\nconsole.log(invoke);\n",
404
+ "/site/src/sections/C.tsx":
405
+ 'import { other } from "~/something-else";\nconsole.log(other);\n',
406
+ });
407
+ const report = runAudit(SITE, fs, { writer });
408
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
409
+ expect(r.findings).toHaveLength(1);
410
+ expect(r.fixes).toHaveLength(1);
411
+ expect(r.fixes![0].detail).toMatch(/rewrote 2 import/);
412
+ expect("/site/src/runtime.ts" in store).toBe(false);
413
+ expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/sdk"');
414
+ expect(store["/site/src/sections/B.tsx"]).toContain("'@decocms/start/sdk'");
415
+ expect(store["/site/src/sections/C.tsx"]).toContain('"~/something-else"');
416
+ expect(log.filter((e) => e.kind === "delete")).toHaveLength(1);
417
+ expect(log.filter((e) => e.kind === "write")).toHaveLength(2);
418
+ });
419
+
420
+ it("fix mode rewrites widgets imports + deletes widgets.ts", () => {
421
+ const { fs, writer, store } = makeMutableFs({
422
+ "/site/src/types/widgets.ts": "export type ImageWidget = string;\n",
423
+ "/site/src/sections/A.tsx":
424
+ 'import type { ImageWidget } from "~/types/widgets";\nexport const x: ImageWidget = "y";\n',
425
+ "/site/src/sections/B.tsx":
426
+ "import type { ImageWidget } from '~/types/widgets';\nexport const y: ImageWidget = 'z';\n",
427
+ });
428
+ const report = runAudit(SITE, fs, { writer });
429
+ const r = report.rules.find((r) => r.rule === "local-widgets-types")!;
430
+ expect(r.fixes).toHaveLength(1);
431
+ expect(r.fixes![0].detail).toMatch(/rewrote 2 import/);
432
+ expect("/site/src/types/widgets.ts" in store).toBe(false);
433
+ expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/types/widgets"');
434
+ expect(store["/site/src/sections/B.tsx"]).toContain("'@decocms/start/types/widgets'");
435
+ });
436
+
437
+ it("fix mode is a no-op for rules without applyFix (e.g. framework-todos)", () => {
438
+ const { fs, writer, store } = makeMutableFs({
439
+ "/site/src/sections/Foo.tsx": "// TODO: move into decoVitePlugin\nexport const x = 1;\n",
440
+ });
441
+ const beforeStore = { ...store };
442
+ const report = runAudit(SITE, fs, { writer });
443
+ const r = report.rules.find((r) => r.rule === "framework-todos")!;
444
+ expect(r.findings).toHaveLength(1);
445
+ expect(r.fixes).toBeUndefined();
446
+ expect(r.supportsAutoFix).toBe(false);
447
+ expect(store).toEqual(beforeStore);
448
+ });
449
+
450
+ it("fix mode rewrites only exact matches, not prefix collisions", () => {
451
+ const { fs, writer, store } = makeMutableFs({
452
+ "/site/src/types/widgets.ts": "export type ImageWidget = string;\n",
453
+ "/site/src/sections/A.tsx":
454
+ 'import type { ImageWidget } from "~/types/widgets";\nimport thing from "~/types/widgets-extra";\n',
455
+ });
456
+ runAudit(SITE, fs, { writer });
457
+ expect(store["/site/src/sections/A.tsx"]).toContain('"@decocms/start/types/widgets"');
458
+ expect(store["/site/src/sections/A.tsx"]).toContain('"~/types/widgets-extra"');
287
459
  });
288
460
  });
@@ -8,22 +8,45 @@
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
10
  import { ALL_RULES } from "./rules";
11
- import type { AuditReport, FsAdapter, Rule, RuleSummary } from "./types";
11
+ import type { AuditReport, FsAdapter, FsWriter, Rule, RuleSummary } from "./types";
12
+
13
+ export interface RunAuditOptions {
14
+ /**
15
+ * When set, rules with an `applyFix` implementation will run their
16
+ * fix and the report will include the resulting `FixAction[]`.
17
+ * Without this, the runner is purely read-only.
18
+ */
19
+ writer?: FsWriter;
20
+ /** Restrict to a subset of rules (defaults to all). */
21
+ rules?: Rule[];
22
+ }
12
23
 
13
24
  export function runAudit(
14
25
  siteDir: string,
15
26
  adapter: FsAdapter,
16
- rules: Rule[] = ALL_RULES,
27
+ options: RunAuditOptions = {},
17
28
  ): AuditReport {
18
- const summaries: RuleSummary[] = rules.map((r) => ({
19
- rule: r.id,
20
- title: r.title,
21
- findings: r.run({ siteDir, fs: adapter }),
22
- }));
29
+ const rules = options.rules ?? ALL_RULES;
30
+ const summaries: RuleSummary[] = rules.map((rule) => {
31
+ const findings = rule.run({ siteDir, fs: adapter });
32
+ const supportsAutoFix = typeof rule.applyFix === "function";
33
+ let fixes: RuleSummary["fixes"];
34
+ if (options.writer && rule.applyFix && findings.length > 0) {
35
+ fixes = rule.applyFix({ siteDir, fs: adapter }, findings, options.writer);
36
+ }
37
+ return {
38
+ rule: rule.id,
39
+ title: rule.title,
40
+ findings,
41
+ supportsAutoFix,
42
+ fixes,
43
+ };
44
+ });
23
45
  return {
24
46
  site: siteDir,
25
47
  rules: summaries,
26
48
  totalFindings: summaries.reduce((acc, s) => acc + s.findings.length, 0),
49
+ totalFixActions: summaries.reduce((acc, s) => acc + (s.fixes?.length ?? 0), 0),
27
50
  };
28
51
  }
29
52
 
@@ -95,3 +118,20 @@ export const realFsAdapter: FsAdapter = {
95
118
  return matches.sort();
96
119
  },
97
120
  };
121
+
122
+ /**
123
+ * Real disk writer used by the CLI when `--fix` is on. Tests should
124
+ * use a recording adapter and never instantiate this.
125
+ *
126
+ * `deleteFile` is intentionally tolerant: if the file is already gone
127
+ * (race with a previous fix or manual cleanup), we treat that as a
128
+ * no-op rather than crashing the audit.
129
+ */
130
+ export const realFsWriter: FsWriter = {
131
+ deleteFile(absPath: string) {
132
+ if (fs.existsSync(absPath)) fs.unlinkSync(absPath);
133
+ },
134
+ writeText(absPath: string, content: string) {
135
+ fs.writeFileSync(absPath, content, "utf-8");
136
+ },
137
+ };
@@ -31,12 +31,21 @@ export interface RuleSummary {
31
31
  /** Human-readable section title. */
32
32
  title: string;
33
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;
34
41
  }
35
42
 
36
43
  export interface AuditReport {
37
44
  site: string;
38
45
  rules: RuleSummary[];
39
46
  totalFindings: number;
47
+ /** Total fix actions across all rules (0 if not in fix mode). */
48
+ totalFixActions: number;
40
49
  }
41
50
 
42
51
  /**
@@ -59,8 +68,39 @@ export interface RuleContext {
59
68
  fs: FsAdapter;
60
69
  }
61
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
+
62
95
  export interface Rule {
63
96
  id: string;
64
97
  title: string;
65
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[];
66
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
+ }
@@ -22,8 +22,8 @@
22
22
  */
23
23
 
24
24
  import * as path from "node:path";
25
- import { banner, bold, gray, green, red, yellow } from "./migrate/colors";
26
- import { realFsAdapter, runAudit } from "./migrate/post-cleanup/runner";
25
+ import { banner, bold, cyan, gray, green, red, yellow } from "./migrate/colors";
26
+ import { realFsAdapter, realFsWriter, runAudit } from "./migrate/post-cleanup/runner";
27
27
  import type { AuditReport, Severity } from "./migrate/post-cleanup/types";
28
28
 
29
29
  interface CliOpts {
@@ -31,6 +31,7 @@ interface CliOpts {
31
31
  json: boolean;
32
32
  strict: boolean;
33
33
  help: boolean;
34
+ fix: boolean;
34
35
  }
35
36
 
36
37
  function parseArgs(args: string[]): CliOpts {
@@ -38,6 +39,7 @@ function parseArgs(args: string[]): CliOpts {
38
39
  let json = false;
39
40
  let strict = false;
40
41
  let help = false;
42
+ let fix = false;
41
43
  for (let i = 0; i < args.length; i++) {
42
44
  switch (args[i]) {
43
45
  case "--source":
@@ -49,13 +51,16 @@ function parseArgs(args: string[]): CliOpts {
49
51
  case "--strict":
50
52
  strict = true;
51
53
  break;
54
+ case "--fix":
55
+ fix = true;
56
+ break;
52
57
  case "--help":
53
58
  case "-h":
54
59
  help = true;
55
60
  break;
56
61
  }
57
62
  }
58
- return { source, json, strict, help };
63
+ return { source, json, strict, help, fix };
59
64
  }
60
65
 
61
66
  function showHelp() {
@@ -70,6 +75,9 @@ function showHelp() {
70
75
 
71
76
  Options:
72
77
  --source <dir> Site directory to audit (default: .)
78
+ --fix Auto-apply mechanical fixes for the safe rules
79
+ (dead-lib-shims, dead-runtime-shim, local-widgets-types).
80
+ Other rules still detect-only.
73
81
  --json Emit machine-readable JSON instead of pretty text
74
82
  --strict Exit code 2 if any warning-severity findings exist
75
83
  --help, -h Show this help
@@ -77,6 +85,8 @@ function showHelp() {
77
85
  Examples:
78
86
  npx -p @decocms/start deco-post-cleanup
79
87
  npx -p @decocms/start deco-post-cleanup --source ./my-site --json
88
+ npx -p @decocms/start deco-post-cleanup --fix
89
+ npx -p @decocms/start deco-post-cleanup --fix --strict # fail CI if anything left
80
90
 
81
91
  See: .agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md
82
92
  `);
@@ -87,22 +97,36 @@ function severityColor(sev: Severity, text: string): string {
87
97
  return gray(text);
88
98
  }
89
99
 
90
- function printText(report: AuditReport): void {
91
- banner("Post-Migration Cleanup Audit");
100
+ function printText(report: AuditReport, fixMode: boolean): void {
101
+ banner(fixMode ? "Post-Migration Cleanup Audit (--fix)" : "Post-Migration Cleanup Audit");
92
102
  console.log(` ${gray("Site:")} ${bold(report.site)}`);
93
103
  console.log(` ${gray("Findings:")} ${bold(String(report.totalFindings))}`);
104
+ if (fixMode) {
105
+ console.log(` ${gray("Auto-fixed:")} ${bold(String(report.totalFixActions))}`);
106
+ }
94
107
  console.log("");
95
108
 
96
109
  let idx = 0;
97
110
  for (const summary of report.rules) {
98
111
  idx++;
99
112
  const count = summary.findings.length;
113
+ const fixCount = summary.fixes?.length ?? 0;
100
114
  const headColor = count === 0 ? green : yellow;
101
- console.log(`${headColor(`[${idx}] ${summary.title}`)} ${gray(`(${count} found)`)}`);
115
+ const suffix = fixMode
116
+ ? gray(`(${count} found, ${fixCount} fixed${summary.supportsAutoFix ? "" : ", manual"})`)
117
+ : gray(`(${count} found)`);
118
+ console.log(`${headColor(`[${idx}] ${summary.title}`)} ${suffix}`);
102
119
  for (const f of summary.findings) {
103
120
  const tag = severityColor(f.severity, `[${f.severity.toUpperCase()}]`);
104
121
  console.log(` ${tag} ${bold(f.file)} — ${f.message}`);
105
- if (f.fix) console.log(` ${gray("fix:")} ${f.fix}`);
122
+ if (f.fix && !summary.fixes) {
123
+ console.log(` ${gray("fix:")} ${f.fix}`);
124
+ }
125
+ }
126
+ if (summary.fixes) {
127
+ for (const a of summary.fixes) {
128
+ console.log(` ${cyan("[FIXED]")} ${bold(a.file)} — ${a.detail}`);
129
+ }
106
130
  }
107
131
  if (count === 0) console.log(` ${gray("(nothing to clean up)")}`);
108
132
  console.log("");
@@ -112,11 +136,15 @@ function printText(report: AuditReport): void {
112
136
  .flatMap((r) => r.findings)
113
137
  .filter((f) => f.severity === "warning").length;
114
138
  const infos = report.totalFindings - warnings;
115
- console.log(
116
- `${bold("Summary:")} ${report.totalFindings} finding(s) — ${yellow(`${warnings} warning(s)`)}, ${gray(`${infos} info`)}`,
117
- );
139
+ const tail = fixMode
140
+ ? `${report.totalFindings} finding(s) — ${cyan(`${report.totalFixActions} auto-fixed`)}, ${yellow(`${warnings} warning(s)`)}, ${gray(`${infos} info`)}`
141
+ : `${report.totalFindings} finding(s) — ${yellow(`${warnings} warning(s)`)}, ${gray(`${infos} info`)}`;
142
+ console.log(`${bold("Summary:")} ${tail}`);
118
143
  if (report.totalFindings > 0) {
119
- console.log(gray(" See post-migration-cleanup.md for the canonical fix steps per rule."));
144
+ const hint = fixMode
145
+ ? " Some rules require manual fixes — see post-migration-cleanup.md."
146
+ : " Run with --fix to auto-correct the safe rules, or see post-migration-cleanup.md.";
147
+ console.log(gray(hint));
120
148
  }
121
149
  }
122
150
 
@@ -137,12 +165,14 @@ async function main() {
137
165
  }
138
166
 
139
167
  const siteDir = path.resolve(opts.source);
140
- const report = runAudit(siteDir, realFsAdapter);
168
+ const report = runAudit(siteDir, realFsAdapter, {
169
+ writer: opts.fix ? realFsWriter : undefined,
170
+ });
141
171
 
142
172
  if (opts.json) {
143
173
  printJson(report);
144
174
  } else {
145
- printText(report);
175
+ printText(report, opts.fix);
146
176
  }
147
177
 
148
178
  if (shouldFail(report, opts.strict)) {
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env tsx
2
+
2
3
  /**
3
4
  * Migration Script: Fresh/Deno/Preact → TanStack Start/React/Cloudflare Workers
4
5
  *
@@ -23,18 +24,20 @@
23
24
  * 6. Verify — Smoke test the migrated output
24
25
  */
25
26
 
26
- import * as path from "node:path";
27
27
  import { execSync } from "node:child_process";
28
+ import * as path from "node:path";
29
+ import { banner, green, red, stat, yellow } from "./migrate/colors";
28
30
  import { loadConfig, validateConfig } from "./migrate/config";
29
- import { createContext, logPhase } from "./migrate/types";
30
31
  import { analyze } from "./migrate/phase-analyze";
31
- import { scaffold } from "./migrate/phase-scaffold";
32
- import { transform } from "./migrate/phase-transform";
33
32
  import { cleanup } from "./migrate/phase-cleanup";
33
+ import { cleanupAudit } from "./migrate/phase-cleanup-audit";
34
+ import { compile } from "./migrate/phase-compile";
34
35
  import { report } from "./migrate/phase-report";
36
+ import { scaffold } from "./migrate/phase-scaffold";
37
+ import { transform } from "./migrate/phase-transform";
35
38
  import { verify } from "./migrate/phase-verify";
36
- import { compile } from "./migrate/phase-compile";
37
- import { banner, stat, red, green, yellow } from "./migrate/colors";
39
+ import { detectSourceLayout, explainNonClassicLayout } from "./migrate/source-layout";
40
+ import { createContext, logPhase } from "./migrate/types";
38
41
 
39
42
  function parseArgs(args: string[]): {
40
43
  source: string;
@@ -44,6 +47,7 @@ function parseArgs(args: string[]): {
44
47
  strict: boolean;
45
48
  withBuild: boolean;
46
49
  noCompile: boolean;
50
+ noCleanupAudit: boolean;
47
51
  } {
48
52
  let source = ".";
49
53
  let dryRun = false;
@@ -52,6 +56,7 @@ function parseArgs(args: string[]): {
52
56
  let strict = false;
53
57
  let withBuild = false;
54
58
  let noCompile = false;
59
+ let noCleanupAudit = false;
55
60
 
56
61
  for (let i = 0; i < args.length; i++) {
57
62
  switch (args[i]) {
@@ -73,6 +78,9 @@ function parseArgs(args: string[]): {
73
78
  case "--no-compile":
74
79
  noCompile = true;
75
80
  break;
81
+ case "--no-cleanup-audit":
82
+ noCleanupAudit = true;
83
+ break;
76
84
  case "--help":
77
85
  case "-h":
78
86
  help = true;
@@ -80,7 +88,16 @@ function parseArgs(args: string[]): {
80
88
  }
81
89
  }
82
90
 
83
- return { source, dryRun, verbose, help, strict, withBuild, noCompile };
91
+ return {
92
+ source,
93
+ dryRun,
94
+ verbose,
95
+ help,
96
+ strict,
97
+ withBuild,
98
+ noCompile,
99
+ noCleanupAudit,
100
+ };
84
101
  }
85
102
 
86
103
  function showHelp() {
@@ -91,13 +108,15 @@ function showHelp() {
91
108
  npx -p @decocms/start deco-migrate [options]
92
109
 
93
110
  Options:
94
- --source <dir> Source directory (default: .)
95
- --dry-run Preview changes without writing files
96
- --verbose Show detailed output for every file
97
- --strict Fail (exit 2) when typecheck/build report errors
98
- --with-build Also run \`vite build\` after typecheck (slower)
99
- --no-compile Skip the post-bootstrap compile phase entirely
100
- --help, -h Show this help message
111
+ --source <dir> Source directory (default: .)
112
+ --dry-run Preview changes without writing files
113
+ --verbose Show detailed output for every file
114
+ --strict Fail (exit 2) when typecheck/build report errors
115
+ --with-build Also run \`vite build\` after typecheck (slower)
116
+ --no-compile Skip the post-bootstrap compile phase entirely
117
+ --no-cleanup-audit Skip the post-migration cleanup audit (run separately
118
+ via \`deco-post-cleanup\` if needed)
119
+ --help, -h Show this help message
101
120
 
102
121
  Examples:
103
122
  npx -p @decocms/start deco-migrate --dry-run --verbose
@@ -137,6 +156,19 @@ async function main() {
137
156
  config: siteConfig,
138
157
  });
139
158
 
159
+ // Phase 0: Source-layout detection (early-abort for unsupported layouts).
160
+ // The analyzer assumes a classic root layout (sections/, islands/, ...);
161
+ // running it on a modern src/ layout silently yields a near-empty
162
+ // migration. Detect-and-abort here so the user gets an actionable error
163
+ // before we touch any files.
164
+ const layout = detectSourceLayout(sourceDir);
165
+ if (layout !== "classic") {
166
+ console.error(red(`Error: ${layout} source layout`));
167
+ console.error("");
168
+ console.error(explainNonClassicLayout(layout, sourceDir));
169
+ process.exit(2);
170
+ }
171
+
140
172
  try {
141
173
  // Phase 1: Analyze source
142
174
  analyze(ctx);
@@ -176,6 +208,17 @@ async function main() {
176
208
  process.exit(2);
177
209
  }
178
210
  }
211
+
212
+ // Phase 9: Post-migration cleanup audit
213
+ // Read-only scan that catches residual debt the migration script
214
+ // can't (or won't) fix. Always informational unless --strict is on,
215
+ // in which case warning-severity findings exit 2.
216
+ if (!opts.noCleanupAudit) {
217
+ const auditFailed = cleanupAudit(ctx, { strict: opts.strict });
218
+ if (auditFailed) {
219
+ process.exit(2);
220
+ }
221
+ }
179
222
  } catch (error) {
180
223
  console.error(`\n ${red("Migration failed:")}`, error);
181
224
  process.exit(1);
@@ -210,7 +253,9 @@ function bootstrap(ctx: { sourceDir: string }) {
210
253
  run("npx tsr generate", "Generate TanStack routes");
211
254
 
212
255
  if (failures > 0) {
213
- console.log(`\n ${yellow("Bootstrap completed with warnings.")} Check errors above before running dev.\n`);
256
+ console.log(
257
+ `\n ${yellow("Bootstrap completed with warnings.")} Check errors above before running dev.\n`,
258
+ );
214
259
  } else {
215
260
  console.log(`\n ${green("Ready!")} Run \`${pm} run dev\` to start the dev server.\n`);
216
261
  }