Files
CapaKraken/scripts/check-architecture-guardrails.mjs
T

267 lines
8.0 KiB
JavaScript

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();
export const rules = [
{
file: "apps/web/src/server/auth.ts",
required: [
{
pattern: /\bassertSecureRuntimeEnv\s*\(/,
message: "Auth startup must validate production runtime env before serving requests",
},
],
forbidden: [],
},
{
file: "apps/web/src/server/runtime-env.ts",
required: [
{
pattern: /\bDISALLOWED_PRODUCTION_SECRETS\b/,
message: "runtime env validation must keep a denylist for known development secrets",
},
{
pattern: /\bAUTH_SECRET\b/,
message: "runtime env validation must check the Auth.js secret environment variables",
},
{
pattern: /\bNEXTAUTH_URL\b/,
message: "runtime env validation must check the public auth url environment variables",
},
],
forbidden: [],
},
{
file: "docker-compose.yml",
required: [
{
pattern: /NEXTAUTH_SECRET:\s+\$\{NEXTAUTH_SECRET:\?set NEXTAUTH_SECRET\}/,
message: "local compose must source NEXTAUTH_SECRET from environment instead of hardcoding it",
},
],
forbidden: [
{
pattern: /NEXTAUTH_SECRET:\s+dev-secret-change-in-production/,
message: "local compose must not hardcode the development Auth.js secret",
},
],
},
{
file: "packages/api/src/sse/event-bus.ts",
required: [],
forbidden: [
{ pattern: /\bRoleSseAudience\b/, message: "role-based SSE audience types must not reappear" },
{ pattern: /\broleAudience\s*\(/, message: "role-derived SSE audiences must not be emitted" },
{ pattern: /\bBROADCAST_SENT\b/, message: "broadcast SSE event resurrection needs explicit architecture review" },
],
},
{
file: "packages/api/src/sse/subscription-policy.ts",
maxLines: 80,
required: [
{
pattern: /\bderiveUserSseSubscription\b/,
message: "subscription derivation must stay centralized in deriveUserSseSubscription",
},
],
forbidden: [
{ pattern: /\broleAudience\s*\(/, message: "subscription policy must not derive role audiences" },
],
},
{
file: "apps/web/src/app/api/sse/timeline/route.ts",
required: [
{
pattern: /\bderiveUserSseSubscription\s*\(/,
message: "timeline SSE route must derive audiences server-side from the authenticated user",
},
],
forbidden: [
{ pattern: /\bsearchParams\b/, message: "timeline SSE route must not accept client-provided audience scoping" },
{ 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: [
{
pattern: /image:\s+\$\{APP_IMAGE:\?set APP_IMAGE\}/,
message: "production compose must deploy the immutable app image",
},
{
pattern: /image:\s+\$\{MIGRATOR_IMAGE:\?set MIGRATOR_IMAGE\}/,
message: "production compose must deploy the immutable migrator image",
},
{
pattern: /http:\/\/localhost:3000\/api\/ready/,
message: "production compose must gate app health on the readiness endpoint",
},
{
pattern: /RATE_LIMIT_BACKEND:\s+\$\{RATE_LIMIT_BACKEND:-redis\}/,
message: "production compose must intentionally pin the Redis-backed rate-limit path",
},
],
forbidden: [
{ pattern: /\bbuild:/, message: "production compose must not build application images on the host" },
],
},
{
file: ".github/workflows/release-image.yml",
required: [
{
pattern: /push:\s*\n\s*branches:\s*\[main\]/,
message: "image releases must build automatically on pushes to main",
},
{
pattern: /workflow_dispatch:/,
message: "image release must remain manually callable for rebuilds and tag overrides",
},
{
pattern: /target:\s+runner/,
message: "release workflow must keep publishing the runner image",
},
{
pattern: /target:\s+migrator/,
message: "release workflow must keep publishing the migrator image",
},
],
forbidden: [],
},
{
file: ".github/workflows/deploy-staging.yml",
required: [
{
pattern: /docker-compose\.prod\.yml tooling\/deploy/,
message: "staging deploy must ship the canonical production compose bundle",
},
],
forbidden: [],
},
{
file: ".github/workflows/deploy-prod.yml",
required: [
{
pattern: /docker-compose\.prod\.yml tooling\/deploy/,
message: "production deploy must ship the canonical production compose bundle",
},
],
forbidden: [],
},
{
file: ".github/workflows/ci.yml",
required: [
{
pattern: /run:\s+pnpm db:generate/,
message: "CI must route Prisma client generation through the workspace env/schema wrapper",
},
],
forbidden: [
{
pattern: /pnpm --filter @capakraken\/db exec prisma generate/,
message: "CI must not call prisma generate directly outside the workspace wrapper",
},
],
},
{
file: "tooling/deploy/deploy-compose.sh",
required: [
{
pattern: /COMPOSE_FILE="\$\{COMPOSE_FILE:-docker-compose\.prod\.yml\}"/,
message: "deploy script must default to the canonical production compose file",
},
{
pattern: /READY_URL="\$\{READY_URL:-http:\/\/127\.0\.0\.1:\$\{APP_HOST_PORT:-3000\}\/api\/ready\}"/,
message: "deploy script must wait on the readiness endpoint",
},
{
pattern: /docker compose -f "\$\{COMPOSE_FILE\}" config -q/,
message: "deploy script must validate the rendered compose file before pulling images",
},
],
forbidden: [],
},
];
export function countLines(source) {
return source.split("\n").length;
}
export function evaluateRule(rule, source) {
const violations = [];
for (const requirement of rule.required) {
if (!requirement.pattern.test(source)) {
violations.push(`${rule.file}: missing guardrail anchor: ${requirement.message}`);
}
}
for (const forbidden of rule.forbidden) {
if (forbidden.pattern.test(source)) {
violations.push(`${rule.file}: forbidden pattern matched: ${forbidden.message}`);
}
}
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`,
);
}
}
return violations;
}
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();
}