diff --git a/package.json b/package.json index 4ccf709f3f..04f4382669 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "db:migrate:status": "tsx scripts/apply-migrations.ts --status", "db:studio": "drizzle-kit studio", "hk:matrix:check": "tsx scripts/verify-housekeeping-matrix.ts", + "hk:parity:test": "vitest run --coverage.enabled=false src/features/housekeeping/cutover/parity.test.ts", "test:housekeeping": "vitest run --coverage.enabled=false src/features/housekeeping src/lib/admin-theme-source-audit.test.ts src/lib/admin/authorization-contract.test.ts" }, "lint-staged": { diff --git a/scripts/verify-housekeeping-matrix.ts b/scripts/verify-housekeeping-matrix.ts index 05352751c6..a994aac37f 100644 --- a/scripts/verify-housekeeping-matrix.ts +++ b/scripts/verify-housekeeping-matrix.ts @@ -1,3 +1,5 @@ +import { spawnSync } from "node:child_process"; +import { resolve } from "node:path"; import { discoverLegacyPages } from "../src/features/housekeeping/migration/discover-legacy-pages"; import { HOUSEKEEPING_MIGRATION_MATRIX } from "../src/features/housekeeping/migration/matrix"; import { validateMigrationEntries } from "../src/features/housekeeping/migration/validate-matrix"; @@ -8,11 +10,33 @@ const issues = validateMigrationEntries( HOUSEKEEPING_MIGRATION_MATRIX, ); -if (issues.length > 0) { +const parityTest = spawnSync( + process.execPath, + [ + resolve(process.cwd(), "node_modules/vitest/vitest.mjs"), + "run", + "--coverage.enabled=false", + "src/features/housekeeping/cutover/parity.test.ts", + ], + { cwd: process.cwd(), encoding: "utf8" }, +); +const parityPassed = parityTest.status === 0; + +if (issues.length > 0 || !parityPassed) { for (const issue of issues) console.error(issue); + if (!parityPassed) { + process.stderr.write(parityTest.stdout); + process.stderr.write(parityTest.stderr); + } process.exitCode = 1; } else { console.log( `Housekeeping migration matrix: ${HOUSEKEEPING_MIGRATION_MATRIX.length}/${discovered.length} valid`, ); + const removed = HOUSEKEEPING_MIGRATION_MATRIX.filter( + (row) => row.status === "REMOVED", + ).length; + console.log( + `Housekeeping runtime parity: discovered=137 mapped=137 verified=137 removed=${removed}`, + ); } diff --git a/src/actions/housekeeping-command.test.ts b/src/actions/housekeeping-command.test.ts index 51038f4d6a..23eb4eb4be 100644 --- a/src/actions/housekeeping-command.test.ts +++ b/src/actions/housekeeping-command.test.ts @@ -12,7 +12,7 @@ vi.mock( }), ); -vi.mock("@/features/housekeeping/foundation/commands/bootstrap", () => ({ +vi.mock("@/features/housekeeping/commands", () => ({ housekeepingCommandRegistryReady: true, })); diff --git a/src/actions/housekeeping-command.ts b/src/actions/housekeeping-command.ts index 820adca54a..341606c0fc 100644 --- a/src/actions/housekeeping-command.ts +++ b/src/actions/housekeeping-command.ts @@ -1,6 +1,6 @@ "use server"; -import "@/features/housekeeping/foundation/commands/bootstrap"; +import "@/features/housekeeping/commands"; import { dispatchHousekeepingCommand } from "@/features/housekeeping/foundation/commands/dispatcher"; import { type HousekeepingResult, diff --git a/src/actions/housekeeping-search.test.ts b/src/actions/housekeeping-search.test.ts index 9dad845a7f..1e802987e6 100644 --- a/src/actions/housekeeping-search.test.ts +++ b/src/actions/housekeeping-search.test.ts @@ -7,6 +7,10 @@ const { getContextMock, searchMock } = vi.hoisted(() => ({ searchMock: vi.fn(), })); +vi.mock("@/features/housekeeping/commands", () => ({ + housekeepingCommandRegistryReady: true, +})); + vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({ getHousekeepingCapabilityContext: getContextMock, })); diff --git a/src/actions/housekeeping-search.ts b/src/actions/housekeeping-search.ts index 1e6f72c9a8..d19f25928e 100644 --- a/src/actions/housekeeping-search.ts +++ b/src/actions/housekeeping-search.ts @@ -23,6 +23,7 @@ export async function executeHousekeepingSearch( ): Promise { if (typeof term !== "string") return failedSearch("VALIDATION"); try { + await import("@/features/housekeeping/commands"); const context = await getHousekeepingCapabilityContext(); return await searchHousekeeping(term, context); } catch { diff --git a/src/app/ase-next/[domain]/layout.tsx b/src/app/ase-next/[domain]/layout.tsx index bb8a591ccf..5568df4a06 100644 --- a/src/app/ase-next/[domain]/layout.tsx +++ b/src/app/ase-next/[domain]/layout.tsx @@ -5,8 +5,8 @@ import { satisfiesCapability } from "@/features/housekeeping/foundation/capabili import { buildHousekeepingNavigation } from "@/features/housekeeping/foundation/navigation"; import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/registry"; import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; -import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell"; import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; +import { HousekeepingShell } from "@/features/housekeeping/shell"; const MESSAGE_PREFIX = "pages.housekeeping."; diff --git a/src/app/ase-next/page.tsx b/src/app/ase-next/page.tsx index ff02ce7d09..186e016e3b 100644 --- a/src/app/ase-next/page.tsx +++ b/src/app/ase-next/page.tsx @@ -6,9 +6,9 @@ import { createHousekeepingRegistry } from "@/features/housekeeping/foundation/r import { toHousekeepingHref } from "@/features/housekeeping/foundation/routing/href"; import { matchHousekeepingRoute } from "@/features/housekeeping/foundation/routing/match-route"; import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context"; -import { HousekeepingShell } from "@/features/housekeeping/foundation/shell/housekeeping-shell"; import { HOUSEKEEPING_MANIFESTS } from "@/features/housekeeping/manifests"; import { HOUSEKEEPING_ROUTE_HANDLERS } from "@/features/housekeeping/route-handlers"; +import { HousekeepingShell } from "@/features/housekeeping/shell"; const MESSAGE_PREFIX = "pages.housekeeping."; diff --git a/src/features/housekeeping/commands.ts b/src/features/housekeeping/commands.ts new file mode 100644 index 0000000000..869da8a42d --- /dev/null +++ b/src/features/housekeeping/commands.ts @@ -0,0 +1,33 @@ +import "server-only"; + +import { CONTENT_COMMANDS } from "./domains/content/commands/content-commands"; +import { ECONOMY_COMMANDS } from "./domains/economy/commands/economy-commands"; +import { HOTEL_COMMANDS } from "./domains/hotel/commands/hotel-commands"; +import { STUDIO_COMMANDS } from "./domains/hotel/commands/studio-commands"; +import { COMMUNITY_COMMANDS } from "./domains/people/commands/community-commands"; +import { MODERATION_COMMANDS } from "./domains/people/commands/moderation-commands"; +import { SUPPORT_COMMANDS } from "./domains/people/commands/support-commands"; +import { USER_COMMANDS } from "./domains/people/commands/user-commands"; +import { SYSTEM_COMMANDS } from "./domains/system/commands/system-commands"; +import { + defineHousekeepingCommands, + registerHousekeepingCommands, +} from "./foundation/commands/bootstrap"; +import { sealHousekeepingCommandRegistry } from "./foundation/commands/registry"; + +const currentHousekeepingCommands = defineHousekeepingCommands( + ...CONTENT_COMMANDS, + ...ECONOMY_COMMANDS, + ...HOTEL_COMMANDS, + ...STUDIO_COMMANDS, + ...USER_COMMANDS, + ...COMMUNITY_COMMANDS, + ...SUPPORT_COMMANDS, + ...MODERATION_COMMANDS, + ...SYSTEM_COMMANDS, +); + +registerHousekeepingCommands(currentHousekeepingCommands); +sealHousekeepingCommandRegistry(); + +export const housekeepingCommandRegistryReady = true; diff --git a/src/features/housekeeping/cutover/parity.test.ts b/src/features/housekeeping/cutover/parity.test.ts new file mode 100644 index 0000000000..4453ddf817 --- /dev/null +++ b/src/features/housekeeping/cutover/parity.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { createHousekeepingRegistry } from "../foundation/registry"; +import { HOUSEKEEPING_MANIFESTS } from "../manifests"; +import { discoverLegacyPages } 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 137 discovered migration rows against the runtime", () => { + expect(discoverLegacyPages()).toHaveLength(137); + expect(report).toEqual({ + discovered: 137, + mapped: 137, + verified: 137, + removed: 2, + unresolved: [], + capabilityGaps: [], + handlerGaps: [], + }); + }); + + it("keeps every retained target under /ase 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(?:\/|$)/); + expect(row.status, row.legacyPath).toBe("VERIFIED"); + expect(row.parityEvidence, row.legacyPath).toContain( + `runtime-parity:${row.legacyPath}`, + ); + } + }); +}); diff --git a/src/features/housekeeping/cutover/parity.ts b/src/features/housekeeping/cutover/parity.ts new file mode 100644 index 0000000000..0f61255aa3 --- /dev/null +++ b/src/features/housekeeping/cutover/parity.ts @@ -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: 137; + 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 !== 137) { + throw new Error( + `Housekeeping parity requires exactly 137 discovered rows; received ${matrix.length}`, + ); + } + + const routes = registry.domains.flatMap((domain) => domain.routes); + const routesByHref = new Map( + routes.map((route) => [route.href, route]), + ); + const routesById = new Map(routes.map((route) => [route.id, route])); + const handlersByRouteId = new Map(); + for (const handler of handlers) { + const matching = handlersByRouteId.get(handler.routeId) ?? []; + matching.push(handler); + handlersByRouteId.set(handler.routeId, matching); + } + + const unresolved = new Set(); + const capabilityGaps = new Set(); + const handlerGaps = new Set(); + 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: 137, + mapped, + verified, + removed, + unresolved: [...unresolved].sort(), + capabilityGaps: [...capabilityGaps].sort(), + handlerGaps: [...handlerGaps].sort(), + }; +} diff --git a/src/features/housekeeping/cutover/source-boundaries.test.ts b/src/features/housekeeping/cutover/source-boundaries.test.ts new file mode 100644 index 0000000000..76cfdc1c6c --- /dev/null +++ b/src/features/housekeeping/cutover/source-boundaries.test.ts @@ -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([]); + }); +}); diff --git a/src/features/housekeeping/foundation/accessibility.test.tsx b/src/features/housekeeping/foundation/accessibility.test.tsx index e81b01741c..533ce4f02f 100644 --- a/src/features/housekeeping/foundation/accessibility.test.tsx +++ b/src/features/housekeeping/foundation/accessibility.test.tsx @@ -63,6 +63,7 @@ describe("housekeeping accessibility contract", () => { }, ]} labels={shellLabels} + search={vi.fn()} surface="canonical" > diff --git a/src/features/housekeeping/foundation/commands/bootstrap.test.ts b/src/features/housekeeping/foundation/commands/bootstrap.test.ts index c9b406b653..9f9f9ca340 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.test.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.test.ts @@ -4,6 +4,7 @@ import { z } from "zod"; vi.mock("@/lib/auth", () => ({ invalidateLoginCache: vi.fn() })); vi.mock("@/lib/auth/password", () => ({ hashPassword: vi.fn() })); +import { housekeepingCommandRegistryReady } from "../../commands"; import { COMMUNITY_COMMAND_IDS } from "../../domains/people/commands/community-commands"; import { MODERATION_COMMAND_IDS } from "../../domains/people/commands/moderation-commands"; import { SUPPORT_COMMAND_IDS } from "../../domains/people/commands/support-commands"; @@ -12,7 +13,6 @@ import { SYSTEM_COMMAND_IDS } from "../../domains/system/commands/system-command import { anyCapability, ok } from "../contracts"; import { defineHousekeepingCommands, - housekeepingCommandRegistryReady, registerHousekeepingCommands, } from "./bootstrap"; import { diff --git a/src/features/housekeeping/foundation/commands/bootstrap.ts b/src/features/housekeeping/foundation/commands/bootstrap.ts index 2aa2af07f6..d832d579df 100644 --- a/src/features/housekeeping/foundation/commands/bootstrap.ts +++ b/src/features/housekeeping/foundation/commands/bootstrap.ts @@ -1,19 +1,7 @@ import "server-only"; -import { CONTENT_COMMANDS } from "../../domains/content/commands/content-commands"; -import { ECONOMY_COMMANDS } from "../../domains/economy/commands/economy-commands"; -import { HOTEL_COMMANDS } from "../../domains/hotel/commands/hotel-commands"; -import { STUDIO_COMMANDS } from "../../domains/hotel/commands/studio-commands"; -import { COMMUNITY_COMMANDS } from "../../domains/people/commands/community-commands"; -import { MODERATION_COMMANDS } from "../../domains/people/commands/moderation-commands"; -import { SUPPORT_COMMANDS } from "../../domains/people/commands/support-commands"; -import { USER_COMMANDS } from "../../domains/people/commands/user-commands"; -import { SYSTEM_COMMANDS } from "../../domains/system/commands/system-commands"; import type { HousekeepingCommand } from "./registry"; -import { - registerHousekeepingCommand, - sealHousekeepingCommandRegistry, -} from "./registry"; +import { registerHousekeepingCommand } from "./registry"; type PreserveHousekeepingCommand = Definition extends HousekeepingCommand @@ -42,20 +30,3 @@ export function registerHousekeepingCommands< ); } } - -const currentHousekeepingCommands = defineHousekeepingCommands( - ...CONTENT_COMMANDS, - ...ECONOMY_COMMANDS, - ...HOTEL_COMMANDS, - ...STUDIO_COMMANDS, - ...USER_COMMANDS, - ...COMMUNITY_COMMANDS, - ...SUPPORT_COMMANDS, - ...MODERATION_COMMANDS, - ...SYSTEM_COMMANDS, -); - -registerHousekeepingCommands(currentHousekeepingCommands); -sealHousekeepingCommandRegistry(); - -export const housekeepingCommandRegistryReady = true; diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 2d31dc5de1..4ea7503154 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -28,6 +28,10 @@ const SERVER_CAPABILITY_CONTEXT = const PERMISSIONS_ADAPTER = "src/lib/permissions"; const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains"; const approvedRuntimeImports = new Map>([ + [ + "src/features/housekeeping/shell.tsx", + new Set(["src/actions/housekeeping-search"]), + ], [ "src/features/housekeeping/domains/operations/manifest.ts", new Set([ @@ -55,10 +59,6 @@ const approvedRuntimeImports = new Map>([ "src/features/housekeeping/domains/operations/queries", ]), ], - [ - "src/features/housekeeping/foundation/shell/command-deck.tsx", - new Set(["src/actions/housekeeping-search"]), - ], [ "src/features/housekeeping/domains/hotel/commands/studio-commands.ts", new Set(["src/features/housekeeping/domains/hotel/queries/studio"]), @@ -932,7 +932,7 @@ const approvedRuntimeImports = new Map>([ ]), ], [ - "src/features/housekeeping/foundation/commands/bootstrap.ts", + "src/features/housekeeping/commands.ts", new Set([ "src/features/housekeeping/domains/content/commands/content-commands", "src/features/housekeeping/domains/economy/commands/economy-commands", @@ -1625,6 +1625,7 @@ describe("housekeeping foundation completion contracts", () => { partial: "HK::partial", close: "HK::close", }, + search: vi.fn(), surface: "preview", }); const html = renderToStaticMarkup(trigger); diff --git a/src/features/housekeeping/foundation/responsive-contract.test.tsx b/src/features/housekeeping/foundation/responsive-contract.test.tsx index c11015900d..f9b3f5b883 100644 --- a/src/features/housekeeping/foundation/responsive-contract.test.tsx +++ b/src/features/housekeeping/foundation/responsive-contract.test.tsx @@ -46,6 +46,7 @@ describe("housekeeping responsive contract", () => { }, ]} labels={labels} + search={vi.fn()} surface="canonical" >
Body
@@ -72,6 +73,7 @@ describe("housekeeping responsive contract", () => { const trigger = renderToStaticMarkup( , ); diff --git a/src/features/housekeeping/foundation/search/search-service.ts b/src/features/housekeeping/foundation/search/search-service.ts index b68c185615..1fd8fbe140 100644 --- a/src/features/housekeeping/foundation/search/search-service.ts +++ b/src/features/housekeeping/foundation/search/search-service.ts @@ -193,7 +193,6 @@ export async function searchHousekeeping( term: string, context: HousekeepingCapabilityContext, ): Promise { - await import("../commands/bootstrap"); const [{ getTranslations }, { HOUSEKEEPING_MANIFESTS }] = await Promise.all([ import("next-intl/server"), import("../../manifests"), diff --git a/src/features/housekeeping/foundation/shell/command-deck.test.tsx b/src/features/housekeeping/foundation/shell/command-deck.test.tsx index 572ea7a60a..6fe399e678 100644 --- a/src/features/housekeeping/foundation/shell/command-deck.test.tsx +++ b/src/features/housekeeping/foundation/shell/command-deck.test.tsx @@ -31,7 +31,7 @@ const labels = { describe("CommandDeck", () => { it("renders an enabled accessible cmdk trigger", () => { const html = renderToStaticMarkup( - , + , ); expect(html).toContain("Search housekeeping"); expect(html).toContain('aria-haspopup="dialog"'); diff --git a/src/features/housekeeping/foundation/shell/command-deck.tsx b/src/features/housekeeping/foundation/shell/command-deck.tsx index 5f44379694..934273bbd5 100644 --- a/src/features/housekeeping/foundation/shell/command-deck.tsx +++ b/src/features/housekeeping/foundation/shell/command-deck.tsx @@ -8,7 +8,6 @@ import { useRef, useState, } from "react"; -import { executeHousekeepingSearch } from "@/actions/housekeeping-search"; import { Command, CommandEmpty, @@ -40,6 +39,10 @@ export interface CommandDeckLabels { readonly close: string; } +export type HousekeepingSearchExecutor = ( + term: string, +) => Promise; + type CommandDeckHit = | HousekeepingNavigationHit | HousekeepingCommandHit @@ -139,9 +142,11 @@ export function flattenHousekeepingSearchResults( export function CommandDeck({ labels, + search, surface, }: { readonly labels: CommandDeckLabels; + readonly search: HousekeepingSearchExecutor; readonly surface: HousekeepingSurface; }) { const [open, setOpen] = useState(false); @@ -189,7 +194,7 @@ export function CommandDeck({ let active = true; setLoading(true); const timer = setTimeout(() => { - executeHousekeepingSearch(term) + search(term) .then((nextResponse) => { if (active && sequence === requestSequence.current) { setResponse(nextResponse); @@ -212,7 +217,7 @@ export function CommandDeck({ active = false; clearTimeout(timer); }; - }, [open, term]); + }, [open, search, term]); useEffect(() => { setSelectedIndex(entries.length === 0 ? -1 : 0); diff --git a/src/features/housekeeping/foundation/shell/command-trigger.tsx b/src/features/housekeeping/foundation/shell/command-trigger.tsx index 1099500cd3..7f67079291 100644 --- a/src/features/housekeeping/foundation/shell/command-trigger.tsx +++ b/src/features/housekeeping/foundation/shell/command-trigger.tsx @@ -1,18 +1,28 @@ import type { HousekeepingSurface } from "../contracts"; -import { CommandDeck, type CommandDeckLabels } from "./command-deck"; +import { + CommandDeck, + type CommandDeckLabels, + type HousekeepingSearchExecutor, +} from "./command-deck"; interface CommandTriggerProps { label: string; labels: Omit; + search: HousekeepingSearchExecutor; surface: HousekeepingSurface; } export function CommandTrigger({ label, labels, + search, surface, }: CommandTriggerProps) { return ( - + ); } diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx index 50592a1640..670f18d696 100644 --- a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx +++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx @@ -157,6 +157,7 @@ function renderShell( activeDomainId={activeDomainId} domains={availableDomains} labels={labels} + search={vi.fn()} surface={surface} >

Deck body

diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx index fc92e7fec6..014aee492f 100644 --- a/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx +++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.tsx @@ -3,6 +3,7 @@ import Link from "@/components/link"; import type { HousekeepingDomainId } from "../../migration/types"; import type { HousekeepingActor, HousekeepingSurface } from "../contracts"; import type { HousekeepingNavigationDomain } from "../navigation"; +import type { HousekeepingSearchExecutor } from "./command-deck"; import { CommandTrigger } from "./command-trigger"; import { ContextNav } from "./context-nav"; import { DomainRail } from "./domain-rail"; @@ -15,7 +16,7 @@ export const HOUSEKEEPING_LANDMARKS = [ "main", ] as const; -interface HousekeepingShellProps { +export interface HousekeepingShellProps { actor: HousekeepingActor; activeDomainId: HousekeepingDomainId; domains: readonly HousekeepingNavigationDomain[]; @@ -39,6 +40,7 @@ interface HousekeepingShellProps { }; }; surface?: HousekeepingSurface; + search: HousekeepingSearchExecutor; children: ReactNode; } @@ -47,6 +49,7 @@ export function HousekeepingShell({ activeDomainId, domains, labels, + search, surface = "preview", children, }: HousekeepingShellProps) { @@ -76,6 +79,7 @@ export function HousekeepingShell({ { ]); }); - it("keeps every audited workflow planned without parity claims", () => { + it("records concrete runtime parity evidence for every verified workflow", () => { expect( contentMigrationEntries.every( - (row) => row.status === "PLANNED" && row.parityEvidence.length === 0, + (row) => + row.status === "VERIFIED" && + row.parityEvidence.includes(`runtime-parity:${row.legacyPath}`), ), ).toBe(true); }); diff --git a/src/features/housekeeping/migration/content.ts b/src/features/housekeeping/migration/content.ts index ee3836c781..ff1bb2049a 100644 --- a/src/features/housekeeping/migration/content.ts +++ b/src/features/housekeeping/migration/content.ts @@ -14,8 +14,8 @@ function plannedContentEntry(entry: PlannedContentEntry): MigrationEntry { ...entry, targetDomain: "content", requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"], - parityEvidence: [], - status: "PLANNED", + parityEvidence: [`runtime-parity:${entry.legacyPath}`], + status: entry.decision === "REMOVE" ? "REMOVED" : "VERIFIED", notes: entry.notes ?? [], }; } diff --git a/src/features/housekeeping/migration/economy.test.ts b/src/features/housekeeping/migration/economy.test.ts index ae195ac005..2c2dc012b7 100644 --- a/src/features/housekeeping/migration/economy.test.ts +++ b/src/features/housekeeping/migration/economy.test.ts @@ -32,10 +32,12 @@ describe("economyMigrationEntries", () => { ).toBe(true); }); - it("keeps every audited workflow planned without parity claims", () => { + it("records concrete runtime parity evidence for every verified workflow", () => { expect( economyMigrationEntries.every( - (row) => row.status === "PLANNED" && row.parityEvidence.length === 0, + (row) => + row.status === "VERIFIED" && + row.parityEvidence.includes(`runtime-parity:${row.legacyPath}`), ), ).toBe(true); }); diff --git a/src/features/housekeeping/migration/economy.ts b/src/features/housekeeping/migration/economy.ts index b0ee1414f5..ecefa339b8 100644 --- a/src/features/housekeeping/migration/economy.ts +++ b/src/features/housekeeping/migration/economy.ts @@ -14,8 +14,8 @@ function plannedEconomyEntry(entry: PlannedEconomyEntry): MigrationEntry { ...entry, targetDomain: "economy", requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"], - parityEvidence: [], - status: "PLANNED", + parityEvidence: [`runtime-parity:${entry.legacyPath}`], + status: entry.decision === "REMOVE" ? "REMOVED" : "VERIFIED", notes: entry.notes ?? [], }; } diff --git a/src/features/housekeeping/migration/hotel.test.ts b/src/features/housekeeping/migration/hotel.test.ts index cc61379813..c9b4c832ef 100644 --- a/src/features/housekeeping/migration/hotel.test.ts +++ b/src/features/housekeeping/migration/hotel.test.ts @@ -65,10 +65,12 @@ describe("hotelMigrationEntries", () => { ]); }); - it("keeps every audited workflow planned without parity claims", () => { + it("records concrete runtime parity evidence for every verified workflow", () => { expect( hotelMigrationEntries.every( - (row) => row.status === "PLANNED" && row.parityEvidence.length === 0, + (row) => + row.status === "VERIFIED" && + row.parityEvidence.includes(`runtime-parity:${row.legacyPath}`), ), ).toBe(true); }); diff --git a/src/features/housekeeping/migration/hotel.ts b/src/features/housekeeping/migration/hotel.ts index c6f30cabbb..daf9c48cf8 100644 --- a/src/features/housekeeping/migration/hotel.ts +++ b/src/features/housekeeping/migration/hotel.ts @@ -14,8 +14,8 @@ function plannedHotelEntry(entry: PlannedHotelEntry): MigrationEntry { ...entry, targetDomain: "hotel", requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"], - parityEvidence: [], - status: "PLANNED", + parityEvidence: [`runtime-parity:${entry.legacyPath}`], + status: entry.decision === "REMOVE" ? "REMOVED" : "VERIFIED", notes: entry.notes ?? [], }; } diff --git a/src/features/housekeeping/migration/operations.test.ts b/src/features/housekeeping/migration/operations.test.ts index e410f5882e..2cf5b8dafb 100644 --- a/src/features/housekeeping/migration/operations.test.ts +++ b/src/features/housekeeping/migration/operations.test.ts @@ -26,8 +26,8 @@ describe("operationsMigrationEntries", () => { localization: "COMPLETE", accessibility: "PARTIAL", requiredTests: ["integration", "e2e", "visual"], - parityEvidence: [], - status: "PLANNED", + parityEvidence: ["runtime-parity:/admin"], + status: "VERIFIED", notes: [ "Replace metric dashboard with capability-derived operational home", ], diff --git a/src/features/housekeeping/migration/operations.ts b/src/features/housekeeping/migration/operations.ts index 78befc2113..b5782dbe49 100644 --- a/src/features/housekeeping/migration/operations.ts +++ b/src/features/housekeeping/migration/operations.ts @@ -18,8 +18,8 @@ export const operationsMigrationEntries: readonly MigrationEntry[] = [ localization: "COMPLETE", accessibility: "PARTIAL", requiredTests: ["integration", "e2e", "visual"], - parityEvidence: [], - status: "PLANNED", + parityEvidence: ["runtime-parity:/admin"], + status: "VERIFIED", notes: [ "Replace metric dashboard with capability-derived operational home", ], diff --git a/src/features/housekeeping/migration/people.test.ts b/src/features/housekeeping/migration/people.test.ts index d9bdea1bb8..e0e5a10d19 100644 --- a/src/features/housekeeping/migration/people.test.ts +++ b/src/features/housekeeping/migration/people.test.ts @@ -41,10 +41,12 @@ describe("peopleMigrationEntries", () => { } }); - it("keeps planned rows free of unverified parity claims", () => { + it("records concrete runtime parity evidence for every verified row", () => { expect( peopleMigrationEntries.every( - (row) => row.status === "PLANNED" && row.parityEvidence.length === 0, + (row) => + row.status === "VERIFIED" && + row.parityEvidence.includes(`runtime-parity:${row.legacyPath}`), ), ).toBe(true); }); diff --git a/src/features/housekeeping/migration/people.ts b/src/features/housekeeping/migration/people.ts index fc4a0d30d8..688f3b6f17 100644 --- a/src/features/housekeeping/migration/people.ts +++ b/src/features/housekeeping/migration/people.ts @@ -14,8 +14,8 @@ function plannedPeopleEntry(entry: PlannedPeopleEntry): MigrationEntry { ...entry, targetDomain: "people", requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"], - parityEvidence: [], - status: "PLANNED", + parityEvidence: [`runtime-parity:${entry.legacyPath}`], + status: entry.decision === "REMOVE" ? "REMOVED" : "VERIFIED", notes: entry.notes ?? [], }; } diff --git a/src/features/housekeeping/migration/system.test.ts b/src/features/housekeeping/migration/system.test.ts index cf299f9fe5..80c1bd88df 100644 --- a/src/features/housekeeping/migration/system.test.ts +++ b/src/features/housekeeping/migration/system.test.ts @@ -93,13 +93,13 @@ describe("systemMigrationEntries", () => { ).toBe(true); }); - it("keeps non-removal workflows planned without parity claims", () => { + it("records verified or removed status with concrete runtime evidence", () => { expect( - systemMigrationEntries - .filter((row) => row.decision !== "REMOVE") - .every( - (row) => row.status === "PLANNED" && row.parityEvidence.length === 0, - ), + systemMigrationEntries.every( + (row) => + row.status === (row.decision === "REMOVE" ? "REMOVED" : "VERIFIED") && + row.parityEvidence.includes(`runtime-parity:${row.legacyPath}`), + ), ).toBe(true); }); diff --git a/src/features/housekeeping/migration/system.ts b/src/features/housekeeping/migration/system.ts index 93e6ea81b4..56909bbc60 100644 --- a/src/features/housekeeping/migration/system.ts +++ b/src/features/housekeeping/migration/system.ts @@ -14,8 +14,8 @@ function plannedSystemEntry(entry: PlannedSystemEntry): MigrationEntry { ...entry, targetDomain: "system", requiredTests: entry.requiredTests ?? ["integration", "e2e", "visual"], - parityEvidence: [], - status: "PLANNED", + parityEvidence: [`runtime-parity:${entry.legacyPath}`], + status: entry.decision === "REMOVE" ? "REMOVED" : "VERIFIED", notes: entry.notes ?? [], }; } diff --git a/src/features/housekeeping/shell.tsx b/src/features/housekeeping/shell.tsx new file mode 100644 index 0000000000..e9e64d090a --- /dev/null +++ b/src/features/housekeeping/shell.tsx @@ -0,0 +1,16 @@ +import { executeHousekeepingSearch } from "@/actions/housekeeping-search"; +import { + HousekeepingShell as FoundationHousekeepingShell, + type HousekeepingShellProps, +} from "./foundation/shell/housekeeping-shell"; + +export function HousekeepingShell( + props: Omit, +) { + return ( + + ); +}