@decocms/start 2.10.0 → 2.11.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.
@@ -5,6 +5,29 @@ 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`). Run it from the
12
+ site repo to get a structured report of which sections below actually
13
+ 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
+ # Machine-readable JSON for CI dashboards
20
+ npx -p @decocms/start deco-post-cleanup --json
21
+
22
+ # Fail the run (exit 2) if any warning-severity findings exist
23
+ npx -p @decocms/start deco-post-cleanup --strict
24
+ ```
25
+
26
+ The audit covers all 7 rules below and prints the exact file path +
27
+ suggested fix for each finding. It is **read-only** — auto-fix support
28
+ is a planned follow-up. Use the audit to scope this checklist to the
29
+ real, current findings instead of triaging the full document by hand.
30
+
8
31
  ## 1. Delete unused `src/lib/*` shims
9
32
 
10
33
  The migration script's `templates/lib-utils.ts` generates 11 shim files
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
7
7
  "bin": {
8
- "deco-migrate": "./scripts/migrate.ts"
8
+ "deco-migrate": "./scripts/migrate.ts",
9
+ "deco-post-cleanup": "./scripts/migrate-post-cleanup.ts"
9
10
  },
10
11
  "exports": {
11
12
  ".": "./src/index.ts",
@@ -59,6 +60,7 @@
59
60
  "./scripts/generate-schema": "./scripts/generate-schema.ts",
60
61
  "./scripts/generate-invoke": "./scripts/generate-invoke.ts",
61
62
  "./scripts/migrate": "./scripts/migrate.ts",
63
+ "./scripts/migrate-post-cleanup": "./scripts/migrate-post-cleanup.ts",
62
64
  "./scripts/tailwind-lint": "./scripts/tailwind-lint.ts",
63
65
  "./vite": "./src/vite/plugin.js",
64
66
  "./daemon": "./src/daemon/index.ts"
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Post-migration cleanup audit — rule implementations.
3
+ *
4
+ * Each rule mirrors a section in
5
+ * `.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md`.
6
+ * The intent is to take the human checklist and make it programmatically
7
+ * detectable so future migrations get the same scrubbing automatically.
8
+ *
9
+ * Rules are intentionally read-only here — `--fix` is a follow-up.
10
+ */
11
+
12
+ import type { Finding, Rule, RuleContext } from "./types";
13
+
14
+ const SRC_GLOB_EXCLUDES = ["node_modules", "dist", ".wrangler", ".vite", ".tanstack", "build"];
15
+
16
+ /* ------------------------------------------------------------------ */
17
+ /* Rule 1 — dead `src/lib/*` shims */
18
+ /* ------------------------------------------------------------------ */
19
+
20
+ const EXPORT_RE = /^export\s+(?:function|const|interface|type|class)\s+([A-Za-z_][A-Za-z0-9_]*)/gm;
21
+
22
+ function extractExports(content: string): string[] {
23
+ const out: string[] = [];
24
+ for (const m of content.matchAll(EXPORT_RE)) {
25
+ out.push(m[1]);
26
+ }
27
+ return out;
28
+ }
29
+
30
+ function symbolUsedOutsideLib(siteDir: string, fs: RuleContext["fs"], symbol: string): boolean {
31
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
32
+ const re = new RegExp(`\\b${symbol}\\b`);
33
+ for (const file of tsFiles) {
34
+ if (file.includes("/src/lib/")) continue;
35
+ const content = fs.readText(file);
36
+ if (re.test(content)) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ const ruleDeadLibShims: Rule = {
42
+ id: "dead-lib-shims",
43
+ title: "Dead src/lib/* shims",
44
+ run({ siteDir, fs }: RuleContext): Finding[] {
45
+ const libFiles = fs.glob(siteDir, "src/lib/*.ts", SRC_GLOB_EXCLUDES);
46
+ if (libFiles.length === 0) return [];
47
+
48
+ const findings: Finding[] = [];
49
+ for (const abs of libFiles) {
50
+ const rel = abs.slice(siteDir.length + 1);
51
+ const content = fs.readText(abs);
52
+ const exports = extractExports(content);
53
+ if (exports.length === 0) continue;
54
+ const allDead = exports.every((s) => !symbolUsedOutsideLib(siteDir, fs, s));
55
+ if (!allDead) continue;
56
+ findings.push({
57
+ rule: "dead-lib-shims",
58
+ severity: "info",
59
+ file: rel,
60
+ message: `${exports.length} export(s), 0 external imports`,
61
+ fix: `rm ${rel}`,
62
+ meta: { exports },
63
+ });
64
+ }
65
+ return findings;
66
+ },
67
+ };
68
+
69
+ /* ------------------------------------------------------------------ */
70
+ /* Rule 2 — obsolete inline vite plugins */
71
+ /* ------------------------------------------------------------------ */
72
+
73
+ const OBSOLETE_VITE_PLUGINS: { name: string; reason: string }[] = [
74
+ {
75
+ name: "site-manual-chunks",
76
+ reason: "framework's decoVitePlugin() now owns chunking",
77
+ },
78
+ {
79
+ name: "deco-stub-meta-gen",
80
+ reason: "framework now stubs meta.gen.{json,ts} on the client by default",
81
+ },
82
+ ];
83
+
84
+ const ruleObsoleteVitePlugins: Rule = {
85
+ id: "obsolete-vite-plugins",
86
+ title: "Obsolete inline Vite plugins",
87
+ run({ siteDir, fs }: RuleContext): Finding[] {
88
+ const findings: Finding[] = [];
89
+ const candidates = ["vite.config.ts", "vite.config.js", "vite.config.mjs"];
90
+ for (const rel of candidates) {
91
+ const abs = `${siteDir}/${rel}`;
92
+ if (!fs.exists(abs)) continue;
93
+ const content = fs.readText(abs);
94
+ for (const plugin of OBSOLETE_VITE_PLUGINS) {
95
+ const re = new RegExp(`name:\\s*["']${plugin.name}["']`);
96
+ if (!re.test(content)) continue;
97
+ findings.push({
98
+ rule: "obsolete-vite-plugins",
99
+ severity: "warning",
100
+ file: rel,
101
+ message: `'${plugin.name}' plugin is obsolete — ${plugin.reason}`,
102
+ fix: `delete the inline '${plugin.name}' plugin from ${rel}`,
103
+ meta: { plugin: plugin.name },
104
+ });
105
+ }
106
+ }
107
+ return findings;
108
+ },
109
+ };
110
+
111
+ /* ------------------------------------------------------------------ */
112
+ /* Rule 3 — dead `src/runtime.ts` invoke shim */
113
+ /* ------------------------------------------------------------------ */
114
+
115
+ const ruleDeadRuntimeShim: Rule = {
116
+ id: "dead-runtime-shim",
117
+ title: "Dead src/runtime.ts invoke shim",
118
+ run({ siteDir, fs }: RuleContext): Finding[] {
119
+ const abs = `${siteDir}/src/runtime.ts`;
120
+ if (!fs.exists(abs)) return [];
121
+ const content = fs.readText(abs);
122
+ // Heuristic: if the file's only meaningful exports are `invoke` /
123
+ // `createNestedInvokeProxy`, it's purely a shim.
124
+ const exports = extractExports(content);
125
+ const onlyInvokeShim =
126
+ exports.length > 0 && exports.every((e) => ["invoke", "createNestedInvokeProxy"].includes(e));
127
+ if (!onlyInvokeShim) return [];
128
+ return [
129
+ {
130
+ rule: "dead-runtime-shim",
131
+ severity: "info",
132
+ file: "src/runtime.ts",
133
+ message: `Only re-exports invoke (${exports.join(", ")}) — replace with @decocms/start/sdk`,
134
+ fix: 'rg -l "from \\"~/runtime\\"" src/ | xargs sed -i \'\' \'s|from "~/runtime"|from "@decocms/start/sdk"|g\' && rm src/runtime.ts',
135
+ },
136
+ ];
137
+ },
138
+ };
139
+
140
+ /* ------------------------------------------------------------------ */
141
+ /* Rule 4 — site-local `withSiteGlobals` workaround */
142
+ /* ------------------------------------------------------------------ */
143
+
144
+ const ruleSiteLocalGlobals: Rule = {
145
+ id: "site-local-with-globals",
146
+ title: "Site-local withSiteGlobals wrapper",
147
+ run({ siteDir, fs }: RuleContext): Finding[] {
148
+ const findings: Finding[] = [];
149
+ const candidates = fs.glob(siteDir, "src/**/withSiteGlobals.ts", SRC_GLOB_EXCLUDES);
150
+ for (const abs of candidates) {
151
+ const content = fs.readText(abs);
152
+ // Heuristic: any local definition (function/const) of withSiteGlobals or
153
+ // cmsRouteWithGlobals indicates a local wrapper, not a re-export from
154
+ // the framework. The framework version would just re-export.
155
+ const definesWrapper =
156
+ /(?:export\s+)?(?:function|const)\s+(?:withSiteGlobals|cmsRouteWithGlobals)\b/.test(
157
+ content,
158
+ );
159
+ const reExportsFromFramework = /from\s+['"]@decocms\/start\/routes['"]/.test(content);
160
+ if (!definesWrapper || reExportsFromFramework) continue;
161
+ const rel = abs.slice(siteDir.length + 1);
162
+ const lineCount = content.split("\n").length;
163
+ findings.push({
164
+ rule: "site-local-with-globals",
165
+ severity: "warning",
166
+ file: rel,
167
+ message: `Local wrapper (~${lineCount} LOC) — framework now exports withSiteGlobals from @decocms/start/routes`,
168
+ fix: "delete the local wrapper and import { withSiteGlobals } from '@decocms/start/routes'",
169
+ meta: { lineCount },
170
+ });
171
+ }
172
+ return findings;
173
+ },
174
+ };
175
+
176
+ /* ------------------------------------------------------------------ */
177
+ /* Rule 5 — `~/lib/vtex-*` shim regression */
178
+ /* ------------------------------------------------------------------ */
179
+
180
+ const ruleVtexShimRegression: Rule = {
181
+ id: "vtex-shim-regression",
182
+ title: "Imports from ~/lib/vtex-* (silent stub regression)",
183
+ run({ siteDir, fs }: RuleContext): Finding[] {
184
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
185
+ const findings: Finding[] = [];
186
+ const re = /from\s+['"]~\/lib\/vtex-([A-Za-z0-9-]+)['"]/g;
187
+ for (const abs of tsFiles) {
188
+ if (abs.includes("/src/lib/")) continue;
189
+ const content = fs.readText(abs);
190
+ const matches = [...content.matchAll(re)];
191
+ if (matches.length === 0) continue;
192
+ const rel = abs.slice(siteDir.length + 1);
193
+ const shims = [...new Set(matches.map((m) => `vtex-${m[1]}`))];
194
+ findings.push({
195
+ rule: "vtex-shim-regression",
196
+ severity: "warning",
197
+ file: rel,
198
+ message: `Imports from dead shim(s): ${shims.join(", ")} — runtime is silently stubbed`,
199
+ fix: "Repoint imports to '@decocms/apps/vtex/...' or 'apps/commerce/utils/...'",
200
+ meta: { shims },
201
+ });
202
+ }
203
+ return findings;
204
+ },
205
+ };
206
+
207
+ /* ------------------------------------------------------------------ */
208
+ /* Rule 6 — local `src/types/widgets.ts` shadowing framework */
209
+ /* ------------------------------------------------------------------ */
210
+
211
+ const ruleLocalWidgetsTypes: Rule = {
212
+ id: "local-widgets-types",
213
+ title: "Local src/types/widgets.ts shadowing framework",
214
+ run({ siteDir, fs }: RuleContext): Finding[] {
215
+ const abs = `${siteDir}/src/types/widgets.ts`;
216
+ if (!fs.exists(abs)) return [];
217
+ const tsFiles = fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES);
218
+ const re = /from\s+['"]~\/types\/widgets['"]/;
219
+ let importCount = 0;
220
+ for (const f of tsFiles) {
221
+ if (f === abs) continue;
222
+ if (re.test(fs.readText(f))) importCount++;
223
+ }
224
+ return [
225
+ {
226
+ rule: "local-widgets-types",
227
+ severity: "info",
228
+ file: "src/types/widgets.ts",
229
+ message: `Local file shadows @decocms/start/types/widgets (used in ${importCount} place(s))`,
230
+ fix: 'rewrite imports to "@decocms/start/types/widgets" and rm src/types/widgets.ts',
231
+ meta: { importCount },
232
+ },
233
+ ];
234
+ },
235
+ };
236
+
237
+ /* ------------------------------------------------------------------ */
238
+ /* Rule 7 — orphan "TODO: framework" comments */
239
+ /* ------------------------------------------------------------------ */
240
+
241
+ const ruleFrameworkTodos: Rule = {
242
+ id: "framework-todos",
243
+ title: "Orphan TODOs deferring to the framework",
244
+ run({ siteDir, fs }: RuleContext): Finding[] {
245
+ const tsFiles = [
246
+ ...fs.glob(siteDir, "src/**/*.{ts,tsx}", SRC_GLOB_EXCLUDES),
247
+ ...fs.glob(siteDir, "vite.config.ts", SRC_GLOB_EXCLUDES),
248
+ ];
249
+ const findings: Finding[] = [];
250
+ const re = /TODO[^\n]*?(?:deco|framework|move into)/i;
251
+ for (const abs of tsFiles) {
252
+ const content = fs.readText(abs);
253
+ const lines = content.split("\n");
254
+ for (let i = 0; i < lines.length; i++) {
255
+ if (!re.test(lines[i])) continue;
256
+ const rel = abs.slice(siteDir.length + 1);
257
+ findings.push({
258
+ rule: "framework-todos",
259
+ severity: "info",
260
+ file: `${rel}:${i + 1}`,
261
+ message: lines[i].trim().slice(0, 120),
262
+ fix: "Triage: shipped → adopt; deferred → file issue; obsolete → delete",
263
+ });
264
+ }
265
+ }
266
+ return findings;
267
+ },
268
+ };
269
+
270
+ export const ALL_RULES: Rule[] = [
271
+ ruleDeadLibShims,
272
+ ruleObsoleteVitePlugins,
273
+ ruleDeadRuntimeShim,
274
+ ruleSiteLocalGlobals,
275
+ ruleVtexShimRegression,
276
+ ruleLocalWidgetsTypes,
277
+ ruleFrameworkTodos,
278
+ ];
279
+
280
+ /** Exported for direct unit tests. */
281
+ export const _internals = {
282
+ extractExports,
283
+ symbolUsedOutsideLib,
284
+ rules: {
285
+ ruleDeadLibShims,
286
+ ruleObsoleteVitePlugins,
287
+ ruleDeadRuntimeShim,
288
+ ruleSiteLocalGlobals,
289
+ ruleVtexShimRegression,
290
+ ruleLocalWidgetsTypes,
291
+ ruleFrameworkTodos,
292
+ },
293
+ };
@@ -0,0 +1,288 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { _internals, ALL_RULES } from "./rules";
3
+ import { runAudit } from "./runner";
4
+ import type { FsAdapter } from "./types";
5
+
6
+ /**
7
+ * In-memory FsAdapter for tests. Maps absolute path → file content.
8
+ * `glob` does a literal substring-pattern match — good enough for our
9
+ * tests which never use complex globs.
10
+ */
11
+ function makeFs(files: Record<string, string>): FsAdapter {
12
+ const norm = Object.fromEntries(
13
+ Object.entries(files).map(([k, v]) => [k.replace(/\\/g, "/"), v]),
14
+ );
15
+ return {
16
+ exists(absPath) {
17
+ return absPath.replace(/\\/g, "/") in norm;
18
+ },
19
+ readText(absPath) {
20
+ const key = absPath.replace(/\\/g, "/");
21
+ if (!(key in norm)) throw new Error(`ENOENT: ${absPath}`);
22
+ return norm[key];
23
+ },
24
+ glob(siteDir, pattern, excludeDirs = []) {
25
+ const root = siteDir.replace(/\\/g, "/");
26
+ const all = Object.keys(norm).filter((p) => p.startsWith(`${root}/`));
27
+ const filtered = all.filter((p) => {
28
+ const rel = p.slice(root.length + 1);
29
+ return !excludeDirs.some((dir) => rel.startsWith(`${dir}/`));
30
+ });
31
+ // Build a regex that handles ** and {a,b} the same way the real
32
+ // adapter does — but lighter, just enough for the test patterns.
33
+ const branches = pattern.includes("{")
34
+ ? pattern
35
+ .match(/\{([^{}]+)\}/)![1]
36
+ .split(",")
37
+ .map((b) => pattern.replace(/\{[^{}]+\}/, b.trim()))
38
+ : [pattern];
39
+ const regexes = branches.map((p) => {
40
+ const re = p
41
+ .replace(/[.+^$()|]/g, "\\$&")
42
+ .replace(/\*\*\//g, "<<DBL>>")
43
+ .replace(/\*\*/g, "<<DBL>>")
44
+ .replace(/\*/g, "[^/]*")
45
+ .replace(/<<DBL>>/g, "(?:.*/)?");
46
+ return new RegExp(`^${re}$`);
47
+ });
48
+ return filtered
49
+ .filter((p) => {
50
+ const rel = p.slice(root.length + 1);
51
+ return regexes.some((re) => re.test(rel));
52
+ })
53
+ .sort();
54
+ },
55
+ };
56
+ }
57
+
58
+ const SITE = "/site";
59
+
60
+ describe("runAudit — empty site", () => {
61
+ it("returns zero findings on an empty tree", () => {
62
+ const fs = makeFs({});
63
+ const report = runAudit(SITE, fs);
64
+ expect(report.site).toBe(SITE);
65
+ expect(report.totalFindings).toBe(0);
66
+ expect(report.rules).toHaveLength(ALL_RULES.length);
67
+ for (const r of report.rules) expect(r.findings).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe("rule: dead-lib-shims", () => {
72
+ it("flags a shim whose only export is unreferenced", () => {
73
+ const fs = makeFs({
74
+ "/site/src/lib/dead.ts": "export const foo = 1;\n",
75
+ "/site/src/sections/Other.tsx": 'export const x = "y";\n',
76
+ });
77
+ const report = runAudit(SITE, fs);
78
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
79
+ expect(r.findings).toHaveLength(1);
80
+ expect(r.findings[0].file).toBe("src/lib/dead.ts");
81
+ expect(r.findings[0].fix).toContain("rm src/lib/dead.ts");
82
+ });
83
+
84
+ it("does not flag a shim referenced from outside src/lib", () => {
85
+ const fs = makeFs({
86
+ "/site/src/lib/used.ts": "export function helper() { return 1; }\n",
87
+ "/site/src/sections/Caller.tsx": 'import { helper } from "~/lib/used";\nhelper();\n',
88
+ });
89
+ const report = runAudit(SITE, fs);
90
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
91
+ expect(r.findings).toEqual([]);
92
+ });
93
+
94
+ it("does not flag a shim with no exports at all (likely intentional empty file)", () => {
95
+ const fs = makeFs({
96
+ "/site/src/lib/empty.ts": "// nothing here\n",
97
+ });
98
+ const report = runAudit(SITE, fs);
99
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
100
+ expect(r.findings).toEqual([]);
101
+ });
102
+
103
+ it("flags only when ALL exports are dead — partial use spares the file", () => {
104
+ const fs = makeFs({
105
+ "/site/src/lib/mixed.ts": "export const used = 1;\nexport const unused = 2;\n",
106
+ "/site/src/sections/Caller.tsx": 'import { used } from "~/lib/mixed";\nconsole.log(used);\n',
107
+ });
108
+ const report = runAudit(SITE, fs);
109
+ const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
110
+ expect(r.findings).toEqual([]);
111
+ });
112
+ });
113
+
114
+ describe("rule: obsolete-vite-plugins", () => {
115
+ it("detects site-manual-chunks", () => {
116
+ const fs = makeFs({
117
+ "/site/vite.config.ts": `
118
+ export default defineConfig({
119
+ plugins: [
120
+ { name: "site-manual-chunks", config() { return {}; } },
121
+ ],
122
+ });
123
+ `,
124
+ });
125
+ const report = runAudit(SITE, fs);
126
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
127
+ expect(r.findings).toHaveLength(1);
128
+ expect(r.findings[0].meta?.plugin).toBe("site-manual-chunks");
129
+ });
130
+
131
+ it("detects deco-stub-meta-gen", () => {
132
+ const fs = makeFs({
133
+ "/site/vite.config.ts": 'plugins: [{ name: "deco-stub-meta-gen", enforce: "pre" }]',
134
+ });
135
+ const report = runAudit(SITE, fs);
136
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
137
+ expect(r.findings).toHaveLength(1);
138
+ expect(r.findings[0].meta?.plugin).toBe("deco-stub-meta-gen");
139
+ });
140
+
141
+ it("returns zero findings when both are absent", () => {
142
+ const fs = makeFs({
143
+ "/site/vite.config.ts": 'plugins: [{ name: "react", enforce: "pre" }]',
144
+ });
145
+ const report = runAudit(SITE, fs);
146
+ const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
147
+ expect(r.findings).toEqual([]);
148
+ });
149
+ });
150
+
151
+ describe("rule: dead-runtime-shim", () => {
152
+ it("flags an invoke-only runtime.ts", () => {
153
+ const fs = makeFs({
154
+ "/site/src/runtime.ts":
155
+ "export const invoke = createNestedInvokeProxy();\nexport function createNestedInvokeProxy() { return {}; }\n",
156
+ });
157
+ const report = runAudit(SITE, fs);
158
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
159
+ expect(r.findings).toHaveLength(1);
160
+ expect(r.findings[0].file).toBe("src/runtime.ts");
161
+ });
162
+
163
+ it("does not flag a runtime.ts that exports site-specific helpers", () => {
164
+ const fs = makeFs({
165
+ "/site/src/runtime.ts": "export const invoke = {};\nexport const customHelper = () => 1;\n",
166
+ });
167
+ const report = runAudit(SITE, fs);
168
+ const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
169
+ expect(r.findings).toEqual([]);
170
+ });
171
+ });
172
+
173
+ describe("rule: site-local-with-globals", () => {
174
+ it("flags a local cmsRouteWithGlobals wrapper", () => {
175
+ const lines = Array(120).fill("// boilerplate").join("\n");
176
+ const fs = makeFs({
177
+ "/site/src/server/routes/withSiteGlobals.ts": `${lines}\nexport function cmsRouteWithGlobals() { return {}; }\n`,
178
+ });
179
+ const report = runAudit(SITE, fs);
180
+ const r = report.rules.find((r) => r.rule === "site-local-with-globals")!;
181
+ expect(r.findings).toHaveLength(1);
182
+ expect(r.findings[0].meta?.lineCount).toBeGreaterThan(100);
183
+ });
184
+
185
+ it("does not flag a re-export from the framework", () => {
186
+ const fs = makeFs({
187
+ "/site/src/server/routes/withSiteGlobals.ts":
188
+ 'export { withSiteGlobals } from "@decocms/start/routes";\n',
189
+ });
190
+ const report = runAudit(SITE, fs);
191
+ const r = report.rules.find((r) => r.rule === "site-local-with-globals")!;
192
+ expect(r.findings).toEqual([]);
193
+ });
194
+ });
195
+
196
+ describe("rule: vtex-shim-regression", () => {
197
+ it("flags imports from ~/lib/vtex-segment", () => {
198
+ const fs = makeFs({
199
+ "/site/src/sections/Foo.tsx": 'import { getSegment } from "~/lib/vtex-segment";\n',
200
+ });
201
+ const report = runAudit(SITE, fs);
202
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
203
+ expect(r.findings).toHaveLength(1);
204
+ expect(r.findings[0].meta?.shims).toContain("vtex-segment");
205
+ });
206
+
207
+ it("does not flag imports from src/lib itself", () => {
208
+ const fs = makeFs({
209
+ "/site/src/lib/vtex-segment.ts": 'import other from "~/lib/vtex-fetch";\n',
210
+ });
211
+ const report = runAudit(SITE, fs);
212
+ const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
213
+ expect(r.findings).toEqual([]);
214
+ });
215
+ });
216
+
217
+ describe("rule: local-widgets-types", () => {
218
+ it("flags presence of src/types/widgets.ts and counts imports", () => {
219
+ const fs = makeFs({
220
+ "/site/src/types/widgets.ts": "export type ImageWidget = string;\n",
221
+ "/site/src/sections/A.tsx": 'import type { ImageWidget } from "~/types/widgets";\n',
222
+ "/site/src/sections/B.tsx": 'import type { ImageWidget } from "~/types/widgets";\n',
223
+ "/site/src/sections/C.tsx": "export const x = 1;\n",
224
+ });
225
+ const report = runAudit(SITE, fs);
226
+ const r = report.rules.find((r) => r.rule === "local-widgets-types")!;
227
+ expect(r.findings).toHaveLength(1);
228
+ expect(r.findings[0].meta?.importCount).toBe(2);
229
+ });
230
+
231
+ it("returns zero findings when the file does not exist", () => {
232
+ const fs = makeFs({
233
+ "/site/src/sections/A.tsx": "export const x = 1;\n",
234
+ });
235
+ const report = runAudit(SITE, fs);
236
+ const r = report.rules.find((r) => r.rule === "local-widgets-types")!;
237
+ expect(r.findings).toEqual([]);
238
+ });
239
+ });
240
+
241
+ describe("rule: framework-todos", () => {
242
+ it("flags TODOs that mention deco/framework/move into", () => {
243
+ const fs = makeFs({
244
+ "/site/src/sections/Foo.tsx":
245
+ "// TODO: move into decoVitePlugin in next release\nexport const x = 1;\n",
246
+ });
247
+ const report = runAudit(SITE, fs);
248
+ const r = report.rules.find((r) => r.rule === "framework-todos")!;
249
+ expect(r.findings).toHaveLength(1);
250
+ expect(r.findings[0].file).toContain(":1");
251
+ });
252
+
253
+ it("does not flag unrelated TODOs", () => {
254
+ const fs = makeFs({
255
+ "/site/src/sections/Foo.tsx": "// TODO: i18n strings\nexport const x = 1;\n",
256
+ });
257
+ const report = runAudit(SITE, fs);
258
+ const r = report.rules.find((r) => r.rule === "framework-todos")!;
259
+ expect(r.findings).toEqual([]);
260
+ });
261
+ });
262
+
263
+ describe("internals", () => {
264
+ it("extractExports parses common forms (top-level, unindented)", () => {
265
+ const code = [
266
+ "export const a = 1;",
267
+ "export function b() {}",
268
+ "export interface C {}",
269
+ "export type D = string;",
270
+ "export class E {}",
271
+ "const private_ = 1;",
272
+ ].join("\n");
273
+ expect(_internals.extractExports(code).sort()).toEqual(["C", "D", "E", "a", "b"]);
274
+ });
275
+ });
276
+
277
+ describe("runAudit — totals", () => {
278
+ it("totalFindings sums across all rules", () => {
279
+ const fs = makeFs({
280
+ "/site/src/lib/dead.ts": "export const x = 1;\n",
281
+ "/site/vite.config.ts": 'plugins: [{ name: "site-manual-chunks", config() {} }]',
282
+ "/site/src/sections/Foo.tsx":
283
+ "// TODO: deco framework should own this\nexport const y = 2;\n",
284
+ });
285
+ const report = runAudit(SITE, fs);
286
+ expect(report.totalFindings).toBe(3);
287
+ });
288
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Post-migration cleanup audit — orchestrator.
3
+ *
4
+ * `runAudit` is the testable, FS-injected entry. The CLI in
5
+ * `../../migrate-post-cleanup.ts` wires up the real disk adapter.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { ALL_RULES } from "./rules";
11
+ import type { AuditReport, FsAdapter, Rule, RuleSummary } from "./types";
12
+
13
+ export function runAudit(
14
+ siteDir: string,
15
+ adapter: FsAdapter,
16
+ rules: Rule[] = ALL_RULES,
17
+ ): AuditReport {
18
+ const summaries: RuleSummary[] = rules.map((r) => ({
19
+ rule: r.id,
20
+ title: r.title,
21
+ findings: r.run({ siteDir, fs: adapter }),
22
+ }));
23
+ return {
24
+ site: siteDir,
25
+ rules: summaries,
26
+ totalFindings: summaries.reduce((acc, s) => acc + s.findings.length, 0),
27
+ };
28
+ }
29
+
30
+ /* ------------------------------------------------------------------ */
31
+ /* Real disk adapter */
32
+ /* ------------------------------------------------------------------ */
33
+
34
+ /**
35
+ * Minimal recursive-walk glob — intentionally tiny, no external deps.
36
+ * Supports `**`, `*`, and `{a,b,c}` brace expansion. Does NOT support
37
+ * extglob, negation, or `?`. That's fine: every pattern in this audit
38
+ * fits the supported subset.
39
+ */
40
+ function expandBraces(pattern: string): string[] {
41
+ const m = pattern.match(/\{([^{}]+)\}/);
42
+ if (!m) return [pattern];
43
+ const [whole, inner] = m;
44
+ const start = pattern.indexOf(whole);
45
+ const before = pattern.slice(0, start);
46
+ const after = pattern.slice(start + whole.length);
47
+ const branches = inner.split(",");
48
+ return branches.flatMap((b) => expandBraces(`${before}${b}${after}`));
49
+ }
50
+
51
+ function patternToRegex(pattern: string): RegExp {
52
+ const re = pattern
53
+ .replace(/[.+^$()|]/g, "\\$&")
54
+ .replace(/\*\*\//g, "<<DBL>>")
55
+ .replace(/\*\*/g, "<<DBL>>")
56
+ .replace(/\*/g, "[^/]*")
57
+ .replace(/<<DBL>>/g, "(?:.*/)?");
58
+ return new RegExp(`^${re}$`);
59
+ }
60
+
61
+ function walk(dir: string, rootDir: string, excludeDirs: Set<string>, out: string[]): void {
62
+ let entries: fs.Dirent[];
63
+ try {
64
+ entries = fs.readdirSync(dir, { withFileTypes: true });
65
+ } catch {
66
+ return;
67
+ }
68
+ for (const entry of entries) {
69
+ if (entry.name.startsWith(".")) continue;
70
+ const abs = path.join(dir, entry.name);
71
+ if (entry.isDirectory()) {
72
+ if (excludeDirs.has(entry.name)) continue;
73
+ walk(abs, rootDir, excludeDirs, out);
74
+ } else if (entry.isFile()) {
75
+ out.push(abs);
76
+ }
77
+ }
78
+ }
79
+
80
+ export const realFsAdapter: FsAdapter = {
81
+ exists(absPath: string) {
82
+ return fs.existsSync(absPath);
83
+ },
84
+ readText(absPath: string) {
85
+ return fs.readFileSync(absPath, "utf-8");
86
+ },
87
+ glob(siteDir: string, pattern: string, excludeDirs: string[] = []) {
88
+ const allFiles: string[] = [];
89
+ walk(siteDir, siteDir, new Set(excludeDirs), allFiles);
90
+ const patterns = expandBraces(pattern).map(patternToRegex);
91
+ const matches = allFiles.filter((abs) => {
92
+ const rel = abs.slice(siteDir.length + 1).replace(/\\/g, "/");
93
+ return patterns.some((re) => re.test(rel));
94
+ });
95
+ return matches.sort();
96
+ },
97
+ };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Post-migration cleanup audit — shared types.
3
+ *
4
+ * The audit runner is a thin orchestrator: it loads the site, runs each
5
+ * rule, and prints the findings. The interesting bits live in `rules.ts`.
6
+ *
7
+ * Rules are pure(ish) functions over an injected `FsAdapter`, which means
8
+ * they can be unit-tested with an in-memory file system and never touch
9
+ * the real disk in CI.
10
+ */
11
+
12
+ export type Severity = "info" | "warning";
13
+
14
+ export interface Finding {
15
+ /** Stable rule identifier (e.g. "dead-lib-shims"). */
16
+ rule: string;
17
+ severity: Severity;
18
+ /** Site-relative path of the file the finding refers to. */
19
+ file: string;
20
+ /** One-line message — shown in default text output. */
21
+ message: string;
22
+ /** Suggested human action, if any. */
23
+ fix?: string;
24
+ /** Free-form structured payload for JSON consumers. */
25
+ meta?: Record<string, unknown>;
26
+ }
27
+
28
+ export interface RuleSummary {
29
+ /** Stable rule identifier. */
30
+ rule: string;
31
+ /** Human-readable section title. */
32
+ title: string;
33
+ findings: Finding[];
34
+ }
35
+
36
+ export interface AuditReport {
37
+ site: string;
38
+ rules: RuleSummary[];
39
+ totalFindings: number;
40
+ }
41
+
42
+ /**
43
+ * Minimal file-system adapter — read + glob. Keeping the surface tiny
44
+ * is what lets us pass an in-memory map in unit tests.
45
+ */
46
+ export interface FsAdapter {
47
+ exists(absPath: string): boolean;
48
+ readText(absPath: string): string;
49
+ /**
50
+ * Return absolute paths matching the glob, ordered by path. Globs are
51
+ * relative to `siteDir`. Implementations must respect `excludeDirs` and
52
+ * skip them entirely.
53
+ */
54
+ glob(siteDir: string, pattern: string, excludeDirs?: string[]): string[];
55
+ }
56
+
57
+ export interface RuleContext {
58
+ siteDir: string;
59
+ fs: FsAdapter;
60
+ }
61
+
62
+ export interface Rule {
63
+ id: string;
64
+ title: string;
65
+ run(ctx: RuleContext): Finding[];
66
+ }
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Post-Migration Cleanup Audit
4
+ *
5
+ * Read-only audit that scans a migrated site for dead code and obsolete
6
+ * boilerplate that the framework now owns. Mirrors the human checklist at
7
+ * `.agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md`
8
+ * but turns it into something CI can actually run.
9
+ *
10
+ * Usage (from a migrated site directory):
11
+ * npx -p @decocms/start deco-post-cleanup
12
+ * npx -p @decocms/start deco-post-cleanup --json
13
+ *
14
+ * Options:
15
+ * --source <dir> Site directory to audit (default: current directory)
16
+ * --json Emit machine-readable JSON instead of pretty text
17
+ * --strict Exit code 2 if any warning-severity findings exist
18
+ * --help, -h Show this help
19
+ *
20
+ * This script is intentionally read-only. Auto-fix support (`--fix`) is
21
+ * a planned follow-up — see the SKILL doc.
22
+ */
23
+
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";
27
+ import type { AuditReport, Severity } from "./migrate/post-cleanup/types";
28
+
29
+ interface CliOpts {
30
+ source: string;
31
+ json: boolean;
32
+ strict: boolean;
33
+ help: boolean;
34
+ }
35
+
36
+ function parseArgs(args: string[]): CliOpts {
37
+ let source = ".";
38
+ let json = false;
39
+ let strict = false;
40
+ let help = false;
41
+ for (let i = 0; i < args.length; i++) {
42
+ switch (args[i]) {
43
+ case "--source":
44
+ source = args[++i];
45
+ break;
46
+ case "--json":
47
+ json = true;
48
+ break;
49
+ case "--strict":
50
+ strict = true;
51
+ break;
52
+ case "--help":
53
+ case "-h":
54
+ help = true;
55
+ break;
56
+ }
57
+ }
58
+ return { source, json, strict, help };
59
+ }
60
+
61
+ function showHelp() {
62
+ console.log(`
63
+ @decocms/start — Post-Migration Cleanup Audit
64
+
65
+ Scans a migrated site for dead code and obsolete boilerplate that the
66
+ framework now owns. Read-only — prints findings, does not modify files.
67
+
68
+ Usage:
69
+ npx -p @decocms/start deco-post-cleanup [options]
70
+
71
+ Options:
72
+ --source <dir> Site directory to audit (default: .)
73
+ --json Emit machine-readable JSON instead of pretty text
74
+ --strict Exit code 2 if any warning-severity findings exist
75
+ --help, -h Show this help
76
+
77
+ Examples:
78
+ npx -p @decocms/start deco-post-cleanup
79
+ npx -p @decocms/start deco-post-cleanup --source ./my-site --json
80
+
81
+ See: .agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md
82
+ `);
83
+ }
84
+
85
+ function severityColor(sev: Severity, text: string): string {
86
+ if (sev === "warning") return yellow(text);
87
+ return gray(text);
88
+ }
89
+
90
+ function printText(report: AuditReport): void {
91
+ banner("Post-Migration Cleanup Audit");
92
+ console.log(` ${gray("Site:")} ${bold(report.site)}`);
93
+ console.log(` ${gray("Findings:")} ${bold(String(report.totalFindings))}`);
94
+ console.log("");
95
+
96
+ let idx = 0;
97
+ for (const summary of report.rules) {
98
+ idx++;
99
+ const count = summary.findings.length;
100
+ const headColor = count === 0 ? green : yellow;
101
+ console.log(`${headColor(`[${idx}] ${summary.title}`)} ${gray(`(${count} found)`)}`);
102
+ for (const f of summary.findings) {
103
+ const tag = severityColor(f.severity, `[${f.severity.toUpperCase()}]`);
104
+ console.log(` ${tag} ${bold(f.file)} — ${f.message}`);
105
+ if (f.fix) console.log(` ${gray("fix:")} ${f.fix}`);
106
+ }
107
+ if (count === 0) console.log(` ${gray("(nothing to clean up)")}`);
108
+ console.log("");
109
+ }
110
+
111
+ const warnings = report.rules
112
+ .flatMap((r) => r.findings)
113
+ .filter((f) => f.severity === "warning").length;
114
+ const infos = report.totalFindings - warnings;
115
+ console.log(
116
+ `${bold("Summary:")} ${report.totalFindings} finding(s) — ${yellow(`${warnings} warning(s)`)}, ${gray(`${infos} info`)}`,
117
+ );
118
+ if (report.totalFindings > 0) {
119
+ console.log(gray(" See post-migration-cleanup.md for the canonical fix steps per rule."));
120
+ }
121
+ }
122
+
123
+ function printJson(report: AuditReport): void {
124
+ console.log(JSON.stringify(report, null, 2));
125
+ }
126
+
127
+ function shouldFail(report: AuditReport, strict: boolean): boolean {
128
+ if (!strict) return false;
129
+ return report.rules.some((r) => r.findings.some((f) => f.severity === "warning"));
130
+ }
131
+
132
+ async function main() {
133
+ const opts = parseArgs(process.argv.slice(2));
134
+ if (opts.help) {
135
+ showHelp();
136
+ process.exit(0);
137
+ }
138
+
139
+ const siteDir = path.resolve(opts.source);
140
+ const report = runAudit(siteDir, realFsAdapter);
141
+
142
+ if (opts.json) {
143
+ printJson(report);
144
+ } else {
145
+ printText(report);
146
+ }
147
+
148
+ if (shouldFail(report, opts.strict)) {
149
+ process.exit(2);
150
+ }
151
+ }
152
+
153
+ main().catch((err) => {
154
+ console.error(red(`Audit failed: ${(err as Error).message}`));
155
+ process.exit(1);
156
+ });