feat(housekeeping): unify complete rebuild in gated preview
This commit is contained in:
1 parent
4ad007d241
commit
43470ebf82
402 files changed
+78313
-1663
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHousekeepingRegistry } from "../foundation/registry";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import {
|
||||
discoverLegacyPages,
|
||||
recordedLegacyPages,
|
||||
} from "../migration/discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||
import { HOUSEKEEPING_ROUTE_HANDLERS } from "../route-handlers";
|
||||
import { verifyHousekeepingRuntimeParity } from "./parity";
|
||||
|
||||
describe("Housekeeping runtime parity", () => {
|
||||
const report = verifyHousekeepingRuntimeParity(
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
|
||||
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||
);
|
||||
|
||||
it("closes all 138 recorded migration rows while stable routes coexist", () => {
|
||||
expect(discoverLegacyPages()).toEqual(
|
||||
recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
);
|
||||
expect(report).toEqual({
|
||||
discovered: 138,
|
||||
mapped: 138,
|
||||
verified: 138,
|
||||
removed: 2,
|
||||
unresolved: [],
|
||||
capabilityGaps: [],
|
||||
handlerGaps: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every retained target under /ase-next with concrete parity evidence", () => {
|
||||
for (const row of HOUSEKEEPING_MIGRATION_MATRIX) {
|
||||
if (row.decision === "REMOVE") {
|
||||
expect(row.targetPath, row.legacyPath).toBeNull();
|
||||
expect(row.status, row.legacyPath).toBe("REMOVED");
|
||||
expect(row.parityEvidence, row.legacyPath).toContain(
|
||||
`runtime-parity:${row.legacyPath}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
expect(row.targetPath, row.legacyPath).toMatch(/^\/ase-next(?:\/|$)/);
|
||||
expect(row.status, row.legacyPath).toBe("VERIFIED");
|
||||
expect(row.parityEvidence, row.legacyPath).toContain(
|
||||
`runtime-parity:${row.legacyPath}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { HousekeepingRegistry } from "../foundation/registry";
|
||||
import type { MigrationEntry } from "../migration/types";
|
||||
import type { HousekeepingRouteHandler } from "../route-handlers";
|
||||
|
||||
export interface HousekeepingParityReport {
|
||||
readonly discovered: 138;
|
||||
readonly mapped: number;
|
||||
readonly verified: number;
|
||||
readonly removed: number;
|
||||
readonly unresolved: readonly string[];
|
||||
readonly capabilityGaps: readonly string[];
|
||||
readonly handlerGaps: readonly string[];
|
||||
}
|
||||
|
||||
function expectedEvidence(row: MigrationEntry): string {
|
||||
return `runtime-parity:${row.legacyPath}`;
|
||||
}
|
||||
|
||||
function capabilityMatches(
|
||||
row: MigrationEntry,
|
||||
route: HousekeepingRegistry["domains"][number]["routes"][number],
|
||||
): boolean {
|
||||
const declared = new Set(row.capabilities.read);
|
||||
if (route.capability.mode === "all") {
|
||||
return route.capability.slugs.every((slug) => declared.has(slug));
|
||||
}
|
||||
return route.capability.slugs.some((slug) => declared.has(slug));
|
||||
}
|
||||
|
||||
export function verifyHousekeepingRuntimeParity(
|
||||
matrix: readonly MigrationEntry[],
|
||||
registry: HousekeepingRegistry,
|
||||
handlers: readonly HousekeepingRouteHandler[],
|
||||
): HousekeepingParityReport {
|
||||
if (matrix.length !== 138) {
|
||||
throw new Error(
|
||||
`Housekeeping parity requires exactly 138 discovered rows; received ${matrix.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const routes = registry.domains.flatMap((domain) => domain.routes);
|
||||
const routesByHref = new Map<string, (typeof routes)[number]>(
|
||||
routes.map((route) => [route.href, route]),
|
||||
);
|
||||
const routesById = new Map(routes.map((route) => [route.id, route]));
|
||||
const handlersByRouteId = new Map<string, HousekeepingRouteHandler[]>();
|
||||
for (const handler of handlers) {
|
||||
const matching = handlersByRouteId.get(handler.routeId) ?? [];
|
||||
matching.push(handler);
|
||||
handlersByRouteId.set(handler.routeId, matching);
|
||||
}
|
||||
|
||||
const unresolved = new Set<string>();
|
||||
const capabilityGaps = new Set<string>();
|
||||
const handlerGaps = new Set<string>();
|
||||
let mapped = 0;
|
||||
let verified = 0;
|
||||
let removed = 0;
|
||||
|
||||
for (const row of matrix) {
|
||||
let rowMapped = false;
|
||||
let rowCapabilityVerified = true;
|
||||
let rowHandlerVerified = true;
|
||||
|
||||
if (row.decision === "REMOVE") {
|
||||
removed += 1;
|
||||
rowMapped = row.targetPath === null;
|
||||
if (!rowMapped)
|
||||
unresolved.add(`${row.legacyPath}: removed target exists`);
|
||||
} else if (row.targetPath === null) {
|
||||
unresolved.add(`${row.legacyPath}: retained target is missing`);
|
||||
} else {
|
||||
const route = routesByHref.get(row.targetPath);
|
||||
if (!route) {
|
||||
unresolved.add(
|
||||
`${row.legacyPath}: no registered route for ${row.targetPath}`,
|
||||
);
|
||||
} else {
|
||||
rowMapped = true;
|
||||
if (!capabilityMatches(row, route)) {
|
||||
rowCapabilityVerified = false;
|
||||
capabilityGaps.add(
|
||||
`${row.legacyPath}: ${row.targetPath} capability mismatch`,
|
||||
);
|
||||
}
|
||||
|
||||
const matchingHandlers = handlersByRouteId.get(route.id) ?? [];
|
||||
if (matchingHandlers.length !== 1) {
|
||||
rowHandlerVerified = false;
|
||||
handlerGaps.add(
|
||||
`${row.legacyPath}: ${route.id} has ${matchingHandlers.length} handlers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rowMapped) mapped += 1;
|
||||
const expectedStatus = row.decision === "REMOVE" ? "REMOVED" : "VERIFIED";
|
||||
if (
|
||||
rowMapped &&
|
||||
rowCapabilityVerified &&
|
||||
rowHandlerVerified &&
|
||||
row.status === expectedStatus &&
|
||||
row.parityEvidence.includes(expectedEvidence(row))
|
||||
) {
|
||||
verified += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const route of routes) {
|
||||
const matchingHandlers = handlersByRouteId.get(route.id) ?? [];
|
||||
if (matchingHandlers.length !== 1) {
|
||||
handlerGaps.add(
|
||||
`registry route ${route.id} has ${matchingHandlers.length} handlers`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const handler of handlers) {
|
||||
if (!routesById.has(handler.routeId)) {
|
||||
handlerGaps.add(`orphan handler ${handler.routeId}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
discovered: 138,
|
||||
mapped,
|
||||
verified,
|
||||
removed,
|
||||
unresolved: [...unresolved].sort(),
|
||||
capabilityGaps: [...capabilityGaps].sort(),
|
||||
handlerGaps: [...handlerGaps].sort(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const PREVIEW_ENTRYPOINTS = [
|
||||
"src/app/ase-next/layout.tsx",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase-next/[domain]/layout.tsx",
|
||||
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||
"src/features/housekeeping/route-handlers.ts",
|
||||
] as const;
|
||||
|
||||
const STABLE_UI_ROOTS = ["src/app/admin", "src/app/mod"] as const;
|
||||
const PREMATURE_UI_ROOTS = [["src/app/admin", "-next"].join(""), ["src/app", "ase"].join("/")] as const;
|
||||
const STABLE_GLOBAL_SOURCES = [
|
||||
"src/lib/admin/guard.ts",
|
||||
"src/lib/proxy-access.ts",
|
||||
"src/components/navigation.tsx",
|
||||
"src/components/top-header.tsx",
|
||||
] as const;
|
||||
|
||||
describe("gated Housekeeping preview coexistence", () => {
|
||||
it("publishes the /ase-next entrypoints and dispatcher", () => {
|
||||
for (const path of PREVIEW_ENTRYPOINTS) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the stable /admin and /mod surfaces during preview", () => {
|
||||
for (const path of STABLE_UI_ROOTS) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
}
|
||||
expect(existsSync("src/app/api/admin/csrf/route.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not expose either superseded preview tree", () => {
|
||||
for (const path of PREMATURE_UI_ROOTS) {
|
||||
expect(existsSync(path), path).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the preview environment-gated outside production", () => {
|
||||
expect(
|
||||
existsSync("src/features/housekeeping/foundation/preview-gate.ts"),
|
||||
).toBe(true);
|
||||
for (const path of ["src/env.ts", ".env.example"] as const) {
|
||||
expect(readFileSync(path, "utf8"), path).toContain(
|
||||
"HOUSEKEEPING_NEXT_PREVIEW_ENABLED",
|
||||
);
|
||||
}
|
||||
const gate = readFileSync(
|
||||
"src/features/housekeeping/foundation/preview-gate.ts",
|
||||
"utf8",
|
||||
);
|
||||
expect(gate).toContain('input.nodeEnv !== "production"');
|
||||
expect(gate).toContain("input.flag");
|
||||
});
|
||||
|
||||
it("leaves stable global links and authorization fallbacks on /admin and /mod", () => {
|
||||
for (const path of STABLE_GLOBAL_SOURCES) {
|
||||
expect(readFileSync(path, "utf8"), path).not.toContain("/ase-next");
|
||||
}
|
||||
expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain(
|
||||
'href="/admin"',
|
||||
);
|
||||
expect(readFileSync("src/components/top-header.tsx", "utf8")).toContain(
|
||||
'href="/mod"',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add global redirects into or out of the preview namespace", () => {
|
||||
for (const path of ["next.config.ts", "src/proxy.ts"] as const) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source, path).not.toMatch(
|
||||
/(?:source|destination|redirect)\s*[:(][^\n]*\/ase-next/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, relative, resolve, sep } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const housekeepingRoot = resolve(process.cwd(), "src/features/housekeeping");
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) return sourceFiles(path);
|
||||
if (!/\.(?:ts|tsx)$/u.test(entry.name) || /\.test\./u.test(entry.name)) {
|
||||
return [];
|
||||
}
|
||||
return [path];
|
||||
});
|
||||
}
|
||||
|
||||
function moduleSpecifiers(source: string): string[] {
|
||||
return [
|
||||
...source.matchAll(
|
||||
/(?:from\s+|import\s*\(|import\s+|require\s*\()\s*["']([^"']+)["']/gu,
|
||||
),
|
||||
].flatMap((match) => (match[1] ? [match[1]] : []));
|
||||
}
|
||||
|
||||
function resolveHousekeepingImport(
|
||||
file: string,
|
||||
specifier: string,
|
||||
): string | null {
|
||||
if (specifier.startsWith(".")) {
|
||||
return resolve(dirname(file), specifier).replaceAll("\\", "/");
|
||||
}
|
||||
const prefix = "@/features/housekeeping/";
|
||||
if (specifier.startsWith(prefix)) {
|
||||
return resolve(housekeepingRoot, specifier.slice(prefix.length)).replaceAll(
|
||||
"\\",
|
||||
"/",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function displayPath(path: string): string {
|
||||
return relative(process.cwd(), path).split(sep).join("/");
|
||||
}
|
||||
|
||||
describe("Housekeeping source boundaries", () => {
|
||||
it("keeps foundation independent from domain internals, database, and actions", () => {
|
||||
const violations = sourceFiles(resolve(housekeepingRoot, "foundation"))
|
||||
.flatMap((file) =>
|
||||
moduleSpecifiers(readFileSync(file, "utf8")).flatMap((specifier) => {
|
||||
const resolved = resolveHousekeepingImport(file, specifier);
|
||||
const importsDomain =
|
||||
resolved?.includes("/features/housekeeping/domains/") === true;
|
||||
const importsInfrastructure =
|
||||
specifier === "@/db" ||
|
||||
specifier.startsWith("@/db/") ||
|
||||
specifier === "@/actions" ||
|
||||
specifier.startsWith("@/actions/");
|
||||
return importsDomain || importsInfrastructure
|
||||
? [`${displayPath(file)} -> ${specifier}`]
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
.sort();
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("prevents one domain from importing another domain's internals", () => {
|
||||
const domainsRoot = resolve(housekeepingRoot, "domains");
|
||||
const violations = sourceFiles(domainsRoot)
|
||||
.flatMap((file) => {
|
||||
const owner = relative(domainsRoot, file).split(sep)[0];
|
||||
return moduleSpecifiers(readFileSync(file, "utf8")).flatMap(
|
||||
(specifier) => {
|
||||
const resolved = resolveHousekeepingImport(file, specifier);
|
||||
if (!resolved) return [];
|
||||
const marker = "/features/housekeeping/domains/";
|
||||
const offset = resolved.indexOf(marker);
|
||||
if (offset < 0) return [];
|
||||
const target = resolved.slice(offset + marker.length).split("/")[0];
|
||||
return target && target !== owner
|
||||
? [`${displayPath(file)} -> ${specifier}`]
|
||||
: [];
|
||||
},
|
||||
);
|
||||
})
|
||||
.sort();
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user