@decocms/start 2.17.0 → 2.19.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/package.json +1 -1
- package/scripts/migrate/phase-scaffold.ts +12 -0
- package/scripts/migrate/post-cleanup/rules.ts +299 -0
- package/scripts/migrate/post-cleanup/runner.test.ts +182 -0
- package/scripts/migrate/templates/cursor-rules.test.ts +59 -0
- package/scripts/migrate/templates/cursor-rules.ts +70 -0
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ import { generateCommerceLoaders } from "./templates/commerce-loaders";
|
|
|
18
18
|
import { generateSectionLoaders } from "./templates/section-loaders";
|
|
19
19
|
import { generateCacheConfig } from "./templates/cache-config";
|
|
20
20
|
import { generateSdkFiles } from "./templates/sdk-gen";
|
|
21
|
+
import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
|
|
21
22
|
// `lib-utils` is imported lazily — see end of phase-cleanup. Eager
|
|
22
23
|
// generation of all 11 shims left every site with dead code that had
|
|
23
24
|
// to be cleaned up by hand.
|
|
@@ -139,6 +140,17 @@ export function scaffold(ctx: MigrationContext): void {
|
|
|
139
140
|
writeFile(ctx, "src/components/ui/Theme.tsx", generateSiteThemeComponent());
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
// Migration tooling policy pointer rule (D1–D5 + priorities).
|
|
144
|
+
// The canonical rule lives in decocms/deco-start; this is a tiny
|
|
145
|
+
// pointer that loads on every Cursor session in the migrated site
|
|
146
|
+
// so agents working on the site know where the policy is and what
|
|
147
|
+
// it means here. See MIGRATION_TOOLING_PLAN.md (Wave 12-H).
|
|
148
|
+
writeFile(
|
|
149
|
+
ctx,
|
|
150
|
+
".cursor/rules/migration-tooling-policy.mdc",
|
|
151
|
+
generateMigrationPolicyPointerRule(ctx.siteName),
|
|
152
|
+
);
|
|
153
|
+
|
|
142
154
|
// Create public/ directory
|
|
143
155
|
if (!ctx.dryRun) {
|
|
144
156
|
fs.mkdirSync(path.join(ctx.sourceDir, "public"), { recursive: true });
|
|
@@ -156,8 +156,307 @@ const ruleObsoleteVitePlugins: Rule = {
|
|
|
156
156
|
}
|
|
157
157
|
return findings;
|
|
158
158
|
},
|
|
159
|
+
applyFix({ siteDir, fs }, findings, writer): FixAction[] {
|
|
160
|
+
// Group findings by file so we rewrite each vite.config in one pass.
|
|
161
|
+
const byFile = new Map<string, string[]>();
|
|
162
|
+
for (const f of findings) {
|
|
163
|
+
const plugin = (f.meta?.plugin as string | undefined) ?? "";
|
|
164
|
+
if (!plugin) continue;
|
|
165
|
+
const arr = byFile.get(f.file) ?? [];
|
|
166
|
+
arr.push(plugin);
|
|
167
|
+
byFile.set(f.file, arr);
|
|
168
|
+
}
|
|
169
|
+
const actions: FixAction[] = [];
|
|
170
|
+
for (const [rel, pluginNames] of byFile) {
|
|
171
|
+
const abs = `${siteDir}/${rel}`;
|
|
172
|
+
if (!fs.exists(abs)) continue;
|
|
173
|
+
const before = fs.readText(abs);
|
|
174
|
+
const removed: string[] = [];
|
|
175
|
+
let next = before;
|
|
176
|
+
// Process plugins right-to-left in document order so each removal
|
|
177
|
+
// does not invalidate the indices of the next one.
|
|
178
|
+
const ordered = pluginNames
|
|
179
|
+
.map((name) => ({ name, span: findInlineVitePluginSpan(next, name) }))
|
|
180
|
+
.filter((p): p is { name: string; span: PluginSpan } => p.span !== null)
|
|
181
|
+
.sort((a, b) => b.span.startIdx - a.span.startIdx);
|
|
182
|
+
for (const p of ordered) {
|
|
183
|
+
next = next.slice(0, p.span.startIdx) + next.slice(p.span.endIdx);
|
|
184
|
+
removed.push(p.name);
|
|
185
|
+
}
|
|
186
|
+
if (next !== before) {
|
|
187
|
+
writer.writeText(abs, next);
|
|
188
|
+
actions.push({
|
|
189
|
+
file: rel,
|
|
190
|
+
kind: "rewrite-vite-config",
|
|
191
|
+
detail: `removed obsolete plugin(s): ${removed
|
|
192
|
+
.reverse()
|
|
193
|
+
.join(", ")}`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return actions;
|
|
198
|
+
},
|
|
159
199
|
};
|
|
160
200
|
|
|
201
|
+
/**
|
|
202
|
+
* Span of an inline plugin object literal inside vite.config.ts that
|
|
203
|
+
* the auto-fixer should strip. Includes:
|
|
204
|
+
*
|
|
205
|
+
* - `startIdx`: position of the first attached `// ...` line above the
|
|
206
|
+
* `{`, or the `{` itself if no leading comment is attached. Leading
|
|
207
|
+
* indentation is included.
|
|
208
|
+
* - `endIdx`: exclusive — points just past the trailing `,\n` (or `\n`
|
|
209
|
+
* if there's no comma). The next character is the start of the next
|
|
210
|
+
* plugin (or the closing `]`).
|
|
211
|
+
*
|
|
212
|
+
* Removing `[startIdx, endIdx)` produces a clean diff: comment + literal
|
|
213
|
+
* gone, no orphan separator, surrounding plugins still paired with their
|
|
214
|
+
* own commas / comments.
|
|
215
|
+
*/
|
|
216
|
+
type PluginSpan = { startIdx: number; endIdx: number };
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Brace-balanced search for an inline `{ name: "<plugin>", ... }`
|
|
220
|
+
* object literal inside a vite config. Properly skips over strings,
|
|
221
|
+
* template literals, line comments and block comments so it doesn't
|
|
222
|
+
* miscount braces inside e.g. a `config()` body that contains
|
|
223
|
+
* `{ build: { rollupOptions: ... } }` or template-string interpolation.
|
|
224
|
+
*
|
|
225
|
+
* Returns null when the plugin name isn't present, or when we can't
|
|
226
|
+
* walk the braces unambiguously (defensive — the rule's `run()` will
|
|
227
|
+
* still flag the finding for a manual fix).
|
|
228
|
+
*/
|
|
229
|
+
export function findInlineVitePluginSpan(
|
|
230
|
+
content: string,
|
|
231
|
+
pluginName: string,
|
|
232
|
+
): PluginSpan | null {
|
|
233
|
+
const re = new RegExp(`name:\\s*(['"\`])${escapeRegex(pluginName)}\\1`);
|
|
234
|
+
const m = re.exec(content);
|
|
235
|
+
if (!m) return null;
|
|
236
|
+
const namePropIdx = m.index;
|
|
237
|
+
|
|
238
|
+
// Walk backwards from the name property to find the enclosing `{`.
|
|
239
|
+
// Track string / comment state so we don't false-match on `{` inside
|
|
240
|
+
// a string literal somewhere earlier in the file.
|
|
241
|
+
const openIdx = findEnclosingObjectOpen(content, namePropIdx);
|
|
242
|
+
if (openIdx < 0) return null;
|
|
243
|
+
|
|
244
|
+
// Walk forward from the open brace counting matching braces, again
|
|
245
|
+
// skipping strings / comments. Returns the index of the matching `}`.
|
|
246
|
+
const closeIdx = findMatchingClose(content, openIdx);
|
|
247
|
+
if (closeIdx < 0) return null;
|
|
248
|
+
|
|
249
|
+
// Compute trailingEnd: consume `,` (optional) then whitespace up to
|
|
250
|
+
// and including the first `\n` after the closing brace. If we never
|
|
251
|
+
// hit `\n`, just stop at the comma / next non-whitespace.
|
|
252
|
+
let trailingEnd = closeIdx + 1;
|
|
253
|
+
while (
|
|
254
|
+
trailingEnd < content.length &&
|
|
255
|
+
(content[trailingEnd] === " " || content[trailingEnd] === "\t")
|
|
256
|
+
) {
|
|
257
|
+
trailingEnd++;
|
|
258
|
+
}
|
|
259
|
+
if (content[trailingEnd] === ",") trailingEnd++;
|
|
260
|
+
while (
|
|
261
|
+
trailingEnd < content.length &&
|
|
262
|
+
(content[trailingEnd] === " " || content[trailingEnd] === "\t")
|
|
263
|
+
) {
|
|
264
|
+
trailingEnd++;
|
|
265
|
+
}
|
|
266
|
+
if (content[trailingEnd] === "\n") trailingEnd++;
|
|
267
|
+
|
|
268
|
+
// Compute leadingStart: walk backwards over consecutive `//`-only
|
|
269
|
+
// lines that are immediately attached to the `{` (no blank line
|
|
270
|
+
// between them and the literal). Block comments are left alone.
|
|
271
|
+
const leadingStart = findAttachedLeadingComments(content, openIdx);
|
|
272
|
+
|
|
273
|
+
return { startIdx: leadingStart, endIdx: trailingEnd };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function escapeRegex(s: string): string {
|
|
277
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Walk backwards from `fromIdx` to find the index of the `{` that
|
|
282
|
+
* opens the object literal currently containing `fromIdx`. Returns
|
|
283
|
+
* -1 if no such `{` is found before the start of the file or if
|
|
284
|
+
* the walk is too ambiguous (mismatched balance).
|
|
285
|
+
*
|
|
286
|
+
* Skips over string, template-literal and comment regions so braces
|
|
287
|
+
* inside those don't affect the count.
|
|
288
|
+
*/
|
|
289
|
+
function findEnclosingObjectOpen(content: string, fromIdx: number): number {
|
|
290
|
+
// Strategy: scan the file from the start to fromIdx, maintaining a
|
|
291
|
+
// stack of open `{` positions, skipping inside strings/comments.
|
|
292
|
+
// The top of the stack at fromIdx is our enclosing open.
|
|
293
|
+
const stack: number[] = [];
|
|
294
|
+
let i = 0;
|
|
295
|
+
const n = Math.min(content.length, fromIdx);
|
|
296
|
+
while (i < n) {
|
|
297
|
+
const ch = content[i];
|
|
298
|
+
const next = content[i + 1];
|
|
299
|
+
if (ch === "/" && next === "/") {
|
|
300
|
+
i += 2;
|
|
301
|
+
while (i < n && content[i] !== "\n") i++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (ch === "/" && next === "*") {
|
|
305
|
+
i += 2;
|
|
306
|
+
while (i < n && !(content[i] === "*" && content[i + 1] === "/")) i++;
|
|
307
|
+
i += 2;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (ch === '"' || ch === "'") {
|
|
311
|
+
const quote = ch;
|
|
312
|
+
i++;
|
|
313
|
+
while (i < n && content[i] !== quote) {
|
|
314
|
+
if (content[i] === "\\") i++;
|
|
315
|
+
i++;
|
|
316
|
+
}
|
|
317
|
+
i++;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (ch === "`") {
|
|
321
|
+
i++;
|
|
322
|
+
while (i < n && content[i] !== "`") {
|
|
323
|
+
if (content[i] === "\\") {
|
|
324
|
+
i += 2;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (content[i] === "$" && content[i + 1] === "{") {
|
|
328
|
+
i += 2;
|
|
329
|
+
// Recursively skip until matching `}` of the interpolation.
|
|
330
|
+
let depth = 1;
|
|
331
|
+
while (i < n && depth > 0) {
|
|
332
|
+
if (content[i] === "{") depth++;
|
|
333
|
+
else if (content[i] === "}") depth--;
|
|
334
|
+
if (depth === 0) break;
|
|
335
|
+
i++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
i++;
|
|
339
|
+
}
|
|
340
|
+
i++;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (ch === "{") {
|
|
344
|
+
stack.push(i);
|
|
345
|
+
i++;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (ch === "}") {
|
|
349
|
+
stack.pop();
|
|
350
|
+
i++;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
i++;
|
|
354
|
+
}
|
|
355
|
+
return stack.length > 0 ? stack[stack.length - 1] : -1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* From the `{` at `openIdx`, walk forward to find the matching `}`.
|
|
360
|
+
* Skips strings / template literals / comments.
|
|
361
|
+
*/
|
|
362
|
+
function findMatchingClose(content: string, openIdx: number): number {
|
|
363
|
+
let i = openIdx + 1;
|
|
364
|
+
let depth = 1;
|
|
365
|
+
const n = content.length;
|
|
366
|
+
while (i < n) {
|
|
367
|
+
const ch = content[i];
|
|
368
|
+
const next = content[i + 1];
|
|
369
|
+
if (ch === "/" && next === "/") {
|
|
370
|
+
i += 2;
|
|
371
|
+
while (i < n && content[i] !== "\n") i++;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (ch === "/" && next === "*") {
|
|
375
|
+
i += 2;
|
|
376
|
+
while (i < n && !(content[i] === "*" && content[i + 1] === "/")) i++;
|
|
377
|
+
i += 2;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (ch === '"' || ch === "'") {
|
|
381
|
+
const quote = ch;
|
|
382
|
+
i++;
|
|
383
|
+
while (i < n && content[i] !== quote) {
|
|
384
|
+
if (content[i] === "\\") i++;
|
|
385
|
+
i++;
|
|
386
|
+
}
|
|
387
|
+
i++;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (ch === "`") {
|
|
391
|
+
i++;
|
|
392
|
+
while (i < n && content[i] !== "`") {
|
|
393
|
+
if (content[i] === "\\") {
|
|
394
|
+
i += 2;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (content[i] === "$" && content[i + 1] === "{") {
|
|
398
|
+
i += 2;
|
|
399
|
+
let d = 1;
|
|
400
|
+
while (i < n && d > 0) {
|
|
401
|
+
if (content[i] === "{") d++;
|
|
402
|
+
else if (content[i] === "}") d--;
|
|
403
|
+
if (d === 0) break;
|
|
404
|
+
i++;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
i++;
|
|
408
|
+
}
|
|
409
|
+
i++;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (ch === "{") {
|
|
413
|
+
depth++;
|
|
414
|
+
i++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (ch === "}") {
|
|
418
|
+
depth--;
|
|
419
|
+
if (depth === 0) return i;
|
|
420
|
+
i++;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
i++;
|
|
424
|
+
}
|
|
425
|
+
return -1;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Walk backwards from `openIdx` consuming the contiguous block of
|
|
430
|
+
* `//`-only lines immediately preceding the `{`. Stops at the first
|
|
431
|
+
* blank line, the first non-comment line, or block-comment territory.
|
|
432
|
+
* Returns the absolute index where the leading comment block (plus
|
|
433
|
+
* its indentation) starts — equal to `openIdx` minus its own line's
|
|
434
|
+
* indentation when no comment is attached.
|
|
435
|
+
*/
|
|
436
|
+
function findAttachedLeadingComments(content: string, openIdx: number): number {
|
|
437
|
+
// Walk back to start of the line containing `{`.
|
|
438
|
+
let lineStart = openIdx;
|
|
439
|
+
while (lineStart > 0 && content[lineStart - 1] !== "\n") lineStart--;
|
|
440
|
+
// Now climb up: each iteration considers the line ending at
|
|
441
|
+
// `lineStart - 1`. If it is a `//`-only line, include it; else stop.
|
|
442
|
+
let cursor = lineStart;
|
|
443
|
+
while (cursor > 0) {
|
|
444
|
+
const prevLineEnd = cursor - 1; // index of the `\n` separating
|
|
445
|
+
if (prevLineEnd <= 0) break;
|
|
446
|
+
let prevLineStart = prevLineEnd;
|
|
447
|
+
while (prevLineStart > 0 && content[prevLineStart - 1] !== "\n") {
|
|
448
|
+
prevLineStart--;
|
|
449
|
+
}
|
|
450
|
+
const line = content.slice(prevLineStart, prevLineEnd);
|
|
451
|
+
if (/^\s*\/\/.*$/.test(line)) {
|
|
452
|
+
cursor = prevLineStart;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
return cursor;
|
|
458
|
+
}
|
|
459
|
+
|
|
161
460
|
/* ------------------------------------------------------------------ */
|
|
162
461
|
/* Rule 3 — dead `src/runtime.ts` invoke shim */
|
|
163
462
|
/* ------------------------------------------------------------------ */
|
|
@@ -591,6 +591,7 @@ describe("runAudit — totals", () => {
|
|
|
591
591
|
"dead-lib-shims",
|
|
592
592
|
"dead-runtime-shim",
|
|
593
593
|
"local-widgets-types",
|
|
594
|
+
"obsolete-vite-plugins",
|
|
594
595
|
"vtex-shim-regression",
|
|
595
596
|
].sort(),
|
|
596
597
|
);
|
|
@@ -804,3 +805,184 @@ describe("runAudit — fix mode — vtex-shim-regression swap cases", () => {
|
|
|
804
805
|
);
|
|
805
806
|
});
|
|
806
807
|
});
|
|
808
|
+
|
|
809
|
+
/* ------------------------------------------------------------------ */
|
|
810
|
+
/* W12-F — obsolete-vite-plugins --fix */
|
|
811
|
+
/* ------------------------------------------------------------------ */
|
|
812
|
+
|
|
813
|
+
describe("runAudit — fix mode — obsolete-vite-plugins", () => {
|
|
814
|
+
it("removes a single inline obsolete plugin literal cleanly", () => {
|
|
815
|
+
const before = `import { defineConfig } from "vite";
|
|
816
|
+
export default defineConfig({
|
|
817
|
+
plugins: [
|
|
818
|
+
react(),
|
|
819
|
+
{
|
|
820
|
+
name: "site-manual-chunks",
|
|
821
|
+
config(_cfg, { command }) {
|
|
822
|
+
if (command !== "build") return;
|
|
823
|
+
return { build: { rollupOptions: { output: { manualChunks(_id: string) {} } } } };
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
tailwindcss(),
|
|
827
|
+
],
|
|
828
|
+
});
|
|
829
|
+
`;
|
|
830
|
+
const { fs, writer, store } = makeMutableFs({
|
|
831
|
+
"/site/vite.config.ts": before,
|
|
832
|
+
});
|
|
833
|
+
const report = runAudit(SITE, fs, { writer });
|
|
834
|
+
const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
|
|
835
|
+
expect(r.findings).toHaveLength(1);
|
|
836
|
+
expect(r.fixes).toHaveLength(1);
|
|
837
|
+
expect(r.fixes![0].kind).toBe("rewrite-vite-config");
|
|
838
|
+
expect(r.fixes![0].detail).toContain("site-manual-chunks");
|
|
839
|
+
const after = store["/site/vite.config.ts"];
|
|
840
|
+
// Plugin literal is gone; siblings remain intact.
|
|
841
|
+
expect(after).not.toContain('name: "site-manual-chunks"');
|
|
842
|
+
expect(after).toContain("react()");
|
|
843
|
+
expect(after).toContain("tailwindcss()");
|
|
844
|
+
// The literal's body has been fully unindented out — no dangling braces.
|
|
845
|
+
expect(after).not.toContain("manualChunks(_id");
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("removes the leading `//` comment block attached to the plugin literal", () => {
|
|
849
|
+
const before = `export default defineConfig({
|
|
850
|
+
plugins: [
|
|
851
|
+
decoVitePlugin(),
|
|
852
|
+
// Override decoVitePlugin's default manualChunks — circular deps
|
|
853
|
+
// would crash if split.
|
|
854
|
+
// (mirrors the framework's improved splitting)
|
|
855
|
+
{
|
|
856
|
+
name: "site-manual-chunks",
|
|
857
|
+
config() { return {}; },
|
|
858
|
+
},
|
|
859
|
+
tailwindcss(),
|
|
860
|
+
],
|
|
861
|
+
});
|
|
862
|
+
`;
|
|
863
|
+
const { fs, writer, store } = makeMutableFs({
|
|
864
|
+
"/site/vite.config.ts": before,
|
|
865
|
+
});
|
|
866
|
+
runAudit(SITE, fs, { writer });
|
|
867
|
+
const after = store["/site/vite.config.ts"];
|
|
868
|
+
expect(after).not.toContain("Override decoVitePlugin");
|
|
869
|
+
expect(after).not.toContain("circular deps");
|
|
870
|
+
expect(after).not.toContain("framework's improved splitting");
|
|
871
|
+
expect(after).not.toContain("site-manual-chunks");
|
|
872
|
+
// decoVitePlugin call still there, no orphan separator left behind.
|
|
873
|
+
expect(after).toContain("decoVitePlugin(),");
|
|
874
|
+
expect(after).toContain("tailwindcss()");
|
|
875
|
+
expect(after).not.toMatch(/,\s*,/);
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("removes BOTH obsolete plugins in one pass without disturbing each other", () => {
|
|
879
|
+
const before = `export default defineConfig({
|
|
880
|
+
plugins: [
|
|
881
|
+
decoVitePlugin(),
|
|
882
|
+
// chunks override
|
|
883
|
+
{
|
|
884
|
+
name: "site-manual-chunks",
|
|
885
|
+
config() { return {}; },
|
|
886
|
+
},
|
|
887
|
+
// meta.gen client stub
|
|
888
|
+
{
|
|
889
|
+
name: "deco-stub-meta-gen",
|
|
890
|
+
enforce: "pre" as const,
|
|
891
|
+
load(id: string) { if (id === "x") return "export default {}"; },
|
|
892
|
+
},
|
|
893
|
+
tailwindcss(),
|
|
894
|
+
],
|
|
895
|
+
});
|
|
896
|
+
`;
|
|
897
|
+
const { fs, writer, store } = makeMutableFs({
|
|
898
|
+
"/site/vite.config.ts": before,
|
|
899
|
+
});
|
|
900
|
+
const report = runAudit(SITE, fs, { writer });
|
|
901
|
+
const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
|
|
902
|
+
expect(r.findings).toHaveLength(2);
|
|
903
|
+
expect(r.fixes).toHaveLength(1);
|
|
904
|
+
expect(r.fixes![0].detail).toContain("site-manual-chunks");
|
|
905
|
+
expect(r.fixes![0].detail).toContain("deco-stub-meta-gen");
|
|
906
|
+
const after = store["/site/vite.config.ts"];
|
|
907
|
+
expect(after).not.toContain("site-manual-chunks");
|
|
908
|
+
expect(after).not.toContain("deco-stub-meta-gen");
|
|
909
|
+
expect(after).not.toContain("chunks override");
|
|
910
|
+
expect(after).not.toContain("meta.gen client stub");
|
|
911
|
+
expect(after).toContain("decoVitePlugin(),");
|
|
912
|
+
expect(after).toContain("tailwindcss()");
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it("does not miscount braces inside template literals or strings inside the plugin body", () => {
|
|
916
|
+
// Template literal containing `${'}'}` and string with `}` must not
|
|
917
|
+
// throw off the brace walker.
|
|
918
|
+
const before = `export default defineConfig({
|
|
919
|
+
plugins: [
|
|
920
|
+
{
|
|
921
|
+
name: "site-manual-chunks",
|
|
922
|
+
load(id: string) {
|
|
923
|
+
const a = \`a\${'}'}b\`;
|
|
924
|
+
const b = "}";
|
|
925
|
+
return id + a + b;
|
|
926
|
+
},
|
|
927
|
+
},
|
|
928
|
+
tailwindcss(),
|
|
929
|
+
],
|
|
930
|
+
});
|
|
931
|
+
`;
|
|
932
|
+
const { fs, writer, store } = makeMutableFs({
|
|
933
|
+
"/site/vite.config.ts": before,
|
|
934
|
+
});
|
|
935
|
+
runAudit(SITE, fs, { writer });
|
|
936
|
+
const after = store["/site/vite.config.ts"];
|
|
937
|
+
expect(after).not.toContain("site-manual-chunks");
|
|
938
|
+
expect(after).toContain("tailwindcss()");
|
|
939
|
+
// Sanity: closing brackets of defineConfig + plugins array preserved.
|
|
940
|
+
expect(after).toContain("],");
|
|
941
|
+
expect(after.trim().endsWith("});")).toBe(true);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it("is a no-op (no fixes) when no obsolete plugins are present", () => {
|
|
945
|
+
const before = `export default defineConfig({
|
|
946
|
+
plugins: [react(), tailwindcss()],
|
|
947
|
+
});
|
|
948
|
+
`;
|
|
949
|
+
const { fs, writer, store } = makeMutableFs({
|
|
950
|
+
"/site/vite.config.ts": before,
|
|
951
|
+
});
|
|
952
|
+
const report = runAudit(SITE, fs, { writer });
|
|
953
|
+
const r = report.rules.find((r) => r.rule === "obsolete-vite-plugins")!;
|
|
954
|
+
expect(r.findings).toEqual([]);
|
|
955
|
+
expect(r.fixes).toBeUndefined();
|
|
956
|
+
expect(store["/site/vite.config.ts"]).toBe(before);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("is idempotent — running --fix twice is a no-op the second time", () => {
|
|
960
|
+
const before = `export default defineConfig({
|
|
961
|
+
plugins: [
|
|
962
|
+
decoVitePlugin(),
|
|
963
|
+
{
|
|
964
|
+
name: "deco-stub-meta-gen",
|
|
965
|
+
load() { return "export default {};"; },
|
|
966
|
+
},
|
|
967
|
+
tailwindcss(),
|
|
968
|
+
],
|
|
969
|
+
});
|
|
970
|
+
`;
|
|
971
|
+
const { fs, writer, store } = makeMutableFs({
|
|
972
|
+
"/site/vite.config.ts": before,
|
|
973
|
+
});
|
|
974
|
+
runAudit(SITE, fs, { writer });
|
|
975
|
+
const afterFirst = store["/site/vite.config.ts"];
|
|
976
|
+
runAudit(SITE, fs, { writer });
|
|
977
|
+
expect(store["/site/vite.config.ts"]).toBe(afterFirst);
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it("includes obsolete-vite-plugins in supportsAutoFix", () => {
|
|
981
|
+
const fs = makeFs({});
|
|
982
|
+
const report = runAudit(SITE, fs);
|
|
983
|
+
const supported = report.rules
|
|
984
|
+
.filter((r) => r.supportsAutoFix)
|
|
985
|
+
.map((r) => r.rule);
|
|
986
|
+
expect(supported).toContain("obsolete-vite-plugins");
|
|
987
|
+
});
|
|
988
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { generateMigrationPolicyPointerRule } from "./cursor-rules";
|
|
3
|
+
|
|
4
|
+
describe("generateMigrationPolicyPointerRule", () => {
|
|
5
|
+
const body = generateMigrationPolicyPointerRule("acme");
|
|
6
|
+
|
|
7
|
+
it("emits a Cursor MDC rule with alwaysApply: true frontmatter", () => {
|
|
8
|
+
expect(body.startsWith("---\n")).toBe(true);
|
|
9
|
+
expect(body).toContain("alwaysApply: true");
|
|
10
|
+
expect(body).toContain("description:");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("interpolates the site name into the body", () => {
|
|
14
|
+
expect(body).toContain("`acme`");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("links to the canonical rule and plan in decocms/deco-start", () => {
|
|
18
|
+
expect(body).toContain(
|
|
19
|
+
"https://github.com/decocms/deco-start/blob/main/.cursor/rules/migration-tooling-policy.mdc",
|
|
20
|
+
);
|
|
21
|
+
expect(body).toContain(
|
|
22
|
+
"https://github.com/decocms/deco-start/blob/main/MIGRATION_TOOLING_PLAN.md",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("documents D1–D5 by ID, not just by name", () => {
|
|
27
|
+
expect(body).toContain("**D1**");
|
|
28
|
+
expect(body).toContain("**D2**");
|
|
29
|
+
expect(body).toContain("**D3**");
|
|
30
|
+
expect(body).toContain("**D4**");
|
|
31
|
+
expect(body).toContain("**D5**");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("points at the post-cleanup --fix command rather than restating policy", () => {
|
|
35
|
+
expect(body).toContain("deco-post-cleanup --fix");
|
|
36
|
+
expect(body).toContain("deco-post-cleanup --strict");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does NOT restate the canonical rule body verbatim (pointer, not a copy)", () => {
|
|
40
|
+
// Length budget: pointer must stay short to discourage drift.
|
|
41
|
+
// The canonical rule in decocms/deco-start is ~110 lines / 4–5 KB;
|
|
42
|
+
// the pointer must be substantially smaller than a copy.
|
|
43
|
+
expect(body.length).toBeLessThan(3000);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("is deterministic — same site name, same output", () => {
|
|
47
|
+
const a = generateMigrationPolicyPointerRule("foo");
|
|
48
|
+
const b = generateMigrationPolicyPointerRule("foo");
|
|
49
|
+
expect(a).toBe(b);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("escapes nothing weird from siteName — siteName is used as a label only", () => {
|
|
53
|
+
// We don't sanitise; we trust the migration script to pass a real
|
|
54
|
+
// package name. But verify nothing surprising happens with hyphens
|
|
55
|
+
// (a common shape, e.g. "casaevideo-storefront").
|
|
56
|
+
const out = generateMigrationPolicyPointerRule("casaevideo-storefront");
|
|
57
|
+
expect(out).toContain("`casaevideo-storefront`");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor rule scaffolding for migrated sites.
|
|
3
|
+
*
|
|
4
|
+
* The canonical migration tooling policy (D1–D5, priorities, process)
|
|
5
|
+
* lives in `decocms/deco-start`. We don't duplicate it into every site
|
|
6
|
+
* — that would drift the moment the canonical changes. Instead the
|
|
7
|
+
* migration scaffolds a tiny pointer rule, marked `alwaysApply: true`,
|
|
8
|
+
* that loads on every agent session inside the migrated site and tells
|
|
9
|
+
* the agent where the real policy lives.
|
|
10
|
+
*
|
|
11
|
+
* Closes Wave 12-H from MIGRATION_TOOLING_PLAN.md.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Generate the contents of `.cursor/rules/migration-tooling-policy.mdc`
|
|
16
|
+
* for a freshly migrated site. Pure function: `siteName` is the only
|
|
17
|
+
* input that influences the body, and only as a friendly mention.
|
|
18
|
+
*/
|
|
19
|
+
export function generateMigrationPolicyPointerRule(
|
|
20
|
+
siteName: string,
|
|
21
|
+
): string {
|
|
22
|
+
return `---
|
|
23
|
+
description: Pointer to the canonical migration tooling policy (D1–D5, PR-only, etc.). Always loaded.
|
|
24
|
+
alwaysApply: true
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# Migration Tooling Policy — Pointer
|
|
28
|
+
|
|
29
|
+
> This site (\`${siteName}\`) was generated by the \`@decocms/start\`
|
|
30
|
+
> migration script. The canonical policy that governs how the migration
|
|
31
|
+
> tooling, framework (\`@decocms/start\`), and commerce layer
|
|
32
|
+
> (\`@decocms/apps\`) evolve lives **upstream**, not in this repo.
|
|
33
|
+
|
|
34
|
+
## Where to read
|
|
35
|
+
|
|
36
|
+
- **Rule (always-applied) — full text:**
|
|
37
|
+
https://github.com/decocms/deco-start/blob/main/.cursor/rules/migration-tooling-policy.mdc
|
|
38
|
+
- **Plan (living tracker, decisions + waves):**
|
|
39
|
+
https://github.com/decocms/deco-start/blob/main/MIGRATION_TOOLING_PLAN.md
|
|
40
|
+
- **Migration skill (phase playbook):**
|
|
41
|
+
\`@decocms/start/.agents/skills/deco-to-tanstack-migration\`
|
|
42
|
+
|
|
43
|
+
## What you need to know in this site
|
|
44
|
+
|
|
45
|
+
| ID | Decision | What it means here |
|
|
46
|
+
|----|----------|--------------------|
|
|
47
|
+
| **D1** | Force convergence — no fork runtime support | Site customisations live in \`src/apps/local/\` or open a PR to \`@decocms/apps\`. Don't wrap framework/commerce code in soft adapters. |
|
|
48
|
+
| **D2** | Rewrite HTMX on migration | If you find HTMX residue, rewrite to React. Don't bring back \`hx-*\` runtime. |
|
|
49
|
+
| **D3** | Generated stubs throw at runtime | If a \`~/lib/vtex-*\` import comes from a stub that returns \`null\` / \`{}\` / identity-cast, replace it. \`npx -p @decocms/start deco-post-cleanup --fix\` does the safe swaps automatically. |
|
|
50
|
+
| **D4** | Site-local apps by default, promote at 3+ sites | Don't try to upstream a pattern that has only shipped here. Build it twice in different sites first, then PR it to \`@decocms/apps\`. |
|
|
51
|
+
| **D5** | Failed migrations: \`rm -rf\` and re-run | No restart-mode magic. If the migration goes sideways, blow away the working tree and run again. |
|
|
52
|
+
|
|
53
|
+
## How to find issues this rule wants you to fix
|
|
54
|
+
|
|
55
|
+
\`\`\`bash
|
|
56
|
+
npx -p @decocms/start deco-post-cleanup # audit only
|
|
57
|
+
npx -p @decocms/start deco-post-cleanup --fix # auto-fix the safe rules
|
|
58
|
+
npx -p @decocms/start deco-post-cleanup --strict # exit 1 on any finding (CI)
|
|
59
|
+
\`\`\`
|
|
60
|
+
|
|
61
|
+
## Process
|
|
62
|
+
|
|
63
|
+
- **PR-only.** No direct pushes to \`main\`. Self-merge after green CI is fine.
|
|
64
|
+
- **Conventional commits** (\`feat\`, \`fix\`, \`chore\`, \`docs\`, \`refactor\`, \`test\`, \`perf\`).
|
|
65
|
+
- **CI must be green before merge.**
|
|
66
|
+
- When the canonical rule changes upstream, update this pointer if any
|
|
67
|
+
link or table heading goes stale. Keep it short — body content lives
|
|
68
|
+
upstream.
|
|
69
|
+
`;
|
|
70
|
+
}
|