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: "apps/web/src/hooks/timelineLivePreview.ts", maxLines: 140, required: [ { pattern: /\bexport function scheduleLivePreview\b/, message: "timeline live preview helpers must keep frame scheduling centralized", }, { pattern: /\bexport function clearLivePreview\b/, message: "timeline live preview helpers must keep preview reset logic centralized", }, { pattern: /\bexport function preserveLivePreview\b/, message: "timeline live preview helpers must keep snapshot preservation centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineTouch.ts", maxLines: 80, required: [ { pattern: /\bexport function getTouchPoint\b/, message: "timeline touch helpers must keep touch coordinate fallback centralized", }, { pattern: /\bexport function resolveTouchDragDecision\b/, message: "timeline touch helpers must keep scroll-vs-drag disambiguation centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineMultiSelect.ts", maxLines: 90, required: [ { pattern: /\bexport function createMultiSelectState\b/, message: "timeline multi-select helpers must keep selection bootstrap centralized", }, { pattern: /\bexport function finalizeMultiSelectDraft\b/, message: "timeline multi-select helpers must keep minimal-drag reset logic centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineRangeSelection.ts", maxLines: 90, required: [ { pattern: /\bexport function createRangeSelectionState\b/, message: "timeline range helpers must keep selection bootstrap centralized", }, { pattern: /\bexport function updateRangeSelectionDraft\b/, message: "timeline range helpers must keep preview date derivation centralized", }, { pattern: /\bexport function finalizeRangeSelection\b/, message: "timeline range helpers must keep ordered range finalization centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineOptimisticAllocations.ts", maxLines: 80, required: [ { pattern: /\bexport function reconcileOptimisticEntries\b/, message: "timeline optimistic helpers must keep server-reconciliation logic centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineAllocationFinalize.ts", maxLines: 100, required: [ { pattern: /\bexport function hasAllocationDateChange\b/, message: "timeline allocation finalize helpers must keep date-change detection centralized", }, { pattern: /\bexport function shouldTreatAllocationDragAsClick\b/, message: "timeline allocation finalize helpers must keep click-vs-drag classification centralized", }, { pattern: /\bexport function requiresAllocationFragmentExtraction\b/, message: "timeline allocation finalize helpers must keep segment extraction rules centralized", }, { pattern: /\bexport function buildAllocationMovedSnapshot\b/, message: "timeline allocation finalize helpers must keep mutation snapshot creation centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineAllocationMultiDrag.ts", maxLines: 90, required: [ { pattern: /\bexport function isAllocationMultiSelected\b/, message: "timeline allocation multi-drag helpers must keep multi-selection eligibility centralized", }, { pattern: /\bexport function updateAllocationMultiDrag\b/, message: "timeline allocation multi-drag helpers must keep same-day delta suppression centralized", }, { pattern: /\bexport function finalizeAllocationMultiDrag\b/, message: "timeline allocation multi-drag helpers must keep reset-on-release behavior centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineAllocationActions.ts", maxLines: 90, required: [ { pattern: /\bexport function buildAllocationBlockClickInfo\b/, message: "timeline allocation action helpers must keep popover click payload derivation centralized", }, { pattern: /\bexport function buildAllocationMutationPlan\b/, message: "timeline allocation action helpers must keep mutation plan derivation centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineDocumentDrag.ts", maxLines: 50, required: [ { pattern: /\bexport function attachDocumentMouseDrag\b/, message: "timeline document drag helpers must keep document mouse listener wiring centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineAllocationDragState.ts", maxLines: 80, required: [ { pattern: /\bexport function createAllocationDragState\b/, message: "timeline allocation drag state helpers must keep drag bootstrap centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/timelineProjectDrag.ts", maxLines: 80, required: [ { pattern: /\bexport function createProjectDragState\b/, message: "timeline project drag helpers must keep drag-state bootstrap centralized", }, { pattern: /\bexport function buildProjectShiftMutationInput\b/, message: "timeline project drag helpers must keep no-op project-shift mutation gating centralized", }, ], forbidden: [], }, { file: "apps/web/src/hooks/useTimelineDrag.ts", required: [ { pattern: /from "\.\/timelineLivePreview\.js"/, message: "timeline drag must keep live preview behavior delegated to the extracted helper module", }, { pattern: /from "\.\/timelineTouch\.js"/, message: "timeline drag must keep touch fallback and drag disambiguation delegated to the extracted helper module", }, { pattern: /from "\.\/timelineMultiSelect\.js"/, message: "timeline drag must keep multi-select rectangle lifecycle delegated to the extracted helper module", }, { pattern: /from "\.\/timelineRangeSelection\.js"/, message: "timeline drag must keep range preview and finalization delegated to the extracted helper module", }, { pattern: /from "\.\/timelineOptimisticAllocations\.js"/, message: "timeline drag must keep optimistic allocation reconciliation delegated to the extracted helper module", }, { pattern: /from "\.\/timelineAllocationFinalize\.js"/, message: "timeline drag must keep allocation drag completion rules delegated to the extracted helper module", }, { pattern: /from "\.\/timelineAllocationActions\.js"/, message: "timeline drag must keep allocation click and mutation plan derivation delegated to the extracted helper module", }, { pattern: /from "\.\/timelineDocumentDrag\.js"/, message: "timeline drag must keep document mouse listener lifecycle delegated to the extracted helper module", }, { pattern: /from "\.\/timelineAllocationMultiDrag\.js"/, message: "timeline drag must keep allocation multi-drag rules delegated to the extracted helper module", }, { pattern: /from "\.\/timelineAllocationDragState\.js"/, message: "timeline drag must keep allocation drag bootstrap delegated to the extracted helper module", }, { pattern: /from "\.\/timelineProjectDrag\.js"/, message: "timeline drag must keep project drag bootstrap and mutation gating delegated to the extracted helper module", }, ], forbidden: [ { pattern: /\bfunction (?:toPxValue|joinTransforms|captureLivePreviewTargets|renderLivePreview|scheduleLivePreview|clearLivePreview|datesMatch|preserveLivePreview|createRangeSelectionState)\b/, message: "timeline drag must not re-inline live preview helper implementations", }, { pattern: /\bfunction toClientX\b/, message: "timeline drag must not re-inline touch coordinate fallback helpers", }, { pattern: /\bfunction (?:hasAllocationDateChange|shouldTreatAllocationDragAsClick|requiresAllocationFragmentExtraction|buildAllocationMovedSnapshot|reconcileOptimisticEntries)\b/, message: "timeline drag must not re-inline extracted optimistic or allocation finalize helper implementations", }, { pattern: /\bfunction (?:buildAllocationBlockClickInfo|buildAllocationMutationPlan)\b/, message: "timeline drag must not re-inline extracted allocation action helper implementations", }, { pattern: /\bfunction attachDocumentMouseDrag\b/, message: "timeline drag must not re-inline extracted document listener helper implementations", }, { pattern: /\bfunction (?:isAllocationMultiSelected|startAllocationMultiDrag|updateAllocationMultiDrag|finalizeAllocationMultiDrag)\b/, message: "timeline drag must not re-inline extracted allocation multi-drag helper implementations", }, { pattern: /\bfunction createAllocationDragState\b/, message: "timeline drag must not re-inline extracted allocation drag bootstrap helpers", }, { pattern: /\bfunction (?:createProjectDragState|buildProjectShiftMutationInput)\b/, message: "timeline drag must not re-inline extracted project drag helper implementations", }, ], }, { 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(); }