diff --git a/src/features/housekeeping/migration/discover-legacy-pages.test.ts b/src/features/housekeeping/migration/discover-legacy-pages.test.ts new file mode 100644 index 00000000..b93853e0 --- /dev/null +++ b/src/features/housekeeping/migration/discover-legacy-pages.test.ts @@ -0,0 +1,82 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { discoverLegacyPages } from "./discover-legacy-pages"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }); + } +}); + +function createRouteTree(files: readonly string[]): string { + const root = mkdtempSync(join(tmpdir(), "housekeeping-routes-")); + temporaryRoots.push(root); + + for (const file of files) { + const path = join(root, file); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, "export default function Page() {}\n"); + } + + return root; +} + +describe("discoverLegacyPages", () => { + it("discovers the exact legacy administration inventory", () => { + const pages = discoverLegacyPages(); + + expect(pages).toHaveLength(137); + expect(pages).toContainEqual({ + surface: "admin", + legacyPath: "/admin/users/:id/edit", + sourceFile: "src/app/admin/users/[id]/edit/page.tsx", + }); + expect(pages).toContainEqual({ + surface: "mod", + legacyPath: "/mod/cfh/:id", + sourceFile: "src/app/mod/cfh/[id]/page.tsx", + }); + }); + + it("sorts a controlled route tree by surface, route, then source file", () => { + const root = createRouteTree([ + "src/app/mod/zebra/page.tsx", + "src/app/admin/users/[id]/page.tsx", + "src/app/admin/(hidden)/alpha/page.tsx", + "src/app/admin/users/[...slug]/page.tsx", + "src/app/mod/(group)/aardvark/page.tsx", + ]); + + expect(discoverLegacyPages(root)).toEqual([ + { + surface: "admin", + legacyPath: "/admin/alpha", + sourceFile: "src/app/admin/(hidden)/alpha/page.tsx", + }, + { + surface: "admin", + legacyPath: "/admin/users/:id", + sourceFile: "src/app/admin/users/[id]/page.tsx", + }, + { + surface: "admin", + legacyPath: "/admin/users/:slug*", + sourceFile: "src/app/admin/users/[...slug]/page.tsx", + }, + { + surface: "mod", + legacyPath: "/mod/aardvark", + sourceFile: "src/app/mod/(group)/aardvark/page.tsx", + }, + { + surface: "mod", + legacyPath: "/mod/zebra", + sourceFile: "src/app/mod/zebra/page.tsx", + }, + ]); + }); +}); diff --git a/src/features/housekeeping/migration/discover-legacy-pages.ts b/src/features/housekeeping/migration/discover-legacy-pages.ts new file mode 100644 index 00000000..9a6148cf --- /dev/null +++ b/src/features/housekeeping/migration/discover-legacy-pages.ts @@ -0,0 +1,74 @@ +import { readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import type { LegacyPage, LegacySurface } from "./types"; + +const pageFileName = "page.tsx"; + +function toLegacyPath( + surface: LegacySurface, + segments: readonly string[], +): string { + const routeSegments = segments + .filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))) + .map((segment) => + segment.replace(/^\[\.\.\.(.+)\]$/, ":$1*").replace(/^\[(.+)\]$/, ":$1"), + ); + + return `/${[surface, ...routeSegments].join("/")}`; +} + +function discoverSurface( + rootDir: string, + surface: LegacySurface, +): LegacyPage[] { + const surfaceDirectory = join(rootDir, "src", "app", surface); + const pages: LegacyPage[] = []; + + function walk(directory: string): void { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + + if (entry.isDirectory()) { + walk(entryPath); + continue; + } + + if (!entry.isFile() || entry.name !== pageFileName) continue; + + const sourceFile = relative(rootDir, entryPath).replaceAll("\\", "/"); + const routeSegments = relative(surfaceDirectory, directory) + .split(/[/\\]/) + .filter(Boolean); + pages.push({ + surface, + legacyPath: toLegacyPath(surface, routeSegments), + sourceFile, + }); + } + } + + walk(surfaceDirectory); + return pages; +} + +export function discoverLegacyPages(rootDir = process.cwd()): LegacyPage[] { + return (["admin", "mod"] as const) + .flatMap((surface) => discoverSurface(rootDir, surface)) + .sort((a, b) => + `${a.surface}:${a.legacyPath}:${a.sourceFile}`.localeCompare( + `${b.surface}:${b.legacyPath}:${b.sourceFile}`, + ), + ); +} + +export function ownedLegacyPages( + prefixes: readonly string[], + pages: readonly LegacyPage[] = discoverLegacyPages(), +): LegacyPage[] { + return pages.filter((page) => + prefixes.some( + (prefix) => + page.legacyPath === prefix || page.legacyPath.startsWith(`${prefix}/`), + ), + ); +} diff --git a/src/features/housekeeping/migration/types.ts b/src/features/housekeeping/migration/types.ts new file mode 100644 index 00000000..41544a0b --- /dev/null +++ b/src/features/housekeeping/migration/types.ts @@ -0,0 +1,40 @@ +export const HOUSEKEEPING_DOMAIN_IDS = [ + "operations", + "people", + "content", + "economy", + "hotel", + "system", +] as const; + +export type HousekeepingDomainId = (typeof HOUSEKEEPING_DOMAIN_IDS)[number]; +export type LegacySurface = "admin" | "mod"; +export type MigrationDecision = "REHOST" | "REBUILD" | "MERGE" | "REMOVE"; +export type AuditRequirement = "NONE" | "MUTATION" | "PRIVILEGED_MUTATION"; +export type AuditState = "UNAUDITED" | "PARTIAL" | "COMPLETE"; +export type MigrationStatus = + | "PLANNED" + | "IN_PROGRESS" + | "VERIFIED" + | "REMOVED"; + +export interface LegacyPage { + surface: LegacySurface; + legacyPath: string; + sourceFile: string; +} + +export interface MigrationEntry extends LegacyPage { + targetDomain: HousekeepingDomainId; + targetPath: string | null; + decision: MigrationDecision; + capabilities: { read: readonly string[]; mutate: readonly string[] }; + dependencies: { queries: readonly string[]; mutations: readonly string[] }; + auditRequirement: AuditRequirement; + localization: AuditState; + accessibility: AuditState; + requiredTests: readonly ("unit" | "integration" | "e2e" | "visual")[]; + parityEvidence: readonly string[]; + status: MigrationStatus; + notes: readonly string[]; +} diff --git a/src/features/housekeeping/migration/validate-matrix.test.ts b/src/features/housekeeping/migration/validate-matrix.test.ts new file mode 100644 index 00000000..f2d0f3d5 --- /dev/null +++ b/src/features/housekeeping/migration/validate-matrix.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import type { LegacyPage, MigrationEntry } from "./types"; +import { validateMigrationEntries } from "./validate-matrix"; + +const page = (legacyPath: string): LegacyPage => ({ + surface: legacyPath.startsWith("/mod") ? "mod" : "admin", + legacyPath, + sourceFile: `src/app${legacyPath}/page.tsx`, +}); + +const entry = (legacyPath: string): MigrationEntry => ({ + ...page(legacyPath), + targetDomain: "system", + targetPath: "/admin/system/example", + decision: "REHOST", + capabilities: { read: ["admin.dashboard"], mutate: [] }, + dependencies: { queries: [], mutations: [] }, + auditRequirement: "NONE", + localization: "COMPLETE", + accessibility: "COMPLETE", + requiredTests: ["unit"], + parityEvidence: [], + status: "PLANNED", + notes: [], +}); + +describe("validateMigrationEntries", () => { + it("reports missing, duplicate, and unknown legacy rows", () => { + const discovered = [page("/admin"), page("/admin/users")]; + + expect( + validateMigrationEntries(discovered, [ + entry("/admin"), + entry("/admin"), + entry("/admin/ghost"), + ]), + ).toEqual([ + "duplicate legacyPath: /admin", + "missing legacyPath: /admin/users", + "unknown legacyPath: /admin/ghost", + ]); + }); + + it("rejects incomplete decisions", () => { + const issues = validateMigrationEntries( + [page("/admin")], + [{ ...entry("/admin"), targetPath: null, decision: "REBUILD" }], + ); + + expect(issues).toContain("REBUILD requires targetPath: /admin"); + }); + + it("enforces migration safety evidence", () => { + const issues = validateMigrationEntries( + [page("/admin")], + [ + { + ...entry("/admin"), + targetPath: "/mod/unsupported", + decision: "MERGE", + requiredTests: [], + dependencies: { queries: [], mutations: ["users.disable"] }, + status: "VERIFIED", + notes: ["TODO confirm ownership"], + }, + ], + ); + + expect(issues).toEqual([ + "MERGE requires requiredTests: /admin", + "mutation dependencies require auditRequirement: /admin", + "mutation dependencies require mutate capabilities: /admin", + "non-REMOVE targetPath must start with /admin/: /admin", + "prohibited placeholder: /admin", + "VERIFIED requires parityEvidence: /admin", + ]); + }); + + it("allows removal without a target and rejects non-removal null targets", () => { + expect( + validateMigrationEntries( + [page("/admin")], + [{ ...entry("/admin"), decision: "REMOVE", targetPath: null }], + ), + ).toEqual([]); + + expect( + validateMigrationEntries( + [page("/admin")], + [{ ...entry("/admin"), targetPath: null }], + ), + ).toEqual(["REHOST requires targetPath: /admin"]); + }); +}); diff --git a/src/features/housekeeping/migration/validate-matrix.ts b/src/features/housekeeping/migration/validate-matrix.ts new file mode 100644 index 00000000..222e9d02 --- /dev/null +++ b/src/features/housekeeping/migration/validate-matrix.ts @@ -0,0 +1,93 @@ +import type { LegacyPage, MigrationEntry } from "./types"; + +const prohibitedPlaceholder = /\b(?:TBD|TODO|FIXME|UNCLASSIFIED)\b/i; + +function hasProhibitedPlaceholder(value: unknown): boolean { + if (typeof value === "string") return prohibitedPlaceholder.test(value); + if (Array.isArray(value)) return value.some(hasProhibitedPlaceholder); + if (value && typeof value === "object") + return Object.values(value).some(hasProhibitedPlaceholder); + + return false; +} + +export function validateMigrationEntries( + discovered: readonly LegacyPage[], + entries: readonly MigrationEntry[], +): string[] { + const issues = new Set(); + const discoveredPaths = new Set(discovered.map((page) => page.legacyPath)); + const entriesByPath = new Map(); + + for (const entry of entries) { + const matchingEntries = entriesByPath.get(entry.legacyPath) ?? []; + matchingEntries.push(entry); + entriesByPath.set(entry.legacyPath, matchingEntries); + + if (!discoveredPaths.has(entry.legacyPath)) { + issues.add(`unknown legacyPath: ${entry.legacyPath}`); + } + } + + for (const page of discovered) { + const matchingEntries = entriesByPath.get(page.legacyPath) ?? []; + + if (matchingEntries.length === 0) { + issues.add(`missing legacyPath: ${page.legacyPath}`); + continue; + } + + if (matchingEntries.length > 1) { + issues.add(`duplicate legacyPath: ${page.legacyPath}`); + } + } + + for (const entry of entries) { + const { legacyPath } = entry; + + if (entry.targetPath === null && entry.decision !== "REMOVE") { + issues.add(`${entry.decision} requires targetPath: ${legacyPath}`); + } + + if ( + entry.targetPath !== null && + entry.decision !== "REMOVE" && + !entry.targetPath.startsWith("/admin/") + ) { + issues.add( + `non-REMOVE targetPath must start with /admin/: ${legacyPath}`, + ); + } + + if ( + (entry.decision === "MERGE" || entry.decision === "REBUILD") && + entry.requiredTests.length === 0 + ) { + issues.add(`${entry.decision} requires requiredTests: ${legacyPath}`); + } + + if (entry.dependencies.mutations.length > 0) { + if (entry.capabilities.mutate.length === 0) { + issues.add( + `mutation dependencies require mutate capabilities: ${legacyPath}`, + ); + } + + if (entry.auditRequirement === "NONE") { + issues.add( + `mutation dependencies require auditRequirement: ${legacyPath}`, + ); + } + } + + if (entry.status === "VERIFIED" && entry.parityEvidence.length === 0) { + issues.add(`VERIFIED requires parityEvidence: ${legacyPath}`); + } + + if (hasProhibitedPlaceholder(entry)) { + issues.add(`prohibited placeholder: ${legacyPath}`); + } + } + + return [...issues].sort((a, b) => a.localeCompare(b)); +}