From 2eb01f59b3b29d7633cd1381840b0ea6cbaf166e Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Sun, 24 May 2026 01:07:13 +0200 Subject: [PATCH] feat(ci): catalogue-drift gate for @RequirePrivilege/@RequireRole literals (ADR-0025) (#211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Phase 3 (and last) of [ADR-0025](docs/decisions/0025-authorization-model-privileges-roles-scopes.md)'s implementation phasing (§"More Information" / §347): a small CI gate that asserts every string literal passed to the authorization decorators belongs to the closed catalogue declared in `libs/shared/auth/src/lib/authorization.types.ts`. The TypeScript decorator signatures (`(...privileges: [Privilege, ...Privilege[]])`) already enforce this at compile time. The gate is **defence-in-depth** against the escape hatches the type system cannot catch: - explicit `as Privilege` / `as FunctionalRole` casts (`'Portal.Foo' as Privilege`), - the rare case where a developer hand-edits the catalogue type union without updating the runtime constant. Runs in `<1s`, so it rides the existing `check` CI job rather than spinning up its own. ## What lands | File | Role | | --- | --- | | `scripts/check-catalogue-drift.mjs` | The gate. TypeScript compiler API. Parses `authorization.types.ts` to extract the catalogues, walks every `.ts` file under `apps/` and `libs/`, finds each `CallExpression` whose callee identifier matches `RequirePrivilege` / `RequireRole`, validates every string-literal argument. Non-literal args are skipped (the TypeScript signature catches them already). Reports grouped by file with `line:column` per violation, exits 1 on drift. | | `scripts/check-catalogue-drift.spec.mjs` | 13 tests via `node --test` (built-in runner, no Vitest dep). Covers catalogue parsing, file-level violation detection, workspace-wide aggregation, skipped folders, gen-stub exclusion, line/column reporting. | | `package.json` | `ci:catalogue-drift` + `ci:catalogue-drift:test` scripts. | | `.gitea/workflows/ci.yml` | The existing `check` job now runs the gate's unit tests first (fail-fast if the gate itself is broken), then the gate against the live workspace. | ## Notes for the reviewer - **Why not an ESLint custom rule.** ADR-0025 §347 floats either option. The ESLint route would give editor-time feedback (red squiggles) but means standing up a local ESLint plugin lib — net ~300 lines of plumbing for a gate the TypeScript signature already enforces in real time via the literal-union types. The pnpm-script route mirrors the existing `ci:gzip-budgets` pattern; ~250 lines including tests; runs in the same `check` CI job that already installs deps. Editor feedback is **already provided by `tsc`**, so the gate's job reduces to "catch escape hatches in CI", which the script does fine. - **Why `node --test` rather than Vitest / Jest.** The script lives in `scripts/`, outside any Nx project. Wiring Vitest for one spec file would mean a vitest.config + tsconfig.spec + Nx project just to host it. Node's built-in test runner ships with the runtime we already pin (Node 24 in `.nvmrc`), no config, ~250ms wall clock for 13 tests. - **Generated gRPC stubs are skipped.** `apps/portal-bff/src/grpc/gen/apf-ai/common.ts` defines a `roles: string[]` proto field used by the AI-bridge — entirely unrelated to the ADR-0025 functional-role catalogue. The skip list is explicit (`SKIPPED_SUBPATHS`); adding a new codegen output later is one line. - **Spec files are NOT skipped.** `auth-guards.persona-matrix.spec.ts` references catalogue values via the decorators in its test fixtures. Those are deliberate references and must stay in sync — if a future ADR amendment removes a role, the spec catches it on the same run as the production code. Explicitly tested in `does NOT skip spec files`. - **Non-literal arguments (variable indirection) are skipped.** A pattern like `const slug = getRole(); @RequireRole(slug)` would not be caught by string-literal inspection. This is intentional: the TypeScript decorator signature already requires `slug` to be typed `FunctionalRole`, so the type system handles it; the gate's value-add is on literal misspellings the type system cannot see past an `as Privilege` cast. - **Self-test confirmed the gate works.** Injected `RequirePrivilege('Portal.RogueDrift')` and `RequireRole('rogue-role-x')` into an existing spec, ran `pnpm ci:catalogue-drift`, observed: ``` catalogue-drift: violations found apps/portal-bff/src/auth/require-privilege.guard.spec.ts 135:18 @RequirePrivilege('Portal.RogueDrift') — not in PRIVILEGES 136:13 @RequireRole('rogue-role-x') — not in FUNCTIONAL_ROLES ``` Exit code 1. Reverted the injection; gate green again. - **Adding a third decorator** (per ADR-0025's anticipated growth) is one line in `DECORATOR_TO_CATALOGUE` plus the matching test fixture. The script does not hardcode the decorator name list beyond that map. - **Why no scope-kind check.** `@RequireScope` takes an `(req) => ScopableResource` extractor, not literals. The scope `kind` values are constrained by the `ScopeKind` discriminated union, which TypeScript enforces at every `{ kind: ... }` construction site. No literal escape hatch worth gating in v1. ## Test plan - [x] `pnpm ci:catalogue-drift:test` — 13/13 green via `node --test`. - [x] `pnpm ci:catalogue-drift` — clean against the live workspace: `catalogue-drift: clean (catalogues: 4 privileges, 24 roles).` - [x] Self-test by injection — script catches `'Portal.RogueDrift'` and `'rogue-role-x'` with file:line:column, exits 1. - [x] `pnpm exec prettier --check` on the new files — clean. - [ ] CI run on this PR exercises the new step in the `check` job. ## What's next ADR-0025's phasing closes with this PR. Remaining authorization work waits on [ADR-0026](docs/decisions/) (proposed) — the `Person` + `User` schema brings the Prisma-backed `user_scopes` table; that PR replaces `StubScopeResolver` with a `PrismaScopeResolver` and unlocks the first concrete `@RequireScope` consumer surfaces. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/211 --- .gitea/workflows/ci.yml | 9 + package.json | 2 + scripts/check-catalogue-drift.mjs | 273 +++++++++++++++++++++++++ scripts/check-catalogue-drift.spec.mjs | 233 +++++++++++++++++++++ 4 files changed, 517 insertions(+) create mode 100644 scripts/check-catalogue-drift.mjs create mode 100644 scripts/check-catalogue-drift.spec.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7123a19..3b09f26 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -44,6 +44,15 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm ci:check + # Catalogue-vs-code drift gate per ADR-0025 §"Confirmation". + # Cheap (parses ~workspace .ts files via the TypeScript + # compiler API, ~1s on a warm cache) so it rides the same + # `check` job rather than spinning up a new one. The + # script's own unit tests run first — if they fail the gate + # itself is broken and the actual catalogue check would be + # noise. + - run: pnpm ci:catalogue-drift:test + - run: pnpm ci:catalogue-drift scan: runs-on: [self-hosted, on-prem] diff --git a/package.json b/package.json index 3196ab0..81f301d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "prepare": "husky", "ci:check": "pnpm exec nx affected -t format:check lint test build", "ci:audit": "pnpm audit --audit-level=moderate", + "ci:catalogue-drift": "node scripts/check-catalogue-drift.mjs", + "ci:catalogue-drift:test": "node --test scripts/check-catalogue-drift.spec.mjs", "ci:commits": "pnpm exec commitlint --from ${COMMIT_LINT_FROM:-origin/main} --to HEAD --verbose", "ci:gzip-budgets": "node scripts/check-gzip-budgets.mjs", "ci:perf": "pnpm exec nx build portal-shell --configuration=production && pnpm ci:gzip-budgets && pnpm exec lhci autorun --config=./lighthouserc.js", diff --git a/scripts/check-catalogue-drift.mjs b/scripts/check-catalogue-drift.mjs new file mode 100644 index 0000000..23e32c5 --- /dev/null +++ b/scripts/check-catalogue-drift.mjs @@ -0,0 +1,273 @@ +#!/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'); + +/** + * 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; +} + +/** + * 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 out = []; + for (const dirName of ['apps', 'libs']) { + const dir = join(workspaceRoot, dirName); + for (const file of walkTsFiles(dir, workspaceRoot)) { + out.push(...findViolationsInFile(file, catalogues)); + } + } + return { violations: out, catalogues }; +} + +function main() { + const { violations, catalogues } = scanWorkspace(); + + if (violations.length === 0) { + const privCount = catalogues.PRIVILEGES.size; + const roleCount = catalogues.FUNCTIONAL_ROLES.size; + console.log( + `catalogue-drift: clean (catalogues: ${privCount} privileges, ${roleCount} roles).`, + ); + 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) { + console.error( + ` ${v.line}:${v.column} @${v.callee}('${v.value}') — not in ${v.catalogue}`, + ); + } + } + console.error(''); + console.error( + `Catalogues are closed in v1 per ADR-0025. To add a value, amend ` + + `the ADR and the corresponding constant in ` + + `libs/shared/auth/src/lib/authorization.types.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(); +} diff --git a/scripts/check-catalogue-drift.spec.mjs b/scripts/check-catalogue-drift.spec.mjs new file mode 100644 index 0000000..e3452a5 --- /dev/null +++ b/scripts/check-catalogue-drift.spec.mjs @@ -0,0 +1,233 @@ +/** + * Tests for `check-catalogue-drift.mjs`. Run via: + * node --test scripts/check-catalogue-drift.spec.mjs + * + * Uses Node's built-in test runner (no Vitest / Jest dependency) + * because the script itself has no per-app project hosting it; + * shipping a third test runner just for this one file would be + * overkill. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + extractCatalogues, + findViolationsInFile, + scanWorkspace, +} from './check-catalogue-drift.mjs'; + +/** + * Build a self-contained workspace fixture with: + * - a synthesised authorization.types.ts file mirroring the real + * catalogue declarations, + * - a bag of .ts files under apps/ + libs/ exercising the + * decorators with whichever values the caller asks for. + */ +function makeFixture({ files }) { + const root = mkdtempSync(join(tmpdir(), 'drift-fixture-')); + mkdirSync(join(root, 'libs/shared/auth/src/lib'), { recursive: true }); + writeFileSync( + join(root, 'libs/shared/auth/src/lib/authorization.types.ts'), + `export const PRIVILEGES = [ + 'Portal.Admin', + 'Portal.Auditor', + ] as const; + export const FUNCTIONAL_ROLES = [ + 'collaborateur', + 'rh', + 'dpo', + ] as const; + export const SCOPE_KINDS = [ + 'self', + 'unrestricted', + ] as const; + `, + ); + for (const [relativePath, contents] of Object.entries(files)) { + const full = join(root, relativePath); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, contents); + } + return root; +} + +describe('extractCatalogues', () => { + it('reads the three closed catalogues from authorization.types.ts', () => { + const root = makeFixture({ files: {} }); + const cataloguePath = join(root, 'libs/shared/auth/src/lib/authorization.types.ts'); + const cats = extractCatalogues(cataloguePath); + assert.deepEqual([...cats.PRIVILEGES].sort(), ['Portal.Admin', 'Portal.Auditor']); + assert.deepEqual([...cats.FUNCTIONAL_ROLES].sort(), ['collaborateur', 'dpo', 'rh']); + }); + + it('throws when the catalogue file omits an expected declaration', () => { + const root = mkdtempSync(join(tmpdir(), 'drift-fixture-')); + mkdirSync(join(root, 'libs/shared/auth/src/lib'), { recursive: true }); + writeFileSync( + join(root, 'libs/shared/auth/src/lib/authorization.types.ts'), + `export const PRIVILEGES = ['Portal.Admin'] as const;\n`, + ); + assert.throws( + () => extractCatalogues(join(root, 'libs/shared/auth/src/lib/authorization.types.ts')), + /FUNCTIONAL_ROLES/, + ); + }); +}); + +describe('findViolationsInFile', () => { + const catalogues = { + PRIVILEGES: new Set(['Portal.Admin', 'Portal.Auditor']), + FUNCTIONAL_ROLES: new Set(['collaborateur', 'rh', 'dpo']), + }; + + it('returns no violations on a file that uses only catalogue values', () => { + const text = ` + class Foo { + @RequirePrivilege('Portal.Admin') + @RequireRole('rh', 'collaborateur') + method() {} + } + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 0); + }); + + it('flags every off-catalogue privilege value', () => { + const text = ` + class Foo { + @RequirePrivilege('Portal.Admin', 'Portal.Bogus') + method() {} + } + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 1); + assert.equal(violations[0].callee, 'RequirePrivilege'); + assert.equal(violations[0].value, 'Portal.Bogus'); + assert.equal(violations[0].catalogue, 'PRIVILEGES'); + }); + + it('flags every off-catalogue role value', () => { + const text = ` + class Foo { + @RequireRole('rh', 'rogue-role') + method() {} + } + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 1); + assert.equal(violations[0].callee, 'RequireRole'); + assert.equal(violations[0].value, 'rogue-role'); + assert.equal(violations[0].catalogue, 'FUNCTIONAL_ROLES'); + }); + + it('skips non-literal arguments (variable indirection)', () => { + // A real-world escape hatch — TypeScript catches this at + // compile time via the decorator signature, the drift gate is + // best-effort here. + const text = ` + const slug = 'rogue-role'; + class Foo { + @RequireRole(slug) + method() {} + } + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 0); + }); + + it('also detects the literal when the decorator is invoked as a function call (non-decorator usage)', () => { + // A test fixture might call RequirePrivilege as a function to + // produce a decorator value for a manual UseGuards pattern; the + // call site shape is identical so the script catches it. + const text = ` + const dec = RequirePrivilege('Portal.Bogus'); + `; + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 1); + assert.equal(violations[0].value, 'Portal.Bogus'); + }); + + it('reports line and column of the offending literal', () => { + const text = ['line1', 'line2', " @RequireRole('rogue-role')"].join('\n'); + const violations = findViolationsInFile('virtual.ts', catalogues, text); + assert.equal(violations.length, 1); + assert.equal(violations[0].line, 3); + // The literal starts at column 16 (0-indexed 15, +1 in the report). + assert.equal(violations[0].column, 16); + }); +}); + +describe('scanWorkspace', () => { + it('returns 0 violations on a clean fixture', () => { + const root = makeFixture({ + files: { + 'apps/x/src/main.ts': ` + class Foo { + @RequirePrivilege('Portal.Admin') + @RequireRole('rh', 'collaborateur') + m() {} + } + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 0); + }); + + it('aggregates violations across multiple files', () => { + const root = makeFixture({ + files: { + 'apps/x/src/a.ts': ` + @RequirePrivilege('Portal.Ghost') class A {} + `, + 'libs/y/src/b.ts': ` + @RequireRole('phantom-role') class B {} + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 2); + const values = violations.map((v) => v.value).sort(); + assert.deepEqual(values, ['Portal.Ghost', 'phantom-role']); + }); + + it('skips dist/ and node_modules/ folders even when they contain decorator calls', () => { + const root = makeFixture({ + files: { + 'apps/x/dist/built.ts': `@RequireRole('rogue-role') class X {}`, + 'apps/x/node_modules/whatever/foo.ts': `@RequireRole('also-rogue') class Y {}`, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 0); + }); + + it('skips the generated gRPC stubs (different `roles` semantics)', () => { + const root = makeFixture({ + files: { + 'apps/portal-bff/src/grpc/gen/apf-ai/common.ts': ` + // Codegen output: this is NOT an ADR-0025 decorator call, + // it just happens to share an identifier. + @RequireRole('not-a-real-role') class FromGen {} + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 0); + }); + + it('does NOT skip spec files (deliberate decorator usage in tests must stay in sync)', () => { + const root = makeFixture({ + files: { + 'libs/y/src/y.spec.ts': ` + @RequireRole('rogue-role') class TestSubject {} + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 1); + }); +});