#!/usr/bin/env node /** * Ratchet guard against post-audit antipatterns. * * The audit found two repository-wide problems that are being remediated in * dedicated campaigns (A1 = route auth/MFA, D1 = money rounding). Those touch * hundreds of sites and won't land in one PR: so this guard makes sure the * count can only go DOWN, never up, while the migrations are in flight. * * Checks: * 1. raw-route-auth : an `app/api/**\/route.ts` that calls * `supabase.auth.getUser()` directly instead of going through * `requireAuth()` / `withRouteContext()` (the only guards that enforce * MFA AAL2 on hosted). Tracked as a file-set so a NEW offending route * fails CI even if an old one was fixed in the same PR. * 2. naive-ore-round: `Math.round(x * 100) / 100`, which is subtly wrong on * exact-half values (see lib/money.ts `roundOre`). Tracked as a count. * The canonical rounding modules are excluded. * 3. direct-jel-insert: a file that inserts into `journal_entry_lines` * outside the sanctioned writers. During the dimensions dual-write window * every line writer must derive cost_center/project via * lineDimensionColumns() from the dimensions JSONB map * (lib/bookkeeping/dimension-resolver.ts): a new direct insert site can * silently diverge the mirror columns. Tracked as a file-set. * 4. pinned-dep : a dependency pinned to an exact version (PINNED_DEPS) * whose package.json spec or locked version drifted from the pin. Guards * against a repeat of the @anthropic-ai/bedrock-sdk 0.32.0 prod outage * (empty Bedrock stream). No baseline: any drift is a hard failure. * 5. raw-user-error: raw caught-error messages passed to API response fields, * client error state, or toast fields. Engine, database, and upstream * messages must pass through getErrorMessage() or errorResponse(). * * Usage: * node scripts/checks/no-new-antipatterns.mjs # check (CI) * node scripts/checks/no-new-antipatterns.mjs --update # re-baseline after a migration ratchets the count down * * Exit code 1 if either check regressed past its baseline. */ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') const BASELINE_PATH = path.join(ROOT, 'scripts', 'checks', 'antipatterns-baseline.json') const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage']) // The sanctioned home of the öre-round implementation: must not count against itself. const ROUND_EXEMPT = new Set(['lib/money.ts', 'lib/bokslut/rounding.ts']) const RAW_AUTH_RE = /\.auth\.getUser\(/ // Match the guard at its CALL site, not a bare import, so a file that imports // withRouteContext but still hand-rolls getUser() on another handler is still // flagged. withRouteContext is usually called with a generic (`withRouteContext<…>(`), // so accept either `<` or `(` after the name. const GUARD_RE = /requireAuth\(|withRouteContext[<(]/ const NAIVE_ROUND_RE = /Math\.round\([^\n]*\*\s*100\s*\)\s*\/\s*100/ function walk(dir, exts, out = []) { let entries try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return out } for (const e of entries) { if (e.name.startsWith('.') && e.name !== '.well-known') continue const full = path.join(dir, e.name) if (e.isDirectory()) { if (!IGNORE_DIRS.has(e.name)) walk(full, exts, out) } else if (exts.some((x) => e.name.endsWith(x))) { out.push(full) } } return out } const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/') /** Route files that hand-roll auth instead of the MFA-enforcing guard. */ function findRawRouteAuth() { const apiDir = path.join(ROOT, 'app', 'api') return walk(apiDir, ['route.ts']) .filter((f) => { const src = fs.readFileSync(f, 'utf8') return RAW_AUTH_RE.test(src) && !GUARD_RE.test(src) }) .map(rel) .sort() } // Sanctioned journal_entry_lines insert sites. engine/storno write mirrors via // dimension-resolver; sie-import and sandbox seed write neither dims nor // mirrors (DB defaults keep them consistent). const JEL_INSERT_SANCTIONED = new Set([ 'lib/bookkeeping/engine.ts', 'lib/core/bookkeeping/storno-service.ts', 'lib/import/sie-import.ts', 'app/api/sandbox/seed/route.ts', ]) // Matches an insert CHAINED on the lines table (`.from('journal_entry_lines').insert(`, // with optional whitespace/newlines in the chain): select-only readers don't count. const JEL_INSERT_CHAIN_RE = /\.from\(\s*['"]journal_entry_lines['"]\s*\)\s*\.\s*(insert|upsert)\(/ /** Files that insert into journal_entry_lines outside the sanctioned writers. */ function findDirectJelInserts() { const files = [ ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']), ] return files .filter((f) => { const r = rel(f) if (JEL_INSERT_SANCTIONED.has(r)) return false if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false return JEL_INSERT_CHAIN_RE.test(fs.readFileSync(f, 'utf8')) }) .map(rel) .sort() } /** Count of naive Math.round(x*100)/100 occurrences (lines) across source. */ function countNaiveRound() { const files = [ ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']), ] let count = 0 for (const f of files) { if (ROUND_EXEMPT.has(rel(f))) continue for (const line of fs.readFileSync(f, 'utf8').split('\n')) { if (NAIVE_ROUND_RE.test(line)) count++ } } return count } // Dependencies pinned to an EXACT version on purpose, because a bump broke prod // and must not silently return via `npm update`, a dependabot bump, or a manual // install. Any drift (in package.json OR the lockfile) fails CI. See DECISIONS.md. const PINNED_DEPS = [ { name: '@anthropic-ai/bedrock-sdk', version: '0.29.1', reason: '0.32.0 (grouped dependabot bump #884) broke Bedrock streaming in prod: empty stream, ' + '"request ended without sending any chunks", taking down the AI assistant + invoice OCR. ' + 'Keep 0.29.1 until 0.32.x streaming is verified against Bedrock.', }, ] /** Pinned deps whose package.json spec or locked version drifted from the pin. */ function findPinnedDepViolations() { const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) const lock = JSON.parse(fs.readFileSync(path.join(ROOT, 'package-lock.json'), 'utf8')) const declared = { ...pkg.dependencies, ...pkg.devDependencies } const out = [] for (const pin of PINNED_DEPS) { const spec = declared[pin.name] if (spec !== undefined && spec !== pin.version) { out.push({ ...pin, where: 'package.json', actual: spec }) } const locked = lock.packages?.[`node_modules/${pin.name}`]?.version if (locked !== undefined && locked !== pin.version) { out.push({ ...pin, where: 'package-lock.json', actual: locked }) } } return out } const USER_ERROR_FIELD_NAMES = new Set([ 'description', 'detail', 'details', 'error', 'message', 'reason', 'title', ]) function propertyNameText(name) { if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) { return name.text } return null } function propertyPath(node) { const parts = [] let current = node while (ts.isPropertyAccessExpression(current)) { parts.unshift(current.name.text) current = current.expression } if (ts.isIdentifier(current)) parts.unshift(current.text) return parts } function isRawErrorMessage(node) { if (!ts.isPropertyAccessExpression(node) || node.name.text !== 'message') return false const parts = propertyPath(node) if (parts.length < 2) return false const root = parts[0] return ( /^(?:e|err|error|cause)$/i.test(root) || /(?:Error|Err)$/.test(root) || parts.slice(0, -1).some((part) => /^(?:error|first_error)$/i.test(part)) ) } function isErrorLikeIdentifier(node) { return ts.isIdentifier(node) && ( /^(?:e|err|error|cause)$/i.test(node.text) || /(?:Error|Err)$/.test(node.text) ) } function isRawErrorString(node) { return ( ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'String' && node.arguments.length === 1 && isErrorLikeIdentifier(node.arguments[0]) ) } function containsRawErrorMessage(node) { let found = false const visit = (child) => { if (found) return if (isRawErrorMessage(child) || isRawErrorString(child)) { found = true return } ts.forEachChild(child, visit) } visit(node) return found } function enclosingCatch(node) { let current = node.parent while (current) { if (ts.isCatchClause(current)) return current if (ts.isFunctionLike(current)) return null current = current.parent } return null } const taintedCatchNames = new WeakMap() function getTaintedNames(catchClause) { const cached = taintedCatchNames.get(catchClause) if (cached) return cached const declarations = [] const collect = (node) => { if ( ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer ) { declarations.push(node) } ts.forEachChild(node, collect) } collect(catchClause.block) const names = new Set() const isTaintedValue = (node) => { if (isRawErrorMessage(node) || isRawErrorString(node)) return true if (ts.isCallExpression(node) && callName(node) === 'getErrorMessage') return false if (ts.isConditionalExpression(node)) { return isTaintedValue(node.whenTrue) || isTaintedValue(node.whenFalse) } if (ts.isBinaryExpression(node) && node.operatorToken.kind !== ts.SyntaxKind.PlusToken) { return false } let tainted = false const visit = (child) => { if (tainted) return if (isRawErrorMessage(child) || isRawErrorString(child)) { tainted = true return } if (ts.isIdentifier(child) && names.has(child.text)) { tainted = true return } if (ts.isCallExpression(child) && callName(child) === 'getErrorMessage') return ts.forEachChild(child, visit) } ts.forEachChild(node, visit) return tainted } let changed = true while (changed) { changed = false for (const declaration of declarations) { if (names.has(declaration.name.text)) continue const tainted = isTaintedValue(declaration.initializer) if (tainted) { names.add(declaration.name.text) changed = true } } } taintedCatchNames.set(catchClause, names) return names } function containsRawOrTaintedError(node) { if (containsRawErrorMessage(node)) return true const catchClause = enclosingCatch(node) if (!catchClause) return false const names = getTaintedNames(catchClause) let found = false const visit = (child) => { if (found) return if (ts.isIdentifier(child) && names.has(child.text)) { found = true return } if (ts.isPropertyAssignment(child)) { visit(child.initializer) return } ts.forEachChild(child, visit) } visit(node) return found } function callName(call) { const expression = call.expression if (ts.isIdentifier(expression)) return expression.text if (ts.isPropertyAccessExpression(expression)) return expression.name.text return '' } function isLoggingCall(call) { const expression = call.expression if (!ts.isPropertyAccessExpression(expression)) return false const owner = expression.expression.getText() return ( owner === 'console' || /(?:^|\.)log$/.test(owner) || /Log$/.test(owner) || owner.endsWith('Logger') ) } function ancestorCall(node, predicate = () => true) { let current = node.parent while (current) { if (ts.isCallExpression(current) && predicate(current)) return current if (ts.isFunctionLike(current)) return null current = current.parent } return null } function isApiResponseCall(call) { const name = callName(call) if (/^(?:errorResponse|errorResponseFromCode|getErrorMessage)$/.test(name)) return false if (/^(?:json|v1ErrorResponse|v1ErrorResponseFromCode)$/.test(name)) return true if (ts.isPropertyAccessExpression(call.expression)) { return call.expression.name.text === 'json' } return false } function isClientErrorSetter(call) { const name = callName(call) return name === 'toast' || /^set[A-Z].*(?:Error|Message)$/.test(name) || name === 'setError' } /** * Raw caught-error messages in user-visible sinks. This is deliberately an * AST check: line regexes cannot distinguish a logger payload from a JSON * response, nor a Zod issue message from err.message. */ function findRawUserErrors() { const files = [ ...walk(path.join(ROOT, 'app', 'api'), ['route.ts']), ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']).filter((f) => !rel(f).startsWith('app/api/')), ...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']), ] const findings = [] for (const file of files) { const sourceText = fs.readFileSync(file, 'utf8') const source = ts.createSourceFile( file, sourceText, ts.ScriptTarget.Latest, true, file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, ) const isApi = rel(file).startsWith('app/api/') const add = (node) => { const pos = source.getLineAndCharacterOfPosition(node.getStart(source)) findings.push(`${rel(file)}:${pos.line + 1}`) } const visit = (node) => { if (ts.isPropertyAssignment(node)) { const field = propertyNameText(node.name) if ( field && USER_ERROR_FIELD_NAMES.has(field) && !ts.isObjectLiteralExpression(node.initializer) && containsRawOrTaintedError(node.initializer) ) { const loggingCall = ancestorCall(node, isLoggingCall) const clientSink = ancestorCall(node, isClientErrorSetter) if (!loggingCall) { if (isApi || clientSink) add(node) } } } if (ts.isCallExpression(node) && node.arguments.some(containsRawOrTaintedError)) { if (!isLoggingCall(node)) { if ((isApi && isApiResponseCall(node)) || (!isApi && isClientErrorSetter(node))) { add(node) } } } ts.forEachChild(node, visit) } visit(source) } return [...new Set(findings)].sort() } const current = { rawRouteAuth: findRawRouteAuth(), naiveOreRound: countNaiveRound(), directJelInsert: findDirectJelInserts(), pinnedDepViolations: findPinnedDepViolations(), rawUserErrors: findRawUserErrors(), } const isUpdate = process.argv.includes('--update') if (isUpdate) { const baseline = { _comment: 'Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).', rawRouteAuth: { count: current.rawRouteAuth.length, files: current.rawRouteAuth }, naiveOreRound: { count: current.naiveOreRound }, } fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n') console.log( `Baseline written: ${current.rawRouteAuth.length} raw-route-auth files, ${current.naiveOreRound} naive-ore-round occurrences.`, ) process.exit(0) } if (!fs.existsSync(BASELINE_PATH)) { console.error('No baseline found. Run: node scripts/checks/no-new-antipatterns.mjs --update') process.exit(1) } const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')) let failed = false // 1. raw-route-auth: any file not in the baseline set is a NEW violation. const baselineSet = new Set(baseline.rawRouteAuth.files) const newAuthFiles = current.rawRouteAuth.filter((f) => !baselineSet.has(f)) const fixedAuthFiles = baseline.rawRouteAuth.files.filter((f) => !current.rawRouteAuth.includes(f)) if (newAuthFiles.length) { failed = true console.error( `\n✗ raw-route-auth: ${newAuthFiles.length} new route(s) call supabase.auth.getUser() directly ` + `instead of requireAuth()/withRouteContext() (skips MFA AAL2 enforcement):`, ) newAuthFiles.forEach((f) => console.error(` ${f}`)) console.error(' → wrap the route in withRouteContext (or call requireAuth) so MFA is enforced.') } // 1b. direct-jel-insert: allowlist lives in this file (JEL_INSERT_SANCTIONED), // no baseline: any unsanctioned insert site is a hard failure. if (current.directJelInsert.length) { failed = true console.error( `\n✗ direct-jel-insert: ${current.directJelInsert.length} file(s) insert into journal_entry_lines ` + `outside the sanctioned writers:`, ) current.directJelInsert.forEach((f) => console.error(` ${f}`)) console.error( ' → route line writes through lib/bookkeeping/engine.ts, or derive cost_center/project via\n' + ' lineDimensionColumns() (lib/bookkeeping/dimension-resolver.ts) and add the file to\n' + ' JEL_INSERT_SANCTIONED in this script with a justification.', ) } // 1c. pinned-dep: a version-pinned dependency must match its pin EXACTLY, in // both package.json and the lockfile. No baseline: any drift is a hard failure. if (current.pinnedDepViolations.length) { failed = true console.error(`\n✗ pinned-dep: ${current.pinnedDepViolations.length} version-pinned dependency change(s):`) current.pinnedDepViolations.forEach((v) => console.error( ` ${v.name} in ${v.where}: found "${v.actual}", must be exactly "${v.version}".\n ${v.reason}`, ), ) console.error( ' → restore the pin (npm install @ --save-exact). Only change PINNED_DEPS in\n' + ' this script once the upstream regression is confirmed fixed.', ) } // 1d. raw-user-error: user-facing sinks must never receive err.message. if (current.rawUserErrors.length) { failed = true console.error( `\n✗ raw-user-error: ${current.rawUserErrors.length} user-visible sink(s) expose a raw caught-error message:`, ) current.rawUserErrors.forEach((finding) => console.error(` ${finding}`)) console.error( ' → map the error through getErrorMessage(), or throw it inside withRouteContext so\n' + ' errorResponse() produces the canonical structured envelope.', ) } // 2. naive-ore-round: count may not increase. if (current.naiveOreRound > baseline.naiveOreRound.count) { failed = true console.error( `\n✗ naive-ore-round: ${current.naiveOreRound} occurrences of Math.round(x*100)/100 ` + `(baseline ${baseline.naiveOreRound.count}, +${current.naiveOreRound - baseline.naiveOreRound.count}).`, ) console.error(' → import roundOre from @/lib/money instead.') } // Report ratchet-down progress (informational, never fails). if (fixedAuthFiles.length || current.naiveOreRound < baseline.naiveOreRound.count) { console.log('\n✓ Progress since baseline:') if (fixedAuthFiles.length) console.log(` raw-route-auth: -${fixedAuthFiles.length} file(s)`) if (current.naiveOreRound < baseline.naiveOreRound.count) console.log(` naive-ore-round: -${baseline.naiveOreRound.count - current.naiveOreRound} occurrence(s)`) console.log(' Run with --update to ratchet the baseline down and lock in the gains.') } if (failed) { console.error('\nAntipattern guard failed: see above.') process.exit(1) } console.log( `\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, direct-jel-insert: 0, pinned-dep: 0, raw-user-error: 0).`, )