From 01e116ce99203d084f4c1d815ddd3b2486c5494c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 09:04:29 +0200 Subject: [PATCH] test(repo): guard critical ownership surfaces --- scripts/check-architecture-guardrails.mjs | 76 ++++++++++++++++--- .../check-architecture-guardrails.test.mjs | 69 +++++++++++++++++ 2 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 scripts/check-architecture-guardrails.test.mjs diff --git a/scripts/check-architecture-guardrails.mjs b/scripts/check-architecture-guardrails.mjs index a1401f9..1c3008b 100644 --- a/scripts/check-architecture-guardrails.mjs +++ b/scripts/check-architecture-guardrails.mjs @@ -1,11 +1,12 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; +import { pathToFileURL } from "node:url"; import { resolveRealWorkspaceRoot } from "./load-env.mjs"; const rootDir = resolveRealWorkspaceRoot(); -const rules = [ +export const rules = [ { file: "apps/web/src/server/auth.ts", required: [ @@ -60,6 +61,7 @@ const rules = [ }, { file: "packages/api/src/sse/subscription-policy.ts", + maxLines: 80, required: [ { pattern: /\bderiveUserSseSubscription\b/, @@ -83,6 +85,21 @@ const rules = [ { pattern: /\baudience\b/, message: "timeline SSE route must not parse raw audience values from the client" }, ], }, + { + file: "apps/web/src/hooks/useTimelineSSE.ts", + maxLines: 120, + required: [ + { + pattern: /\bgetTimelineSseInvalidationKeys\s*\(/, + message: "timeline SSE hook must keep invalidation policy delegated to the extracted policy helper", + }, + { + pattern: /\bparseTimelineSseEvent\s*\(/, + message: "timeline SSE hook must keep event parsing delegated to the extracted policy helper", + }, + ], + forbidden: [], + }, { file: "docker-compose.prod.yml", required: [ @@ -184,11 +201,12 @@ const rules = [ }, ]; -const violations = []; +export function countLines(source) { + return source.split("\n").length; +} -for (const rule of rules) { - const absolutePath = path.join(rootDir, rule.file); - const source = await readFile(absolutePath, "utf8"); +export function evaluateRule(rule, source) { + const violations = []; for (const requirement of rule.required) { if (!requirement.pattern.test(source)) { @@ -201,14 +219,48 @@ for (const rule of rules) { violations.push(`${rule.file}: forbidden pattern matched: ${forbidden.message}`); } } -} -if (violations.length > 0) { - console.error("Architecture guardrail check failed:"); - for (const violation of violations) { - console.error(`- ${violation}`); + if (typeof rule.maxLines === "number") { + const lines = countLines(source); + if (lines > rule.maxLines) { + violations.push( + `${rule.file}: file grew to ${lines} lines and exceeds maxLines=${rule.maxLines}; split the ownership surface before expanding it further`, + ); + } } - process.exit(1); + + return violations; } -console.log("Architecture guardrails passed."); +export async function collectArchitectureGuardrailViolations( + architectureRules = rules, + { readSource = readFile, workspaceRoot = rootDir } = {}, +) { + const violations = []; + + for (const rule of architectureRules) { + const absolutePath = path.join(workspaceRoot, rule.file); + const source = await readSource(absolutePath, "utf8"); + violations.push(...evaluateRule(rule, source)); + } + + return violations; +} + +async function main() { + const violations = await collectArchitectureGuardrailViolations(); + + if (violations.length > 0) { + console.error("Architecture guardrail check failed:"); + for (const violation of violations) { + console.error(`- ${violation}`); + } + process.exit(1); + } + + console.log("Architecture guardrails passed."); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/scripts/check-architecture-guardrails.test.mjs b/scripts/check-architecture-guardrails.test.mjs new file mode 100644 index 0000000..235de3f --- /dev/null +++ b/scripts/check-architecture-guardrails.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + collectArchitectureGuardrailViolations, + countLines, + evaluateRule, +} from "./check-architecture-guardrails.mjs"; + +describe("architecture guardrails", () => { + it("counts lines consistently for maxLines checks", () => { + assert.equal(countLines("a\nb\nc"), 3); + }); + + it("reports required, forbidden, and maxLines violations together", () => { + const violations = evaluateRule( + { + file: "apps/web/src/example.ts", + maxLines: 2, + required: [{ pattern: /\bexpectedHelper\b/, message: "must call the extracted helper" }], + forbidden: [{ pattern: /\binlineLogic\b/, message: "must not re-inline complex logic" }], + }, + "inlineLogic();\nconst a = 1;\nconst b = 2;\n", + ); + + assert.deepEqual(violations, [ + "apps/web/src/example.ts: missing guardrail anchor: must call the extracted helper", + "apps/web/src/example.ts: forbidden pattern matched: must not re-inline complex logic", + "apps/web/src/example.ts: file grew to 4 lines and exceeds maxLines=2; split the ownership surface before expanding it further", + ]); + }); + + it("returns no violations when a rule is satisfied", () => { + const violations = evaluateRule( + { + file: "packages/api/src/example.ts", + maxLines: 4, + required: [{ pattern: /\bderiveThing\b/, message: "must keep derivation centralized" }], + forbidden: [{ pattern: /\bunsafeThing\b/, message: "must not use unsafe helper" }], + }, + "export function deriveThing() {\n return true;\n}\n", + ); + + assert.deepEqual(violations, []); + }); + + it("reads sources through the injected reader when collecting violations", async () => { + const violations = await collectArchitectureGuardrailViolations( + [ + { + file: "apps/web/src/example.ts", + maxLines: 2, + required: [], + forbidden: [], + }, + ], + { + workspaceRoot: "/virtual/repo", + readSource: async (filePath) => { + assert.equal(filePath, "/virtual/repo/apps/web/src/example.ts"); + return "line1\nline2\nline3\n"; + }, + }, + ); + + assert.deepEqual(violations, [ + "apps/web/src/example.ts: file grew to 4 lines and exceeds maxLines=2; split the ownership surface before expanding it further", + ]); + }); +});