@decocms/start 2.9.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.
@@ -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
+ };