Files
accounted/scripts/checks/raw-reference-fetch.mjs
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

137 lines
5.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Guard: reference data fetched outside lib/reference-data.
*
* Fiscal periods, company settings, the chart of accounts, cash accounts,
* dimensions, booking templates, customers, suppliers and articles are
* session-cached behind the hooks in lib/reference-data/hooks.ts (seeded
* from the dashboard layout, invalidated after writes). Before that layer
* existed the same lists were fetched raw from 47 / 27 / 14 / 8 / 12 / 5
* independent call sites, uncached, on every mount and every dialog open,
* which is what a customer described as "it takes time before all fields
* load when clicking around" (2026-08-26). This check keeps that number
* going down: an existing raw call site is grandfathered in the baseline,
* a NEW one fails CI.
*
* Two shapes are flagged:
* 1. A GET-shaped `fetch('/api/<reference path>')` anywhere under app/,
* components/, extensions/ or lib/ (a relative URL is client code by
* definition). Writes (`method: 'POST' | 'PUT' | ...`) are fine: they
* go through the API and then call invalidateReferenceData().
* 2. A browser-side `.from('<reference table>').select(` in a file that
* carries the 'use client' directive. Server code reading those tables
* is legitimate and is not scanned.
*
* Sanctioned (RAW_REFERENCE_SANCTIONED): the fetchers themselves, the
* pre-existing SWR settings hook, and the static BAS catalog loader (a
* different, module-cached data set).
*/
import fs from 'node:fs'
import path from 'node:path'
export const REFERENCE_API_PATHS = [
'bookkeeping/fiscal-periods',
'settings/booking-templates',
'settings',
'bookkeeping/accounts',
'cash-accounts',
'dimensions',
'customers',
'suppliers',
'articles',
]
export const REFERENCE_TABLES = [
'fiscal_periods',
'company_settings',
'chart_of_accounts',
'cash_accounts',
]
export const RAW_REFERENCE_SANCTIONED = new Set([
'lib/reference-data/fetchers.ts',
'components/settings/useSettings.ts',
'lib/bookkeeping/bas-catalog-client.ts',
])
const SCAN_DIRS = ['app/(dashboard)', 'components', 'extensions', 'lib']
const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', 'coverage', '__tests__'])
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
// fetch(`/api/settings`), fetch('/api/settings?x=1'), fetch('/api/settings', { signal })
// The init object may nest one level ({ headers: { ... } }); its text is
// captured so a write method can be excluded. Trailing commas (prettier's
// multi-line call style) are tolerated before the closing paren. Every
// optional whitespace run is anchored by a literal (`,` or `)`) so the
// pattern has no ambiguous backtracking (CodeQL js/redos on the earlier
// `\s*,?\s*\)` shape).
const REFERENCE_URL = String.raw`[\x60'"]/api/(?:${REFERENCE_API_PATHS.map(escape).join('|')})(?:\?[^\x60'"]*)?[\x60'"]`
const INIT_OBJECT = String.raw`\{(?:[^{}]|\{[^{}]*\})*\}`
const API_FETCH_RE = new RegExp(
String.raw`fetch\(\s*${REFERENCE_URL}(?:\s*,\s*(${INIT_OBJECT}))?(?:\s*,)?\s*\)`,
'g',
)
const WRITE_METHOD_RE = /method\s*:\s*[\x60'"](?!GET\b)/i
const TABLE_SELECT_RE = new RegExp(
String.raw`\.from\(\s*['"](?:${REFERENCE_TABLES.map(escape).join('|')})['"]\s*\)\s*\.\s*select\(`,
'g',
)
// Leading whitespace and comments before the directive. A block comment body
// is `(?:[^*]|\*(?!\/))*`, which cannot cross a `*/`, so each iteration of the
// outer star has exactly one parse: the lazy `[\s\S]*?` form let an unclosed
// `/*` be re-split at every later `/*` (CodeQL js/redos).
// Single-character whitespace alternative (not \s+): a `+` inside the outer
// `*` is a nested quantifier on the same character (CodeQL js/redos).
const USE_CLIENT_RE = /^(?:\s|\/\/[^\n]*\n|\/\*(?:[^*]|\*(?!\/))*\*\/)*['"]use client['"]/
export function isClientSource(source) {
return USE_CLIENT_RE.test(source)
}
/**
* Findings for one file's source: `{ kind: 'api' | 'table', index }`.
* Exported for the unit test; the file-level scan below uses it.
*/
export function findRawReferenceFetchesInSource(source) {
const findings = []
for (const match of source.matchAll(API_FETCH_RE)) {
const init = match[1]
if (init && WRITE_METHOD_RE.test(init)) continue
findings.push({ kind: 'api', index: match.index ?? 0 })
}
if (isClientSource(source)) {
for (const match of source.matchAll(TABLE_SELECT_RE)) {
findings.push({ kind: 'table', index: match.index ?? 0 })
}
}
return findings
}
function walk(dir, out) {
if (!fs.existsSync(dir)) return
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full, out)
else if (/\.(?:ts|tsx)$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) out.push(full)
}
}
/** Sorted repo-relative paths of files with at least one raw reference fetch. */
export function findRawReferenceFetches(root) {
const files = []
for (const dir of SCAN_DIRS) walk(path.join(root, dir), files)
const offenders = []
for (const file of files) {
const rel = path.relative(root, file).split(path.sep).join('/')
if (rel.startsWith('app/api/') || RAW_REFERENCE_SANCTIONED.has(rel)) continue
const source = fs.readFileSync(file, 'utf8')
if (findRawReferenceFetchesInSource(source).length) offenders.push(rel)
}
return [...new Set(offenders)].sort()
}