@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,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
+ }
@@ -1,4 +1,8 @@
1
- import type { TransformResult, SectionMeta } from "../types";
1
+ import type {
2
+ SectionConventionSets,
3
+ } from "../config";
4
+ import { resolveSectionConventions } from "../config";
5
+ import type { SectionMeta, TransformResult } from "../types";
2
6
 
3
7
  /**
4
8
  * Adds section convention exports (sync, eager, layout, cache)
@@ -6,158 +10,179 @@ import type { TransformResult, SectionMeta } from "../types";
6
10
  *
7
11
  * These exports are read by generate-sections.ts in @decocms/start
8
12
  * to build the sections.gen.ts registry.
13
+ *
14
+ * The set of section *names* that get hints applied is configurable
15
+ * via `.deco-migrate.config.json` (see `migrate/config.ts`). The exported
16
+ * `transformSectionConventions` keeps a back-compat signature using the
17
+ * baked-in defaults; new callers should prefer
18
+ * `createSectionConventionsTransform(sets)` so config can drive the lists.
9
19
  */
10
20
 
11
- const EAGER_SYNC_SECTIONS = new Set([
12
- "UtilLinks",
13
- "DepartamentList",
14
- "ImageGallery",
15
- "BannersGrid",
16
- "Carousel",
17
- "Tipbar",
18
- "Live",
19
- ]);
20
-
21
- const SYNC_SECTIONS = new Set([
22
- "ProductShelf",
23
- "ProductShelfTabbed",
24
- "ProductShelfGroup",
25
- "ProductShelfTopSort",
26
- "CouponList",
27
- "NotFoundChallenge",
28
- "MountedPDP",
29
- "BackgroundWrapper",
30
- "SearchResult",
31
- "LpCartao",
32
- ]);
33
-
34
- const LISTING_CACHE_SECTIONS = new Set([
35
- "ProductShelf",
36
- "ProductShelfTabbed",
37
- "ProductShelfGroup",
38
- "ProductShelfTimedOffers",
39
- ]);
40
-
41
- const STATIC_CACHE_SECTIONS = new Set([
42
- "InstagramPosts",
43
- "Faq",
44
- ]);
45
-
46
21
  function getSectionBasename(filePath: string): string {
47
- return filePath.split("/").pop()?.replace(/\.\w+$/, "") || "";
22
+ return filePath.split("/").pop()?.replace(/\.\w+$/, "") || "";
23
+ }
24
+
25
+ /**
26
+ * Build a `transformSectionConventions` closure bound to the given
27
+ * resolved sets. This is the preferred entry point — the caller (usually
28
+ * `phase-transform`) loads config once and passes the sets in.
29
+ */
30
+ export function createSectionConventionsTransform(
31
+ sets: SectionConventionSets,
32
+ ) {
33
+ return (
34
+ content: string,
35
+ sectionMeta: SectionMeta | undefined,
36
+ ): TransformResult => transformWithSets(content, sectionMeta, sets);
48
37
  }
49
38
 
39
+ /** Default-configured transform. Uses the baked-in defaults from `config.ts`. */
40
+ const defaultSets = resolveSectionConventions(null);
41
+
50
42
  export function transformSectionConventions(
51
- content: string,
52
- sectionMeta: SectionMeta | undefined,
43
+ content: string,
44
+ sectionMeta: SectionMeta | undefined,
45
+ ): TransformResult {
46
+ return transformWithSets(content, sectionMeta, defaultSets);
47
+ }
48
+
49
+ function transformWithSets(
50
+ content: string,
51
+ sectionMeta: SectionMeta | undefined,
52
+ sets: SectionConventionSets,
53
53
  ): TransformResult {
54
- if (!sectionMeta) {
55
- return { content, changed: false, notes: [] };
56
- }
57
-
58
- const notes: string[] = [];
59
- let result = content;
60
- let changed = false;
61
- const basename = getSectionBasename(sectionMeta.path);
62
-
63
- // Header, footer, theme → eager + sync + layout
64
- if (sectionMeta.isHeader || sectionMeta.isFooter || sectionMeta.isTheme) {
65
- if (!result.includes("export const eager")) {
66
- result += "\nexport const eager = true;\n";
67
- notes.push("Added: export const eager = true");
68
- changed = true;
69
- }
70
- if (!result.includes("export const sync")) {
71
- result += "export const sync = true;\n";
72
- notes.push("Added: export const sync = true");
73
- changed = true;
74
- }
75
- // Header in golden does NOT have layout=true; only footer+theme do
76
- if ((sectionMeta.isFooter || sectionMeta.isTheme) && !result.includes("export const layout")) {
77
- result += "export const layout = true;\n";
78
- notes.push("Added: export const layout = true");
79
- changed = true;
80
- }
81
- }
82
-
83
- // Known eager+sync sections (non-layout)
84
- if (EAGER_SYNC_SECTIONS.has(basename)) {
85
- if (!result.includes("export const eager")) {
86
- result += "\nexport const eager = true;\n";
87
- notes.push(`Added: export const eager = true (${basename})`);
88
- changed = true;
89
- }
90
- if (!result.includes("export const sync")) {
91
- result += "export const sync = true;\n";
92
- notes.push(`Added: export const sync = true (${basename})`);
93
- changed = true;
94
- }
95
- }
96
-
97
- // Known sync-only sections
98
- if (SYNC_SECTIONS.has(basename) && !result.includes("export const sync")) {
99
- result += "\nexport const sync = true;\n";
100
- notes.push(`Added: export const sync = true (${basename})`);
101
- changed = true;
102
- }
103
-
104
- // Listing cache sections
105
- if (LISTING_CACHE_SECTIONS.has(basename) && !result.includes("export const cache")) {
106
- result += '\nexport const cache = "listing";\n';
107
- notes.push(`Added: export const cache = "listing" (${basename})`);
108
- changed = true;
109
- }
110
-
111
- // Static cache sections
112
- if (STATIC_CACHE_SECTIONS.has(basename) && !result.includes("export const cache")) {
113
- result += '\nexport const cache = "static";\n';
114
- notes.push(`Added: export const cache = "static" (${basename})`);
115
- changed = true;
116
- }
117
-
118
- // Generic: listing sections not already matched above
119
- if (sectionMeta.isListing && !result.includes("export const cache")) {
120
- result += '\nexport const cache = "listing";\n';
121
- notes.push('Added: export const cache = "listing"');
122
- changed = true;
123
- }
124
-
125
- // Sections with loaders that use device → add sync (needs SSR device detection)
126
- if (sectionMeta.hasLoader && sectionMeta.loaderUsesDevice && !result.includes("export const sync")) {
127
- result += "\nexport const sync = true;\n";
128
- notes.push("Added: export const sync = true (loader uses device)");
129
- changed = true;
130
- }
131
-
132
- // Sections that render nested Section children need sync so they're in
133
- // the syncComponents registry (SectionRenderer resolves the string key).
134
- const hasNestedSections =
135
- /children:\s*Section\b/.test(result) || /fallback:\s*Section\b/.test(result);
136
- if (hasNestedSections && !result.includes("export const sync")) {
137
- result += "\nexport const sync = true;\n";
138
- notes.push("Added: export const sync = true (renders nested Section children)");
139
- changed = true;
140
- }
141
-
142
- // Re-export sections that wrap PDP/nested content need sync too.
143
- // Detect: file is a re-export AND the target component renders nested Sections
144
- const isReExport = /^export\s+\{[^}]*default[^}]*\}\s+from\s+/.test(result.trim());
145
- if (isReExport && (basename === "MountedPDP" || basename === "NotFoundChallenge")) {
146
- if (!result.includes("export const sync")) {
147
- result += "\nexport const sync = true;\n";
148
- notes.push(`Added: export const sync = true (re-export: ${basename})`);
149
- changed = true;
150
- }
151
- }
152
-
153
- // Don't add LoadingFallback re-exports to thin section files
154
- // we can't guarantee the target component exports it.
155
- // Instead, if it's a listing section, a generic skeleton will be added below.
156
-
157
- // Generate a basic LoadingFallback if the section doesn't have one
158
- // and it's a listing section (visible skeleton improvement)
159
- if (sectionMeta.isListing && !sectionMeta.hasLoadingFallback && !result.includes("LoadingFallback")) {
160
- result += `
54
+ if (!sectionMeta) {
55
+ return { content, changed: false, notes: [] };
56
+ }
57
+
58
+ const notes: string[] = [];
59
+ let result = content;
60
+ let changed = false;
61
+ const basename = getSectionBasename(sectionMeta.path);
62
+
63
+ // Header, footer, theme → eager + sync + layout
64
+ if (sectionMeta.isHeader || sectionMeta.isFooter || sectionMeta.isTheme) {
65
+ if (!result.includes("export const eager")) {
66
+ result += "\nexport const eager = true;\n";
67
+ notes.push("Added: export const eager = true");
68
+ changed = true;
69
+ }
70
+ if (!result.includes("export const sync")) {
71
+ result += "export const sync = true;\n";
72
+ notes.push("Added: export const sync = true");
73
+ changed = true;
74
+ }
75
+ // Header in golden does NOT have layout=true; only footer+theme do
76
+ if (
77
+ (sectionMeta.isFooter || sectionMeta.isTheme) &&
78
+ !result.includes("export const layout")
79
+ ) {
80
+ result += "export const layout = true;\n";
81
+ notes.push("Added: export const layout = true");
82
+ changed = true;
83
+ }
84
+ }
85
+
86
+ // Known eager+sync sections (non-layout)
87
+ if (sets.eagerSync.has(basename)) {
88
+ if (!result.includes("export const eager")) {
89
+ result += "\nexport const eager = true;\n";
90
+ notes.push(`Added: export const eager = true (${basename})`);
91
+ changed = true;
92
+ }
93
+ if (!result.includes("export const sync")) {
94
+ result += "export const sync = true;\n";
95
+ notes.push(`Added: export const sync = true (${basename})`);
96
+ changed = true;
97
+ }
98
+ }
99
+
100
+ // Known sync-only sections
101
+ if (sets.sync.has(basename) && !result.includes("export const sync")) {
102
+ result += "\nexport const sync = true;\n";
103
+ notes.push(`Added: export const sync = true (${basename})`);
104
+ changed = true;
105
+ }
106
+
107
+ // Listing cache sections
108
+ if (
109
+ sets.listingCache.has(basename) &&
110
+ !result.includes("export const cache")
111
+ ) {
112
+ result += '\nexport const cache = "listing";\n';
113
+ notes.push(`Added: export const cache = "listing" (${basename})`);
114
+ changed = true;
115
+ }
116
+
117
+ // Static cache sections
118
+ if (
119
+ sets.staticCache.has(basename) &&
120
+ !result.includes("export const cache")
121
+ ) {
122
+ result += '\nexport const cache = "static";\n';
123
+ notes.push(`Added: export const cache = "static" (${basename})`);
124
+ changed = true;
125
+ }
126
+
127
+ // Generic: listing sections not already matched above
128
+ if (sectionMeta.isListing && !result.includes("export const cache")) {
129
+ result += '\nexport const cache = "listing";\n';
130
+ notes.push('Added: export const cache = "listing"');
131
+ changed = true;
132
+ }
133
+
134
+ // Sections with loaders that use device → add sync (needs SSR device detection)
135
+ if (
136
+ sectionMeta.hasLoader &&
137
+ sectionMeta.loaderUsesDevice &&
138
+ !result.includes("export const sync")
139
+ ) {
140
+ result += "\nexport const sync = true;\n";
141
+ notes.push("Added: export const sync = true (loader uses device)");
142
+ changed = true;
143
+ }
144
+
145
+ // Sections that render nested Section children need sync so they're in
146
+ // the syncComponents registry (SectionRenderer resolves the string key).
147
+ const hasNestedSections =
148
+ /children:\s*Section\b/.test(result) ||
149
+ /fallback:\s*Section\b/.test(result);
150
+ if (hasNestedSections && !result.includes("export const sync")) {
151
+ result += "\nexport const sync = true;\n";
152
+ notes.push(
153
+ "Added: export const sync = true (renders nested Section children)",
154
+ );
155
+ changed = true;
156
+ }
157
+
158
+ // Re-export sections that wrap PDP/nested content need sync too.
159
+ // Detect: file is a re-export AND the target component renders nested Sections
160
+ const isReExport = /^export\s+\{[^}]*default[^}]*\}\s+from\s+/.test(
161
+ result.trim(),
162
+ );
163
+ if (
164
+ isReExport &&
165
+ (basename === "MountedPDP" || basename === "NotFoundChallenge")
166
+ ) {
167
+ if (!result.includes("export const sync")) {
168
+ result += "\nexport const sync = true;\n";
169
+ notes.push(`Added: export const sync = true (re-export: ${basename})`);
170
+ changed = true;
171
+ }
172
+ }
173
+
174
+ // Don't add LoadingFallback re-exports to thin section files —
175
+ // we can't guarantee the target component exports it.
176
+ // Instead, if it's a listing section, a generic skeleton will be added below.
177
+
178
+ // Generate a basic LoadingFallback if the section doesn't have one
179
+ // and it's a listing section (visible skeleton improvement)
180
+ if (
181
+ sectionMeta.isListing &&
182
+ !sectionMeta.hasLoadingFallback &&
183
+ !result.includes("LoadingFallback")
184
+ ) {
185
+ result += `
161
186
  export function LoadingFallback() {
162
187
  return (
163
188
  <div className="w-full py-8">
@@ -177,9 +202,9 @@ export function LoadingFallback() {
177
202
  );
178
203
  }
179
204
  `;
180
- notes.push("Added: LoadingFallback skeleton for listing section");
181
- changed = true;
182
- }
205
+ notes.push("Added: LoadingFallback skeleton for listing section");
206
+ changed = true;
207
+ }
183
208
 
184
- return { content: result, changed, notes };
209
+ return { content: result, changed, notes };
185
210
  }
@@ -137,6 +137,14 @@ export interface MigrationContext {
137
137
  vtexAccount: string | null;
138
138
  gtmId: string | null;
139
139
 
140
+ /**
141
+ * Per-site config loaded from `.deco-migrate.config.json`. `null` means
142
+ * no config file was present — defaults apply throughout. Imported
143
+ * lazily as `MigrateConfig` to avoid a circular dependency between
144
+ * `types.ts` and `config.ts`.
145
+ */
146
+ config?: import("./config").MigrateConfig | null;
147
+
140
148
  /** deno.json import map entries */
141
149
  importMap: Record<string, string>;
142
150
 
@@ -191,7 +199,11 @@ export interface TransformResult {
191
199
 
192
200
  export function createContext(
193
201
  sourceDir: string,
194
- opts: { dryRun?: boolean; verbose?: boolean } = {},
202
+ opts: {
203
+ dryRun?: boolean;
204
+ verbose?: boolean;
205
+ config?: import("./config").MigrateConfig | null;
206
+ } = {},
195
207
  ): MigrationContext {
196
208
  return {
197
209
  sourceDir,
@@ -199,6 +211,7 @@ export function createContext(
199
211
  platform: "custom",
200
212
  vtexAccount: null,
201
213
  gtmId: null,
214
+ config: opts.config ?? null,
202
215
  importMap: {},
203
216
  discoveredNpmDeps: {},
204
217
  themeColors: {},
@@ -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
+ });
@@ -25,6 +25,7 @@
25
25
 
26
26
  import * as path from "node:path";
27
27
  import { execSync } from "node:child_process";
28
+ import { loadConfig, validateConfig } from "./migrate/config";
28
29
  import { createContext, logPhase } from "./migrate/types";
29
30
  import { analyze } from "./migrate/phase-analyze";
30
31
  import { scaffold } from "./migrate/phase-scaffold";
@@ -121,9 +122,19 @@ async function main() {
121
122
  stat("Mode", opts.dryRun ? yellow("DRY RUN") : green("EXECUTE"));
122
123
  stat("Verbose", opts.verbose ? "yes" : "no");
123
124
 
125
+ // Load optional per-site config from `.deco-migrate.config.json`. Drives
126
+ // section-conventions hardcoded lists today; future fields will tune
127
+ // import rewrites, scaffolding, etc.
128
+ const siteConfig = loadConfig(sourceDir);
129
+ if (siteConfig) {
130
+ validateConfig(siteConfig);
131
+ stat("Config", green(".deco-migrate.config.json (loaded)"));
132
+ }
133
+
124
134
  const ctx = createContext(sourceDir, {
125
135
  dryRun: opts.dryRun,
126
136
  verbose: opts.verbose,
137
+ config: siteConfig,
127
138
  });
128
139
 
129
140
  try {