From 3705f70ddb843823bdd674986fe7778d4946136a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 00:52:34 -0700 Subject: [PATCH 1/3] Add tests for unsnooze, bulk, and turn-queue reportError --- .../inbox-unsnooze-sweep-report-error.test.ts | 65 ++++++++ .../chat/src/turn-queue-report-error.test.ts | 155 ++++++++++++++++++ packages/inbox/test/bulk-report-error.test.ts | 65 ++++++++ 3 files changed, 285 insertions(+) create mode 100644 apps/hub/test/inbox-unsnooze-sweep-report-error.test.ts create mode 100644 packages/chat/src/turn-queue-report-error.test.ts create mode 100644 packages/inbox/test/bulk-report-error.test.ts diff --git a/apps/hub/test/inbox-unsnooze-sweep-report-error.test.ts b/apps/hub/test/inbox-unsnooze-sweep-report-error.test.ts new file mode 100644 index 000000000..a2826185f --- /dev/null +++ b/apps/hub/test/inbox-unsnooze-sweep-report-error.test.ts @@ -0,0 +1,65 @@ +// A bus publish failure after a committed reopen must still reach +// reportError — the sweep's other catches already do; the publish helper +// used to log only, so a flaky event bus left no refId. +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { DueSnooze } from "@corbits/inbox"; +import type { MailboxEvent, MailboxEventScope } from "@corbits/mailbox"; +import type { InboxUnsnoozeSweepStore } from "../src/inbox-unsnooze-sweep"; + +const reportErrorCalls: { + error: unknown; + context: Record; +}[] = []; + +mock.module("@corbits/error-sink", () => ({ + reportError: (error: unknown, context: Record) => { + reportErrorCalls.push({ error, context }); + return "ref_test"; + }, + generateRefId: () => "ref_test", +})); + +const { tickInboxUnsnoozeSweep } = await import("../src/inbox-unsnooze-sweep"); + +function row(overrides: Partial = {}): DueSnooze { + return { + tenantId: "tnt_1", + principalId: "prn_1", + messageId: "msg_1", + ...overrides, + }; +} + +beforeEach(() => { + reportErrorCalls.length = 0; +}); +afterAll(() => { + mock.restore(); +}); + +describe("tickInboxUnsnoozeSweep reportError", () => { + test("a throwing mailbox publish reports through reportError and does not fail the tick", async () => { + const store: InboxUnsnoozeSweepStore = { + findDueSnoozes: async () => [row()], + claimAndReopen: async () => true, + }; + const bus = { + publish(_scope: MailboxEventScope, _event: MailboxEvent) { + throw new Error("bus unavailable"); + }, + }; + + await tickInboxUnsnoozeSweep({ store, bus }, new Date()); + + expect(reportErrorCalls).toHaveLength(1); + const reported = reportErrorCalls[0]?.error; + expect(reported).toBeInstanceOf(Error); + if (!(reported instanceof Error)) throw new Error("expected Error"); + expect(reported.message).toBe("bus unavailable"); + expect(reportErrorCalls[0]?.context).toEqual({ + operation: "inbox_unsnooze_sweep_publish", + tenantId: "tnt_1", + extra: { messageId: "msg_1", principalId: "prn_1" }, + }); + }); +}); diff --git a/packages/chat/src/turn-queue-report-error.test.ts b/packages/chat/src/turn-queue-report-error.test.ts new file mode 100644 index 000000000..1b48d7ac3 --- /dev/null +++ b/packages/chat/src/turn-queue-report-error.test.ts @@ -0,0 +1,155 @@ +// The turn queue's three catch paths (rejecting dispatch, claim-store +// throw mid-drain, reclaim throw on enqueue) must call reportError in +// the catch itself so a failure always has a refId — helpers that wrap +// reportError are invisible to check:report-error. +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { createInMemoryTurnClaimStore } from "./turn-claims"; +import type { TurnClaimStore } from "./turn-claims"; +import type { QueuedTurn } from "./turn-queue"; + +const reportErrorCalls: { + error: unknown; + context: Record; +}[] = []; + +mock.module("@corbits/error-sink", () => ({ + reportError: (error: unknown, context: Record) => { + reportErrorCalls.push({ error, context }); + return "ref_test"; + }, + generateRefId: () => "ref_test", +})); + +const { createWorkbenchTurnQueue } = await import("./turn-queue"); + +function turn(messageId: string, text: string): QueuedTurn { + return { + messageId, + principalId: "prn_1", + recipients: ["ins_echo1@acme.example"], + parts: [{ kind: "text", text }], + }; +} + +beforeEach(() => { + reportErrorCalls.length = 0; +}); +afterAll(() => { + mock.restore(); +}); + +describe("createWorkbenchTurnQueue reportError", () => { + test("a rejecting dispatch reports through reportError and does not reject run()", async () => { + const queue = createWorkbenchTurnQueue({ + claims: createInMemoryTurnClaimStore({ ttlMs: 60_000 }), + publish: () => undefined, + }); + const boom = new Error("dispatch blew up"); + + await queue.run("wb_1", turn("msg_1", "one"), async () => { + throw boom; + }); + + expect(reportErrorCalls).toHaveLength(1); + expect(reportErrorCalls[0]?.error).toBe(boom); + expect(reportErrorCalls[0]?.context).toEqual({ + operation: "chat.turnQueue.dispatch", + roomId: "wb_1", + extra: { messageIds: ["msg_1"] }, + }); + }); + + test("a claim-store throw mid-drain reports through reportError", async () => { + const holders = new Map(); + let n = 0; + const boom = new Error("claim store connection reset"); + const claims: TurnClaimStore = { + async tryClaim(c) { + if (holders.has(c.workbenchId)) return false; + const t = String(++n); + holders.set(c.workbenchId, t); + return t; + }, + async release(c, t) { + if (holders.get(c.workbenchId) !== t) return false; + holders.delete(c.workbenchId); + return true; + }, + async holds() { + throw boom; + }, + }; + const queue = createWorkbenchTurnQueue({ + claims, + publish: () => undefined, + }); + + await queue.run("wb", turn("m1", "one"), async () => undefined); + + expect(reportErrorCalls).toHaveLength(1); + expect(reportErrorCalls[0]?.error).toBe(boom); + expect(reportErrorCalls[0]?.context).toEqual({ + operation: "chat.turnQueue.drain", + roomId: "wb", + }); + }); + + test("a throwing tryClaim on enqueue reclaim reports through reportError", async () => { + const holders = new Map(); + let n = 0; + let tryClaimCalls = 0; + const boom = new Error("reclaim connection reset"); + const claims: TurnClaimStore = { + async tryClaim(c) { + tryClaimCalls += 1; + // First run wins. The second run's opening tryClaim sees the holder + // and returns false (enqueue). Its follow-up reclaim tryClaim throws. + if (tryClaimCalls === 1) { + const t = String(++n); + holders.set(c.workbenchId, t); + return t; + } + if (tryClaimCalls === 2) return false; + throw boom; + }, + async release(c, t) { + if (holders.get(c.workbenchId) !== t) return false; + holders.delete(c.workbenchId); + return true; + }, + async holds(c, t) { + return holders.get(c.workbenchId) === t; + }, + }; + const queue = createWorkbenchTurnQueue({ + claims, + publish: () => undefined, + }); + let resolveFirst: (() => void) | undefined; + let holdFirstDispatch = true; + const first = queue.run("wb", turn("m1", "one"), () => { + if (!holdFirstDispatch) return Promise.resolve(); + holdFirstDispatch = false; + return new Promise((resolve) => { + resolveFirst = resolve; + }); + }); + for (let i = 0; i < 20 && resolveFirst === undefined; i++) { + await Promise.resolve(); + } + expect(resolveFirst).toBeDefined(); + + // In-flight claim: this run enqueues, then its reclaim tryClaim throws. + await queue.run("wb", turn("m2", "two"), async () => undefined); + + expect(reportErrorCalls).toHaveLength(1); + expect(reportErrorCalls[0]?.error).toBe(boom); + expect(reportErrorCalls[0]?.context).toEqual({ + operation: "chat.turnQueue.enqueueReclaim", + roomId: "wb", + }); + + resolveFirst?.(); + await first; + }); +}); diff --git a/packages/inbox/test/bulk-report-error.test.ts b/packages/inbox/test/bulk-report-error.test.ts new file mode 100644 index 000000000..0620a3b2a --- /dev/null +++ b/packages/inbox/test/bulk-report-error.test.ts @@ -0,0 +1,65 @@ +// Per-item bulk failures must reach reportError even when the caller +// omits onError — otherwise mark-all-read / clear-done swallow a row +// with no refId (the catch used to only bump `failed`). +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; + +const reportErrorCalls: { + error: unknown; + context: Record; +}[] = []; + +mock.module("@corbits/error-sink", () => ({ + reportError: (error: unknown, context: Record) => { + reportErrorCalls.push({ error, context }); + return "ref_test"; + }, + generateRefId: () => "ref_test", +})); + +const { runBulkOperation } = await import("../src/bulk-run"); + +beforeEach(() => { + reportErrorCalls.length = 0; +}); +afterAll(() => { + mock.restore(); +}); + +describe("runBulkOperation reportError", () => { + test("a thrown item is reported even when onError is omitted", async () => { + const boom = new Error("transient write failure"); + const result = await runBulkOperation(["a", "b"], async (id) => { + if (id === "b") throw boom; + }); + + expect(result).toEqual({ succeeded: 1, failed: 1 }); + expect(reportErrorCalls).toHaveLength(1); + expect(reportErrorCalls[0]?.error).toBe(boom); + expect(reportErrorCalls[0]?.context).toEqual({ + operation: "inbox.bulk", + }); + }); + + test("caller-supplied operation and tenantId ride on the report", async () => { + const boom = new Error("row locked"); + await runBulkOperation( + [{ id: "msg_1" }], + async () => { + throw boom; + }, + { + operation: "inbox_mark_all_read_item", + tenantId: "tnt_1", + extraFor: (item) => ({ id: item.id }), + }, + ); + + expect(reportErrorCalls).toHaveLength(1); + expect(reportErrorCalls[0]?.error).toBe(boom); + expect(reportErrorCalls[0]?.context).toEqual({ + operation: "inbox_mark_all_read_item", + tenantId: "tnt_1", + extra: { id: "msg_1" }, + }); + }); +}); From f73e05c8580e92e6e27f76acf534b2e77814025f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 00:52:41 -0700 Subject: [PATCH 2/3] Call reportError from unsnooze, bulk, and turn-queue catches --- apps/hub/src/inbox-unsnooze-sweep.ts | 16 +++---- packages/chat/src/turn-queue.ts | 67 +++++++++++----------------- packages/inbox/src/bulk-run.ts | 59 ++++++++++++++++++++++++ packages/inbox/src/bulk.ts | 40 ++--------------- packages/inbox/src/routes.ts | 29 +++++------- packages/inbox/test/bulk.test.ts | 4 +- 6 files changed, 108 insertions(+), 107 deletions(-) create mode 100644 packages/inbox/src/bulk-run.ts diff --git a/apps/hub/src/inbox-unsnooze-sweep.ts b/apps/hub/src/inbox-unsnooze-sweep.ts index ae5f44796..0d7790470 100644 --- a/apps/hub/src/inbox-unsnooze-sweep.ts +++ b/apps/hub/src/inbox-unsnooze-sweep.ts @@ -15,7 +15,6 @@ // leaves the row for the next tick to retry rather than orphaning it — // the mail-then-claim lesson CL-7209 applied to the credential-expiry // sweep, applied here to the claim itself. -import { getLogger } from "@intx/log"; import { reportError } from "@corbits/error-sink"; import { claimAndReopenSnooze, @@ -49,7 +48,6 @@ export type InboxUnsnoozeSweepDeps = { }; const POLL_INTERVAL_MS = 60 * 1000; -const publishLog = getLogger(["hub", "inbox-unsnooze-sweep"]); function publishReopened( bus: Pick, @@ -65,15 +63,11 @@ function publishReopened( { type: "mailbox", id: row.messageId, op: "enrich" }, ); } catch (error) { - publishLog.error( - "mailbox reopen event publish failed for {id} on tenant {tenantId}, principal {principalId}: {error}", - { - id: row.messageId, - tenantId: row.tenantId, - principalId: row.principalId, - error: error instanceof Error ? error.message : String(error), - }, - ); + reportError(error, { + operation: "inbox_unsnooze_sweep_publish", + tenantId: row.tenantId, + extra: { messageId: row.messageId, principalId: row.principalId }, + }); } } diff --git a/packages/chat/src/turn-queue.ts b/packages/chat/src/turn-queue.ts index 399d64d1f..39c4fd189 100644 --- a/packages/chat/src/turn-queue.ts +++ b/packages/chat/src/turn-queue.ts @@ -87,40 +87,6 @@ export type WorkbenchTurnQueue = { ): Promise; }; -function reportDispatchFailure( - workbenchId: string, - batch: readonly QueuedTurn[], - err: unknown, -): void { - const messageIds = batch.map((t) => t.messageId); - const refId = reportError(err, { - operation: "chat.turnQueue.dispatch", - roomId: workbenchId, - extra: { messageIds }, - }); - // `dispatch` documents "must never reject" (see `DispatchTurnBatch`); - // reaching here means some caller broke that contract. - log.error( - 'turn queue: dispatch rejected for workbench {workbenchId}, message(s) {messageIds} (ref {refId}), violating its "never reject" contract: {err}', - { workbenchId, messageIds, refId, err }, - ); -} - -function reportClaimStoreFailure( - operation: string, - workbenchId: string, - err: unknown, -): void { - const refId = reportError(err, { - operation, - roomId: workbenchId, - }); - log.error( - "turn queue: claim store call failed for workbench {workbenchId} (ref {refId}): {err}", - { workbenchId, refId, err }, - ); -} - export function createWorkbenchTurnQueue( deps: WorkbenchTurnQueueDeps, ): WorkbenchTurnQueue { @@ -169,7 +135,18 @@ export function createWorkbenchTurnQueue( try { await dispatch(batch); } catch (err) { - reportDispatchFailure(workbenchId, batch, err); + const messageIds = batch.map((t) => t.messageId); + const refId = reportError(err, { + operation: "chat.turnQueue.dispatch", + roomId: workbenchId, + extra: { messageIds }, + }); + // `dispatch` documents "must never reject" (see `DispatchTurnBatch`); + // reaching here means some caller broke that contract. + log.error( + 'turn queue: dispatch rejected for workbench {workbenchId}, message(s) {messageIds} (ref {refId}), violating its "never reject" contract: {err}', + { workbenchId, messageIds, refId, err }, + ); } if (!(await deps.claims.holds({ workbenchId }, token))) { @@ -215,7 +192,14 @@ export function createWorkbenchTurnQueue( batch = afterReclaim; } } catch (err) { - reportClaimStoreFailure("chat.turnQueue.drain", workbenchId, err); + const refId = reportError(err, { + operation: "chat.turnQueue.drain", + roomId: workbenchId, + }); + log.error( + "turn queue: claim store call failed for workbench {workbenchId} (ref {refId}): {err}", + { workbenchId, refId, err }, + ); // Best-effort: `token` may already be released (this call then // no-ops per the store's contract) or may be the one this loop // never got to release because the failure happened first either @@ -265,10 +249,13 @@ export function createWorkbenchTurnQueue( } await drain(workbenchId, reclaimed, batch, dispatch); } catch (err) { - reportClaimStoreFailure( - "chat.turnQueue.enqueueReclaim", - workbenchId, - err, + const refId = reportError(err, { + operation: "chat.turnQueue.enqueueReclaim", + roomId: workbenchId, + }); + log.error( + "turn queue: claim store call failed for workbench {workbenchId} (ref {refId}): {err}", + { workbenchId, refId, err }, ); } }, diff --git a/packages/inbox/src/bulk-run.ts b/packages/inbox/src/bulk-run.ts new file mode 100644 index 000000000..ec64a35e3 --- /dev/null +++ b/packages/inbox/src/bulk-run.ts @@ -0,0 +1,59 @@ +// Server-only per-item bulk runner. Lives apart from `./bulk` so the +// eligibility helpers stay browser-safe (`./client` re-exports them) +// while this catch path can call `reportError` without pulling +// `@intx/log` into a browser bundle. + +import { reportError } from "@corbits/error-sink"; + +export interface BulkOperationResult { + readonly succeeded: number; + readonly failed: number; +} + +/** Optional reportError context and per-item hook for a bulk failure. */ +export type BulkOperationOptions = { + readonly onError?: (item: T, error: unknown) => void; + readonly operation?: string; + readonly tenantId?: string; + readonly extra?: Record; + readonly extraFor?: (item: T) => Record; +}; + +/** + * Apply `apply` to every item, one at a time, never letting one item's + * failure abort the rest (CL-7207). Previously `mark-all-read` and + * `clear-done` ran their per-item writes in a plain loop with no + * per-item try/catch: a throw on item N left the handler throwing out of + * the whole request — a 500 with items before N already mutated, item N + * left in a new inconsistent state, and everything after N untouched, with + * no way for the caller to tell how far it got. Catching per item instead + * means a transient failure on one row costs that one row, not the rest of + * the inbox, and the caller gets back exactly how many succeeded and how + * many didn't rather than an opaque 500. + */ +export async function runBulkOperation( + items: readonly T[], + apply: (item: T) => Promise, + options?: BulkOperationOptions, +): Promise { + let succeeded = 0; + let failed = 0; + for (const item of items) { + try { + await apply(item); + succeeded += 1; + } catch (error) { + failed += 1; + const extra = options?.extraFor?.(item) ?? options?.extra; + reportError(error, { + operation: options?.operation ?? "inbox.bulk", + ...(options?.tenantId !== undefined + ? { tenantId: options.tenantId } + : {}), + ...(extra !== undefined ? { extra } : {}), + }); + options?.onError?.(item, error); + } + } + return { succeeded, failed }; +} diff --git a/packages/inbox/src/bulk.ts b/packages/inbox/src/bulk.ts index d840afff1..9089ca0a4 100644 --- a/packages/inbox/src/bulk.ts +++ b/packages/inbox/src/bulk.ts @@ -1,6 +1,8 @@ // Pure product rules for bulk inbox ops. Kept free of the DB so the // mark-all-read / clear-done contracts are unit-testable without the -// platform tenant/principal tables the mailbox FKs require. +// platform tenant/principal tables the mailbox FKs require. Also kept +// free of `@corbits/error-sink` so `./client` can re-export the +// eligibility helpers without pulling `@intx/log` into a browser bundle. import type { InboxItem } from "./project"; @@ -22,39 +24,3 @@ export function itemsEligibleForClearDone( ): InboxItem[] { return items.filter((item) => item.status === "done"); } - -export interface BulkOperationResult { - readonly succeeded: number; - readonly failed: number; -} - -/** - * Apply `apply` to every item, one at a time, never letting one item's - * failure abort the rest (CL-7207). Previously `mark-all-read` and - * `clear-done` ran their per-item writes in a plain loop with no - * per-item try/catch: a throw on item N left the handler throwing out of - * the whole request — a 500 with items before N already mutated, item N - * left in a new inconsistent state, and everything after N untouched, with - * no way for the caller to tell how far it got. Catching per item instead - * means a transient failure on one row costs that one row, not the rest of - * the inbox, and the caller gets back exactly how many succeeded and how - * many didn't rather than an opaque 500. - */ -export async function runBulkOperation( - items: readonly T[], - apply: (item: T) => Promise, - onError?: (item: T, error: unknown) => void, -): Promise { - let succeeded = 0; - let failed = 0; - for (const item of items) { - try { - await apply(item); - succeeded += 1; - } catch (error) { - failed += 1; - onError?.(item, error); - } - } - return { succeeded, failed }; -} diff --git a/packages/inbox/src/routes.ts b/packages/inbox/src/routes.ts index 0977a800c..6a04f8930 100644 --- a/packages/inbox/src/routes.ts +++ b/packages/inbox/src/routes.ts @@ -23,11 +23,8 @@ import { getLogger } from "@intx/log"; import { type } from "arktype"; import { Hono, type Context } from "hono"; -import { - itemsEligibleForClearDone, - itemsEligibleForMarkAllRead, - runBulkOperation, -} from "./bulk"; +import { itemsEligibleForClearDone, itemsEligibleForMarkAllRead } from "./bulk"; +import { runBulkOperation } from "./bulk-run"; import { cursorScopeMismatch } from "./cursor"; import { isInboxGroup, type InboxGroup } from "./group"; import { @@ -193,12 +190,11 @@ export function createInboxRoutes( }); publish(bus, scope, item.id, "mark_read"); }, - (item, error) => - reportError(error, { - operation: "inbox_mark_all_read_item", - tenantId: tenant.id, - extra: { id: item.id }, - }), + { + operation: "inbox_mark_all_read_item", + tenantId: tenant.id, + extraFor: (item) => ({ id: item.id }), + }, ); // A 200 must mean "every eligible item was marked" — a partial result // is reported as 207 so a caller that only checks the status code (not @@ -227,12 +223,11 @@ export function createInboxRoutes( if (!ok) throw new Error(`message ${item.id} not found to trash`); publish(bus, scope, item.id, "trash"); }, - (item, error) => - reportError(error, { - operation: "inbox_clear_done_item", - tenantId: tenant.id, - extra: { id: item.id }, - }), + { + operation: "inbox_clear_done_item", + tenantId: tenant.id, + extraFor: (item) => ({ id: item.id }), + }, ); // Same partial-vs-complete signal as mark-all-read: 207 whenever any // item failed, so a status-code-only caller can't read it as success. diff --git a/packages/inbox/test/bulk.test.ts b/packages/inbox/test/bulk.test.ts index aa9ecf5fe..c70d4472f 100644 --- a/packages/inbox/test/bulk.test.ts +++ b/packages/inbox/test/bulk.test.ts @@ -3,8 +3,8 @@ import { describe, expect, test } from "bun:test"; import { itemsEligibleForClearDone, itemsEligibleForMarkAllRead, - runBulkOperation, } from "../src/bulk"; +import { runBulkOperation } from "../src/bulk-run"; import type { InboxItem } from "../src/project"; function item( @@ -53,7 +53,7 @@ describe("runBulkOperation", () => { if (id === "b") throw new Error("transient write failure"); applied.push(id); }, - (id, error) => failures.push({ id, error }), + { onError: (id, error) => failures.push({ id, error }) }, ); // Both non-failing items still ran, despite "b" throwing between them — From 66728efbd649e4ea79d4d84261d2d01a0385e486 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 31 Aug 2026 00:52:47 -0700 Subject: [PATCH 3/3] Run CI structural checks from the local check:structural list --- .github/workflows/ci.yml | 17 +---------------- AGENTS.md | 7 +++++-- scripts/ci-jobs.test.ts | 12 ++++++++++++ 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a524516a..0cca38512 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,22 +59,7 @@ jobs: with: fetch-depth: 0 - uses: ./.github/actions/setup-workbench - - name: Structural check self-tests - run: bun test scripts/checks/test - - run: bun run check:deletion - - run: bun run check:killdates - - run: bun run check:packages - - run: bun run check:licenses - - run: bun run check:db-gate - - run: bun run check:no-product-tenancy - - run: bun run check:browser-safe-subpaths - - run: bun run check:web-utilities - - run: bun run check:tailwind-source - - run: bun run check:ui-vocabulary - - run: bun run check:react-ui-drift - - run: bun run check:react-ui-pin - - run: bun run check:tool-package-pins - - run: bun run check:tool-package-freshness + - run: bun run check:structural e2e: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 28550bab6..f52596f9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,9 @@ behind human approval. ## Working conventions - `bun run check` (typecheck, lint, test, structural checks) must pass - before every commit. + before every commit. CI's `structural` job runs the same + `check:structural` list as local, including `check:report-error`, so + the two cannot drift. - Commit sequence per change: tests first ("Add tests for X"), then implementation ("X: what changed"), then docs ("Update docs: X"). One logical change per commit; commit messages are written for a public @@ -130,7 +132,8 @@ check:*` script, so a violation fails CI rather than waiting for review. the current change's diff touches its line; a pre-existing catch is instead recorded in `scripts/checks/report-error-baseline.txt`, a debt ledger — not an allowlist — of 280 violations as of this check landing, - each one a real bug still to fix. Regenerate it with `bun run + each one a real bug still to fix. The same script runs in CI via + `check:structural`. Regenerate it with `bun run scripts/checks/report-error.ts --write-baseline` after fixing (or newly opting out) entries; a stale entry with no matching violation fails the check, so the ledger can only shrink. A violation already diff --git a/scripts/ci-jobs.test.ts b/scripts/ci-jobs.test.ts index 58d8e5e0e..f9b9fab06 100644 --- a/scripts/ci-jobs.test.ts +++ b/scripts/ci-jobs.test.ts @@ -69,6 +69,18 @@ test("CI splits e2e, isolation, and db-suites onto their own Postgres jobs", asy expect(e2eSetupIndex).toBeLessThan(hubSuiteIndex); }); +test("the structural job runs the same list as local check:structural", async () => { + const yaml = await readFile(join(ROOT, ".github/workflows/ci.yml"), "utf8"); + const structural = jobBodies(yaml).get("structural") ?? ""; + + expect(structural).toContain("bun run check:structural"); + // One list: CI must not re-enumerate the structural sub-checks, or + // local and CI drift the moment a new check: script is added. + expect(structural).not.toContain("check:deletion"); + expect(structural).not.toContain("check:report-error"); + expect(structural).not.toContain("check:packages"); +}); + test("jobs that need merge-base fetch full history; the rest stay shallow", async () => { const yaml = await readFile(join(ROOT, ".github/workflows/ci.yml"), "utf8"); const jobs = jobBodies(yaml);