import * as fs from "node:fs"; import * as path from "node:path"; import type { MigrationContext } from "./types"; import { logPhase } from "./types"; interface Check { name: string; fn: (ctx: MigrationContext) => boolean; severity: "error" | "warning"; } const REQUIRED_FILES = [ "package.json", "tsconfig.json", "vite.config.ts", // Required for @cloudflare/vite-plugin to mount the worker in local dev // (without it every route returns a silent 404). For deploy, Cloudflare // Workers Builds ignores the `name` field (D6.3 — wired in CF dashboard). "wrangler.jsonc", // Deploy / preview / sync-secrets pipelines are owned by Cloudflare // Workers Builds (D6.3) -- configured in the CF dashboard, not via // GitHub workflow files in the site repo. ".github/workflows/lockfile-check.yml", ".github/workflows/ci.yml", ".github/workflows/main-push-guard.yml", ".github/workflows/playwright.yml", ".github/workflows/react-doctor.yml", ".github/workflows/parity.yml", ".github/workflows/sync-blocks-bot.yml", "tools/gates/no-suppressions.sh", "playwright.config.ts", "knip.config.ts", ".prettierrc", "src/server.ts", "src/worker-entry.ts", "src/router.tsx", "src/runtime.ts", "src/context.ts", "src/setup.ts", "src/cache-config.ts", "src/setup/commerce-loaders.ts", "src/setup/commerce-init.ts", "src/setup/section-loaders.ts", "src/styles/app.css", "src/apps/site.ts", "src/hooks/useCart.ts", "src/hooks/useUser.ts", "src/hooks/useWishlist.ts", // src/types/widgets.ts intentionally omitted — provided by the // framework at `@decocms/blocks/types/widgets`; sites no longer // shadow the file locally. "src/types/deco.ts", "src/types/commerce-app.ts", "src/components/ui/Image.tsx", "src/components/ui/Picture.tsx", "src/routes/__root.tsx", "src/routes/index.tsx", "src/routes/$.tsx", "src/routes/deco/meta.ts", "src/routes/deco/invoke.$.ts", "src/routes/deco/render.ts", ]; const MUST_NOT_EXIST = [ "deno.json", "fresh.gen.ts", "manifest.gen.ts", "dev.ts", "main.ts", "routes/_app.tsx", "routes/_middleware.ts", ]; export const checks: Check[] = [ { name: "All scaffolded files exist", severity: "error", fn: (ctx) => { const missing = REQUIRED_FILES.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f))); if (missing.length > 0) { console.log(` Missing: ${missing.join(", ")}`); return false; } return true; }, }, { name: "Old artifacts removed", severity: "error", fn: (ctx) => { const remaining = MUST_NOT_EXIST.filter((f) => fs.existsSync(path.join(ctx.sourceDir, f))); if (remaining.length > 0) { console.log(` Still exists: ${remaining.join(", ")}`); return false; } return true; }, }, { name: "No preact imports in src/", severity: "error", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /from\s+["']preact/); if (bad.length > 0) { console.log(` Still has preact imports: ${bad.join(", ")}`); return false; } return true; }, }, { name: "No $fresh imports in src/", severity: "error", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /from\s+["']\$fresh/); if (bad.length > 0) { console.log(` Still has $fresh imports: ${bad.join(", ")}`); return false; } return true; }, }, { name: "No deno-lint-ignore in src/", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /deno-lint-ignore/); if (bad.length > 0) { console.log(` Still has deno-lint-ignore: ${bad.join(", ")}`); return false; } return true; }, }, { name: "No class= in JSX (should be className=)", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /<[a-zA-Z][^>]*\sclass\s*=/); if (bad.length > 0) { console.log( ` Still has class= in JSX: ${bad.slice(0, 5).join(", ")}${bad.length > 5 ? ` (+${bad.length - 5} more)` : ""}`, ); return false; } return true; }, }, { name: "public/ has static assets", severity: "warning", fn: (ctx) => { const publicDir = path.join(ctx.sourceDir, "public"); if (!fs.existsSync(publicDir)) { console.log(" public/ directory missing"); return false; } const hasSprites = fs.existsSync(path.join(publicDir, "sprites.svg")); const hasFavicon = fs.existsSync(path.join(publicDir, "favicon.ico")); if (!hasSprites) console.log(" Missing: public/sprites.svg"); if (!hasFavicon) console.log(" Missing: public/favicon.ico"); return hasSprites && hasFavicon; }, }, { name: "package.json has correct dependencies", severity: "error", fn: (ctx) => { const pkgPath = path.join(ctx.sourceDir, "package.json"); if (!fs.existsSync(pkgPath)) return false; const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; const required = [ "@decocms/blocks", "@decocms/tanstack", "@decocms/apps-commerce", "react", "react-dom", "@tanstack/react-start", "vite", "knip", ]; const missing = required.filter((d) => !deps[d]); if (missing.length > 0) { console.log(` Missing deps: ${missing.join(", ")}`); return false; } return true; }, }, { name: "No site/ or $store/ imports (should be ~/)", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /from\s+["'](site\/|\$store\/)/); if (bad.length > 0) { console.log(` Still has site/ or $store/ imports: ${bad.join(", ")}`); return false; } return true; }, }, { name: "No .ts/.tsx extensions in relative import paths", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; // Match relative imports with .ts/.tsx extensions const bad = findFilesWithPattern(srcDir, /from\s+["'](?:\.\.?\/|~\/)[^"']*\.tsx?["']/); if (bad.length > 0) { console.log(` Still has .ts/.tsx extensions in imports: ${bad.join(", ")}`); return false; } return true; }, }, { name: "No for= in JSX (should be htmlFor=)", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /]*\sfor\s*=/); if (bad.length > 0) { console.log(` Still has for= in JSX: ${bad.join(", ")}`); return false; } return true; }, }, { // Only flag imports to files that phase-cleanup actually deletes: // `sdk/clx.ts`, `sdk/useId.ts`, `sdk/usePlatform.tsx`. Earlier versions // also matched `useOffer` and `useVariantPossiblities`, which are // explicitly KEPT as site files (RELATIVE_SDK_REWRITES in // transforms/imports.ts skips them on purpose because sites customize // them). That false positive made every Magento/custom-SDK migration // fail verify even when the imports were valid. See #212. name: "No relative imports to deleted SDK files", severity: "error", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const pattern = /from\s+["'](?:\.\.?\/)[^"']*\/sdk\/(?:clx|useId|usePlatform)(?:\.tsx?)?["']/; const bad = findMatchesWithPattern(srcDir, pattern); if (bad.length > 0) { for (const { file, line } of bad) { console.log(` ${file}: ${line.trim()}`); } return false; } return true; }, }, { name: "No negative z-index on non-image elements", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; // Find -z-{n} that are NOT on img/Image elements (those are auto-fixed to z-0) const bad = findFilesWithPattern(srcDir, /(?]*)-z-\d+/); if (bad.length > 0) { console.log(` Negative z-index on non-image elements: ${bad.join(", ")}`); console.log( ` These may be invisible due to stacking contexts. Replace with z-0 or positive z-index.`, ); return false; } return true; }, }, { name: "No imports to deleted static files", severity: "error", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /from\s+["'][^"']*static\/adminIcons/); if (bad.length > 0) { console.log(` Still has imports to static/adminIcons: ${bad.join(", ")}`); return false; } return true; }, }, { name: "No dead cache/cacheKey exports (old SWR system)", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; // Only flag the OLD cache patterns: cache = "stale-while-revalidate" or cache = { maxAge: ... } // NOT the new-stack section convention: cache = "listing" / "product" / "search" / "static" const bad = findFilesWithPattern(srcDir, /^export\s+const\s+cacheKey\s*=/m); if (bad.length > 0) { console.log(` Dead cacheKey exports found: ${bad.join(", ")}`); return false; } return true; }, }, { name: "invoke.* calls have runtime.ts proxy available", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const hasInvoke = findFilesWithPattern(srcDir, /\binvoke\.\w+\.\w+/); if (hasInvoke.length > 0) { // Check that runtime.ts exists and has the invoke proxy const runtimePath = path.join(srcDir, "runtime.ts"); if (!fs.existsSync(runtimePath)) { console.log( ` Files use invoke.* but src/runtime.ts is missing: ${hasInvoke.join(", ")}`, ); return false; } } return true; }, }, { name: "No Deno-only crypto.subtle.digestSync", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /digestSync/); if (bad.length > 0) { console.log(` Deno-only digestSync found: ${bad.join(", ")}`); return false; } return true; }, }, { name: ".gitignore exists with new stack entries", severity: "warning", fn: (ctx) => { const gitignorePath = path.join(ctx.sourceDir, ".gitignore"); if (!fs.existsSync(gitignorePath)) { console.log(" .gitignore missing"); return false; } const content = fs.readFileSync(gitignorePath, "utf-8"); const required = [ ".wrangler/", "node_modules/", ".tanstack/", "src/routeTree.gen.ts", // The decofile snapshot must be ignored — its 10MB+ single line causes // constant PR merge conflicts (see generateGitignore in phase-scaffold). // meta.gen.json stays committed on purpose, so it is NOT required here. ".deco/blocks.gen.json", ]; const missing = required.filter((r) => !content.includes(r)); if (missing.length > 0) { console.log(` .gitignore missing entries: ${missing.join(", ")}`); return false; } return true; }, }, { name: "No HTMX attributes (hx-*) in components", severity: "warning", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern( srcDir, /\bhx-(?:get|post|put|delete|patch|trigger|target|swap|on|indicator|sync|select)\b/, ); if (bad.length > 0) { console.log(` HTMX attributes found (needs manual React migration): ${bad.join(", ")}`); return false; } return true; }, }, { name: "No @deco/deco imports in src/", severity: "error", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /from\s+["']@deco\/deco/); if (bad.length > 0) { console.log(` Still has @deco/deco imports: ${bad.join(", ")}`); return false; } return true; }, }, { name: "No apps/ imports in src/ (should be @decocms/apps-* or ~/)", severity: "error", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = findFilesWithPattern(srcDir, /from\s+["']apps\//); if (bad.length > 0) { console.log(` Still has apps/ imports: ${bad.join(", ")}`); return false; } return true; }, }, { // Fleet-wide regression guard, complementing the scaffolder-only check // in templates/no-legacy-packages.test.ts (#367): the frozen // pre-7.x-split packages (@decocms/start@6.30.0, @decocms/apps@5.4.0) // must never appear in a migrated site's src/, even via a transform // hand-off marker that leaked past cleanup (see jsx.ts's // `@decocms/start/hooks` same-run hand-off to phase-cleanup.ts). name: "No frozen pre-split package specifiers in src/ (@decocms/start, @decocms/apps/*)", severity: "error", fn: (ctx) => { const srcDir = path.join(ctx.sourceDir, "src"); if (!fs.existsSync(srcDir)) return true; const bad = [ ...findFilesWithPattern(srcDir, /(?:from|import)\s+["']@decocms\/start(?:\/|["'])/), ...findFilesWithPattern(srcDir, /from\s+["']@decocms\/apps\//), ]; const unique = [...new Set(bad)]; if (unique.length > 0) { console.log( ` Still references frozen @decocms/start or @decocms/apps/* specifiers: ${unique.join(", ")}`, ); return false; } return true; }, }, { name: "Setup infrastructure is complete", severity: "error", fn: (ctx) => { const setupFiles = [ "src/setup.ts", "src/cache-config.ts", "src/setup/commerce-loaders.ts", "src/setup/commerce-init.ts", "src/setup/section-loaders.ts", ]; const missing = setupFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f))); if (missing.length > 0) { console.log(` Missing setup infrastructure: ${missing.join(", ")}`); return false; } return true; }, }, { name: "No islands/ directory (should be eliminated)", severity: "warning", fn: (ctx) => { const islandsDir = path.join(ctx.sourceDir, "src", "islands"); if (fs.existsSync(islandsDir)) { try { const files = fs.readdirSync(islandsDir, { recursive: true }); const tsxFiles = (files as string[]).filter( (f: string) => f.endsWith(".tsx") || f.endsWith(".ts"), ); if (tsxFiles.length > 0) { console.log( ` src/islands/ still has ${tsxFiles.length} files — should be moved to components/`, ); return false; } } catch {} } return true; }, }, { name: "Hooks are scaffolded", severity: "warning", fn: (ctx) => { const hookFiles = [ "src/hooks/useCart.ts", "src/hooks/useUser.ts", "src/hooks/useWishlist.ts", ]; const missing = hookFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f))); if (missing.length > 0) { console.log(` Missing hooks: ${missing.join(", ")}`); return false; } return true; }, }, { name: "Type files are scaffolded", severity: "warning", fn: (ctx) => { const typeFiles = [ // widgets.ts is provided by @decocms/blocks/types/widgets, not // scaffolded locally. "src/types/deco.ts", "src/types/commerce-app.ts", ]; const missing = typeFiles.filter((f) => !fs.existsSync(path.join(ctx.sourceDir, f))); if (missing.length > 0) { console.log(` Missing type files: ${missing.join(", ")}`); return false; } return true; }, }, ]; function findFilesWithPattern( dir: string, pattern: RegExp, results: string[] = [], baseDir?: string, ): string[] { const root = baseDir ?? dir; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server") continue; findFilesWithPattern(fullPath, pattern, results, root); } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { const content = fs.readFileSync(fullPath, "utf-8"); // Only test non-comment lines const uncommented = content .split("\n") .filter((line) => !line.trimStart().startsWith("//") && !line.trimStart().startsWith("*")) .join("\n"); if (pattern.test(uncommented)) { results.push(path.basename(fullPath)); } } } return results; } /** * Like {@link findFilesWithPattern} but returns each offending line alongside * its file, so the verify output can show *what* matched — not just *where*. * Skips comment-only lines so a doc reference doesn't trigger a false fail. */ function findMatchesWithPattern( dir: string, pattern: RegExp, results: Array<{ file: string; line: string }> = [], baseDir?: string, ): Array<{ file: string; line: string }> { const root = baseDir ?? dir; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "server") continue; findMatchesWithPattern(fullPath, pattern, results, root); } else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) { const content = fs.readFileSync(fullPath, "utf-8"); const rel = path.relative(root, fullPath); for (const line of content.split("\n")) { const trimmed = line.trimStart(); if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue; if (pattern.test(line)) results.push({ file: rel, line }); } } } return results; } export function verify(ctx: MigrationContext): boolean { logPhase("Verify (Smoke Test)"); if (ctx.dryRun) { console.log(" Skipping verify in dry-run mode\n"); return true; } let errors = 0; let warnings = 0; for (const check of checks) { const pass = check.fn(ctx); const icon = pass ? "\x1b[32m✓\x1b[0m" : check.severity === "error" ? "\x1b[31m✗\x1b[0m" : "\x1b[33m⚠\x1b[0m"; console.log(` ${icon} ${check.name}`); if (!pass) { if (check.severity === "error") errors++; else warnings++; } } console.log( `\n Result: ${checks.length - errors - warnings} passed, ${errors} errors, ${warnings} warnings`, ); if (errors > 0) { console.log(" \x1b[31mVerification FAILED — migration has issues that must be fixed\x1b[0m"); return false; } if (warnings > 0) { console.log(" \x1b[33mVerification passed with warnings\x1b[0m"); } else { console.log(" \x1b[32mVerification PASSED\x1b[0m"); } return true; }