#!/usr/bin/env node /** * Catalogue-vs-code drift gate per * [ADR-0025 §"Confirmation"](../docs/decisions/0025-authorization-model-privileges-roles-scopes.md). * * Asserts that every string literal passed to the authorization * decorators in the codebase belongs to the closed catalogue * declared in `libs/shared/auth/src/lib/authorization.types.ts`: * * - `@RequirePrivilege('Portal.X', …)` → every arg ∈ PRIVILEGES * - `@RequireRole('rh', …)` → every arg ∈ FUNCTIONAL_ROLES * * The TypeScript type system enforces the same constraint at * compile time — the decorator signatures are typed against the * `Privilege` / `FunctionalRole` literal unions — so the gate is * defence-in-depth against escape hatches: * * - explicit `as Privilege` casts (`'Portal.Foo' as Privilege`), * - indirection through a `string`-typed local * (`const slug = 'rh'; @RequireRole(slug)` — non-literal * args are skipped here, but the gate at least catches the * literal misspellings), * - and the rarer case where a developer hand-edits the * catalogue type union without updating the runtime constant. * * Implementation: the script parses the catalogue file and every * `.ts` file under `apps/` and `libs/` with the TypeScript compiler * API. For each `CallExpression` whose callee identifier matches * one of the decorator names, every string-literal argument is * checked against the catalogue. Non-literal arguments are * skipped silently; the gate is best-effort for those, but they * are caught by the TypeScript signature anyway. * * Exit code: 0 if no drift, 1 with a grouped report if drift is * found. The script does not consume any env vars; running it * directly (`node scripts/check-catalogue-drift.mjs`) and via * `pnpm ci:catalogue-drift` are equivalent. */ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const WORKSPACE_ROOT = resolve(__dirname, '..'); const CATALOGUE_PATH = join(WORKSPACE_ROOT, 'libs/shared/auth/src/lib/authorization.types.ts'); const STRUCTURE_KIND_PATH = 'apps/portal-bff/src/structures/structure-kind.ts'; /** * A file is only scanned for `Structure.kind` violations if it * imports from the structure-kind module. Outside that context * `kind` is a common property name on unrelated objects and the * scanner would false-positive. The check is text-level (cheap) * and runs before any AST work — files that fail it short-circuit. */ const STRUCTURE_KIND_IMPORT_PATTERN = /\bfrom\s+['"][^'"]*structure-kind['"]/; /** * Decorator name → catalogue array name. Adding a third decorator * (per ADR-0025's anticipated growth) is one line here plus the * matching test fixture. */ const DECORATOR_TO_CATALOGUE = { RequirePrivilege: 'PRIVILEGES', RequireRole: 'FUNCTIONAL_ROLES', }; /** * Folders to skip when walking the workspace. Stays small on * purpose: every other path under `apps/` and `libs/` is fair * game for catalogue references, including specs (the persona * matrix in `auth-guards.persona-matrix.spec.ts` is a deliberate * usage and must stay in sync). */ const SKIPPED_DIRS = new Set([ 'node_modules', 'dist', 'coverage', '.nx', '.angular', '__screenshots__', ]); /** * Files / subpaths to skip outright. The generated gRPC stubs * under `apps/portal-bff/src/grpc/gen/` carry their own * `roles[]` field that has nothing to do with the ADR-0025 * functional-role catalogue — skipping the whole dir keeps the * scanner focused on hand-written code. */ const SKIPPED_SUBPATHS = ['apps/portal-bff/src/grpc/gen']; /** * Parse `authorization.types.ts` and extract the catalogue * arrays as `Set` keyed on the constant's name. * Returns `{ PRIVILEGES: Set, FUNCTIONAL_ROLES: Set }`. * * Exported for the spec — keeps the parser logic testable without * spawning the CLI. */ export function extractCatalogues(sourceFilePath = CATALOGUE_PATH) { const text = readFileSync(sourceFilePath, 'utf8'); const sourceFile = ts.createSourceFile(sourceFilePath, text, ts.ScriptTarget.Latest, true); const out = {}; for (const stmt of sourceFile.statements) { if (!ts.isVariableStatement(stmt)) continue; for (const decl of stmt.declarationList.declarations) { if (!ts.isIdentifier(decl.name) || !decl.initializer) continue; const name = decl.name.text; if (!Object.values(DECORATOR_TO_CATALOGUE).includes(name)) continue; // Catalogues are declared as `[...] as const`; strip the // type assertion to reach the array literal. let init = decl.initializer; if (ts.isAsExpression(init)) init = init.expression; if (!ts.isArrayLiteralExpression(init)) continue; const values = new Set(); for (const el of init.elements) { if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) { values.add(el.text); } } out[name] = values; } } for (const expected of Object.values(DECORATOR_TO_CATALOGUE)) { if (!out[expected]) { throw new Error( `Failed to extract catalogue "${expected}" from ${sourceFilePath} — ` + `the script expected a top-level "export const ${expected} = [...] as const" declaration.`, ); } } return out; } /** * Parse `structure-kind.ts` and extract the `STRUCTURE_KINDS` * constant as `Set`. Mirror of `extractCatalogues` for the * ADR-0027 `Structure.kind` catalogue — same `as const` array * literal pattern, single declaration per file. Exported for the spec. */ export function extractStructureKinds(sourceFilePath = join(WORKSPACE_ROOT, STRUCTURE_KIND_PATH)) { const text = readFileSync(sourceFilePath, 'utf8'); const sourceFile = ts.createSourceFile(sourceFilePath, text, ts.ScriptTarget.Latest, true); for (const stmt of sourceFile.statements) { if (!ts.isVariableStatement(stmt)) continue; for (const decl of stmt.declarationList.declarations) { if (!ts.isIdentifier(decl.name) || decl.name.text !== 'STRUCTURE_KINDS') continue; let init = decl.initializer; if (init && ts.isAsExpression(init)) init = init.expression; if (!init || !ts.isArrayLiteralExpression(init)) continue; const values = new Set(); for (const el of init.elements) { if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) { values.add(el.text); } } return values; } } throw new Error( `Failed to extract STRUCTURE_KINDS from ${sourceFilePath} — ` + `the script expected a top-level "export const STRUCTURE_KINDS = [...] as const" declaration.`, ); } /** * Find every `kind: 'X'` property literal whose X is not in the * STRUCTURE_KINDS catalogue. Restricted to files that import from * the structure-kind module (heuristic — `kind` is a common * property name on unrelated objects; without the restriction the * scanner would false-positive everywhere). Exported for the spec. */ export function findStructureKindViolationsInFile(filePath, validKinds, sourceText) { const text = sourceText ?? readFileSync(filePath, 'utf8'); if (!STRUCTURE_KIND_IMPORT_PATTERN.test(text)) return []; const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true); const violations = []; function visit(node) { if (ts.isPropertyAssignment(node)) { const name = node.name; const isKindKey = (ts.isIdentifier(name) && name.text === 'kind') || (ts.isStringLiteral(name) && name.text === 'kind'); if (isKindKey) { const init = node.initializer; if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) { if (!validKinds.has(init.text)) { const { line, character } = sourceFile.getLineAndCharacterOfPosition(init.getStart()); violations.push({ file: filePath, line: line + 1, column: character + 1, callee: 'Structure.kind', value: init.text, catalogue: 'STRUCTURE_KINDS', }); } } } } ts.forEachChild(node, visit); } visit(sourceFile); return violations; } /** * Walk `dir` recursively and yield every `.ts` file path * (excluding declaration files and the dirs listed in * SKIPPED_DIRS / SKIPPED_SUBPATHS). */ function* walkTsFiles(dir, rootForRelative) { let entries; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { if (SKIPPED_DIRS.has(entry) || entry.startsWith('.')) continue; const full = join(dir, entry); const rel = relative(rootForRelative, full).replaceAll('\\', '/'); if (SKIPPED_SUBPATHS.some((skip) => rel === skip || rel.startsWith(skip + '/'))) { continue; } let st; try { st = statSync(full); } catch { continue; } if (st.isDirectory()) { yield* walkTsFiles(full, rootForRelative); } else if (entry.endsWith('.ts') && !entry.endsWith('.d.ts')) { yield full; } } } /** * Find every catalogue-violation in a single source file. * Returns an array of `{ file, line, column, callee, value, * catalogue }` records. Exported for the spec. */ export function findViolationsInFile(filePath, catalogues, sourceText) { const text = sourceText ?? readFileSync(filePath, 'utf8'); const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true); const violations = []; function visit(node) { if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { const callee = node.expression.text; const catalogueName = DECORATOR_TO_CATALOGUE[callee]; if (catalogueName) { const valid = catalogues[catalogueName]; for (const arg of node.arguments) { if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) { if (!valid.has(arg.text)) { const { line, character } = sourceFile.getLineAndCharacterOfPosition(arg.getStart()); violations.push({ file: filePath, line: line + 1, column: character + 1, callee, value: arg.text, catalogue: catalogueName, }); } } } } } ts.forEachChild(node, visit); } visit(sourceFile); return violations; } /** * Top-level scan: walk `apps/` and `libs/`, accumulate * violations, return them all. Pure (no I/O outside the file * reads it does itself). Exported for the spec. */ export function scanWorkspace(workspaceRoot = WORKSPACE_ROOT) { const catalogues = extractCatalogues( join(workspaceRoot, 'libs/shared/auth/src/lib/authorization.types.ts'), ); const structureKinds = extractStructureKinds(join(workspaceRoot, STRUCTURE_KIND_PATH)); const out = []; for (const dirName of ['apps', 'libs']) { const dir = join(workspaceRoot, dirName); for (const file of walkTsFiles(dir, workspaceRoot)) { out.push(...findViolationsInFile(file, catalogues)); out.push(...findStructureKindViolationsInFile(file, structureKinds)); } } return { violations: out, catalogues, structureKinds }; } function main() { const { violations, catalogues, structureKinds } = scanWorkspace(); if (violations.length === 0) { const privCount = catalogues.PRIVILEGES.size; const roleCount = catalogues.FUNCTIONAL_ROLES.size; const kindCount = structureKinds.size; console.log( `catalogue-drift: clean (catalogues: ${privCount} privileges, ${roleCount} roles, ${kindCount} structure kinds).`, ); process.exit(0); } console.error('catalogue-drift: violations found'); console.error(''); // Group by file for a readable report. Within a file, sort by // line so the output mirrors the editor's view. const byFile = new Map(); for (const v of violations) { if (!byFile.has(v.file)) byFile.set(v.file, []); byFile.get(v.file).push(v); } for (const [file, list] of byFile) { list.sort((a, b) => a.line - b.line || a.column - b.column); const rel = relative(WORKSPACE_ROOT, file).replaceAll('\\', '/'); console.error(` ${rel}`); for (const v of list) { // Decorator-style violations are printed as `@Callee('value')`; // property-literal violations (Structure.kind) are printed as // `kind: 'value'` to match the offending code shape. const display = v.callee.includes('.') ? `${v.callee.split('.').pop()}: '${v.value}'` : `@${v.callee}('${v.value}')`; console.error(` ${v.line}:${v.column} ${display} — not in ${v.catalogue}`); } } console.error(''); console.error( `Catalogues are closed in v1 per ADR-0025 (PRIVILEGES / FUNCTIONAL_ROLES) ` + `and ADR-0027 (STRUCTURE_KINDS). To add a value, amend the ADR and the ` + `corresponding constant in libs/shared/auth/src/lib/authorization.types.ts ` + `or apps/portal-bff/src/structures/structure-kind.ts.`, ); process.exit(1); } // Only run main() when invoked as a CLI (not when imported by the // spec). const isMain = import.meta.url === `file://${process.argv[1]}` || import.meta.url === `file://${resolve(process.argv[1])}`; if (isMain) { main(); }