test: define housekeeping migration inventory
This commit is contained in:
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));
|
||||
}
|
||||
Reference in new issue
Block a user