test: harden housekeeping foundation boundaries
This commit is contained in:
1 parent
a158c78a7c
commit
ebc263da35
1 file changed
+119
-21
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { join, posix } from "node:path";
|
||||
import { createElement } from "react";
|
||||
import { createElement, type ReactElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
@@ -51,8 +51,15 @@ interface BabelParser {
|
||||
): BabelNode;
|
||||
}
|
||||
|
||||
interface ModuleAccess {
|
||||
kind: "import" | "export" | "runtime";
|
||||
importedNames: readonly string[];
|
||||
specifier: string;
|
||||
typeOnly: boolean;
|
||||
}
|
||||
|
||||
interface ModuleAccessScan {
|
||||
specifiers: readonly string[];
|
||||
accesses: readonly ModuleAccess[];
|
||||
violations: readonly string[];
|
||||
}
|
||||
|
||||
@@ -185,13 +192,30 @@ function isTypeOnlyDeclaration(
|
||||
})
|
||||
);
|
||||
}
|
||||
function namedImportNames(node: BabelNode): readonly string[] {
|
||||
const specifiers = Array.isArray(node.specifiers) ? node.specifiers : [];
|
||||
|
||||
return specifiers.flatMap((specifier) => {
|
||||
const declaration = isBabelNode(specifier) ? specifier : null;
|
||||
if (declaration?.type !== "ImportSpecifier") return [];
|
||||
|
||||
const imported = unwrapModuleArgument(declaration.imported);
|
||||
if (imported?.type === "Identifier" && typeof imported.name === "string") {
|
||||
return [imported.name];
|
||||
}
|
||||
return imported?.type === "StringLiteral" &&
|
||||
typeof imported.value === "string"
|
||||
? [imported.value]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||
const root = babelParser.parse(source, {
|
||||
createImportExpressions: true,
|
||||
plugins: ["typescript", "jsx"],
|
||||
sourceType: "module",
|
||||
});
|
||||
const specifiers: string[] = [];
|
||||
const accesses: ModuleAccess[] = [];
|
||||
const violations: string[] = [];
|
||||
|
||||
function recordArgument(argument: unknown, kind: "import" | "require") {
|
||||
@@ -200,7 +224,12 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||
violations.push(`<non-literal ${kind}>`);
|
||||
return;
|
||||
}
|
||||
specifiers.push(specifier);
|
||||
accesses.push({
|
||||
kind: "runtime",
|
||||
importedNames: [],
|
||||
specifier,
|
||||
typeOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
function visit(value: unknown): void {
|
||||
@@ -211,18 +240,28 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||
if (!isBabelNode(value)) return;
|
||||
|
||||
if (value.type === "ImportDeclaration") {
|
||||
if (!isTypeOnlyDeclaration(value, "importKind")) {
|
||||
const specifier = readLiteralModuleSpecifier(value.source);
|
||||
if (specifier !== null) specifiers.push(specifier);
|
||||
if (specifier !== null) {
|
||||
accesses.push({
|
||||
kind: "import",
|
||||
importedNames: namedImportNames(value),
|
||||
specifier,
|
||||
typeOnly: isTypeOnlyDeclaration(value, "importKind"),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
(value.type === "ExportNamedDeclaration" ||
|
||||
value.type === "ExportAllDeclaration") &&
|
||||
value.source !== null
|
||||
) {
|
||||
if (!isTypeOnlyDeclaration(value, "exportKind")) {
|
||||
const specifier = readLiteralModuleSpecifier(value.source);
|
||||
if (specifier !== null) specifiers.push(specifier);
|
||||
if (specifier !== null) {
|
||||
accesses.push({
|
||||
kind: "export",
|
||||
importedNames: [],
|
||||
specifier,
|
||||
typeOnly: isTypeOnlyDeclaration(value, "exportKind"),
|
||||
});
|
||||
}
|
||||
} else if (value.type === "ImportExpression") {
|
||||
recordArgument(value.source, "import");
|
||||
@@ -246,7 +285,7 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||
}
|
||||
|
||||
visit(root);
|
||||
return { specifiers, violations };
|
||||
return { accesses, violations };
|
||||
}
|
||||
|
||||
function canonicalizeModuleSpecifier(
|
||||
@@ -276,12 +315,7 @@ function canonicalizeModuleSpecifier(
|
||||
}
|
||||
|
||||
return {
|
||||
candidates: [
|
||||
...new Set([
|
||||
normalized,
|
||||
normalized.replace(resolverExtensionPattern, ""),
|
||||
]),
|
||||
],
|
||||
candidates: [normalized.replace(resolverExtensionPattern, "")],
|
||||
violation: null,
|
||||
};
|
||||
}
|
||||
@@ -306,6 +340,21 @@ function isForbiddenModulePath(path: string, sourceFile: string): boolean {
|
||||
isDomainWorkflowModule(path)
|
||||
);
|
||||
}
|
||||
function isAllowedPermissionSetTypeImport(
|
||||
access: ModuleAccess,
|
||||
canonical: CanonicalModuleSpecifier,
|
||||
sourceFile: string,
|
||||
): boolean {
|
||||
return (
|
||||
sourceFile ===
|
||||
"src/features/housekeeping/foundation/capability-context.ts" &&
|
||||
access.kind === "import" &&
|
||||
access.typeOnly &&
|
||||
access.importedNames.length === 1 &&
|
||||
access.importedNames[0] === "PermissionSet" &&
|
||||
canonical.candidates.includes(PERMISSIONS_ADAPTER)
|
||||
);
|
||||
}
|
||||
|
||||
function findHousekeepingImportBoundaryViolations(
|
||||
source: string,
|
||||
@@ -314,9 +363,11 @@ function findHousekeepingImportBoundaryViolations(
|
||||
const scan = scanModuleAccesses(source);
|
||||
const violations = [...scan.violations];
|
||||
|
||||
for (const specifier of scan.specifiers) {
|
||||
const canonical = canonicalizeModuleSpecifier(sourceFile, specifier);
|
||||
for (const access of scan.accesses) {
|
||||
const canonical = canonicalizeModuleSpecifier(sourceFile, access.specifier);
|
||||
if (canonical.violation) violations.push(canonical.violation);
|
||||
if (isAllowedPermissionSetTypeImport(access, canonical, sourceFile))
|
||||
continue;
|
||||
const forbiddenPath = canonical.candidates.find((candidate) =>
|
||||
isForbiddenModulePath(candidate, sourceFile),
|
||||
);
|
||||
@@ -325,6 +376,14 @@ function findHousekeepingImportBoundaryViolations(
|
||||
|
||||
return violations;
|
||||
}
|
||||
function executablePropNames(element: ReactElement): readonly string[] {
|
||||
const props = element.props;
|
||||
if (typeof props !== "object" || props === null) return [];
|
||||
|
||||
return Object.entries(props).flatMap(([name, value]) =>
|
||||
/^on/i.test(name) && typeof value === "function" ? [name] : [],
|
||||
);
|
||||
}
|
||||
|
||||
describe("housekeeping runtime import boundary", () => {
|
||||
it("keeps every runtime module inside the foundation boundary", () => {
|
||||
@@ -344,6 +403,18 @@ describe("housekeeping runtime import boundary", () => {
|
||||
'import { db } from "@/lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"aliased type-only database import",
|
||||
"src/features/housekeeping/foundation/registry.ts",
|
||||
'import type { Database } from "@/lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"aliased type-only action export",
|
||||
"src/features/housekeeping/foundation/registry.ts",
|
||||
'export type { ActionInput } from "@/actions/users";',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"relative action import",
|
||||
"src/features/housekeeping/foundation/registry.ts",
|
||||
@@ -451,6 +522,24 @@ describe("housekeeping runtime import boundary", () => {
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows a harmless type-only module lookalike", () => {
|
||||
expect(
|
||||
findHousekeepingImportBoundaryViolations(
|
||||
'import type { DatabaseDocument } from "@/lib/database";',
|
||||
"src/features/housekeeping/manifests.ts",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows a normalized domain manifest with a resolver extension", () => {
|
||||
expect(
|
||||
findHousekeepingImportBoundaryViolations(
|
||||
'import manifest from "./domains/people/manifest.ts";',
|
||||
"src/features/housekeeping/manifests.ts",
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("housekeeping foundation completion contracts", () => {
|
||||
@@ -486,16 +575,25 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects executable React props before markup serialization", () => {
|
||||
const mutatedTrigger = createElement(
|
||||
"button",
|
||||
{ onClick: () => undefined, type: "button" },
|
||||
"mutation witness",
|
||||
);
|
||||
|
||||
expect(executablePropNames(mutatedTrigger)).toEqual(["onClick"]);
|
||||
});
|
||||
|
||||
it("renders one inert localized command affordance", () => {
|
||||
const sentinel = "HK::comando-localizzato-disabilitato";
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(CommandTrigger, { label: sentinel }),
|
||||
);
|
||||
const trigger = CommandTrigger({ label: sentinel });
|
||||
const html = renderToStaticMarkup(trigger);
|
||||
const buttons = html.match(/<button\b[^>]*>/g) ?? [];
|
||||
expect(executablePropNames(trigger)).toEqual([]);
|
||||
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]).toMatch(/\sdisabled(?:=""|(?=\s|>))/);
|
||||
expect(buttons[0]).not.toMatch(/\son[a-z][a-z0-9-]*\s*=/i);
|
||||
expect(html).toContain(`>${sentinel}</button>`);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user