@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.
- package/.agents/skills/deco-migrate-script/SKILL.md +38 -0
- package/.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +24 -10
- package/.cursor/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +37 -0
- package/package.json +1 -1
- package/scripts/migrate/phase-cleanup-audit.test.ts +137 -0
- package/scripts/migrate/phase-cleanup-audit.ts +105 -0
- package/scripts/migrate/post-cleanup/rules.ts +79 -1
- package/scripts/migrate/post-cleanup/runner.test.ts +173 -1
- package/scripts/migrate/post-cleanup/runner.ts +47 -7
- package/scripts/migrate/post-cleanup/types.ts +40 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate-post-cleanup.ts +43 -13
- package/scripts/migrate.ts +60 -15
|
@@ -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`
|
|
12
|
-
site repo to get a structured report
|
|
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
|
-
#
|
|
20
|
-
|
|
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
|
-
#
|
|
23
|
-
npx -p @decocms/start deco-post-cleanup --
|
|
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.
|
|
28
|
-
|
|
29
|
-
|
|
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
|
@@ -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
|
-
|
|
27
|
+
options: RunAuditOptions = {},
|
|
17
28
|
): AuditReport {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
`${
|
|
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
|
-
|
|
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)) {
|
package/scripts/migrate.ts
CHANGED
|
@@ -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 {
|
|
37
|
-
import {
|
|
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 {
|
|
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>
|
|
95
|
-
--dry-run
|
|
96
|
-
--verbose
|
|
97
|
-
--strict
|
|
98
|
-
--with-build
|
|
99
|
-
--no-compile
|
|
100
|
-
--
|
|
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(
|
|
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
|
}
|