chore(connect): provider-host ratchet in check:guards, drop the dead Arcim gateway client (#2178)

* chore(connect): provider-host ratchet in check:guards, drop the dead Arcim gateway client

Two boundary chores from the Connect plan. (1) A per-file ratchet in
scripts/checks/no-new-antipatterns.mjs over files under lib/, app/ and
extensions/ that name a provider API host (Enable Banking, Skatteverket,
Qvalia, Fortnox, Visma, Briox, Bjorn Lunden, Bokio, Bolagsverket, TIC, Meta,
Gmail). The 22 files that do so today are grandfathered in the baseline; a
new one fails the guard with the connector routing as the remedy, and the set
may only shrink as upstreams move behind the connector. (2) The client for
the retired Arcim Sync gateway (extensions/general/arcim-migration/lib/
arcim-client.ts) is deleted with its test: provider-client.ts replaced it and
nothing else imported it. The --update rewrite also locks in the lower
naive-ore-round count (620 to 617) that main already reached.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMUUom4fUk6zi4xYZSSfat
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

* chore(guards): provider-host ratchet is case-insensitive and skips colocated .test.tsx; document the own-credentials exception

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>

---------

Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 20:57:37 +02:00
committed by GitHub
parent c0ecf2fa3b
commit 8c8996773f
5 changed files with 93 additions and 513 deletions
+8
View File
@@ -98,6 +98,14 @@ The boundary is strict and CI-enforced:
extensions enabled, so a direct import breaks the build.
- Extensions integrate through the event bus and documented extension APIs,
and are wired via a generated static registry (`npm run setup:extensions`).
- Provider integrations (banks, Skatteverket, Peppol, migration sources) are
moving behind the connector: a self-hosted instance with a connector key
and no credentials of its own for an upstream reaches that upstream through
the hosted `app/api/connect/*` side (an instance running on its own
registered credentials talks to the provider directly, see
`docs/SELF-HOSTING.md`), and the open ledger keeps the contract plus the
manual file paths. `npm run check:guards` ratchets the set of files that
name a provider API host directly; that set may only shrink.
Licensing follows the same boundary: the project is AGPL-3.0, with an
extension exception that allows third-party extensions using only the
@@ -1,357 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.stubEnv('ARCIM_SYNC_GATEWAY_URL', 'https://arcim.test.com')
vi.stubEnv('ARCIM_SYNC_API_KEY', 'test-api-key')
import { getConsent, createConsent, fetchCompanyInfo, fetchCustomers } from '../arcim-client'
describe('arcim-client', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
fetchSpy = vi.spyOn(globalThis, 'fetch')
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
fetchSpy.mockRestore()
warnSpy.mockRestore()
})
// -------------------------------------------------------------------------
// Auth & headers
// -------------------------------------------------------------------------
describe('request headers', () => {
it('sends Authorization and Content-Type headers', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 'c1', status: 'active' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
await getConsent('c1')
expect(fetchSpy).toHaveBeenCalledTimes(1)
const [, opts] = fetchSpy.mock.calls[0]
expect(opts?.headers).toMatchObject({
Authorization: 'Bearer test-api-key',
'Content-Type': 'application/json',
})
})
})
// -------------------------------------------------------------------------
// Retry on retryable HTTP status
// -------------------------------------------------------------------------
describe('retry', () => {
it('retries on 503 and succeeds', async () => {
const fail = new Response('Service Unavailable', { status: 503 })
const success = new Response(
JSON.stringify({ id: 'c1', status: 'active' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
fetchSpy
.mockResolvedValueOnce(fail)
.mockResolvedValueOnce(success)
const result = await getConsent('c1')
expect(result).toEqual({ id: 'c1', status: 'active' })
expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('returned 503, retrying')
)
})
it('retries on 429 (rate limit) and succeeds', async () => {
const rateLimit = new Response('Too Many Requests', { status: 429 })
const success = new Response(
JSON.stringify({ id: 'c1', status: 'active' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
fetchSpy
.mockResolvedValueOnce(rateLimit)
.mockResolvedValueOnce(success)
const result = await getConsent('c1')
expect(result).toEqual({ id: 'c1', status: 'active' })
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it('retries on 502 and 504 as well', async () => {
const bad502 = new Response('Bad Gateway', { status: 502 })
const bad504 = new Response('Gateway Timeout', { status: 504 })
const success = new Response(
JSON.stringify({ id: 'c1', status: 'active' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
fetchSpy
.mockResolvedValueOnce(bad502)
.mockResolvedValueOnce(bad504)
.mockResolvedValueOnce(success)
const result = await getConsent('c1')
expect(result).toEqual({ id: 'c1', status: 'active' })
expect(fetchSpy).toHaveBeenCalledTimes(3)
})
it('throws after exhausting all retries on retryable status', async () => {
const fail = new Response('Service Unavailable', { status: 503 })
fetchSpy
.mockResolvedValueOnce(fail.clone())
.mockResolvedValueOnce(fail.clone())
.mockResolvedValueOnce(fail.clone())
await expect(getConsent('c1')).rejects.toThrow('Arcim API 503')
expect(fetchSpy).toHaveBeenCalledTimes(3) // 1 original + 2 retries
})
it('does not retry on 400 errors', async () => {
const badRequest = new Response('Bad Request', { status: 400 })
fetchSpy.mockResolvedValueOnce(badRequest)
await expect(getConsent('c1')).rejects.toThrow('Arcim API 400')
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
it('does not retry on 404 errors', async () => {
const notFound = new Response('Not Found', { status: 404 })
fetchSpy.mockResolvedValueOnce(notFound)
await expect(getConsent('c1')).rejects.toThrow('Arcim API 404')
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
})
// -------------------------------------------------------------------------
// Retry on timeout (AbortError)
// -------------------------------------------------------------------------
describe('timeout retry', () => {
it('retries on AbortError and succeeds', async () => {
const abortError = new DOMException('Aborted', 'AbortError')
const success = new Response(
JSON.stringify({ id: 'c1', status: 'active' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
fetchSpy
.mockRejectedValueOnce(abortError)
.mockResolvedValueOnce(success)
const result = await getConsent('c1')
expect(result).toEqual({ id: 'c1', status: 'active' })
expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('timed out, retrying')
)
})
it('throws timeout error after exhausting retries on AbortError', async () => {
const abortError = new DOMException('Aborted', 'AbortError')
fetchSpy
.mockRejectedValueOnce(abortError)
.mockRejectedValueOnce(abortError)
.mockRejectedValueOnce(abortError)
await expect(getConsent('c1')).rejects.toThrow('Arcim API timeout')
expect(fetchSpy).toHaveBeenCalledTimes(3)
})
it('does not retry on non-abort network errors', async () => {
const networkError = new TypeError('fetch failed')
fetchSpy.mockRejectedValueOnce(networkError)
await expect(getConsent('c1')).rejects.toThrow('fetch failed')
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
})
// -------------------------------------------------------------------------
// Exponential backoff
// -------------------------------------------------------------------------
describe('backoff', () => {
it('increases delay on successive retries', async () => {
const delays: number[] = []
const realSetTimeout = globalThis.setTimeout
// Only intercept retry delays (1000-10000ms range), pass abort timers through
vi.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => {
if (ms && ms >= 1000 && ms < 120_000) {
delays.push(ms as number)
// Execute retry delay callback immediately
if (typeof fn === 'function') fn()
return 0 as unknown as ReturnType<typeof setTimeout>
}
// Let abort controller timers run through real setTimeout
return realSetTimeout(fn, ms)
})
const fail = new Response('Service Unavailable', { status: 503 })
const success = new Response(
JSON.stringify({ id: 'c1', status: 'active' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
fetchSpy
.mockResolvedValueOnce(fail.clone())
.mockResolvedValueOnce(fail.clone())
.mockResolvedValueOnce(success)
await getConsent('c1')
// attempt 0 → delay = 1000 * (0+1) = 1000
// attempt 1 → delay = 1000 * (1+1) = 2000
expect(delays).toEqual([1000, 2000])
vi.restoreAllMocks()
// Re-set our spies since restoreAllMocks clears them
fetchSpy = vi.spyOn(globalThis, 'fetch')
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
})
// -------------------------------------------------------------------------
// POST body
// -------------------------------------------------------------------------
describe('request body', () => {
it('sends JSON body for POST requests', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({ id: 'c1', status: 'pending', provider: 'fortnox' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
await createConsent('fortnox', 'Test', '5591234567', 'Test AB')
const [url, opts] = fetchSpy.mock.calls[0]
expect(url).toBe('https://arcim.test.com/api/v1/consents')
expect(opts?.method).toBe('POST')
expect(JSON.parse(opts?.body as string)).toEqual({
name: 'Test',
provider: 'fortnox',
orgNumber: '5591234567',
companyName: 'Test AB',
})
})
})
// -------------------------------------------------------------------------
// Pagination (fetchAllPages)
// -------------------------------------------------------------------------
describe('pagination', () => {
it('fetches all pages until hasMore is false', async () => {
const page1 = { data: [{ id: 'c1' }, { id: 'c2' }], hasMore: true }
const page2 = { data: [{ id: 'c3' }], hasMore: false }
fetchSpy
.mockResolvedValueOnce(
new Response(JSON.stringify(page1), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify(page2), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const result = await fetchCustomers('consent-1')
expect(result).toHaveLength(3)
expect(result.map((c) => c.id)).toEqual(['c1', 'c2', 'c3'])
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
it('stops when page returns empty data', async () => {
const page1 = { data: [{ id: 'c1' }], hasMore: true }
const page2 = { data: [], hasMore: true }
fetchSpy
.mockResolvedValueOnce(
new Response(JSON.stringify(page1), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
.mockResolvedValueOnce(
new Response(JSON.stringify(page2), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const result = await fetchCustomers('consent-1')
expect(result).toHaveLength(1)
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
})
// -------------------------------------------------------------------------
// Singleton resource (fetchCompanyInfo)
// -------------------------------------------------------------------------
describe('singleton resource', () => {
it('unwraps { data } envelope for company info', async () => {
const company = { orgNumber: '5591234567', name: 'Test AB' }
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ data: company }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const result = await fetchCompanyInfo('consent-1')
expect(result).toEqual(company)
})
it('returns null when data is undefined', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
)
const result = await fetchCompanyInfo('consent-1')
expect(result).toBeNull()
})
})
// -------------------------------------------------------------------------
// Missing env vars
// -------------------------------------------------------------------------
describe('environment validation', () => {
it('throws when ARCIM_SYNC_GATEWAY_URL is missing', async () => {
const orig = process.env.ARCIM_SYNC_GATEWAY_URL
delete process.env.ARCIM_SYNC_GATEWAY_URL
try {
await expect(getConsent('c1')).rejects.toThrow(
'ARCIM_SYNC_GATEWAY_URL is not configured'
)
} finally {
process.env.ARCIM_SYNC_GATEWAY_URL = orig
}
})
it('throws when ARCIM_SYNC_API_KEY is missing', async () => {
const orig = process.env.ARCIM_SYNC_API_KEY
delete process.env.ARCIM_SYNC_API_KEY
try {
await expect(getConsent('c1')).rejects.toThrow(
'ARCIM_SYNC_API_KEY is not configured'
)
} finally {
process.env.ARCIM_SYNC_API_KEY = orig
}
})
})
})
@@ -1,154 +0,0 @@
/**
* HTTP client for the Arcim Sync gateway API.
*
* Targets the consent-based resource API (/api/v1/consents/...) which
* provides typed, normalized access to any Swedish accounting provider.
*/
import type {
ArcimProvider,
ConsentRecord,
PaginatedResponse,
CompanyInformationDto,
CustomerDto,
} from '../types'
function getBaseUrl(): string {
const url = process.env.ARCIM_SYNC_GATEWAY_URL
if (!url) throw new Error('ARCIM_SYNC_GATEWAY_URL is not configured')
return url.replace(/\/$/, '')
}
function getApiKey(): string {
const key = process.env.ARCIM_SYNC_API_KEY
if (!key) throw new Error('ARCIM_SYNC_API_KEY is not configured')
return key
}
const MAX_RETRIES = 2
const RETRY_DELAY_MS = 1_000
const RETRYABLE_STATUSES = [429, 502, 503, 504]
async function request<T>(
path: string,
options: RequestInit = {},
timeoutMs: number = 120_000
): Promise<T> {
const url = `${getBaseUrl()}${path}`
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
let response: Response
try {
response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
'Authorization': `Bearer ${getApiKey()}`,
'Content-Type': 'application/json',
...options.headers,
},
})
} catch (err) {
const isAbort = err instanceof DOMException || (err instanceof Error && err.name === 'AbortError')
if (attempt < MAX_RETRIES && isAbort) {
console.warn(`[arcim] ${path} timed out, retrying (attempt ${attempt + 1})`)
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)))
continue
}
if (isAbort) {
throw new Error(`Arcim API timeout after ${Math.round(timeoutMs / 1000)}s: ${path}`)
}
throw err
} finally {
clearTimeout(timer)
}
if (attempt < MAX_RETRIES && RETRYABLE_STATUSES.includes(response.status)) {
console.warn(`[arcim] ${path} returned ${response.status}, retrying (attempt ${attempt + 1})`)
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)))
continue
}
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`Arcim API ${response.status}: ${body || response.statusText}`)
}
return response.json()
}
throw new Error(`Arcim API failed after ${MAX_RETRIES + 1} attempts: ${path}`)
}
// ── Consent lifecycle ───────────────────────────────────────────────
export async function createConsent(
provider: ArcimProvider,
name: string,
orgNumber?: string,
companyName?: string
): Promise<ConsentRecord> {
return request<ConsentRecord>('/api/v1/consents', {
method: 'POST',
body: JSON.stringify({ name, provider, orgNumber, companyName }),
})
}
export async function getConsent(consentId: string): Promise<ConsentRecord> {
return request<ConsentRecord>(`/api/v1/consents/${consentId}`)
}
// ── Resource fetching (paginated) ───────────────────────────────────
async function fetchAllPages<T>(
consentId: string,
resource: string,
params?: Record<string, string>,
pageSize: number = 100,
maxPages: number = 500
): Promise<T[]> {
const all: T[] = []
let page = 1
while (page <= maxPages) {
const query = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
...params,
})
const result = await request<PaginatedResponse<T>>(
`/api/v1/consents/${consentId}/${resource}?${query}`
)
all.push(...result.data)
if (!result.hasMore || result.data.length === 0) break
page++
}
return all
}
// ── Typed resource accessors ────────────────────────────────────────
export async function fetchCompanyInfo(
consentId: string
): Promise<CompanyInformationDto | null> {
// CompanyInformation is a singleton resource: gateway returns { data: object }
const result = await request<{ data: CompanyInformationDto }>(
`/api/v1/consents/${consentId}/companyinformation`
)
return result.data ?? null
}
export async function fetchCustomers(consentId: string): Promise<CustomerDto[]> {
return fetchAllPages<CustomerDto>(consentId, 'customers')
}
// The gateway SIE export path (fetchSIEExport/SIEExportFile) was deliberately
// removed: it returned SIE as a pre-decoded string, and the gateway's decode of
// CP437 bytes as windows-1252 caused the 2026-03-17 mojibake incident. Provider
// SIE now travels as raw bytes through lib/sie-fetcher.ts and the repo's own
// encoding detection. Do not re-add a string-typed SIE fetch here.
+28 -1
View File
@@ -7,7 +7,7 @@
]
},
"naiveOreRound": {
"count": 620
"count": 617
},
"handRolledInvariants": {
"count": 113
@@ -36,5 +36,32 @@
"rawReferenceFetch": {
"count": 0,
"files": []
},
"providerHosts": {
"count": 22,
"files": [
"app/api/connect/bank/[...path]/route.ts",
"extensions/general/bolagsverket/lib/client.ts",
"extensions/general/enable-banking/index.ts",
"extensions/general/enable-banking/lib/api-client.ts",
"extensions/general/mail/lib/gmail-client.ts",
"extensions/general/skatteverket/index.ts",
"extensions/general/skatteverket/lib/connector-mode.ts",
"extensions/general/skatteverket/lib/ombud-client.ts",
"extensions/general/skatteverket/lib/skattekonto-client.ts",
"extensions/general/tic/index.ts",
"extensions/general/tic/lib/bankid-client.ts",
"extensions/general/tic/lib/bankid-types.ts",
"extensions/general/whatsapp-inbox/lib/graph-api.ts",
"lib/connect/upstreams/enable-banking-jwt.ts",
"lib/invoices/transports/qvalia.ts",
"lib/providers/bjornlunden/config.ts",
"lib/providers/bjornlunden/oauth.ts",
"lib/providers/bokio/client.ts",
"lib/providers/bokio/config.ts",
"lib/providers/briox/config.ts",
"lib/providers/fortnox/config.ts",
"lib/providers/visma/config.ts"
]
}
}
+57 -1
View File
@@ -390,6 +390,34 @@ function countNaiveRound() {
return count
}
/**
* 9. provider-host: files that talk to an external provider API directly.
* Provider integration logic is moving behind the connector (hosted
* `app/api/connect/*` today, the Accounted Connect service later): the open
* repo keeps the ledger, the contract and the manual file paths, and a
* self-hosted instance reaches every provider through its connector key.
* Per-file ratchet: the grandfathered set may only shrink. A NEW file naming a
* provider API host is a boundary violation unless it is the connector's own
* hosted adapter side.
*/
const PROVIDER_HOST_RE =
/api\.enablebanking\.com|api\.tilisy\.com|api\.skatteverket\.se|peroauth2\.skatteverket\.se|sso\.skatteverket\.se|api\.qvalia\.com|api-test\.qvalia\.com|api\.fortnox\.se|apps\.fortnox\.se|vismaonline\.com|briox\.services|apigateway\.blinfo\.se|api\.bokio\.se|api\.bolagsverket\.se|api-accept2\.bolagsverket\.se|id\.tic\.io|graph\.facebook\.com|gmail\.googleapis\.com/i
function findProviderHostFiles() {
const files = [
...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']),
...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']),
]
const found = []
for (const f of files) {
const r = rel(f)
if (r.includes('__tests__/') || r.endsWith('.test.ts') || r.endsWith('.test.tsx')) continue
if (PROVIDER_HOST_RE.test(fs.readFileSync(f, 'utf8'))) found.push(r)
}
return found.sort()
}
/**
* Occurrences of a shared format rule written out by hand instead of imported
* from lib/invariants/. Counted, not file-setted: the campaign lowers the
@@ -1025,6 +1053,7 @@ const current = {
rawRouteAuth: findRawRouteAuth(),
naiveOreRound: countNaiveRound(),
handRolledInvariants: countHandRolledInvariants(),
providerHosts: findProviderHostFiles(),
ledgerScanningReports: findLedgerScanningReports(),
directJelInsert: findDirectJelInserts(),
leakySupabaseClients: findLeakySupabaseClients(),
@@ -1064,6 +1093,10 @@ if (isUpdate) {
count: current.rawReferenceFetch.length,
files: current.rawReferenceFetch,
},
providerHosts: {
count: current.providerHosts.length,
files: current.providerHosts,
},
}
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + '\n')
console.log(
@@ -1344,6 +1377,26 @@ if (newLedgerScans.length) {
)
}
// 1c2. provider-host: a file naming a provider API host outside the
// grandfathered set is a NEW direct integration in the open repo.
const providerHostBaseline = new Set(baseline.providerHosts?.files ?? [])
const newProviderHosts = current.providerHosts.filter((f) => !providerHostBaseline.has(f))
const fixedProviderHosts = (baseline.providerHosts?.files ?? []).filter((f) => !current.providerHosts.includes(f))
if (baseline.providerHosts && newProviderHosts.length) {
failed = true
console.error(
`\n✗ provider-host: ${newProviderHosts.length} new file(s) call a provider API host directly:`,
)
newProviderHosts.forEach((f) => console.error(` ${f}`))
console.error(
' → provider integration logic lives behind the connector, not in the open ledger:\n' +
' route the call through the hosted connector (app/api/connect/*) and the\n' +
' instance-side connector-mode seam (lib/connect/instance/upstreams.ts), or\n' +
' keep the manual file path. If this file IS the connector\'s own hosted adapter\n' +
' side, re-baseline with --update and say so in the PR.',
)
}
// 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
@@ -1410,6 +1463,7 @@ if (
fixedLedgerScans.length ||
fixedDialogOverflow.length ||
fixedRawRefs.length ||
fixedProviderHosts.length ||
current.naiveOreRound < baseline.naiveOreRound.count
) {
console.log('\n✓ Progress since baseline:')
@@ -1422,6 +1476,8 @@ if (
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)`)
if (fixedProviderHosts.length)
console.log(` provider-host: -${fixedProviderHosts.length} file(s) no longer call a provider directly`)
console.log(' Run with --update to ratchet the baseline down and lock in the gains.')
}
if (migratedDirectAi.length) {
@@ -1444,5 +1500,5 @@ if (failed) {
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).`,
`\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}, provider-host: ${current.providerHosts.length} file(s), direct-ai-client: ${current.directAiClients.length}/${DIRECT_AI_CLIENT_ALLOWED.size} allowlisted).`,
)