diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index 401ce8caaa..8f00c82c08 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -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(``); 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); + const specifier = readLiteralModuleSpecifier(value.source); + 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); + const specifier = readLiteralModuleSpecifier(value.source); + 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(/]*>/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}`); });