#!/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). Judged per exported handler, not per file: a * wrapped PATCH next to a hand-rolled DELETE in the same file is still * a violation (that exact shape hid two MFA bypasses until 2026-08-26). * 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. * 3b. ledger-scanning-report: a statement generator under lib/reports or * lib/bokslut that aggregates `journal_entry_lines` itself instead of * going through generateTrialBalance. Aggregating raw lines means * remembering, per report, that the resultatavslut posts the mirror image * of every P&L account into 2099 inside the same fiscal period. Three * reports forgot (årsredovisning 2026-07-23, INK2R and NE-bilaga * 2026-07-29) and each read ZERO revenue for a closed year while the * balance sheet still tied out, so nothing warned. generateTrialBalance * now requires an explicit closingEntry mode, which turns the decision * into a compile error; this guard keeps new reports on that path. * Tracked as a file-set. Voucher/line LISTINGS are sanctioned in * LEDGER_SCAN_SANCTIONED: they have no closingEntry decision to make. * 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(). * 6. sek-labelled-amount: a single-argument formatCurrency() call on a value * read off a record the same file reads `.currency` from, which prints a * foreign amount with the SEK symbol. Implementation and rationale in * format-currency-sek-label.mjs. No baseline: the count is 0 today. * 7. extension-route guards: physical routes under app/api/extensions// * (the sanctioned core-build carve-out for crons/OAuth callbacks) may * only import their OWN extension (hard fail, 0 today) and must gate on * extensionRegistry.get('') so a disabled extension never exposes a * live surface (allowlisted file-set, may only shrink). Implementation * and rationale in extension-route-guards.mjs. * 8. hand-rolled-invariant: a shared format rule (BAS account number, ISO * date, four-digit fiscal year) spelled out inline instead of imported * from lib/invariants/. The BAS account rule was written out at 20 sites * and the ISO date rule at 68, with error messages that differed per site; * the four Skatteverket-bound org-number paths disagreed outright about * what "valid" meant, which is the kind of drift a customer only discovers * when a filing fails at the deadline. Tracked as a count. * 9. leaky-supabase-client: server code importing supabase-js's `createClient` * as a value instead of `createServiceRoleClient()`. The default * `autoRefreshToken: true` starts a 30 s setInterval that is never * cleared; `unref()` keeps the process exitable but not the timer * collectable, so each constructed client retains its whole request scope. * Killed a self-hosted instance after 42 idle hours (2026-08-13). No * baseline: the count is 0 today. * 10. off-ladder-radius: a border-radius class outside the locked ladder * (pill / rounded-xl overlays / rounded-lg surfaces / rounded-sm leaves; * see .claude/rules/design.md). Before the 2026-08 migration the UI had * seven radii in circulation (4/5/6/8/12/16px + pill) and one toolbar row * could mix four of them. `rounded-md`, bare `rounded`, `rounded-2xl`+ * and arbitrary `rounded-[Npx]` are dead vocabulary in app/ and * components/. No baseline: the count is 0, any new one is a hard * failure. * 10. folded-public-flag: `process.env.NEXT_PUBLIC_X === 'true'` compared in * place. The Docker image bakes sentinels that docker-entrypoint.sh * substitutes at container start; an in-place comparison is constant- * folded and dead-code-eliminated at build time, erasing both the name * and the sentinel, so the flag is permanently false however the operator * configures it. Every Docker self-host consequently ran with the * entitlement paywall live (diagnosed 2026-08-17). Read flags as values * via lib/env/public-flags. No baseline: the count is 0, any new one is * a hard failure. * 11. dialog-overflow-risk: patterns that make a dialog scroll sideways. * (a) a bare `1fr` grid track inside grid-cols-[...] in a file that * imports DialogContent/SheetContent: per the CSS Grid spec a bare fr * track's implicit minimum is auto (its content's min-content size), so * the track refuses to shrink below its content and overflows the dialog * (StrikeLinesDialog and CorrectionEntryDialog shipped this, fixed * 2026-08-19; TransactionBookingDialog had the safe minmax(0,1fr) idiom * all along). (b) whitespace-nowrap inside a / * JSX region outside DIALOG_NOWRAP_ALLOWED (numeric * columns inside their own overflow-x-auto wrapper are fine and * allowlisted per file). (c) a hand-rolled absolute overlay forcing * min-w-[>=20rem] in a file that never portals anything to * document.body: DialogContent's overflow-y-auto computes overflow-x to * auto as well, so an oversized non-portaled panel grows the dialog a * horizontal scrollbar instead of repositioning (AccountCombobox's * dropdown pre-2026-08-19). Tracked as a per-file baseline set that may * only shrink. * 12. ambiguous-embed: a PostgREST `.select()` that embeds a table joined to * the from-table by more than one foreign key, without naming the * relationship. PostgREST answers PGRST201 instead of picking one, and * neither a mocked-Supabase unit test (a mock never resolves a * relationship) nor a pg-real test (it bypasses PostgREST) can see that, * so a static guard is the only thing that catches the class. Two sites * shipped the same journal_entries -> fiscal_periods embed: the nightly * underlag cron (fixed 2026-08-31, ~60 period-lock trigger rejections a * night) and supplier-invoice underlag anchoring, which swallowed the * error and therefore never anchored a single document in production. * The ambiguous pairs are derived from supabase/migrations; both hint * forms PostgREST accepts count as disambiguated. Implementation and * rationale in ambiguous-embed.mjs. No baseline: the count is 0 today. * * Usage: * node scripts/checks/no-new-antipatterns.mjs # check (CI) * 11. direct-ai-client: a file outside lib/ai that imports createAiClient, * calls `.messages.create/stream(` on an Anthropic client, or imports * the Vercel AI SDK. Every model call goes through getAiService() so the * backend (Bedrock on hosted, a Swedish OpenAI-compatible endpoint on a * sovereign self-host) stays an environment decision. Allowlist of the * pre-abstraction call sites in this file, may only shrink. * * 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' import { findSekLabelledFxAmounts } from './format-currency-sek-label.mjs' import { findRawReferenceFetches } from './raw-reference-fetch.mjs' import { findClientNodeBuiltins } from './client-node-builtin.mjs' import { findAmbiguousEmbeds } from './ambiguous-embed.mjs' import { findExtensionRouteFindings, UNGATED_EXTENSION_ROUTES, } from './extension-route-guards.mjs' 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[<(]/ // Each top-level `export` starts a new segment, so every handler (and the // preamble of shared helpers above the first export) is judged on its own. // Without this split, one wrapped handler exempted the whole file. const TOP_LEVEL_EXPORT_RE = /^(?=export\s)/m const NAIVE_ROUND_RE = /Math\.round\([^\n]*\*\s*100\s*\)\s*\/\s*100/ // 8. hand-rolled-invariant. Shared format contracts live in lib/invariants/ // (account number, ISO date, four-digit fiscal year, org number). Before that // module the BAS account rule was written out at 20 sites and the ISO date rule // at 68, with error messages that differed per site, and the four // Skatteverket-bound org-number paths did not agree on what "valid" meant. // // Only the two unambiguous regex families are counted. An org-number // digit-strip is too varied in shape to match reliably by regex; the // cross-path test in lib/invariants/__tests__/org-number-cross-path.test.ts is // the guard on that one instead. const HAND_ROLLED_INVARIANT_RES = [ // /^\d{4}$/ or /^[0-9]{4}$/ → accountNumberSchema or fiscalYearSchema /\/\^(?:\\d|\[0-9\])\{4\}\$\//, // /^\d{4}-\d{2}-\d{2}$/ → isoDateSchema or ISO_DATE_RE /\/\^(?:\\d|\[0-9\])\{4\}-(?:\\d|\[0-9\])\{2\}-(?:\\d|\[0-9\])\{2\}\$\//, ] // The sanctioned home of these rules: must not count against itself. const INVARIANT_EXEMPT_PREFIX = 'lib/invariants/' 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('/') /** True when any handler segment calls getUser() without an MFA-enforcing guard. */ function handRollsRouteAuth(src) { return src .split(TOP_LEVEL_EXPORT_RE) .some((segment) => RAW_AUTH_RE.test(segment) && !GUARD_RE.test(segment)) } /** 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) => handRollsRouteAuth(fs.readFileSync(f, 'utf8'))) .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() } // The one module allowed to import supabase-js's createClient as a value: it // is the wrapper that applies SERVER_AUTH_OPTIONS. const LEAKY_CLIENT_SANCTIONED = new Set(['lib/supabase/service-client.ts']) const SUPABASE_JS_IMPORT_RE = /import\s+(type\s+)?\{([^}]*)\}\s*from\s*['"]@supabase\/supabase-js['"]/g // A namespace import hands over the whole module, so `sb.createClient(...)` is // reachable without ever naming it in the import. Treat any value-namespace // import as leaky rather than trying to track member access. const SUPABASE_JS_NAMESPACE_RE = /import\s+(type\s+)?\*\s+as\s+\w+\s+from\s*['"]@supabase\/supabase-js['"]/g /** * Files that import supabase-js's `createClient` as a VALUE instead of going * through createServiceRoleClient(). * * `autoRefreshToken` defaults to true, and auth-js starts the 30 s refresh * ticker unconditionally off-browser. The ticker calls unref(), so the process * still exits and nothing fails in tests or on Vercel, but unref does not make * a timer collectable: it stays a GC root for its callback and retains the * client plus the whole request scope around it. A self-hosted instance died of * heap exhaustion after 42 idle hours this way (2026-08-13), holding 445 * request graphs and ~1050 Timeouts in the 30 000 ms bucket. * * Both named (`{ createClient }`) and namespace (`* as sb`) value imports count: * the latter reaches createClient through member access without naming it. * * Type-only imports are fine; so is the browser client, which needs the ticker * and is built on @supabase/ssr's createBrowserClient anyway. */ function findLeakySupabaseClients() { 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 (LEAKY_CLIENT_SANCTIONED.has(r)) return false if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false const src = fs.readFileSync(f, 'utf8') for (const m of src.matchAll(SUPABASE_JS_IMPORT_RE)) { const [, typeOnly, bindings] = m if (typeOnly) continue const bindsCreateClient = bindings .split(',') .map((b) => b.trim()) .some((b) => b === 'createClient' || b.startsWith('createClient as')) if (bindsCreateClient) return true } for (const m of src.matchAll(SUPABASE_JS_NAMESPACE_RE)) { if (!m[1]) return true } return false }) .map(rel) .sort() } // Statement generators that legitimately read journal_entry_lines directly: // the trial-balance stack itself, and the reports whose whole job is to list // vouchers or lines rather than to aggregate a fiscal year's balances. const LEDGER_SCAN_SANCTIONED = new Set([ // The shared balance source and its helpers. 'lib/reports/trial-balance.ts', 'lib/reports/opening-balances.ts', // Voucher/line listings: they must show the ledger as posted, closing // verifikat included, so there is no closingEntry decision to get wrong. 'lib/reports/general-ledger.ts', 'lib/reports/journal-register.ts', 'lib/reports/latest-vouchers.ts', 'lib/reports/source-lines.ts', 'lib/reports/sie-export.ts', 'lib/reports/full-archive-export.ts', // Aggregate their own dimension-tagged or month-bucketed slice, and each // carries an explicit year-end exclusion of its own. 'lib/reports/dimension-pnl.ts', 'lib/reports/monthly-breakdown.ts', // Reconciliation and diagnostics: they compare against the ledger as posted. 'lib/reports/ar-reconciliation.ts', 'lib/reports/supplier-reconciliation.ts', 'lib/reports/reskontra-payments.ts', 'lib/reports/imbalance-diagnosis.ts', 'lib/reports/continuity-check.ts', 'lib/reports/rc-basis-gaps.ts', 'lib/reports/vat-settlement.ts', 'lib/reports/vat-declaration.ts', 'lib/reports/periodisk-sammanstallning.ts', 'lib/reports/avgifter-basis.ts', 'lib/reports/salary-journal.ts', 'lib/reports/vacation-liability.ts', ]) const LEDGER_SCAN_RE = /\.from\(\s*['"]journal_entry_lines['"]\s*\)|fetchEntryLines\s*[<(]|lines:\s*journal_entry_lines\(/ /** * Statement generators that scan journal_entry_lines instead of going through * generateTrialBalance. * * WHY: a generator that aggregates a fiscal year's balances from raw lines has * to remember, on its own, that the resultatavslut posts the mirror image of * every P&L account into 2099 inside the same period. Three shipped without * remembering (årsredovisning 2026-07-23, INK2R and NE-bilaga 2026-07-29) and * each reported ZERO revenue for a closed year while the balance sheet still * tied out, so nothing warned. generateTrialBalance now REQUIRES a * closingEntry mode, which makes the decision a compile error instead: this * guard is what keeps new generators on that path. */ function findLedgerScanningReports() { const files = [ ...walk(path.join(ROOT, 'lib', 'reports'), ['.ts']), ...walk(path.join(ROOT, 'lib', 'bokslut'), ['.ts']), ] return files .filter((f) => { const r = rel(f) if (LEDGER_SCAN_SANCTIONED.has(r)) return false if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false return LEDGER_SCAN_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 } /** * Occurrences of a shared format rule written out by hand instead of imported * from lib/invariants/. Counted, not file-setted: the campaign lowers the * number file by file and the count may only go down. */ function countHandRolledInvariants() { 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) { const relPath = rel(f) if (relPath.startsWith(INVARIANT_EXEMPT_PREFIX)) continue // Tests legitimately spell out the pattern they are asserting about. if (relPath.includes('__tests__/') || relPath.endsWith('.test.ts')) continue for (const line of fs.readFileSync(f, 'utf8').split('\n')) { if (HAND_ROLLED_INVARIANT_RES.some((re) => re.test(line))) count++ } } return count } // 9. off-ladder-radius. The radius ladder (.claude/rules/design.md) allows // exactly: rounded-full (interactive toolbar controls, chips, dots), // rounded-xl (page panel, dialogs, slide-overs, hero surfaces), rounded-lg // (cards, form fields, popover/menu content, bordered boxes), rounded-sm // (nested leaf elements), rounded-none, and directional variants of those. // Everything else is off-ladder. Bare `rounded` is banned as vocabulary: it // renders the same 4px as rounded-sm but hides from a rounded-sm grep. const OFF_LADDER_RADIUS_RES = [ // rounded-md and any directional variant (rounded-t-md, rounded-bl-md, ...) /\brounded(?:-[trbl]{1,2})?-(?:md|2xl|3xl|4xl)\b/, // arbitrary radius values: rounded-[5px], rounded-t-[10px], ... /\brounded(?:-[trbl]{1,2})?-\[/, ] // Bare `rounded` as a class token (not rounded-*): renders the same 4px as // rounded-sm but hides from a rounded-sm grep. `rounded` is also a common // variable name and an ordinary English word, so this one only counts inside // a quoted string that looks like a Tailwind class list (contains at least // one other utility-class token). const BARE_ROUNDED_RE = /(? BARE_ROUNDED_RE.test(s) && CLASS_LIST_HINT_RE.test(s.slice(1, -1)), ) } /** Off-ladder border-radius classes in UI code. */ function findOffLadderRadii() { const files = [ ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']), ] const findings = [] for (const f of files) { const lines = fs.readFileSync(f, 'utf8').split('\n') for (let i = 0; i < lines.length; i++) { const line = lines[i] // Prose mentions of "rounded" in comments are not class tokens. const trimmed = line.trim() if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue if (OFF_LADDER_RADIUS_RES.some((re) => re.test(line)) || lineHasBareRoundedClass(line)) { findings.push(`${rel(f)}:${i + 1}`) } } } return findings.sort() } // 10. folded-public-flag. The Docker image is built once with sentinel values // (ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__) that // docker-entrypoint.sh seds into .next at container start. Comparing the var in // place defeats that: the bundler inlines the sentinel, the minifier folds // `"__NEXT_PUBLIC_SELF_HOSTED__" === 'true'` to false and eliminates the // branch, so BOTH the name and the sentinel vanish and sed has nothing to // replace. The flag is then permanently false whatever the operator sets. // // That shipped and stayed invisible for weeks: every Docker self-host ran with // the entitlement paywall live, killing ai/bank_sync/skatteverket/email_send 30 // days after company creation. Read public flags as VALUES instead // (flagEnabled(process.env.NEXT_PUBLIC_X) from lib/env/public-flags), which // keeps the sentinel in the output as a live string literal. // // No baseline: the count is 0, any new one is a hard failure. const PUBLIC_FLAG_EXEMPT = new Set(['lib/env/public-flags.ts']) const EQUALITY_OPS = new Set([ ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken, ]) /** `process.env.NEXT_PUBLIC_ANYTHING` as an expression node. */ function isPublicEnvRead(node) { return ( ts.isPropertyAccessExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'process' && node.expression.name.text === 'env' && ts.isIdentifier(node.name) && node.name.text.startsWith('NEXT_PUBLIC_') ) } /** Public env flags compared in place, which the Docker build folds away. */ function findFoldedPublicFlags() { const files = [ ...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'components'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'contexts'), ['.ts', '.tsx']), ...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']), ] const findings = [] for (const file of files) { const relPath = rel(file) if (PUBLIC_FLAG_EXEMPT.has(relPath)) continue // Tests never ship in the image, and they legitimately assert on raw env. // Both layouts: the __tests__/ convention, and a colocated *.test.ts(x), // which would otherwise be a false positive that invites weakening this // guard rather than fixing a real call site. if (relPath.includes('__tests__/') || /\.test\.tsx?$/.test(relPath)) continue const text = fs.readFileSync(file, 'utf8') if (!text.includes('NEXT_PUBLIC_')) continue const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true) const visit = (node) => { if ( ts.isBinaryExpression(node) && EQUALITY_OPS.has(node.operatorToken.kind) && (isPublicEnvRead(node.left) || isPublicEnvRead(node.right)) ) { const pos = source.getLineAndCharacterOfPosition(node.getStart(source)) findings.push(`${relPath}:${pos.line + 1}`) } ts.forEachChild(node, visit) } visit(source) } return [...new Set(findings)].sort() } // 11. dialog-overflow-risk. See the header comment for the three patterns. // Files whose whitespace-nowrap cells are fixed-width numeric/tabular columns // living inside their OWN overflow-x-auto scroll container, so they cannot // widen the dialog itself: // - PaymentFileDialog: payment-line table wrapped in an overflow-x-auto div. // 11. direct-ai-client. Every model call goes through the job-shaped service // in lib/ai (getAiService): that is what lets hosted stay on Bedrock while a // sovereign self-host points at an OpenAI-compatible Swedish endpoint, and // what stops new AI surfaces from hard-wiring one SDK. Outside lib/ai/, a // file may not import createAiClient, call `.messages.create/stream(` on an // Anthropic client, or import the Vercel AI SDK (`ai`, `@ai-sdk/*`). The // allowlist is the pre-abstraction call sites that still speak the Anthropic // SDK directly (chat loop, composer, receipt hunt, WhatsApp interpreter, the // legacy smoke script); it may only shrink as they migrate or are deleted. const DIRECT_AI_CLIENT_ALLOWED = new Set([ 'lib/agent/chat/run-turn.ts', 'lib/agent/composer/atom-selection.ts', 'lib/agent/composer/client.ts', 'lib/agent/composer/narrative.ts', 'lib/agent/composer/prewarm.ts', 'lib/receipt-hunt/adjudicate.ts', 'lib/receipt-hunt/mail-intelligence.ts', 'extensions/general/whatsapp-inbox/lib/interpret-answer.ts', 'scripts/smoke-ai.ts', // Out-of-tree CI reviewer with its own pinned SDK install (see the // compliance workflow); deliberately not part of the app's AI layer. 'scripts/swedish-compliance-review.mjs', ]) const DIRECT_AI_CLIENT_RES = [ { rule: 'createAiClient-import', re: /import[^;]*\bcreateAiClient\b[^;]*from\s+['"]@\/lib\/ai\/provider['"]/ }, { rule: 'anthropic-messages-call', re: /\.messages\.(create|stream)\(/ }, { rule: 'ai-sdk-import', re: /from\s+['"](ai|ai\/[\w-]+|@ai-sdk\/[\w-]+)['"]/ }, ] function findDirectAiClients() { const out = [] for (const dir of ['lib', 'app', 'extensions', 'components', 'scripts']) { for (const file of walk(path.join(ROOT, dir), ['.ts', '.tsx', '.mjs'])) { const r = rel(file) if (r.startsWith('lib/ai/')) continue if (r.includes('/__tests__/') || r.endsWith('.test.ts') || r.endsWith('.test.tsx')) continue const src = fs.readFileSync(file, 'utf8') for (const { rule, re } of DIRECT_AI_CLIENT_RES) { if (re.test(src)) out.push({ file: r, rule }) } } } return out } const DIALOG_NOWRAP_ALLOWED = new Set([ 'components/supplier-invoices/PaymentFileDialog.tsx', ]) const DIALOG_CONTENT_IMPORT_RE = /import\s*\{[^}]*\b(?:DialogContent|SheetContent)\b[^}]*\}\s*from\s*['"]@\/components\/ui\/(?:dialog|sheet)['"]/ const GRID_COLS_TEMPLATE_RE = /grid-cols-\[([^\]]+)\]/g const BARE_FR_TOKEN_RE = /^\d+(?:\.\d+)?fr$/ const WIDE_MIN_W_RE = /min-w-\[(\d+(?:\.\d+)?)(rem|px)\]/g const PORTAL_HINT_RE = /createPortal|\bPortal\b/ const OVERLAY_ABSOLUTE_RE = /\babsolute\b/ const OVERLAY_Z_RE = /\bz-(?:40|50|\[\d+\])/ /** * Overflow-risky patterns in dialog/sheet hosts. Returns * { file, where, rule } findings; the ratchet compares the file set. */ function findDialogOverflowRisks() { const files = [ ...walk(path.join(ROOT, 'app'), ['.tsx']), ...walk(path.join(ROOT, 'components'), ['.tsx']), ...walk(path.join(ROOT, 'extensions'), ['.tsx']), ] const findings = [] for (const f of files) { const r = rel(f) const src = fs.readFileSync(f, 'utf8') const lines = src.split('\n') if (DIALOG_CONTENT_IMPORT_RE.test(src)) { // (a) bare fr grid tracks anywhere in a dialog-hosting file. lines.forEach((line, i) => { for (const m of line.matchAll(GRID_COLS_TEMPLATE_RE)) { if (m[1].split('_').some((token) => BARE_FR_TOKEN_RE.test(token))) { findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'bare-fr-grid-track' }) } } }) // (b) whitespace-nowrap inside the / // region. Line-based depth tracking is a heuristic, but dialog JSX in // this repo keeps the tags on their own lines. if (!DIALOG_NOWRAP_ALLOWED.has(r)) { let depth = 0 lines.forEach((line, i) => { if (/<(?:Dialog|Sheet)Content\b/.test(line)) depth++ if (depth > 0 && line.includes('whitespace-nowrap')) { findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'nowrap-in-dialog' }) } const closes = (line.match(/<\/(?:Dialog|Sheet)Content>/g) || []).length depth = Math.max(0, depth - closes) }) } } // (c) a hand-rolled absolute overlay forcing a >=20rem minimum width in a // file that never portals anything: inside a scrollable DialogContent // that minimum becomes a horizontal scrollbar on the dialog. if (!PORTAL_HINT_RE.test(src) && OVERLAY_ABSOLUTE_RE.test(src) && OVERLAY_Z_RE.test(src)) { lines.forEach((line, i) => { for (const m of line.matchAll(WIDE_MIN_W_RE)) { const value = parseFloat(m[1]) if ((m[2] === 'rem' && value >= 20) || (m[2] === 'px' && value >= 320)) { findings.push({ file: r, where: `${r}:${i + 1}`, rule: 'unportaled-wide-overlay' }) } } }) } } return findings.sort((a, b) => a.where.localeCompare(b.where)) } // 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.', }, { name: '@anthropic-ai/sdk', version: '0.95.0', reason: 'Declared explicitly at the version bedrock-sdk 0.29.1 pulls in transitively (#1406 Tier 1), so ' + 'the lockfile dedupes to one copy; a drift here is a second SDK copy and an untested wire surface.', }, { name: 'ai', version: '6.0.259', reason: 'Vercel AI SDK backs lib/ai/services/openai-compatible.ts (Tier 2 BYO endpoints). Major versions ' + 'rename core APIs; upgrades are deliberate PRs with the provider test suite, never a silent bump.', }, { name: '@ai-sdk/openai-compatible', version: '2.0.69', reason: 'Paired with ai 6.x; the provider package follows its own major cadence and must move together with ' + 'the core pin in one reviewed change.', }, { name: 'nodemailer', version: '9.0.5', reason: 'SMTP mailer for self-hosts (extensions/general/email/lib/smtp-service.ts). Zero-dependency MIT-0 ' + 'package on the outbound-mail path; bumps are deliberate, reviewed PRs (audit surface), never silent.', }, ] const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') /** * Pinned deps whose package.json spec or locked version drifted from the pin. * * Also scans .github/workflows/*.yml for literal `@` installs: * a workflow that installs the SDK by version (e.g. the compliance review's * out-of-tree `npm install ...@0.29.1`) bypasses package.json AND the * lockfile, so it is exactly where the 0.32.0 regression can drift back in * without either file changing. */ 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 = [] const workflowFiles = walk(path.join(ROOT, '.github', 'workflows'), ['.yml', '.yaml']) 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 }) } const literalInstall = new RegExp( `${escapeRegExp(pin.name)}@(\\d+\\.\\d+\\.\\d+(?:[-+][\\w.-]+)?)`, 'g', ) for (const wf of workflowFiles) { const src = fs.readFileSync(wf, 'utf8') for (const match of src.matchAll(literalInstall)) { if (match[1] !== pin.version) { out.push({ ...pin, where: rel(wf), actual: `${pin.name}@${match[1]}` }) } } } } 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(), handRolledInvariants: countHandRolledInvariants(), ledgerScanningReports: findLedgerScanningReports(), directJelInsert: findDirectJelInserts(), leakySupabaseClients: findLeakySupabaseClients(), pinnedDepViolations: findPinnedDepViolations(), rawUserErrors: findRawUserErrors(), sekLabelledAmounts: findSekLabelledFxAmounts(ROOT), extensionRoutes: findExtensionRouteFindings(ROOT), offLadderRadii: findOffLadderRadii(), foldedPublicFlags: findFoldedPublicFlags(), dialogOverflowRisk: findDialogOverflowRisks(), directAiClients: findDirectAiClients(), rawReferenceFetch: findRawReferenceFetches(ROOT), clientNodeBuiltins: findClientNodeBuiltins(ROOT), ambiguousEmbeds: findAmbiguousEmbeds(ROOT), } const dialogOverflowFiles = [...new Set(current.dialogOverflowRisk.map((f) => f.file))].sort() 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 }, handRolledInvariants: { count: current.handRolledInvariants }, ledgerScanningReports: { count: current.ledgerScanningReports.length, files: current.ledgerScanningReports, }, dialogOverflowRisk: { count: dialogOverflowFiles.length, files: dialogOverflowFiles, }, rawReferenceFetch: { count: current.rawReferenceFetch.length, files: current.rawReferenceFetch, }, } 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.', ) } // 1b3. client-node-builtin: a 'use client' module whose static import closure // reaches a Node builtin ships the browser polyfill chunk (~327 KB) with every // route that renders it. No baseline: 0 today, any reacher is a hard failure. if (current.clientNodeBuiltins.length) { failed = true console.error( `\n✗ client-node-builtin: ${current.clientNodeBuiltins.length} client module(s) reach a Node builtin ` + `through their static imports (this ships crypto-browserify/Buffer/vm polyfills to the browser):`, ) current.clientNodeBuiltins.forEach((f) => console.error(` ${f.file} -> ${f.builtin}\n ${f.chain.join('\n > ')}`), ) console.error( ' → move the pure part the client needs into a sibling module without the Node import\n' + ' (see lib/auth/bankid-flags.ts, lib/import/bank-file/formats.ts, lib/salary/personnummer-format.ts,\n' + ' lib/auth/api-key-scopes.ts) and import that from the client. scripts/perf/client-import-closure.mjs\n' + ' prints the full chain for any module.', ) } // 1b4. ambiguous-embed: an embed between two tables joined by more than one // foreign key must name the relationship, or PostgREST answers PGRST201 at // runtime. No baseline: the count is 0 today, any new one is a hard failure. if (current.ambiguousEmbeds.length) { failed = true console.error( `\n✗ ambiguous-embed: ${current.ambiguousEmbeds.length} PostgREST embed(s) between a table pair ` + `that shares more than one foreign key, with no relationship named:`, ) current.ambiguousEmbeds.forEach((f) => console.error(` ${f.where} ${f.from} -> ${f.target}`), ) console.error( ' → name the relationship in the embed, either by constraint\n' + " (.select('fiscal_period:fiscal_periods!journal_entries_fiscal_period_id_fkey(...)'))\n" + " or by foreign key column (.select('journal_entries!opening_balance_entry_id(...)')).\n" + ' Without it PostgREST returns PGRST201 for every call, and no mocked-Supabase test\n' + ' or pg-real test can see it: a mock never resolves a relationship and pg-real does\n' + ' not go through PostgREST at all.', ) } // 1b2. leaky-supabase-client: server code must construct clients through // createServiceRoleClient(). No baseline: the count is 0 today. if (current.leakySupabaseClients.length) { failed = true console.error( `\n✗ leaky-supabase-client: ${current.leakySupabaseClients.length} file(s) import supabase-js's ` + `createClient as a value instead of createServiceRoleClient():`, ) current.leakySupabaseClients.forEach((f) => console.error(` ${f}`)) console.error( ' → import { createServiceRoleClient } from "@/lib/supabase/service-client". Constructing a\n' + ' client directly leaves autoRefreshToken on, which starts a 30 s setInterval that is never\n' + ' cleared and retains the client plus the whole request scope (heap death after ~42 h).\n' + ' Type-only imports are fine: use `import type { SupabaseClient } from "@supabase/supabase-js"`.', ) } // 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.', ) } // 1e. sek-labelled-amount: a foreign amount must never be printed with the SEK // symbol. No baseline: every current single-argument call formats a SEK twin or // a ledger column, so the count is 0 and any new one is a hard failure. if (current.sekLabelledAmounts.length) { failed = true console.error( `\n✗ sek-labelled-amount: ${current.sekLabelledAmounts.length} formatCurrency() call(s) print a possibly-foreign amount as SEK:`, ) current.sekLabelledAmounts.forEach((f) => console.error(` ${f.where} formatCurrency(${f.expr})\n ${f.reason}`), ) console.error( ' → pass the record\'s currency as the second argument, formatCurrency(amount, record.currency),\n' + ' or format the SEK twin (record.amount_sek / record.total_sek) when one exists.', ) } // 1e1. off-ladder-radius: no baseline, the count is 0 after the 2026-08 // migration and any new off-ladder radius class is a hard failure. if (current.offLadderRadii.length) { failed = true console.error( `\n✗ off-ladder-radius: ${current.offLadderRadii.length} border-radius class(es) outside the locked ladder:`, ) current.offLadderRadii.forEach((f) => console.error(` ${f}`)) console.error( ' → use the radius ladder (.claude/rules/design.md): rounded-full for toolbar controls/chips,\n' + ' rounded-xl for overlays, rounded-lg for cards/fields/menu content, rounded-sm for nested\n' + ' leaves. rounded-md, bare `rounded`, rounded-2xl and rounded-[Npx] are dead vocabulary.', ) } // 1e1b. folded-public-flag: no baseline, the count is 0 and any new in-place // comparison is a hard failure. This one is invisible in dev and in the Vercel // build (both have real env values); it only misfires in the Docker image, and // then silently. if (current.foldedPublicFlags.length) { failed = true console.error( `\n✗ folded-public-flag: ${current.foldedPublicFlags.length} NEXT_PUBLIC_* flag(s) compared in place:`, ) current.foldedPublicFlags.forEach((f) => console.error(` ${f}`)) console.error( ' → read the value instead: flagEnabled(process.env.NEXT_PUBLIC_X) from @/lib/env/public-flags\n' + ' (or isSelfHosted()). Comparing in place lets the minifier fold the Docker sentinel to\n' + ' false and delete the branch, so the flag can never be switched on by an operator.', ) } // 1e1c. direct-ai-client: allowlist in this file, may only shrink. A file // outside the allowlist that talks to a model SDK directly is a NEW // violation; allowlisted files that no longer do are reported as progress. const newDirectAi = current.directAiClients.filter((f) => !DIRECT_AI_CLIENT_ALLOWED.has(f.file)) const directAiFilesNow = new Set(current.directAiClients.map((f) => f.file)) const migratedDirectAi = [...DIRECT_AI_CLIENT_ALLOWED].filter((f) => !directAiFilesNow.has(f)) if (newDirectAi.length) { failed = true console.error( `\n✗ direct-ai-client: ${newDirectAi.length} file(s) outside lib/ai talk to a model SDK directly:`, ) newDirectAi.forEach((f) => console.error(` ${f.file} (${f.rule})`)) console.error( ' → use getAiService() from @/lib/ai (generateText / generateStructured / extractFromDocument).\n' + ' Hosted and self-host resolve the backend from the environment there; a direct SDK call\n' + ' hard-wires one provider and breaks the sovereign self-host path.', ) } // 1e2. hand-rolled-invariant: counted, may only go down. if (current.handRolledInvariants > (baseline.handRolledInvariants?.count ?? Infinity)) { failed = true console.error( `\n✗ hand-rolled-invariant: ${current.handRolledInvariants} inline copies of a shared format rule ` + `(baseline ${baseline.handRolledInvariants?.count}):`, ) console.error( ' → import the rule instead: accountNumberSchema / isoDateSchema / saneIsoDateSchema /\n' + ' fiscalYearSchema from @/lib/invariants/zod, or the ACCOUNT_NUMBER_RE / ISO_DATE_RE\n' + ' constants from @/lib/invariants. See lib/invariants/README.md.', ) } // 1f. cross-extension-import: a physical extension route may only import its // own extension. No baseline: the count is 0 today, any hit is a hard failure. if (current.extensionRoutes.crossImports.length) { failed = true console.error( `\n✗ cross-extension-import: ${current.extensionRoutes.crossImports.length} route(s) under ` + `app/api/extensions// import a DIFFERENT extension:`, ) current.extensionRoutes.crossImports.forEach((c) => console.error(` ${c.file} imports @/extensions/*/${c.imported}/`), ) console.error( ' → a physical extension route may only import its own extension; shared logic belongs in lib/\n' + ' or behind the Extension.services registry bridge.', ) } // 1g. ungated-extension-route: allowlist lives in extension-route-guards.mjs // (UNGATED_EXTENSION_ROUTES) and may only shrink. A NEW physical extension // route must check extensionRegistry.get('') before executing extension // code, so disabling the extension in extensions.config.json actually // disarms the deployed route. const newUngatedRoutes = current.extensionRoutes.ungated.filter( (f) => !UNGATED_EXTENSION_ROUTES.has(f), ) const gatedSinceBaseline = [...UNGATED_EXTENSION_ROUTES].filter( (f) => !current.extensionRoutes.ungated.includes(f), ) if (newUngatedRoutes.length) { failed = true console.error( `\n✗ ungated-extension-route: ${newUngatedRoutes.length} new physical extension route(s) run ` + `extension code without checking the registry:`, ) newUngatedRoutes.forEach((f) => console.error(` ${f}`)) console.error( " → call loadExtensions() and refuse (503 EXTENSION_DISABLED) when extensionRegistry.get('')\n" + ' is undefined, like app/api/extensions/push-notifications/cron/route.ts.', ) } // 1c. ledger-scanning-report: any statement generator not in the baseline set // is a NEW violation. Grandfathered files stay until they migrate. const ledgerScanBaseline = new Set(baseline.ledgerScanningReports?.files ?? []) const newLedgerScans = current.ledgerScanningReports.filter((f) => !ledgerScanBaseline.has(f)) const fixedLedgerScans = (baseline.ledgerScanningReports?.files ?? []).filter( (f) => !current.ledgerScanningReports.includes(f), ) if (newLedgerScans.length) { failed = true console.error( `\n✗ ledger-scanning-report: ${newLedgerScans.length} statement generator(s) aggregate ` + `journal_entry_lines directly instead of going through generateTrialBalance:`, ) newLedgerScans.forEach((f) => console.error(` ${f}`)) console.error( ' → call generateTrialBalance with an explicit closingEntry mode. Aggregating raw\n' + ' lines means remembering the resultatavslut yourself, and three reports already\n' + ' forgot (each read ZERO revenue for a closed year while the balance sheet still\n' + ' tied out, so nothing warned). If the report genuinely lists vouchers rather\n' + ' than balances, add it to LEDGER_SCAN_SANCTIONED in this file with a reason.', ) } // 1d. raw-reference-fetch: per-file ratchet. A file outside the baseline set // that fetches reference data raw (see raw-reference-fetch.mjs) is a NEW // violation; grandfathered files stay until they move to the hooks. Once the // baseline reaches 0, delete the entry so any new site is a hard failure. const rawRefBaseline = new Set(baseline.rawReferenceFetch?.files ?? []) const newRawRefs = current.rawReferenceFetch.filter((f) => !rawRefBaseline.has(f)) const fixedRawRefs = (baseline.rawReferenceFetch?.files ?? []).filter( (f) => !current.rawReferenceFetch.includes(f), ) if (newRawRefs.length) { failed = true console.error( `\n✗ raw-reference-fetch: ${newRawRefs.length} file(s) fetch reference data raw ` + `(fiscal periods, settings, accounts, cash accounts, dimensions, templates, customers, suppliers, articles):`, ) newRawRefs.forEach((f) => console.error(` ${f}`)) console.error( ' → read it through the hooks in lib/reference-data/hooks.ts (useFiscalPeriods, useAccounts,\n' + ' useCashAccounts, useCompanySettings, useDimensions, useBookingTemplates, useCustomers,\n' + ' useSuppliers, useArticles) and call invalidateReferenceData() after writes. Those hooks\n' + ' share one session cache and are seeded by the dashboard layout, so the fields render\n' + ' on first paint instead of after another round trip.', ) } // 1e3. dialog-overflow-risk: per-file ratchet, a finding in a file outside // the baseline set is a NEW violation. Grandfathered files stay until fixed. const dialogOverflowBaseline = new Set(baseline.dialogOverflowRisk?.files ?? []) const newDialogOverflow = current.dialogOverflowRisk.filter( (finding) => !dialogOverflowBaseline.has(finding.file), ) const fixedDialogOverflow = [...dialogOverflowBaseline].filter( (file) => !dialogOverflowFiles.includes(file), ) if (newDialogOverflow.length) { failed = true console.error( `\n✗ dialog-overflow-risk: ${newDialogOverflow.length} overflow-risky pattern(s) in new dialog/sheet file(s):`, ) newDialogOverflow.forEach((finding) => console.error(` ${finding.where} (${finding.rule})`)) console.error( ' → bare-fr-grid-track: a bare 1fr track refuses to shrink below its content; use\n' + ' minmax(0,1fr), plus min-w-0 on the cell when a combobox/long text lives in it.\n' + ' nowrap-in-dialog: give the table/row its own overflow-x-auto wrapper, then allowlist\n' + ' the file in DIALOG_NOWRAP_ALLOWED in this script with a reason.\n' + ' unportaled-wide-overlay: portal the panel to document.body with viewport-clamped\n' + ' geometry, like AccountCombobox\'s dropdown or info-tooltip.tsx.', ) } // 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 || fixedLedgerScans.length || fixedDialogOverflow.length || fixedRawRefs.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 (fixedLedgerScans.length) console.log(` ledger-scanning-report: -${fixedLedgerScans.length} file(s)`) if (fixedDialogOverflow.length) console.log(` dialog-overflow-risk: -${fixedDialogOverflow.length} file(s)`) if (fixedRawRefs.length) console.log(` raw-reference-fetch: -${fixedRawRefs.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 (migratedDirectAi.length) { console.log( `\n✓ direct-ai-client progress: ${migratedDirectAi.length} allowlisted file(s) no longer call a model SDK directly.` + ' Remove them from DIRECT_AI_CLIENT_ALLOWED in this script to lock it in:', ) migratedDirectAi.forEach((f) => console.log(` ${f}`)) } if (gatedSinceBaseline.length) { console.log( `\n✓ ungated-extension-route progress: ${gatedSinceBaseline.length} allowlisted route(s) now gated or gone.` + ' Remove them from UNGATED_EXTENSION_ROUTES in scripts/checks/extension-route-guards.mjs to lock it in:', ) gatedSinceBaseline.forEach((f) => console.log(` ${f}`)) } 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}, hand-rolled-invariant: ${current.handRolledInvariants}, ledger-scanning-report: ${current.ledgerScanningReports.length}, direct-jel-insert: 0, leaky-supabase-client: 0, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, off-ladder-radius: 0, folded-public-flag: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted, dialog-overflow-risk: ${dialogOverflowFiles.length} file(s), raw-reference-fetch: ${current.rawReferenceFetch.length} file(s), client-node-builtin: ${current.clientNodeBuiltins.length}, ambiguous-embed: ${current.ambiguousEmbeds.length}, direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`, )