Files
accounted/scripts/checks/no-new-antipatterns.mjs
T
16fbcefbbc feat(invariants): shared format contracts + upgrade-path CI (#1364)
* feat(invariants): centralise shared format contracts, reconcile the org-number paths

The same format rules were written out independently across the codebase, and
where they disagreed the disagreement was invisible until a filing failed.

Worst case, now fixed: four Skatteverket- and Bolagsverket-bound export paths
each had their own idea of a valid organisationsnummer.

  lib/skatteverket/format.ts      strip '-' only      threw on any input with a space
  lib/salary/ku/ku10-generator.ts replace('-', '')    first hyphen only, spaces survived
  lib/salary/agi/xml-generator.ts strip non-digits    stray letters passed the length check
  lib/bokslut/ixbrl/validate      /^\d{6}-?\d{4}$/    rejected the 12-digit form, no Luhn

A company stored with a space or in 12-digit form could file AGI all year and
then fail at the arsredovisning deadline with a message that did not say why.

lib/invariants/ now owns account number, ISO date, four-digit fiscal year and
org number, each with the rationale recorded next to the rule. normalizeOrgNumber
moves here from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both
old paths re-export, so no caller changes. lib/api/schemas.ts builds its
primitives on the module, so ~100 schemas inherit any correction.

The arsredovisning check-digit verdict is a warn, not an error: a wrong Luhn
digit is almost certainly a typo worth surfacing, but whether every org number
Bolagsverket accepts satisfies Luhn is a Swedish domain question we have not
verified against a primary source, and an error there blocks Skicka in. We do
not block a statutory filing on an unverified assumption.

KU10 still passes a 12-digit stored org number through unfolded. That is
pre-existing, and whether the KU10 schema wants 10 or 12 digits is not covered
by the swedish-payroll skill, so it is pinned by a test rather than changed
silently.

Guard 8 (hand-rolled-invariant) tracks the remaining 114 inline copies as a
ratchet that may only go down, same mechanism as the roundOre guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ci): add an upgrade-path job that applies new migrations against real data

The pg-real job applies all 548 migrations to an EMPTY database. Empty means
zero rows, so a migration that adds a NOT NULL, adds a CHECK, creates a unique
index or backfills passes trivially in CI and can still fail on production,
where the rows exist. CI proved that a fresh install works; nothing proved that
an existing install upgrades.

The new pg-upgrade job: apply the schema as it stands at the merge base, seed a
small real company (three posted verifikat, balanced lines, one ore-level
amount), then apply ONLY the migrations this PR adds, then assert the data
survived (entries still posted, lines intact, ledger still balances, ore
unchanged, voucher numbers sequential). A PR with no migration no-ops.

Verified locally against supabase/postgres:15.8.1.060 rather than assumed, with
three deliberately bad migrations:

  rescale money on posted lines   empty: would pass   seeded: ERROR (immutability trigger)
  CHECK violating the ore row     empty: exit 0       seeded: exit 3
  NOT NULL on a populated column  empty: exit 0       seeded: exit 3

Base migrations are read out of the merge-base git tree, not the working tree,
so a PR that edits an already-shipped migration still gets the original applied
and the edit surfaces as a failure here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the invariants and upgrade-CI decisions

Two entries covering what this PR changes and, more importantly, the calls that
are not obvious from the diff: why the arsredovisning check-digit verdict is a
warning rather than an error, why KU10's 12-digit passthrough is pinned instead
of fixed, and why the ROT/RUT brf org-number schemas stay on their own rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): mark the upgrade fixture as CI-only, never a production template

The fixture writes posted journal_entries and their lines directly, bypassing
the engine and the atomic commit RPC. That is the only way to hand a migration
pre-existing posted rows to break, and it is safe against a throwaway CI
database, but it reads like a sanctioned pattern to anyone who finds it later.

Says so explicitly, with the reason it is confined here (no voucher sequence to
keep gapless, no retention obligation on a database destroyed with the job) and
a pointer back to Hard Rule 2 for anything touching a real database.

Raised by the Swedish compliance review bot on #1364.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:30:14 +02:00

844 lines
33 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). 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.
*
* Usage:
* node scripts/checks/no-new-antipatterns.mjs # check (CI)
* node scripts/checks/no-new-antipatterns.mjs --update # re-baseline after a migration ratchets the count down
*
* Exit code 1 if either check regressed past its baseline.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import ts from 'typescript'
import { findSekLabelledFxAmounts } from './format-currency-sek-label.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[<(]/
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('/')
/** Route files that hand-roll auth instead of the MFA-enforcing guard. */
function findRawRouteAuth() {
const apiDir = path.join(ROOT, 'app', 'api')
return walk(apiDir, ['route.ts'])
.filter((f) => {
const src = fs.readFileSync(f, 'utf8')
return RAW_AUTH_RE.test(src) && !GUARD_RE.test(src)
})
.map(rel)
.sort()
}
// Sanctioned journal_entry_lines insert sites. engine/storno write mirrors via
// dimension-resolver; sie-import and sandbox seed write neither dims nor
// mirrors (DB defaults keep them consistent).
const JEL_INSERT_SANCTIONED = new Set([
'lib/bookkeeping/engine.ts',
'lib/core/bookkeeping/storno-service.ts',
'lib/import/sie-import.ts',
'app/api/sandbox/seed/route.ts',
])
// Matches an insert CHAINED on the lines table (`.from('journal_entry_lines').insert(`,
// with optional whitespace/newlines in the chain): select-only readers don't count.
const JEL_INSERT_CHAIN_RE = /\.from\(\s*['"]journal_entry_lines['"]\s*\)\s*\.\s*(insert|upsert)\(/
/** Files that insert into journal_entry_lines outside the sanctioned writers. */
function findDirectJelInserts() {
const files = [
...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']),
]
return files
.filter((f) => {
const r = rel(f)
if (JEL_INSERT_SANCTIONED.has(r)) return false
if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false
return JEL_INSERT_CHAIN_RE.test(fs.readFileSync(f, 'utf8'))
})
.map(rel)
.sort()
}
// 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
}
// 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.',
},
]
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(),
pinnedDepViolations: findPinnedDepViolations(),
rawUserErrors: findRawUserErrors(),
sekLabelledAmounts: findSekLabelledFxAmounts(ROOT),
extensionRoutes: findExtensionRouteFindings(ROOT),
}
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,
},
}
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
console.log(
`Baseline written: ${current.rawRouteAuth.length} raw-route-auth files, ${current.naiveOreRound} naive-ore-round occurrences.`,
)
process.exit(0)
}
if (!fs.existsSync(BASELINE_PATH)) {
console.error('No baseline found. Run: node scripts/checks/no-new-antipatterns.mjs --update')
process.exit(1)
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
let failed = false
// 1. raw-route-auth: any file not in the baseline set is a NEW violation.
const baselineSet = new Set(baseline.rawRouteAuth.files)
const newAuthFiles = current.rawRouteAuth.filter((f) => !baselineSet.has(f))
const fixedAuthFiles = baseline.rawRouteAuth.files.filter((f) => !current.rawRouteAuth.includes(f))
if (newAuthFiles.length) {
failed = true
console.error(
`\n✗ raw-route-auth: ${newAuthFiles.length} new route(s) call supabase.auth.getUser() directly ` +
`instead of requireAuth()/withRouteContext() (skips MFA AAL2 enforcement):`,
)
newAuthFiles.forEach((f) => console.error(` ${f}`))
console.error(' → wrap the route in withRouteContext (or call requireAuth) so MFA is enforced.')
}
// 1b. direct-jel-insert: allowlist lives in this file (JEL_INSERT_SANCTIONED),
// no baseline: any unsanctioned insert site is a hard failure.
if (current.directJelInsert.length) {
failed = true
console.error(
`\n✗ direct-jel-insert: ${current.directJelInsert.length} file(s) insert into journal_entry_lines ` +
`outside the sanctioned writers:`,
)
current.directJelInsert.forEach((f) => console.error(` ${f}`))
console.error(
' → route line writes through lib/bookkeeping/engine.ts, or derive cost_center/project via\n' +
' lineDimensionColumns() (lib/bookkeeping/dimension-resolver.ts) and add the file to\n' +
' JEL_INSERT_SANCTIONED in this script with a justification.',
)
}
// 1c. pinned-dep: a version-pinned dependency must match its pin EXACTLY, in
// both package.json and the lockfile. No baseline: any drift is a hard failure.
if (current.pinnedDepViolations.length) {
failed = true
console.error(`\n✗ pinned-dep: ${current.pinnedDepViolations.length} version-pinned dependency change(s):`)
current.pinnedDepViolations.forEach((v) =>
console.error(
` ${v.name} in ${v.where}: found "${v.actual}", must be exactly "${v.version}".\n ${v.reason}`,
),
)
console.error(
' → restore the pin (npm install <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.',
)
}
// 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.',
)
}
// 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 || 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 (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 (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, pinned-dep: 0, raw-user-error: 0, sek-labelled-amount: 0, cross-extension-import: 0, ungated-extension-route: ${current.extensionRoutes.ungated.length}/${UNGATED_EXTENSION_ROUTES.size} allowlisted).`,
)