@decocms/start 2.11.0 → 2.12.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/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 +24 -7
|
@@ -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
|
@@ -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,19 @@
|
|
|
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 { compile } from "./migrate/phase-compile";
|
|
34
34
|
import { report } from "./migrate/phase-report";
|
|
35
|
+
import { scaffold } from "./migrate/phase-scaffold";
|
|
36
|
+
import { transform } from "./migrate/phase-transform";
|
|
35
37
|
import { verify } from "./migrate/phase-verify";
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
+
import { detectSourceLayout, explainNonClassicLayout } from "./migrate/source-layout";
|
|
39
|
+
import { createContext, logPhase } from "./migrate/types";
|
|
38
40
|
|
|
39
41
|
function parseArgs(args: string[]): {
|
|
40
42
|
source: string;
|
|
@@ -137,6 +139,19 @@ async function main() {
|
|
|
137
139
|
config: siteConfig,
|
|
138
140
|
});
|
|
139
141
|
|
|
142
|
+
// Phase 0: Source-layout detection (early-abort for unsupported layouts).
|
|
143
|
+
// The analyzer assumes a classic root layout (sections/, islands/, ...);
|
|
144
|
+
// running it on a modern src/ layout silently yields a near-empty
|
|
145
|
+
// migration. Detect-and-abort here so the user gets an actionable error
|
|
146
|
+
// before we touch any files.
|
|
147
|
+
const layout = detectSourceLayout(sourceDir);
|
|
148
|
+
if (layout !== "classic") {
|
|
149
|
+
console.error(red(`Error: ${layout} source layout`));
|
|
150
|
+
console.error("");
|
|
151
|
+
console.error(explainNonClassicLayout(layout, sourceDir));
|
|
152
|
+
process.exit(2);
|
|
153
|
+
}
|
|
154
|
+
|
|
140
155
|
try {
|
|
141
156
|
// Phase 1: Analyze source
|
|
142
157
|
analyze(ctx);
|
|
@@ -210,7 +225,9 @@ function bootstrap(ctx: { sourceDir: string }) {
|
|
|
210
225
|
run("npx tsr generate", "Generate TanStack routes");
|
|
211
226
|
|
|
212
227
|
if (failures > 0) {
|
|
213
|
-
console.log(
|
|
228
|
+
console.log(
|
|
229
|
+
`\n ${yellow("Bootstrap completed with warnings.")} Check errors above before running dev.\n`,
|
|
230
|
+
);
|
|
214
231
|
} else {
|
|
215
232
|
console.log(`\n ${green("Ready!")} Run \`${pm} run dev\` to start the dev server.\n`);
|
|
216
233
|
}
|