Files
accounted/scripts/checks/no-new-antipatterns.mjs
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

1449 lines
62 KiB
JavaScript

#!/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/<id>/
* (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('<id>') 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 <DialogContent>/
* <SheetContent> 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 = /(?<![-\w])rounded(?![-\w])/
const CLASS_LIST_HINT_RE =
/(?:^|\s)(?:[a-z-]+:)*(?:flex|inline-flex|grid|hidden|absolute|relative|sticky|fixed|bg-\S|text-\S|border\b|border-\S|shadow\S*|p-\d|px-\S|py-\S|pl-\S|pr-\S|pt-\S|pb-\S|h-\S|w-\S|gap-\S|items-\S|justify-\S|font-\S|overflow-\S|transition\S*|animate-\S)/
function lineHasBareRoundedClass(line) {
const strings = line.match(/"[^"]*"|'[^']*'|`[^`]*`/g)
if (!strings) return false
return strings.some(
(s) => 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 <DialogContent>/<SheetContent>
// 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 `<name>@<version>` 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 <name>@<version> --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/<id>/ 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('<id>') 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('<id>')\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).`,
)