test: harden housekeeping foundation boundaries
This commit is contained in:
1 parent
a158c78a7c
commit
ebc263da35
1 file changed
+121
-23
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { join, posix } from "node:path";
|
import { join, posix } from "node:path";
|
||||||
import { createElement } from "react";
|
import { createElement, type ReactElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||||
@@ -51,8 +51,15 @@ interface BabelParser {
|
|||||||
): BabelNode;
|
): BabelNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModuleAccess {
|
||||||
|
kind: "import" | "export" | "runtime";
|
||||||
|
importedNames: readonly string[];
|
||||||
|
specifier: string;
|
||||||
|
typeOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ModuleAccessScan {
|
interface ModuleAccessScan {
|
||||||
specifiers: readonly string[];
|
accesses: readonly ModuleAccess[];
|
||||||
violations: readonly string[];
|
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 {
|
function scanModuleAccesses(source: string): ModuleAccessScan {
|
||||||
const root = babelParser.parse(source, {
|
const root = babelParser.parse(source, {
|
||||||
createImportExpressions: true,
|
createImportExpressions: true,
|
||||||
plugins: ["typescript", "jsx"],
|
plugins: ["typescript", "jsx"],
|
||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
});
|
});
|
||||||
const specifiers: string[] = [];
|
const accesses: ModuleAccess[] = [];
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
|
|
||||||
function recordArgument(argument: unknown, kind: "import" | "require") {
|
function recordArgument(argument: unknown, kind: "import" | "require") {
|
||||||
@@ -200,7 +224,12 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
|||||||
violations.push(`<non-literal ${kind}>`);
|
violations.push(`<non-literal ${kind}>`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
specifiers.push(specifier);
|
accesses.push({
|
||||||
|
kind: "runtime",
|
||||||
|
importedNames: [],
|
||||||
|
specifier,
|
||||||
|
typeOnly: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function visit(value: unknown): void {
|
function visit(value: unknown): void {
|
||||||
@@ -211,18 +240,28 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
|||||||
if (!isBabelNode(value)) return;
|
if (!isBabelNode(value)) return;
|
||||||
|
|
||||||
if (value.type === "ImportDeclaration") {
|
if (value.type === "ImportDeclaration") {
|
||||||
if (!isTypeOnlyDeclaration(value, "importKind")) {
|
const specifier = readLiteralModuleSpecifier(value.source);
|
||||||
const specifier = readLiteralModuleSpecifier(value.source);
|
if (specifier !== null) {
|
||||||
if (specifier !== null) specifiers.push(specifier);
|
accesses.push({
|
||||||
|
kind: "import",
|
||||||
|
importedNames: namedImportNames(value),
|
||||||
|
specifier,
|
||||||
|
typeOnly: isTypeOnlyDeclaration(value, "importKind"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
(value.type === "ExportNamedDeclaration" ||
|
(value.type === "ExportNamedDeclaration" ||
|
||||||
value.type === "ExportAllDeclaration") &&
|
value.type === "ExportAllDeclaration") &&
|
||||||
value.source !== null
|
value.source !== null
|
||||||
) {
|
) {
|
||||||
if (!isTypeOnlyDeclaration(value, "exportKind")) {
|
const specifier = readLiteralModuleSpecifier(value.source);
|
||||||
const specifier = readLiteralModuleSpecifier(value.source);
|
if (specifier !== null) {
|
||||||
if (specifier !== null) specifiers.push(specifier);
|
accesses.push({
|
||||||
|
kind: "export",
|
||||||
|
importedNames: [],
|
||||||
|
specifier,
|
||||||
|
typeOnly: isTypeOnlyDeclaration(value, "exportKind"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else if (value.type === "ImportExpression") {
|
} else if (value.type === "ImportExpression") {
|
||||||
recordArgument(value.source, "import");
|
recordArgument(value.source, "import");
|
||||||
@@ -246,7 +285,7 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
visit(root);
|
visit(root);
|
||||||
return { specifiers, violations };
|
return { accesses, violations };
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalizeModuleSpecifier(
|
function canonicalizeModuleSpecifier(
|
||||||
@@ -276,12 +315,7 @@ function canonicalizeModuleSpecifier(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
candidates: [
|
candidates: [normalized.replace(resolverExtensionPattern, "")],
|
||||||
...new Set([
|
|
||||||
normalized,
|
|
||||||
normalized.replace(resolverExtensionPattern, ""),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
violation: null,
|
violation: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -306,6 +340,21 @@ function isForbiddenModulePath(path: string, sourceFile: string): boolean {
|
|||||||
isDomainWorkflowModule(path)
|
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(
|
function findHousekeepingImportBoundaryViolations(
|
||||||
source: string,
|
source: string,
|
||||||
@@ -314,9 +363,11 @@ function findHousekeepingImportBoundaryViolations(
|
|||||||
const scan = scanModuleAccesses(source);
|
const scan = scanModuleAccesses(source);
|
||||||
const violations = [...scan.violations];
|
const violations = [...scan.violations];
|
||||||
|
|
||||||
for (const specifier of scan.specifiers) {
|
for (const access of scan.accesses) {
|
||||||
const canonical = canonicalizeModuleSpecifier(sourceFile, specifier);
|
const canonical = canonicalizeModuleSpecifier(sourceFile, access.specifier);
|
||||||
if (canonical.violation) violations.push(canonical.violation);
|
if (canonical.violation) violations.push(canonical.violation);
|
||||||
|
if (isAllowedPermissionSetTypeImport(access, canonical, sourceFile))
|
||||||
|
continue;
|
||||||
const forbiddenPath = canonical.candidates.find((candidate) =>
|
const forbiddenPath = canonical.candidates.find((candidate) =>
|
||||||
isForbiddenModulePath(candidate, sourceFile),
|
isForbiddenModulePath(candidate, sourceFile),
|
||||||
);
|
);
|
||||||
@@ -325,6 +376,14 @@ function findHousekeepingImportBoundaryViolations(
|
|||||||
|
|
||||||
return violations;
|
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", () => {
|
describe("housekeeping runtime import boundary", () => {
|
||||||
it("keeps every runtime module inside the foundation boundary", () => {
|
it("keeps every runtime module inside the foundation boundary", () => {
|
||||||
@@ -344,6 +403,18 @@ describe("housekeeping runtime import boundary", () => {
|
|||||||
'import { db } from "@/lib/db";',
|
'import { db } from "@/lib/db";',
|
||||||
"src/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",
|
"relative action import",
|
||||||
"src/features/housekeeping/foundation/registry.ts",
|
"src/features/housekeeping/foundation/registry.ts",
|
||||||
@@ -451,6 +522,24 @@ describe("housekeeping runtime import boundary", () => {
|
|||||||
),
|
),
|
||||||
).toEqual([]);
|
).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", () => {
|
describe("housekeeping foundation completion contracts", () => {
|
||||||
@@ -486,16 +575,25 @@ describe("housekeeping foundation completion contracts", () => {
|
|||||||
).toBe(false);
|
).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", () => {
|
it("renders one inert localized command affordance", () => {
|
||||||
const sentinel = "HK::comando-localizzato-disabilitato";
|
const sentinel = "HK::comando-localizzato-disabilitato";
|
||||||
const html = renderToStaticMarkup(
|
const trigger = CommandTrigger({ label: sentinel });
|
||||||
createElement(CommandTrigger, { label: sentinel }),
|
const html = renderToStaticMarkup(trigger);
|
||||||
);
|
|
||||||
const buttons = html.match(/<button\b[^>]*>/g) ?? [];
|
const buttons = html.match(/<button\b[^>]*>/g) ?? [];
|
||||||
|
expect(executablePropNames(trigger)).toEqual([]);
|
||||||
|
|
||||||
expect(buttons).toHaveLength(1);
|
expect(buttons).toHaveLength(1);
|
||||||
expect(buttons[0]).toMatch(/\sdisabled(?:=""|(?=\s|>))/);
|
expect(buttons[0]).toMatch(/\sdisabled(?:=""|(?=\s|>))/);
|
||||||
expect(buttons[0]).not.toMatch(/\son[a-z][a-z0-9-]*\s*=/i);
|
|
||||||
expect(html).toContain(`>${sentinel}</button>`);
|
expect(html).toContain(`>${sentinel}</button>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user