test: define housekeeping migration inventory

This commit is contained in:
Simo committed 2026-08-25 18:14:27 +02:00
1 parent 3f10db41db
commit dee4031421
5 files changed
+383

No files matched your search

@@ -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",
},
]);
});
});
@@ -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}/`),
),
);
}
@@ -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[];
}
@@ -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"]);
});
});
@@ -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<string>();
const discoveredPaths = new Set(discovered.map((page) => page.legacyPath));
const entriesByPath = new Map<string, MigrationEntry[]>();
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));
}