@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.
@@ -0,0 +1,106 @@
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
+ /** Populated only when fix mode is on. */
35
+ fixes?: FixAction[];
36
+ /**
37
+ * True when the rule has an `applyFix` implementation. Lets the CLI
38
+ * tell users which findings would auto-fix vs require manual work.
39
+ */
40
+ supportsAutoFix: boolean;
41
+ }
42
+
43
+ export interface AuditReport {
44
+ site: string;
45
+ rules: RuleSummary[];
46
+ totalFindings: number;
47
+ /** Total fix actions across all rules (0 if not in fix mode). */
48
+ totalFixActions: number;
49
+ }
50
+
51
+ /**
52
+ * Minimal file-system adapter — read + glob. Keeping the surface tiny
53
+ * is what lets us pass an in-memory map in unit tests.
54
+ */
55
+ export interface FsAdapter {
56
+ exists(absPath: string): boolean;
57
+ readText(absPath: string): string;
58
+ /**
59
+ * Return absolute paths matching the glob, ordered by path. Globs are
60
+ * relative to `siteDir`. Implementations must respect `excludeDirs` and
61
+ * skip them entirely.
62
+ */
63
+ glob(siteDir: string, pattern: string, excludeDirs?: string[]): string[];
64
+ }
65
+
66
+ export interface RuleContext {
67
+ siteDir: string;
68
+ fs: FsAdapter;
69
+ }
70
+
71
+ /**
72
+ * Mutating side of the FS adapter. Kept separate from `FsAdapter` so
73
+ * read-only audits (the default) cannot accidentally write. Tests
74
+ * substitute a recorder that captures actions without touching disk.
75
+ */
76
+ export interface FsWriter {
77
+ deleteFile(absPath: string): void;
78
+ writeText(absPath: string, content: string): void;
79
+ }
80
+
81
+ /**
82
+ * One concrete change applied (or that would have been applied) by a
83
+ * rule's `applyFix` implementation. Consumed by the CLI to render a
84
+ * summary, and by the JSON output for CI dashboards.
85
+ */
86
+ export interface FixAction {
87
+ /** Site-relative path the action targets. */
88
+ file: string;
89
+ /** "delete" | "rewrite-imports" | future: "edit" — kept open. */
90
+ kind: string;
91
+ /** Human-readable description, e.g. "deleted" or "rewrote 44 imports". */
92
+ detail: string;
93
+ }
94
+
95
+ export interface Rule {
96
+ id: string;
97
+ title: string;
98
+ run(ctx: RuleContext): Finding[];
99
+ /**
100
+ * Optional. Implement for rules whose findings can be safely
101
+ * auto-corrected. Called only when the runner is in fix mode.
102
+ * Must return one or more `FixAction`s describing what changed
103
+ * (used both for output and for tests with a stubbed writer).
104
+ */
105
+ applyFix?(ctx: RuleContext, findings: Finding[], writer: FsWriter): FixAction[];
106
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ detectSourceLayout,
4
+ explainNonClassicLayout,
5
+ type FsLike,
6
+ type SourceLayout,
7
+ } from "./source-layout";
8
+
9
+ /**
10
+ * In-memory FsLike for tests. Holds a Set of paths that "exist" — no
11
+ * content needed since `detectSourceLayout` only calls `existsSync`.
12
+ */
13
+ function makeFs(paths: string[]): FsLike {
14
+ const set = new Set(paths.map((p) => p.replace(/\\/g, "/")));
15
+ return {
16
+ existsSync(p: string) {
17
+ return set.has(p.replace(/\\/g, "/"));
18
+ },
19
+ };
20
+ }
21
+
22
+ const SITE = "/site";
23
+
24
+ describe("detectSourceLayout — classic layout", () => {
25
+ it("classifies a site with sections/ at root as classic", () => {
26
+ const fs = makeFs(["/site/sections"]);
27
+ expect(detectSourceLayout(SITE, fs)).toBe("classic");
28
+ });
29
+
30
+ it("classifies multi-dir root layout as classic", () => {
31
+ const fs = makeFs(["/site/sections", "/site/islands", "/site/components", "/site/loaders"]);
32
+ expect(detectSourceLayout(SITE, fs)).toBe("classic");
33
+ });
34
+
35
+ it("any single recognised root dir is enough", () => {
36
+ for (const d of ["sections", "islands", "components", "loaders", "actions"]) {
37
+ const fs = makeFs([`/site/${d}`]);
38
+ expect(detectSourceLayout(SITE, fs)).toBe("classic");
39
+ }
40
+ });
41
+ });
42
+
43
+ describe("detectSourceLayout — modern layout", () => {
44
+ it("classifies src/sections-only as modern", () => {
45
+ const fs = makeFs(["/site/src/sections"]);
46
+ expect(detectSourceLayout(SITE, fs)).toBe("modern");
47
+ });
48
+
49
+ it("classifies multi-dir src/ layout as modern", () => {
50
+ const fs = makeFs(["/site/src/sections", "/site/src/islands", "/site/src/components"]);
51
+ expect(detectSourceLayout(SITE, fs)).toBe("modern");
52
+ });
53
+ });
54
+
55
+ describe("detectSourceLayout — mixed layout", () => {
56
+ it("flags both root + src/ as mixed", () => {
57
+ const fs = makeFs(["/site/sections", "/site/src/sections"]);
58
+ expect(detectSourceLayout(SITE, fs)).toBe("mixed");
59
+ });
60
+
61
+ it("flags partial overlap as mixed (root islands + src sections)", () => {
62
+ const fs = makeFs(["/site/islands", "/site/src/sections"]);
63
+ expect(detectSourceLayout(SITE, fs)).toBe("mixed");
64
+ });
65
+ });
66
+
67
+ describe("detectSourceLayout — empty layout", () => {
68
+ it("returns empty when neither root nor src/ has recognised dirs", () => {
69
+ const fs = makeFs(["/site/package.json", "/site/README.md"]);
70
+ expect(detectSourceLayout(SITE, fs)).toBe("empty");
71
+ });
72
+
73
+ it("returns empty for an unrelated dir", () => {
74
+ const fs = makeFs(["/site/random-dir/x.txt"]);
75
+ expect(detectSourceLayout(SITE, fs)).toBe("empty");
76
+ });
77
+ });
78
+
79
+ describe("detectSourceLayout — works against the real disk", () => {
80
+ it("uses real fs by default", () => {
81
+ // Just call with no fsAdapter on a real path that doesn't exist —
82
+ // should return "empty" without throwing. Smoke check that the
83
+ // default-arg wiring works.
84
+ expect(detectSourceLayout("/this/does/not/exist")).toBe("empty");
85
+ });
86
+ });
87
+
88
+ describe("explainNonClassicLayout — message content", () => {
89
+ const cases: Array<[Exclude<SourceLayout, "classic">, string[]]> = [
90
+ ["modern", ['Modern Fresh "src/" layout', "Move src/sections", "File an issue"]],
91
+ ["mixed", ["Mixed layout", "pick one layout", "clean checkout"]],
92
+ [
93
+ "empty",
94
+ [
95
+ "No recognizable Deco layout",
96
+ "sections, islands, components, loaders, actions",
97
+ "--source",
98
+ ],
99
+ ],
100
+ ];
101
+
102
+ for (const [layout, fragments] of cases) {
103
+ it(`${layout}: includes site path + key guidance`, () => {
104
+ const msg = explainNonClassicLayout(layout, "/some/site");
105
+ expect(msg).toContain("/some/site");
106
+ for (const f of fragments) {
107
+ expect(msg).toContain(f);
108
+ }
109
+ });
110
+ }
111
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Source-layout detection.
3
+ *
4
+ * Classic Fresh sites place sections, islands, components etc. at the
5
+ * repo root. Modern Fresh (post 1.6) and several community starters
6
+ * use a `src/` layout where everything lives under `src/sections/`,
7
+ * `src/islands/`, etc. The migration analyzer's `SKIP_DIRS` includes
8
+ * `"src"` (because the OUTPUT site stores migrated code there), so
9
+ * a modern-layout source would be silently scanned as if it were
10
+ * empty — yielding a near-empty migration with no helpful errors.
11
+ *
12
+ * This module classifies a source directory into one of:
13
+ * - "classic": expected layout (sections/, islands/, …) at root
14
+ * - "modern": src/-layout (src/sections/, src/islands/, …)
15
+ * - "mixed": both root sections/ AND src/sections/ — usually a
16
+ * half-migrated repo
17
+ * - "empty": nothing recognizable; could be a fresh scaffold
18
+ *
19
+ * The migration script consumes this for an early-abort with an
20
+ * actionable error message. Eventually the analyzer can be extended
21
+ * to scan `src/` natively, at which point the "modern" branch can
22
+ * proceed instead of aborting.
23
+ */
24
+
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+
28
+ export type SourceLayout = "classic" | "modern" | "mixed" | "empty";
29
+
30
+ const RECOGNISED_DIRS = ["sections", "islands", "components", "loaders", "actions"];
31
+
32
+ export interface FsLike {
33
+ existsSync(p: string): boolean;
34
+ }
35
+
36
+ const realFs: FsLike = { existsSync: fs.existsSync };
37
+
38
+ /**
39
+ * Classify the source directory's layout. Pure function — accepts
40
+ * a `FsLike` so unit tests can stub the disk without mocking node:fs.
41
+ */
42
+ export function detectSourceLayout(sourceDir: string, fsAdapter: FsLike = realFs): SourceLayout {
43
+ const hasRootDir = RECOGNISED_DIRS.some((d) => fsAdapter.existsSync(path.join(sourceDir, d)));
44
+ const hasSrcDir = RECOGNISED_DIRS.some((d) =>
45
+ fsAdapter.existsSync(path.join(sourceDir, "src", d)),
46
+ );
47
+
48
+ if (hasRootDir && hasSrcDir) return "mixed";
49
+ if (hasSrcDir) return "modern";
50
+ if (hasRootDir) return "classic";
51
+ return "empty";
52
+ }
53
+
54
+ /**
55
+ * Build a human-readable, actionable message for a non-classic
56
+ * layout. Consumed by the CLI to print before exiting. Lives in
57
+ * this module so the test can pin the exact wording.
58
+ */
59
+ export function explainNonClassicLayout(
60
+ layout: Exclude<SourceLayout, "classic">,
61
+ sourceDir: string,
62
+ ): string {
63
+ switch (layout) {
64
+ case "modern":
65
+ return [
66
+ `Modern Fresh "src/" layout detected at ${sourceDir}/src.`,
67
+ "",
68
+ " This migration script currently scans only the classic root layout",
69
+ " (sections/, islands/, components/, loaders/, actions/ at the repo root).",
70
+ "",
71
+ " Workaround until native support lands:",
72
+ " 1. Move src/sections, src/islands, src/components, src/loaders, src/actions",
73
+ " up one level to the repo root (the script's expected layout).",
74
+ " 2. Re-run the migration.",
75
+ " 3. (If desired) Restructure to a src/ layout post-migration — the",
76
+ " TanStack Start scaffold uses src/ on the output side regardless.",
77
+ "",
78
+ " File an issue with your site URL so this can be supported natively.",
79
+ ].join("\n");
80
+ case "mixed":
81
+ return [
82
+ `Mixed layout detected at ${sourceDir}.`,
83
+ "",
84
+ " Both root sections/ and src/sections/ are present. This usually means",
85
+ " the migration was previously run partially against this directory, or",
86
+ " the source genuinely has parallel layouts (rare).",
87
+ "",
88
+ " Resolution: pick one layout and remove the other before re-running.",
89
+ " If this is a half-migrated repo, restore the original via git and",
90
+ " run the migration against a clean checkout (--source <fresh-path>).",
91
+ ].join("\n");
92
+ case "empty":
93
+ return [
94
+ `No recognizable Deco layout found at ${sourceDir}.`,
95
+ "",
96
+ " Expected one of these directories at the repo root or under src/:",
97
+ ` ${RECOGNISED_DIRS.join(", ")}`,
98
+ "",
99
+ " Did you point --source at the correct directory? It should be the",
100
+ " root of an existing Fresh-based Deco site, not the new TanStack site.",
101
+ ].join("\n");
102
+ }
103
+ }
@@ -0,0 +1,186 @@
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, cyan, gray, green, red, yellow } from "./migrate/colors";
26
+ import { realFsAdapter, realFsWriter, 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
+ fix: boolean;
35
+ }
36
+
37
+ function parseArgs(args: string[]): CliOpts {
38
+ let source = ".";
39
+ let json = false;
40
+ let strict = false;
41
+ let help = false;
42
+ let fix = false;
43
+ for (let i = 0; i < args.length; i++) {
44
+ switch (args[i]) {
45
+ case "--source":
46
+ source = args[++i];
47
+ break;
48
+ case "--json":
49
+ json = true;
50
+ break;
51
+ case "--strict":
52
+ strict = true;
53
+ break;
54
+ case "--fix":
55
+ fix = true;
56
+ break;
57
+ case "--help":
58
+ case "-h":
59
+ help = true;
60
+ break;
61
+ }
62
+ }
63
+ return { source, json, strict, help, fix };
64
+ }
65
+
66
+ function showHelp() {
67
+ console.log(`
68
+ @decocms/start — Post-Migration Cleanup Audit
69
+
70
+ Scans a migrated site for dead code and obsolete boilerplate that the
71
+ framework now owns. Read-only — prints findings, does not modify files.
72
+
73
+ Usage:
74
+ npx -p @decocms/start deco-post-cleanup [options]
75
+
76
+ Options:
77
+ --source <dir> Site directory to audit (default: .)
78
+ --fix Auto-apply mechanical fixes for the safe rules
79
+ (dead-lib-shims, dead-runtime-shim, local-widgets-types).
80
+ Other rules still detect-only.
81
+ --json Emit machine-readable JSON instead of pretty text
82
+ --strict Exit code 2 if any warning-severity findings exist
83
+ --help, -h Show this help
84
+
85
+ Examples:
86
+ npx -p @decocms/start deco-post-cleanup
87
+ npx -p @decocms/start deco-post-cleanup --source ./my-site --json
88
+ npx -p @decocms/start deco-post-cleanup --fix
89
+ npx -p @decocms/start deco-post-cleanup --fix --strict # fail CI if anything left
90
+
91
+ See: .agents/skills/deco-to-tanstack-migration/references/post-migration-cleanup.md
92
+ `);
93
+ }
94
+
95
+ function severityColor(sev: Severity, text: string): string {
96
+ if (sev === "warning") return yellow(text);
97
+ return gray(text);
98
+ }
99
+
100
+ function printText(report: AuditReport, fixMode: boolean): void {
101
+ banner(fixMode ? "Post-Migration Cleanup Audit (--fix)" : "Post-Migration Cleanup Audit");
102
+ console.log(` ${gray("Site:")} ${bold(report.site)}`);
103
+ console.log(` ${gray("Findings:")} ${bold(String(report.totalFindings))}`);
104
+ if (fixMode) {
105
+ console.log(` ${gray("Auto-fixed:")} ${bold(String(report.totalFixActions))}`);
106
+ }
107
+ console.log("");
108
+
109
+ let idx = 0;
110
+ for (const summary of report.rules) {
111
+ idx++;
112
+ const count = summary.findings.length;
113
+ const fixCount = summary.fixes?.length ?? 0;
114
+ const headColor = count === 0 ? green : yellow;
115
+ const suffix = fixMode
116
+ ? gray(`(${count} found, ${fixCount} fixed${summary.supportsAutoFix ? "" : ", manual"})`)
117
+ : gray(`(${count} found)`);
118
+ console.log(`${headColor(`[${idx}] ${summary.title}`)} ${suffix}`);
119
+ for (const f of summary.findings) {
120
+ const tag = severityColor(f.severity, `[${f.severity.toUpperCase()}]`);
121
+ console.log(` ${tag} ${bold(f.file)} — ${f.message}`);
122
+ if (f.fix && !summary.fixes) {
123
+ console.log(` ${gray("fix:")} ${f.fix}`);
124
+ }
125
+ }
126
+ if (summary.fixes) {
127
+ for (const a of summary.fixes) {
128
+ console.log(` ${cyan("[FIXED]")} ${bold(a.file)} — ${a.detail}`);
129
+ }
130
+ }
131
+ if (count === 0) console.log(` ${gray("(nothing to clean up)")}`);
132
+ console.log("");
133
+ }
134
+
135
+ const warnings = report.rules
136
+ .flatMap((r) => r.findings)
137
+ .filter((f) => f.severity === "warning").length;
138
+ const infos = report.totalFindings - warnings;
139
+ const tail = fixMode
140
+ ? `${report.totalFindings} finding(s) — ${cyan(`${report.totalFixActions} auto-fixed`)}, ${yellow(`${warnings} warning(s)`)}, ${gray(`${infos} info`)}`
141
+ : `${report.totalFindings} finding(s) — ${yellow(`${warnings} warning(s)`)}, ${gray(`${infos} info`)}`;
142
+ console.log(`${bold("Summary:")} ${tail}`);
143
+ if (report.totalFindings > 0) {
144
+ const hint = fixMode
145
+ ? " Some rules require manual fixes — see post-migration-cleanup.md."
146
+ : " Run with --fix to auto-correct the safe rules, or see post-migration-cleanup.md.";
147
+ console.log(gray(hint));
148
+ }
149
+ }
150
+
151
+ function printJson(report: AuditReport): void {
152
+ console.log(JSON.stringify(report, null, 2));
153
+ }
154
+
155
+ function shouldFail(report: AuditReport, strict: boolean): boolean {
156
+ if (!strict) return false;
157
+ return report.rules.some((r) => r.findings.some((f) => f.severity === "warning"));
158
+ }
159
+
160
+ async function main() {
161
+ const opts = parseArgs(process.argv.slice(2));
162
+ if (opts.help) {
163
+ showHelp();
164
+ process.exit(0);
165
+ }
166
+
167
+ const siteDir = path.resolve(opts.source);
168
+ const report = runAudit(siteDir, realFsAdapter, {
169
+ writer: opts.fix ? realFsWriter : undefined,
170
+ });
171
+
172
+ if (opts.json) {
173
+ printJson(report);
174
+ } else {
175
+ printText(report, opts.fix);
176
+ }
177
+
178
+ if (shouldFail(report, opts.strict)) {
179
+ process.exit(2);
180
+ }
181
+ }
182
+
183
+ main().catch((err) => {
184
+ console.error(red(`Audit failed: ${(err as Error).message}`));
185
+ process.exit(1);
186
+ });
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env tsx
2
+
2
3
  /**
3
4
  * Migration Script: Fresh/Deno/Preact → TanStack Start/React/Cloudflare Workers
4
5
  *
@@ -23,18 +24,19 @@
23
24
  * 6. Verify — Smoke test the migrated output
24
25
  */
25
26
 
26
- import * as path from "node:path";
27
27
  import { execSync } from "node:child_process";
28
+ import * as path from "node:path";
29
+ import { banner, green, red, stat, yellow } from "./migrate/colors";
28
30
  import { loadConfig, validateConfig } from "./migrate/config";
29
- import { createContext, logPhase } from "./migrate/types";
30
31
  import { analyze } from "./migrate/phase-analyze";
31
- import { scaffold } from "./migrate/phase-scaffold";
32
- import { transform } from "./migrate/phase-transform";
33
32
  import { cleanup } from "./migrate/phase-cleanup";
33
+ import { compile } from "./migrate/phase-compile";
34
34
  import { report } from "./migrate/phase-report";
35
+ import { scaffold } from "./migrate/phase-scaffold";
36
+ import { transform } from "./migrate/phase-transform";
35
37
  import { verify } from "./migrate/phase-verify";
36
- import { compile } from "./migrate/phase-compile";
37
- import { banner, stat, red, green, yellow } from "./migrate/colors";
38
+ import { detectSourceLayout, explainNonClassicLayout } from "./migrate/source-layout";
39
+ import { createContext, logPhase } from "./migrate/types";
38
40
 
39
41
  function parseArgs(args: string[]): {
40
42
  source: string;
@@ -137,6 +139,19 @@ async function main() {
137
139
  config: siteConfig,
138
140
  });
139
141
 
142
+ // Phase 0: Source-layout detection (early-abort for unsupported layouts).
143
+ // The analyzer assumes a classic root layout (sections/, islands/, ...);
144
+ // running it on a modern src/ layout silently yields a near-empty
145
+ // migration. Detect-and-abort here so the user gets an actionable error
146
+ // before we touch any files.
147
+ const layout = detectSourceLayout(sourceDir);
148
+ if (layout !== "classic") {
149
+ console.error(red(`Error: ${layout} source layout`));
150
+ console.error("");
151
+ console.error(explainNonClassicLayout(layout, sourceDir));
152
+ process.exit(2);
153
+ }
154
+
140
155
  try {
141
156
  // Phase 1: Analyze source
142
157
  analyze(ctx);
@@ -210,7 +225,9 @@ function bootstrap(ctx: { sourceDir: string }) {
210
225
  run("npx tsr generate", "Generate TanStack routes");
211
226
 
212
227
  if (failures > 0) {
213
- console.log(`\n ${yellow("Bootstrap completed with warnings.")} Check errors above before running dev.\n`);
228
+ console.log(
229
+ `\n ${yellow("Bootstrap completed with warnings.")} Check errors above before running dev.\n`,
230
+ );
214
231
  } else {
215
232
  console.log(`\n ${green("Ready!")} Run \`${pm} run dev\` to start the dev server.\n`);
216
233
  }