fix: close housekeeping foundation review findings

This commit is contained in:
Simo committed 2026-08-26 19:16:34 +02:00
1 parent ebc263da35
commit 08f8d54888
21 files changed
+593 -46

No files matched your search

@@ -17,6 +17,17 @@ export const contentManifest = {
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PREFIXES_VIEW,
PERMS.NEWS_EDIT,
PERMS.PAGES_EDIT,
PERMS.BANNERS_EDIT,
PERMS.EVENTS_EDIT,
PERMS.POLLS_EDIT,
PERMS.PREFIXES_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -10,6 +10,14 @@ export const economyManifest = {
descriptionKey: "pages.housekeeping.domains.economy.description",
iconId: "gem",
previewHref: "/admin-next/economy",
capability: anyCapability(PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW),
capability: anyCapability(
PERMS.CATALOG_VIEW,
PERMS.SHOP_VIEW,
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -14,6 +14,14 @@ export const hotelManifest = {
PERMS.ROOMS_VIEW,
PERMS.RADIO_VIEW,
PERMS.ASSETS_IMPORT,
PERMS.ROOMS_EDIT,
PERMS.ROOMS_DELETE,
PERMS.RADIO_EDIT,
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -12,4 +12,7 @@ export const operationsManifest = {
previewHref: "/admin-next/operations",
capability: anyCapability(PERMS.ADMIN_DASHBOARD),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -21,6 +21,21 @@ export const peopleManifest = {
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.MOD_BANS_VIEW,
PERMS.USERS_EDIT,
PERMS.USERS_BAN,
PERMS.USERS_RESET_PASSWORD,
PERMS.MODERATION_EDIT,
PERMS.TICKETS_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
PERMS.WORDFILTER_VIEW,
PERMS.WORDFILTER_EDIT,
PERMS.MOD_ACTIONS,
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -18,6 +18,11 @@ export const systemManifest = {
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
PERMS.SETTINGS_EDIT,
PERMS.NOTIFICATIONS_EDIT,
),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest;
@@ -40,6 +40,9 @@ const searchResult = {
} satisfies HousekeepingSearchResult;
const searchProvider: HousekeepingSearchProvider = {
id: "people.users",
owner: "people",
capability,
search: async () => ok([searchResult], "search-1"),
};
@@ -49,10 +52,17 @@ const inboxSourceResult = {
} satisfies HousekeepingInboxSourceResult;
const inboxSource: HousekeepingInboxSource = {
id: "people.tickets",
owner: "people",
capability,
getItems: async () => ok(inboxSourceResult, "inbox-1"),
};
const widget: HousekeepingWidgetDefinition = {
id: "people.queue",
owner: "people",
capability,
kind: "mandatory",
load: async () => ok({ count: 2 }, "widget-1"),
};
@@ -80,6 +90,9 @@ const manifest: HousekeepingDomainManifest = {
previewHref: "/admin-next/people",
capability,
routes: [],
searchProviders: [searchProvider],
inboxSources: [inboxSource],
widgets: [widget],
};
describe("housekeeping foundation contracts", () => {
@@ -118,4 +131,23 @@ describe("housekeeping foundation contracts", () => {
requirements,
]).toHaveLength(8);
});
it("carries stable ownership and capability metadata for registry entries", () => {
expect(searchProvider).toMatchObject({
id: "people.users",
owner: "people",
capability,
});
expect(inboxSource).toMatchObject({
id: "people.tickets",
owner: "people",
capability,
});
expect(widget).toMatchObject({
id: "people.queue",
owner: "people",
capability,
kind: "mandatory",
});
});
});
@@ -1,5 +1,8 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type { CapabilityRequirement } from "./capability";
import type { HousekeepingInboxSource } from "./inbox";
import type { HousekeepingSearchProvider } from "./search";
import type { HousekeepingWidgetDefinition } from "./widget";
export interface HousekeepingRouteDefinition {
id: string;
@@ -17,4 +20,7 @@ export interface HousekeepingDomainManifest {
previewHref: `/admin-next/${HousekeepingDomainId}`;
capability: CapabilityRequirement;
routes: readonly HousekeepingRouteDefinition[];
searchProviders: readonly HousekeepingSearchProvider[];
inboxSources: readonly HousekeepingInboxSource[];
widgets: readonly HousekeepingWidgetDefinition[];
}
@@ -29,6 +29,9 @@ export interface HousekeepingInboxSourceResult {
}
export interface HousekeepingInboxSource {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
getItems(
context: HousekeepingCapabilityContext,
signal: AbortSignal,
@@ -30,4 +30,7 @@ export type {
HousekeepingSearchProvider,
HousekeepingSearchResult,
} from "./search";
export type { HousekeepingWidgetDefinition } from "./widget";
export type {
HousekeepingWidgetDefinition,
HousekeepingWidgetKind,
} from "./widget";
@@ -1,5 +1,8 @@
import type { HousekeepingDomainId } from "../../migration/types";
import type { HousekeepingCapabilityContext } from "./capability";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export interface HousekeepingSearchInput {
@@ -15,6 +18,9 @@ export interface HousekeepingSearchResult {
}
export interface HousekeepingSearchProvider {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
search(
context: HousekeepingCapabilityContext,
input: HousekeepingSearchInput,
@@ -1,7 +1,17 @@
import type { HousekeepingCapabilityContext } from "./capability";
import type { HousekeepingDomainId } from "../../migration/types";
import type {
CapabilityRequirement,
HousekeepingCapabilityContext,
} from "./capability";
import type { HousekeepingResult } from "./result";
export type HousekeepingWidgetKind = "mandatory" | "optional";
export interface HousekeepingWidgetDefinition {
id: string;
owner: HousekeepingDomainId;
capability: CapabilityRequirement;
kind: HousekeepingWidgetKind;
load(
context: HousekeepingCapabilityContext,
): Promise<HousekeepingResult<unknown>>;
@@ -265,6 +265,8 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
}
} else if (value.type === "ImportExpression") {
recordArgument(value.source, "import");
} else if (value.type === "TSImportType") {
recordArgument(value.argument, "import");
} else if (
value.type === "CallExpression" ||
value.type === "OptionalCallExpression"
@@ -445,6 +447,12 @@ describe("housekeeping runtime import boundary", () => {
'import workflow from "../domains/people/workflow";',
"src/features/housekeeping/domains/people/workflow",
],
[
"TypeScript import type database access",
"src/features/housekeeping/foundation/registry.ts",
'type PrismaClient = import("@prisma/client").PrismaClient;',
"@prisma/client",
],
] as const)("detects %s", (_name, sourceFile, source, expectedPath) => {
expect(
findHousekeepingImportBoundaryViolations(source, sourceFile),
@@ -510,7 +518,7 @@ describe("housekeeping runtime import boundary", () => {
'import database from "@/lib/database";',
'import authentication from "@/lib/authentication";',
'import commandKit from "cmdkit";',
'import manifest from "../domains/people/manifest";',
'import manifest from "./domains/people/manifest";',
"const documentation = \"import db from '@/lib/db'\";",
'// import action from "@/actions/users";',
].join("\n");
@@ -26,6 +26,9 @@ describe("housekeeping navigation", () => {
previewHref: "/admin-next/people",
capability: anyCapability(PERMS.MOD_CFH_VIEW),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
{
id: "economy",
@@ -35,6 +38,9 @@ describe("housekeeping navigation", () => {
previewHref: "/admin-next/economy",
capability: anyCapability(PERMS.CATALOG_VIEW),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
]);
@@ -79,6 +85,9 @@ describe("housekeeping navigation", () => {
capability: anyCapability(PERMS.BANS_VIEW),
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
},
{
id: "system",
@@ -88,6 +97,9 @@ describe("housekeeping navigation", () => {
previewHref: "/admin-next/system",
capability: anyCapability(PERMS.SETTINGS_VIEW),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
},
]);
const translate = vi.fn((key: string) => `translated:${key}`);
@@ -118,7 +130,7 @@ describe("housekeeping navigation", () => {
expect(translate).not.toHaveBeenCalledWith("system.title");
expect(translate).not.toHaveBeenCalledWith("system.description");
});
it("shows the real People manifest only for its view capabilities", () => {
it("shows real domains to operators authorized by owned migration capabilities", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
const translate = (key: string) => key;
@@ -137,15 +149,21 @@ describe("housekeeping navigation", () => {
).map((domain) => domain.id),
).not.toContain("economy");
for (const slug of [PERMS.MOD_ACTIONS, PERMS.MOD_CFH_EDIT]) {
for (const [slug, expectedDomain] of [
[PERMS.MOD_ACTIONS, "people"],
[PERMS.USERS_EDIT, "people"],
[PERMS.SETTINGS_VIEW, "people"],
[PERMS.NEWS_EDIT, "content"],
[PERMS.SHOP_EDIT, "economy"],
[PERMS.ROOMS_EDIT, "hotel"],
] as const) {
const visibleDomainIds = buildHousekeepingNavigation(
registry,
context([slug]),
translate,
).map((domain) => domain.id);
expect(visibleDomainIds).not.toContain("people");
expect(visibleDomainIds).not.toContain("economy");
expect(visibleDomainIds, slug).toContain(expectedDomain);
}
});
});
@@ -1,5 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import en from "@/messages/en.json";
import itMessages from "@/messages/it.json";
import { HousekeepingPageShell } from "./housekeeping-page-shell";
import { HousekeepingPageState } from "./housekeeping-page-state";
@@ -10,20 +12,29 @@ describe("HousekeepingPageState", () => {
["partial", "status"],
["error", "alert"],
] as const)("renders %s with the %s role", (state, role) => {
const html = renderToStaticMarkup(
<HousekeepingPageState
state={state}
title={`${state} title`}
description={`${state} description`}
/>,
);
const pageState =
state === "partial" ? (
<HousekeepingPageState
state="partial"
partialLabel="Partial data"
title={`${state} title`}
description={`${state} description`}
/>
) : (
<HousekeepingPageState
state={state}
title={`${state} title`}
description={`${state} description`}
/>
);
const html = renderToStaticMarkup(pageState);
expect(html).toContain(`role="${role}"`);
expect(html).toContain(`>${state} title<`);
expect(html).toContain(`>${state} description<`);
});
it("announces loading politely and makes the partial warning visible", () => {
it("announces loading politely", () => {
const loading = renderToStaticMarkup(
<HousekeepingPageState
state="loading"
@@ -31,20 +42,33 @@ describe("HousekeepingPageState", () => {
description="Loading description"
/>,
);
const partial = renderToStaticMarkup(
<HousekeepingPageState
state="partial"
title="Partial title"
description="Partial description"
/>,
);
expect(loading).toContain('aria-live="polite"');
expect(partial).toContain("--admin-warning-border");
expect(partial).toContain("--admin-warning-subtle");
expect(partial).toContain(">Partial title<");
});
it.each([
["English", en.pages.housekeeping.states.partial],
["Italian", itMessages.pages.housekeeping.states.partial],
] as const)(
"preserves the partial-state heading and separate localized warning in %s",
(_locale, copy) => {
const html = renderToStaticMarkup(
<HousekeepingPageState
state="partial"
partialLabel={copy.label}
title={copy.title}
description={copy.description}
/>,
);
expect(html).toMatch(new RegExp(`<h2[^>]*>${copy.title}</h2>`));
expect(html).toContain(`>${copy.label}</p>`);
expect(html).toContain('data-state-tone="warning"');
expect(html).toContain("--admin-warning-border");
expect(html).toContain("--admin-warning-subtle");
},
);
it("renders supplied retry content for an error without owning a callback", () => {
const html = renderToStaticMarkup(
<HousekeepingPageState
@@ -1,14 +1,20 @@
import type { ReactNode } from "react";
interface HousekeepingPageStateProps {
state: "loading" | "empty" | "partial" | "error";
interface HousekeepingPageStateBaseProps {
title: string;
description: string;
retryAction?: ReactNode;
}
type HousekeepingPageStateProps = HousekeepingPageStateBaseProps &
(
| { state: "partial"; partialLabel: string }
| { state: "loading" | "empty" | "error"; partialLabel?: never }
);
export function HousekeepingPageState({
state,
partialLabel,
title,
description,
retryAction,
@@ -31,13 +37,12 @@ export function HousekeepingPageState({
{isPartial ? (
<p
data-state-tone="warning"
className="font-medium text-[var(--admin-warning)]"
className="mb-2 inline-flex rounded-full border border-[var(--admin-warning-border)] bg-[var(--admin-surface)] px-2 py-0.5 text-xs font-medium text-[var(--admin-warning)]"
>
{title}
{partialLabel}
</p>
) : (
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
)}
) : null}
<h2 className="font-medium text-[var(--admin-text)]">{title}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{description}
</p>
@@ -98,6 +98,9 @@ const forbiddenModuleRoots = [
"src/app/actions",
"src/app/admin",
"src/app/mod",
"@prisma/client",
"drizzle-orm",
"mysql2",
] as const;
interface BabelNode {
@@ -287,10 +290,6 @@ function canonicalizeLocalSpecifier(
const suffixIndex = specifier.search(/[?#]/);
const withoutSuffix =
suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
const slashNormalized = withoutSuffix.replaceAll("\\", "/");
if (!slashNormalized.startsWith("@/") && !slashNormalized.startsWith(".")) {
return { candidates: [], violation: null };
}
let decoded: string;
try {
@@ -305,12 +304,15 @@ function canonicalizeLocalSpecifier(
} else if (decoded.startsWith(".")) {
normalized = posix.normalize(posix.join(posix.dirname(routeFile), decoded));
} else {
return { candidates: [], violation: "<malformed local specifier>" };
normalized = posix.normalize(decoded);
}
const extensionless = normalized.replace(resolverExtensionPattern, "");
return {
candidates: [...new Set([normalized, extensionless])],
candidates:
decoded.startsWith("@/") || decoded.startsWith(".")
? [...new Set([normalized, extensionless])]
: [normalized],
violation: null,
};
}
@@ -785,6 +787,24 @@ describe("preview route import boundary", () => {
'import("../mod/users/page")',
"src/app/mod/users/page",
],
[
"Prisma TypeScript import type",
"src/app/admin-next/page.tsx",
'type PrismaClient = import("@prisma/client").PrismaClient;',
"@prisma/client",
],
[
"Drizzle package import",
"src/app/admin-next/page.tsx",
'import { sql } from "drizzle-orm";',
"drizzle-orm",
],
[
"mysql2 package import",
"src/app/admin-next/page.tsx",
'import type { Pool } from "mysql2";',
"mysql2",
],
] as const)("detects %s", (_name, routeFile, source, expectedPath) => {
expect(findRouteImportBoundaryViolations(source, routeFile)).toContain(
expectedPath,
@@ -824,6 +844,9 @@ describe("preview route import boundary", () => {
'import dbTools from "../../lib/db-tools.ts";',
'import auth from "../../lib/authentication";',
'import preview from "../admin-next-shared";',
'import prismaTools from "@prisma/client-tools";',
'import drizzleTools from "drizzle-orm-kit";',
'import mysqlTools from "mysql2-wrapper";',
"const documentation = \"import db from '../../lib/db'\";",
'const rawTemplate = `import("../../actions/users")`;',
'// import db from "../../lib/db";',
@@ -1,11 +1,20 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
import {
HOUSEKEEPING_DOMAIN_IDS,
type HousekeepingDomainId,
} from "../migration/types";
import { anyCapability, type HousekeepingDomainManifest } from "./contracts";
import {
anyCapability,
type CapabilityRequirement,
type HousekeepingDomainManifest,
type HousekeepingInboxSource,
type HousekeepingSearchProvider,
type HousekeepingWidgetDefinition,
ok,
} from "./contracts";
import { createHousekeepingRegistry } from "./registry";
const manifest = (
@@ -19,6 +28,45 @@ const manifest = (
previewHref: `/admin-next/${id}`,
capability: anyCapability(capabilitySlug),
routes: [],
searchProviders: [],
inboxSources: [],
widgets: [],
});
const providerCapability = anyCapability(PERMS.USERS_VIEW);
const searchProvider = (
id: string,
owner: HousekeepingDomainId = "people",
capability: CapabilityRequirement = providerCapability,
): HousekeepingSearchProvider => ({
id,
owner,
capability,
search: async () => ok([], "search"),
});
const inboxSource = (
id: string,
owner: HousekeepingDomainId = "people",
capability: CapabilityRequirement = providerCapability,
): HousekeepingInboxSource => ({
id,
owner,
capability,
getItems: async () => ok({ items: [], availability: "available" }, "inbox"),
});
const widget = (
id: string,
owner: HousekeepingDomainId = "people",
capability: CapabilityRequirement = providerCapability,
): HousekeepingWidgetDefinition => ({
id,
owner,
capability,
kind: "optional",
load: async () => ok(null, "widget"),
});
const expectedManifests = [
@@ -47,6 +95,18 @@ const expectedManifests = [
PERMS.MOD_TICKETS_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.MOD_BANS_VIEW,
PERMS.USERS_EDIT,
PERMS.USERS_BAN,
PERMS.USERS_RESET_PASSWORD,
PERMS.MODERATION_EDIT,
PERMS.TICKETS_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
PERMS.WORDFILTER_VIEW,
PERMS.WORDFILTER_EDIT,
PERMS.MOD_ACTIONS,
PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT,
],
},
{
@@ -62,6 +122,14 @@ const expectedManifests = [
PERMS.EVENTS_VIEW,
PERMS.POLLS_VIEW,
PERMS.PREFIXES_VIEW,
PERMS.NEWS_EDIT,
PERMS.PAGES_EDIT,
PERMS.BANNERS_EDIT,
PERMS.EVENTS_EDIT,
PERMS.POLLS_EDIT,
PERMS.PREFIXES_EDIT,
PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT,
],
},
{
@@ -70,7 +138,12 @@ const expectedManifests = [
previewHref: "/admin-next/economy",
labelKey: "pages.housekeeping.domains.economy.title",
descriptionKey: "pages.housekeeping.domains.economy.description",
slugs: [PERMS.CATALOG_VIEW, PERMS.SHOP_VIEW],
slugs: [
PERMS.CATALOG_VIEW,
PERMS.SHOP_VIEW,
PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT,
],
},
{
id: "hotel",
@@ -78,7 +151,16 @@ const expectedManifests = [
previewHref: "/admin-next/hotel",
labelKey: "pages.housekeeping.domains.hotel.title",
descriptionKey: "pages.housekeeping.domains.hotel.description",
slugs: [PERMS.ROOMS_VIEW, PERMS.RADIO_VIEW, PERMS.ASSETS_IMPORT],
slugs: [
PERMS.ROOMS_VIEW,
PERMS.RADIO_VIEW,
PERMS.ASSETS_IMPORT,
PERMS.ROOMS_EDIT,
PERMS.ROOMS_DELETE,
PERMS.RADIO_EDIT,
PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT,
],
},
{
id: "system",
@@ -94,6 +176,8 @@ const expectedManifests = [
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
PERMS.SETTINGS_EDIT,
PERMS.NOTIFICATIONS_EDIT,
],
},
] as const;
@@ -240,10 +324,149 @@ describe("housekeeping registry", () => {
descriptionKey: expected.descriptionKey,
});
expect(actual.routes).toEqual([]);
expect(actual.searchProviders).toEqual([]);
expect(actual.inboxSources).toEqual([]);
expect(actual.widgets).toEqual([]);
expect(actual.capability).toEqual({ mode: "any", slugs: expected.slugs });
}
});
it("covers every capability attributed to each domain's migration rows", () => {
for (const manifest of HOUSEKEEPING_MANIFESTS) {
const attributedCapabilities = new Set(
HOUSEKEEPING_MIGRATION_MATRIX.filter(
(entry) => entry.targetDomain === manifest.id,
).flatMap((entry) => [
...entry.capabilities.read,
...entry.capabilities.mutate,
]),
);
for (const slug of attributedCapabilities) {
expect(
manifest.capability.slugs,
`domain ${manifest.id}: ${slug}`,
).toContain(slug);
}
}
});
it("rejects duplicate provider and widget ids across domain manifests", () => {
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
{
...manifest("content"),
searchProviders: [searchProvider("shared", "content")],
},
]),
).toThrow("duplicate search provider id: shared");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), inboxSources: [inboxSource("shared")] },
{
...manifest("content"),
inboxSources: [inboxSource("shared", "content")],
},
]),
).toThrow("duplicate inbox source id: shared");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), widgets: [widget("shared")] },
{
...manifest("content"),
widgets: [widget("shared", "content")],
},
]),
).toThrow("duplicate widget id: shared");
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), searchProviders: [searchProvider("shared")] },
{
...manifest("content"),
widgets: [widget("shared", "content")],
},
]),
).toThrow("duplicate widget id: shared");
});
it("rejects provider and widget ownership outside their manifest", () => {
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
searchProviders: [searchProvider("people.users", "content")],
},
]),
).toThrow("search provider owner mismatch: people.users");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
inboxSources: [inboxSource("people.tickets", "content")],
},
]),
).toThrow("inbox source owner mismatch: people.tickets");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
widgets: [widget("people.queue", "content")],
},
]),
).toThrow("widget owner mismatch: people.queue");
});
it("rejects empty ids and invalid capability shapes for registry metadata", () => {
const invalidCapability = {
mode: "some",
slugs: [PERMS.USERS_VIEW],
} as unknown as CapabilityRequirement;
expect(() =>
createHousekeepingRegistry([
{ ...manifest("people"), searchProviders: [searchProvider(" ")] },
]),
).toThrow("empty search provider id");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
inboxSources: [
inboxSource("people.tickets", "people", invalidCapability),
],
},
]),
).toThrow("invalid capability requirement");
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
widgets: [
widget(
"people.queue",
"people",
anyCapability("admin.ghost.widget"),
),
],
},
]),
).toThrow("unknown capability slug: admin.ghost.widget");
});
it("rejects widget kinds outside the mandatory or optional contract", () => {
expect(() =>
createHousekeepingRegistry([
{
...manifest("people"),
widgets: [
{ ...widget("people.queue"), kind: "fixed" as "mandatory" },
],
},
]),
).toThrow("invalid widget kind: people.queue");
});
it("rejects empty domain and route capability requirements", () => {
expect(() =>
createHousekeepingRegistry([
@@ -7,6 +7,11 @@ import type {
const approvedDomainIds = new Set<string>(HOUSEKEEPING_DOMAIN_IDS);
const knownCapabilitySlugs = new Set<string>(Object.values(PERMS));
interface OwnedRegistryEntry {
id: string;
owner: HousekeepingDomainManifest["id"];
capability: CapabilityRequirement;
}
export interface HousekeepingRegistry {
domains: readonly HousekeepingDomainManifest[];
@@ -18,6 +23,7 @@ export function createHousekeepingRegistry(
const domainIds = new Set<string>();
const routeIds = new Set<string>();
const routeHrefs = new Set<string>();
const registryEntryIds = new Set<string>();
for (const manifest of manifests) {
if (!approvedDomainIds.has(manifest.id)) {
@@ -34,6 +40,29 @@ export function createHousekeepingRegistry(
validateNonEmpty(manifest.labelKey, "label key");
validateNonEmpty(manifest.descriptionKey, "description key");
validateCapability(manifest.capability);
validateOwnedRegistryEntries(
manifest.searchProviders,
manifest.id,
"search provider",
registryEntryIds,
);
validateOwnedRegistryEntries(
manifest.inboxSources,
manifest.id,
"inbox source",
registryEntryIds,
);
validateOwnedRegistryEntries(
manifest.widgets,
manifest.id,
"widget",
registryEntryIds,
);
for (const widget of manifest.widgets) {
if (widget.kind !== "mandatory" && widget.kind !== "optional") {
throw new Error(`invalid widget kind: ${widget.id}`);
}
}
for (const route of manifest.routes) {
validateNonEmpty(route.id, "route id");
@@ -56,18 +85,51 @@ export function createHousekeepingRegistry(
return { domains: Object.freeze([...manifests]) };
}
function validateOwnedRegistryEntries(
entries: readonly OwnedRegistryEntry[],
owner: HousekeepingDomainManifest["id"],
kind: "search provider" | "inbox source" | "widget",
ids: Set<string>,
): void {
for (const entry of entries) {
validateNonEmpty(entry.id, `${kind} id`);
if (ids.has(entry.id)) {
throw new Error(`duplicate ${kind} id: ${entry.id}`);
}
ids.add(entry.id);
if (entry.owner !== owner) {
throw new Error(`${kind} owner mismatch: ${entry.id}`);
}
validateCapability(entry.capability);
}
}
function validateNonEmpty(value: string, name: string): void {
if (!value.trim()) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`empty ${name}`);
}
}
function validateCapability(requirement: CapabilityRequirement): void {
if (
typeof requirement !== "object" ||
requirement === null ||
(requirement.mode !== "any" && requirement.mode !== "all") ||
!Array.isArray(requirement.slugs)
) {
throw new Error("invalid capability requirement");
}
if (requirement.slugs.length === 0) {
throw new Error("empty capability requirement");
}
for (const slug of requirement.slugs) {
if (typeof slug !== "string") {
throw new Error("invalid capability requirement");
}
if (!knownCapabilitySlugs.has(slug)) {
throw new Error(`unknown capability slug: ${slug}`);
}
@@ -50,6 +50,48 @@ describe("validateMigrationEntries", () => {
expect(issues).toContain("REBUILD requires targetPath: /admin");
});
it("rejects a discovery surface mismatch for the same legacy path", () => {
expect(
validateMigrationEntries(
[page("/admin")],
[{ ...entry("/admin"), surface: "mod" }],
),
).toEqual([
"surface mismatch for legacyPath /admin: expected admin, received mod",
]);
});
it("rejects a discovery source file mismatch for the same legacy path", () => {
expect(
validateMigrationEntries(
[page("/admin")],
[
{
...entry("/admin"),
sourceFile: "src/app/admin/renamed/page.tsx",
},
],
),
).toEqual([
"sourceFile mismatch for legacyPath /admin: expected src/app/admin/page.tsx, received src/app/admin/renamed/page.tsx",
]);
});
it("rejects removal with a non-null target", () => {
expect(
validateMigrationEntries(
[page("/admin")],
[
{
...entry("/admin"),
decision: "REMOVE",
targetPath: "/admin/system/legacy",
},
],
),
).toEqual(["REMOVE requires null targetPath: /admin"]);
});
it("enforces migration safety evidence", () => {
const issues = validateMigrationEntries(
[page("/admin")],
@@ -16,7 +16,13 @@ export function validateMigrationEntries(
entries: readonly MigrationEntry[],
): string[] {
const issues = new Set<string>();
const discoveredPaths = new Set(discovered.map((page) => page.legacyPath));
const discoveredByPath = new Map<string, LegacyPage[]>();
for (const page of discovered) {
const matchingPages = discoveredByPath.get(page.legacyPath) ?? [];
matchingPages.push(page);
discoveredByPath.set(page.legacyPath, matchingPages);
}
const entriesByPath = new Map<string, MigrationEntry[]>();
for (const entry of entries) {
@@ -24,8 +30,30 @@ export function validateMigrationEntries(
matchingEntries.push(entry);
entriesByPath.set(entry.legacyPath, matchingEntries);
if (!discoveredPaths.has(entry.legacyPath)) {
const matchingPages = discoveredByPath.get(entry.legacyPath);
if (!matchingPages) {
issues.add(`unknown legacyPath: ${entry.legacyPath}`);
continue;
}
const surfaceMatch = matchingPages.find(
(page) => page.surface === entry.surface,
);
const identityMatch = matchingPages.some(
(page) =>
page.surface === entry.surface && page.sourceFile === entry.sourceFile,
);
if (!surfaceMatch) {
const expectedSurface = matchingPages
.map((page) => page.surface)
.join(" or ");
issues.add(
`surface mismatch for legacyPath ${entry.legacyPath}: expected ${expectedSurface}, received ${entry.surface}`,
);
} else if (!identityMatch) {
issues.add(
`sourceFile mismatch for legacyPath ${entry.legacyPath}: expected ${surfaceMatch.sourceFile}, received ${entry.sourceFile}`,
);
}
}
@@ -45,6 +73,10 @@ export function validateMigrationEntries(
for (const entry of entries) {
const { legacyPath } = entry;
if (entry.decision === "REMOVE" && entry.targetPath !== null) {
issues.add(`REMOVE requires null targetPath: ${legacyPath}`);
}
if (entry.targetPath === null && entry.decision !== "REMOVE") {
issues.add(`${entry.decision} requires targetPath: ${legacyPath}`);
}