@decocms/start 2.10.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 +37 -0
- package/.cursor/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md +37 -0
- package/package.json +4 -2
- package/scripts/migrate/post-cleanup/rules.ts +371 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +460 -0
- package/scripts/migrate/post-cleanup/runner.ts +137 -0
- package/scripts/migrate/post-cleanup/types.ts +106 -0
- package/scripts/migrate/source-layout.test.ts +111 -0
- package/scripts/migrate/source-layout.ts +103 -0
- package/scripts/migrate-post-cleanup.ts +186 -0
- package/scripts/migrate.ts +24 -7
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { _internals, ALL_RULES } from "./rules";
|
|
3
|
+
import { runAudit } from "./runner";
|
|
4
|
+
import type { FsAdapter, FsWriter } 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
|
+
/**
|
|
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
|
+
|
|
129
|
+
describe("runAudit — empty site", () => {
|
|
130
|
+
it("returns zero findings on an empty tree", () => {
|
|
131
|
+
const fs = makeFs({});
|
|
132
|
+
const report = runAudit(SITE, fs);
|
|
133
|
+
expect(report.site).toBe(SITE);
|
|
134
|
+
expect(report.totalFindings).toBe(0);
|
|
135
|
+
expect(report.rules).toHaveLength(ALL_RULES.length);
|
|
136
|
+
for (const r of report.rules) expect(r.findings).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("rule: dead-lib-shims", () => {
|
|
141
|
+
it("flags a shim whose only export is unreferenced", () => {
|
|
142
|
+
const fs = makeFs({
|
|
143
|
+
"/site/src/lib/dead.ts": "export const foo = 1;\n",
|
|
144
|
+
"/site/src/sections/Other.tsx": 'export const x = "y";\n',
|
|
145
|
+
});
|
|
146
|
+
const report = runAudit(SITE, fs);
|
|
147
|
+
const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
|
|
148
|
+
expect(r.findings).toHaveLength(1);
|
|
149
|
+
expect(r.findings[0].file).toBe("src/lib/dead.ts");
|
|
150
|
+
expect(r.findings[0].fix).toContain("rm src/lib/dead.ts");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("does not flag a shim referenced from outside src/lib", () => {
|
|
154
|
+
const fs = makeFs({
|
|
155
|
+
"/site/src/lib/used.ts": "export function helper() { return 1; }\n",
|
|
156
|
+
"/site/src/sections/Caller.tsx": 'import { helper } from "~/lib/used";\nhelper();\n',
|
|
157
|
+
});
|
|
158
|
+
const report = runAudit(SITE, fs);
|
|
159
|
+
const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
|
|
160
|
+
expect(r.findings).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("does not flag a shim with no exports at all (likely intentional empty file)", () => {
|
|
164
|
+
const fs = makeFs({
|
|
165
|
+
"/site/src/lib/empty.ts": "// nothing here\n",
|
|
166
|
+
});
|
|
167
|
+
const report = runAudit(SITE, fs);
|
|
168
|
+
const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
|
|
169
|
+
expect(r.findings).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("flags only when ALL exports are dead — partial use spares the file", () => {
|
|
173
|
+
const fs = makeFs({
|
|
174
|
+
"/site/src/lib/mixed.ts": "export const used = 1;\nexport const unused = 2;\n",
|
|
175
|
+
"/site/src/sections/Caller.tsx": 'import { used } from "~/lib/mixed";\nconsole.log(used);\n',
|
|
176
|
+
});
|
|
177
|
+
const report = runAudit(SITE, fs);
|
|
178
|
+
const r = report.rules.find((r) => r.rule === "dead-lib-shims")!;
|
|
179
|
+
expect(r.findings).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("rule: obsolete-vite-plugins", () => {
|
|
184
|
+
it("detects site-manual-chunks", () => {
|
|
185
|
+
const fs = makeFs({
|
|
186
|
+
"/site/vite.config.ts": `
|
|
187
|
+
export default defineConfig({
|
|
188
|
+
plugins: [
|
|
189
|
+
{ name: "site-manual-chunks", config() { return {}; } },
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
`,
|
|
193
|
+
});
|
|
194
|
+
const report = runAudit(SITE, fs);
|
|
195
|
+
const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
|
|
196
|
+
expect(r.findings).toHaveLength(1);
|
|
197
|
+
expect(r.findings[0].meta?.plugin).toBe("site-manual-chunks");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("detects deco-stub-meta-gen", () => {
|
|
201
|
+
const fs = makeFs({
|
|
202
|
+
"/site/vite.config.ts": 'plugins: [{ name: "deco-stub-meta-gen", enforce: "pre" }]',
|
|
203
|
+
});
|
|
204
|
+
const report = runAudit(SITE, fs);
|
|
205
|
+
const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
|
|
206
|
+
expect(r.findings).toHaveLength(1);
|
|
207
|
+
expect(r.findings[0].meta?.plugin).toBe("deco-stub-meta-gen");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("returns zero findings when both are absent", () => {
|
|
211
|
+
const fs = makeFs({
|
|
212
|
+
"/site/vite.config.ts": 'plugins: [{ name: "react", enforce: "pre" }]',
|
|
213
|
+
});
|
|
214
|
+
const report = runAudit(SITE, fs);
|
|
215
|
+
const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
|
|
216
|
+
expect(r.findings).toEqual([]);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("rule: dead-runtime-shim", () => {
|
|
221
|
+
it("flags an invoke-only runtime.ts", () => {
|
|
222
|
+
const fs = makeFs({
|
|
223
|
+
"/site/src/runtime.ts":
|
|
224
|
+
"export const invoke = createNestedInvokeProxy();\nexport function createNestedInvokeProxy() { return {}; }\n",
|
|
225
|
+
});
|
|
226
|
+
const report = runAudit(SITE, fs);
|
|
227
|
+
const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
|
|
228
|
+
expect(r.findings).toHaveLength(1);
|
|
229
|
+
expect(r.findings[0].file).toBe("src/runtime.ts");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("does not flag a runtime.ts that exports site-specific helpers", () => {
|
|
233
|
+
const fs = makeFs({
|
|
234
|
+
"/site/src/runtime.ts": "export const invoke = {};\nexport const customHelper = () => 1;\n",
|
|
235
|
+
});
|
|
236
|
+
const report = runAudit(SITE, fs);
|
|
237
|
+
const r = report.rules.find((r) => r.rule === "dead-runtime-shim")!;
|
|
238
|
+
expect(r.findings).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("rule: site-local-with-globals", () => {
|
|
243
|
+
it("flags a local cmsRouteWithGlobals wrapper", () => {
|
|
244
|
+
const lines = Array(120).fill("// boilerplate").join("\n");
|
|
245
|
+
const fs = makeFs({
|
|
246
|
+
"/site/src/server/routes/withSiteGlobals.ts": `${lines}\nexport function cmsRouteWithGlobals() { return {}; }\n`,
|
|
247
|
+
});
|
|
248
|
+
const report = runAudit(SITE, fs);
|
|
249
|
+
const r = report.rules.find((r) => r.rule === "site-local-with-globals")!;
|
|
250
|
+
expect(r.findings).toHaveLength(1);
|
|
251
|
+
expect(r.findings[0].meta?.lineCount).toBeGreaterThan(100);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("does not flag a re-export from the framework", () => {
|
|
255
|
+
const fs = makeFs({
|
|
256
|
+
"/site/src/server/routes/withSiteGlobals.ts":
|
|
257
|
+
'export { withSiteGlobals } from "@decocms/start/routes";\n',
|
|
258
|
+
});
|
|
259
|
+
const report = runAudit(SITE, fs);
|
|
260
|
+
const r = report.rules.find((r) => r.rule === "site-local-with-globals")!;
|
|
261
|
+
expect(r.findings).toEqual([]);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("rule: vtex-shim-regression", () => {
|
|
266
|
+
it("flags imports from ~/lib/vtex-segment", () => {
|
|
267
|
+
const fs = makeFs({
|
|
268
|
+
"/site/src/sections/Foo.tsx": 'import { getSegment } from "~/lib/vtex-segment";\n',
|
|
269
|
+
});
|
|
270
|
+
const report = runAudit(SITE, fs);
|
|
271
|
+
const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
|
|
272
|
+
expect(r.findings).toHaveLength(1);
|
|
273
|
+
expect(r.findings[0].meta?.shims).toContain("vtex-segment");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("does not flag imports from src/lib itself", () => {
|
|
277
|
+
const fs = makeFs({
|
|
278
|
+
"/site/src/lib/vtex-segment.ts": 'import other from "~/lib/vtex-fetch";\n',
|
|
279
|
+
});
|
|
280
|
+
const report = runAudit(SITE, fs);
|
|
281
|
+
const r = report.rules.find((r) => r.rule === "vtex-shim-regression")!;
|
|
282
|
+
expect(r.findings).toEqual([]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("rule: local-widgets-types", () => {
|
|
287
|
+
it("flags presence of src/types/widgets.ts and counts imports", () => {
|
|
288
|
+
const fs = makeFs({
|
|
289
|
+
"/site/src/types/widgets.ts": "export type ImageWidget = string;\n",
|
|
290
|
+
"/site/src/sections/A.tsx": 'import type { ImageWidget } from "~/types/widgets";\n',
|
|
291
|
+
"/site/src/sections/B.tsx": 'import type { ImageWidget } from "~/types/widgets";\n',
|
|
292
|
+
"/site/src/sections/C.tsx": "export const x = 1;\n",
|
|
293
|
+
});
|
|
294
|
+
const report = runAudit(SITE, fs);
|
|
295
|
+
const r = report.rules.find((r) => r.rule === "local-widgets-types")!;
|
|
296
|
+
expect(r.findings).toHaveLength(1);
|
|
297
|
+
expect(r.findings[0].meta?.importCount).toBe(2);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("returns zero findings when the file does not exist", () => {
|
|
301
|
+
const fs = makeFs({
|
|
302
|
+
"/site/src/sections/A.tsx": "export const x = 1;\n",
|
|
303
|
+
});
|
|
304
|
+
const report = runAudit(SITE, fs);
|
|
305
|
+
const r = report.rules.find((r) => r.rule === "local-widgets-types")!;
|
|
306
|
+
expect(r.findings).toEqual([]);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("rule: framework-todos", () => {
|
|
311
|
+
it("flags TODOs that mention deco/framework/move into", () => {
|
|
312
|
+
const fs = makeFs({
|
|
313
|
+
"/site/src/sections/Foo.tsx":
|
|
314
|
+
"// TODO: move into decoVitePlugin in next release\nexport const x = 1;\n",
|
|
315
|
+
});
|
|
316
|
+
const report = runAudit(SITE, fs);
|
|
317
|
+
const r = report.rules.find((r) => r.rule === "framework-todos")!;
|
|
318
|
+
expect(r.findings).toHaveLength(1);
|
|
319
|
+
expect(r.findings[0].file).toContain(":1");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("does not flag unrelated TODOs", () => {
|
|
323
|
+
const fs = makeFs({
|
|
324
|
+
"/site/src/sections/Foo.tsx": "// TODO: i18n strings\nexport const x = 1;\n",
|
|
325
|
+
});
|
|
326
|
+
const report = runAudit(SITE, fs);
|
|
327
|
+
const r = report.rules.find((r) => r.rule === "framework-todos")!;
|
|
328
|
+
expect(r.findings).toEqual([]);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("internals", () => {
|
|
333
|
+
it("extractExports parses common forms (top-level, unindented)", () => {
|
|
334
|
+
const code = [
|
|
335
|
+
"export const a = 1;",
|
|
336
|
+
"export function b() {}",
|
|
337
|
+
"export interface C {}",
|
|
338
|
+
"export type D = string;",
|
|
339
|
+
"export class E {}",
|
|
340
|
+
"const private_ = 1;",
|
|
341
|
+
].join("\n");
|
|
342
|
+
expect(_internals.extractExports(code).sort()).toEqual(["C", "D", "E", "a", "b"]);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("runAudit — totals", () => {
|
|
347
|
+
it("totalFindings sums across all rules", () => {
|
|
348
|
+
const fs = makeFs({
|
|
349
|
+
"/site/src/lib/dead.ts": "export const x = 1;\n",
|
|
350
|
+
"/site/vite.config.ts": 'plugins: [{ name: "site-manual-chunks", config() {} }]',
|
|
351
|
+
"/site/src/sections/Foo.tsx":
|
|
352
|
+
"// TODO: deco framework should own this\nexport const y = 2;\n",
|
|
353
|
+
});
|
|
354
|
+
const report = runAudit(SITE, fs);
|
|
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"');
|
|
459
|
+
});
|
|
460
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
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, 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
|
+
}
|
|
23
|
+
|
|
24
|
+
export function runAudit(
|
|
25
|
+
siteDir: string,
|
|
26
|
+
adapter: FsAdapter,
|
|
27
|
+
options: RunAuditOptions = {},
|
|
28
|
+
): AuditReport {
|
|
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
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
site: siteDir,
|
|
47
|
+
rules: summaries,
|
|
48
|
+
totalFindings: summaries.reduce((acc, s) => acc + s.findings.length, 0),
|
|
49
|
+
totalFixActions: summaries.reduce((acc, s) => acc + (s.fixes?.length ?? 0), 0),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------------------------ */
|
|
54
|
+
/* Real disk adapter */
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Minimal recursive-walk glob — intentionally tiny, no external deps.
|
|
59
|
+
* Supports `**`, `*`, and `{a,b,c}` brace expansion. Does NOT support
|
|
60
|
+
* extglob, negation, or `?`. That's fine: every pattern in this audit
|
|
61
|
+
* fits the supported subset.
|
|
62
|
+
*/
|
|
63
|
+
function expandBraces(pattern: string): string[] {
|
|
64
|
+
const m = pattern.match(/\{([^{}]+)\}/);
|
|
65
|
+
if (!m) return [pattern];
|
|
66
|
+
const [whole, inner] = m;
|
|
67
|
+
const start = pattern.indexOf(whole);
|
|
68
|
+
const before = pattern.slice(0, start);
|
|
69
|
+
const after = pattern.slice(start + whole.length);
|
|
70
|
+
const branches = inner.split(",");
|
|
71
|
+
return branches.flatMap((b) => expandBraces(`${before}${b}${after}`));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function patternToRegex(pattern: string): RegExp {
|
|
75
|
+
const re = pattern
|
|
76
|
+
.replace(/[.+^$()|]/g, "\\$&")
|
|
77
|
+
.replace(/\*\*\//g, "<<DBL>>")
|
|
78
|
+
.replace(/\*\*/g, "<<DBL>>")
|
|
79
|
+
.replace(/\*/g, "[^/]*")
|
|
80
|
+
.replace(/<<DBL>>/g, "(?:.*/)?");
|
|
81
|
+
return new RegExp(`^${re}$`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function walk(dir: string, rootDir: string, excludeDirs: Set<string>, out: string[]): void {
|
|
85
|
+
let entries: fs.Dirent[];
|
|
86
|
+
try {
|
|
87
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
88
|
+
} catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.name.startsWith(".")) continue;
|
|
93
|
+
const abs = path.join(dir, entry.name);
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
if (excludeDirs.has(entry.name)) continue;
|
|
96
|
+
walk(abs, rootDir, excludeDirs, out);
|
|
97
|
+
} else if (entry.isFile()) {
|
|
98
|
+
out.push(abs);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const realFsAdapter: FsAdapter = {
|
|
104
|
+
exists(absPath: string) {
|
|
105
|
+
return fs.existsSync(absPath);
|
|
106
|
+
},
|
|
107
|
+
readText(absPath: string) {
|
|
108
|
+
return fs.readFileSync(absPath, "utf-8");
|
|
109
|
+
},
|
|
110
|
+
glob(siteDir: string, pattern: string, excludeDirs: string[] = []) {
|
|
111
|
+
const allFiles: string[] = [];
|
|
112
|
+
walk(siteDir, siteDir, new Set(excludeDirs), allFiles);
|
|
113
|
+
const patterns = expandBraces(pattern).map(patternToRegex);
|
|
114
|
+
const matches = allFiles.filter((abs) => {
|
|
115
|
+
const rel = abs.slice(siteDir.length + 1).replace(/\\/g, "/");
|
|
116
|
+
return patterns.some((re) => re.test(rel));
|
|
117
|
+
});
|
|
118
|
+
return matches.sort();
|
|
119
|
+
},
|
|
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
|
+
};
|