diff --git a/AGENTS.md b/AGENTS.md index cd6f7f301..a975a5727 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,19 @@ check:*` script, so a violation fails CI rather than waiting for review. `@corbits/error-sink` — never a bare `catch {}`, never a toast alone. It attaches operation/tenant/room/agent context and a `refId` a person can quote to support, and redacts secrets before anything reaches a log - sink. + sink. Unlike every other rule in this section, this one does not yet + hold retroactively: `check:report-error` fails a catch clause that + neither calls `reportError` nor rethrows only when the catch is new or + 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 +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 + tracked on its own ticket opts out instead with a `report-error-ignore: +` comment on the catch or in its body — that's for a violation + actively being fixed, never a way to clear a baseline entry quietly. - A package's `browser-safe` subpath (e.g. `@corbits/routines/client`) may never import a server-only dependency (`postgres`, `drizzle-orm`, `hono`, any `@intx/*`) — `check:browser-safe-subpaths` walks the real diff --git a/package.json b/package.json index 2bc054299..5cebca4b7 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,9 @@ "setup:memory": "bun run scripts/setup-memory.ts", "seed": "bun packages/cli/src/index.ts seed", "reset": "bun packages/cli/src/index.ts reset", - "check:structural": "bun test scripts/checks/test && bun run check:deletion && bun run check:killdates && bun run check:licenses && bun run check:no-product-tenancy && bun run check:browser-safe-subpaths && bun run check:web-utilities && bun run check:tailwind-source && bun run check:ui-vocabulary && bun run check:react-ui-drift && bun run check:react-ui-pin && bun run check:tool-package-pins && bun run check:tool-package-freshness", + "check:structural": "bun test scripts/checks/test && bun run check:deletion && bun run check:killdates && bun run check:licenses && bun run check:no-product-tenancy && bun run check:browser-safe-subpaths && bun run check:web-utilities && bun run check:tailwind-source && bun run check:ui-vocabulary && bun run check:react-ui-drift && bun run check:react-ui-pin && bun run check:tool-package-pins && bun run check:tool-package-freshness && bun run check:report-error", "check:deletion": "bun run scripts/checks/deletion.ts", + "check:report-error": "bun run scripts/checks/report-error.ts", "check:killdates": "bun run scripts/checks/killdates.ts", "check:packages": "bun run scripts/checks/packages.ts", "check:licenses": "bun run scripts/checks/licenses.ts", diff --git a/packages/connections/src/connected-hook.test.ts b/packages/connections/src/connected-hook.test.ts index f983275b8..8cbd20521 100644 --- a/packages/connections/src/connected-hook.test.ts +++ b/packages/connections/src/connected-hook.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { + fireConnectedHook, fireInferenceCredentialSeedableHook, type InferenceCredentialSeedableInfo, + type ServiceConnectedInfo, } from "./connected-hook"; function seedableInfo(): InferenceCredentialSeedableInfo { @@ -15,6 +18,46 @@ function seedableInfo(): InferenceCredentialSeedableInfo { }; } +function connectedInfo(): ServiceConnectedInfo { + return { + tenantId: "tenant_1", + principalId: "principal_1", + connectorId: "github", + displayName: "GitHub", + }; +} + +describe("fireConnectedHook", () => { + test("does nothing when no hook is wired", async () => { + await expect( + fireConnectedHook(undefined, () => {}, connectedInfo()), + ).resolves.toBeUndefined(); + }); + + test("logs and reports a hook failure rather than breaking the connect", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const logged: string[] = []; + await expect( + fireConnectedHook( + () => { + throw new Error("card settle unavailable"); + }, + (line) => logged.push(line), + connectedInfo(), + ), + ).resolves.toBeUndefined(); + expect(logged).toHaveLength(1); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "fire_connected_hook", + tenantId: "tenant_1", + extra: { connectorId: "github" }, + }); + report.mockRestore(); + }); +}); + describe("fireInferenceCredentialSeedableHook", () => { test("does nothing when no hook is wired", async () => { await expect( @@ -34,7 +77,8 @@ describe("fireInferenceCredentialSeedableHook", () => { expect(calls).toEqual([seedableInfo()]); }); - test("logs and swallows a hook failure rather than breaking the connect", async () => { + test("logs and reports a hook failure rather than breaking the connect", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const logged: string[] = []; await expect( fireInferenceCredentialSeedableHook( @@ -48,5 +92,13 @@ describe("fireInferenceCredentialSeedableHook", () => { expect(logged).toHaveLength(1); expect(logged[0]).toContain("tenant_1"); expect(logged[0]).toContain("drain unavailable"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "fire_inference_credential_seedable_hook", + tenantId: "tenant_1", + extra: { provider: "ollama" }, + }); + report.mockRestore(); }); }); diff --git a/packages/connections/src/connected-hook.ts b/packages/connections/src/connected-hook.ts index 8ffd4dd42..3b3372892 100644 --- a/packages/connections/src/connected-hook.ts +++ b/packages/connections/src/connected-hook.ts @@ -5,6 +5,7 @@ // without knowing which door the connection came through. Best-effort // by contract: a hook failure is logged and never breaks the connect // itself — the credential is already stored when this fires. +import { reportError } from "@corbits/error-sink"; export type ServiceConnectedInfo = { readonly tenantId: string; @@ -30,6 +31,11 @@ export async function fireConnectedHook( log( `onConnected hook failed for ${info.connectorId} on tenant ${info.tenantId}: ${message}`, ); + reportError(cause, { + operation: "fire_connected_hook", + tenantId: info.tenantId, + extra: { connectorId: info.connectorId }, + }); } } @@ -77,5 +83,10 @@ export async function fireInferenceCredentialSeedableHook( log( `onInferenceCredentialUsable hook failed for ${info.provider} on tenant ${info.tenantId}: ${message}`, ); + reportError(cause, { + operation: "fire_inference_credential_seedable_hook", + tenantId: info.tenantId, + extra: { provider: info.provider }, + }); } } diff --git a/packages/connections/src/github-connect.test.ts b/packages/connections/src/github-connect.test.ts index f7f7f9b73..cae07ddf1 100644 --- a/packages/connections/src/github-connect.test.ts +++ b/packages/connections/src/github-connect.test.ts @@ -3,7 +3,8 @@ // surface GitHub's own `error`/`error_description` shape (distinct from // a non-2xx status — GitHub's token endpoint answers 200 with an `error` // field on a rejected code). -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { exchangeCodeForGithubToken, type ExchangeFetch, @@ -101,6 +102,7 @@ describe("exchangeCodeForGithubToken", () => { }); test("a transport failure is reported honestly", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const fetchImpl: ExchangeFetch = async () => { throw new Error("getaddrinfo ENOTFOUND"); }; @@ -118,6 +120,12 @@ describe("exchangeCodeForGithubToken", () => { expect(result.message).toContain("Could not reach GitHub"); expect(result.message).toContain("getaddrinfo ENOTFOUND"); } + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "exchange_code_for_github_token", + }); + report.mockRestore(); }); // CL-7235: a GitHub token endpoint that never answers used to leave diff --git a/packages/connections/src/github-connect.ts b/packages/connections/src/github-connect.ts index 9992106be..bc85695e9 100644 --- a/packages/connections/src/github-connect.ts +++ b/packages/connections/src/github-connect.ts @@ -12,6 +12,7 @@ // still names `credentialPlugin: "http"`). import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; import { postExchangeRequest, @@ -67,6 +68,7 @@ export async function exchangeCodeForGithubToken( }), }); } catch (cause) { + reportError(cause, { operation: "exchange_code_for_github_token" }); return { ok: false, message: diff --git a/packages/connections/src/gmail-connect.test.ts b/packages/connections/src/gmail-connect.test.ts index e2b95968a..f4bb6f116 100644 --- a/packages/connections/src/gmail-connect.test.ts +++ b/packages/connections/src/gmail-connect.test.ts @@ -2,7 +2,8 @@ // entirely against a stubbed fetch — no Google credentials involved. // Live-key verification against a real Google OAuth app is a deploy // concern (`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET`), not a test one. -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { exchangeCodeForGoogleToken, @@ -117,3 +118,25 @@ test("wires a bounded AbortSignal into the exchange fetch so a stalled provider expect(capturedSignal).toBeInstanceOf(AbortSignal); expect(capturedSignal?.aborted).toBe(false); }); + +test("a transport failure is reported and never crashes", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const result = await exchangeCodeForGoogleToken({ + code: "auth-code-1", + redirectUri: "https://bench.example.com/callback", + clientId: "client-1", + clientSecret: "secret-1", + fetchImpl: async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }, + }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected failure"); + expect(result.message).toContain("getaddrinfo ENOTFOUND"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "exchange_code_for_google_token", + }); + report.mockRestore(); +}); diff --git a/packages/connections/src/gmail-connect.ts b/packages/connections/src/gmail-connect.ts index 4adbe66b0..f2b0c2e07 100644 --- a/packages/connections/src/gmail-connect.ts +++ b/packages/connections/src/gmail-connect.ts @@ -11,6 +11,7 @@ // authorize URL) for the credential row to keep alongside the secret. import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; import { postExchangeRequest, @@ -85,6 +86,7 @@ export async function exchangeCodeForGoogleToken( body: params.toString(), }); } catch (cause) { + reportError(cause, { operation: "exchange_code_for_google_token" }); const message = cause instanceof Error ? cause.message : String(cause); return { ok: false, message: `Google token exchange failed: ${message}` }; } diff --git a/packages/connections/src/huggingface-connect.test.ts b/packages/connections/src/huggingface-connect.test.ts index cbde44b49..30ef381bf 100644 --- a/packages/connections/src/huggingface-connect.test.ts +++ b/packages/connections/src/huggingface-connect.test.ts @@ -1,7 +1,8 @@ // Unit tests for the Hugging Face connector's code-for-token exchange, // driven entirely against a stubbed fetch -- no Hugging Face credentials // involved. -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { exchangeCodeForToken, @@ -40,6 +41,7 @@ test("exchanges the code and verifier for an access token and expiry", async () }); test("a transport failure is reported honestly, never as a token", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const fetchImpl: ExchangeFetch = async () => { throw new Error("getaddrinfo ENOTFOUND"); }; @@ -55,7 +57,14 @@ test("a transport failure is reported honestly, never as a token", async () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.message).toContain("Could not reach Hugging Face"); + expect(result.message).toContain("getaddrinfo ENOTFOUND"); } + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "exchange_code_for_huggingface_token", + }); + report.mockRestore(); }); // CL-7235: a Hugging Face token endpoint that never answers used to diff --git a/packages/connections/src/huggingface-connect.ts b/packages/connections/src/huggingface-connect.ts index 45d3bbcac..e253aa355 100644 --- a/packages/connections/src/huggingface-connect.ts +++ b/packages/connections/src/huggingface-connect.ts @@ -14,6 +14,7 @@ // later expiry sweep reads. import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; import { postExchangeRequest, @@ -88,6 +89,7 @@ export async function exchangeCodeForToken( body: body.toString(), }); } catch (cause) { + reportError(cause, { operation: "exchange_code_for_huggingface_token" }); return { ok: false, message: diff --git a/packages/connections/src/mcp-oauth-routes.test.ts b/packages/connections/src/mcp-oauth-routes.test.ts index 90c6c75bd..0e2fab7c1 100644 --- a/packages/connections/src/mcp-oauth-routes.test.ts +++ b/packages/connections/src/mcp-oauth-routes.test.ts @@ -8,7 +8,8 @@ // `deps.probe` (the same test seam `mcp-server-routes.test.ts` uses), // since this suite is about the OAuth mechanics, not a second proof that // `@corbits/mcp-tools`' transport works. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { Hono, type MiddlewareHandler } from "hono"; import { createNoopCredentialCipher } from "@intx/crypto"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -621,6 +622,7 @@ describe("MCP OAuth connect flow", () => { }); test("start redirects discovery_failed when the authorization server is unreachable", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const hub = fakeHub(); const routes = createMcpOAuthRoutes({ hubUrl: "http://hub.test", @@ -638,6 +640,13 @@ describe("MCP OAuth connect flow", () => { expect(response.headers.get("location")).toBe( "/plugins?mcpOauth=canva&outcome=error&code=discovery_failed", ); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "mcp_oauth_start", + extra: { slug: "canva" }, + }); + report.mockRestore(); }); test("callback completes the token exchange and stores a bearer credential", async () => { @@ -677,6 +686,106 @@ describe("MCP OAuth connect flow", () => { } }); + test("a token-exchange failure is reported without leaking the code or verifier", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const as = startStubAuthorizationServer(); + try { + const hub = fakeHub(); + const routes = createMcpOAuthRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + credentialCipher: createNoopCredentialCipher(), + apiCall: hub.apiCall, + }); + const app = mountAs(routes); + + const startResponse = await app.request( + `/exa/start?url=${encodeURIComponent(as.resourcePath)}&name=Exa`, + { redirect: "manual" }, + ); + const cookieHeader = startResponse.headers.get("set-cookie") ?? ""; + const cookie = cookieHeader.split(";")[0] ?? ""; + const authorizeLocation = startResponse.headers.get("location") ?? ""; + const authorizeResponse = await fetch(authorizeLocation, { + redirect: "manual", + }); + const redirectToCallback = + authorizeResponse.headers.get("location") ?? ""; + const callbackUrl = new URL(redirectToCallback); + // A code the stub server never issued: its /token endpoint 400s + // with invalid_grant, which auth() surfaces as a throw. + callbackUrl.searchParams.set("code", "code_never_issued"); + + const callbackResponse = await app.request( + `${callbackUrl.pathname}${callbackUrl.search}`, + { headers: { cookie }, redirect: "manual" }, + ); + + expect(callbackResponse.headers.get("location") ?? "").toContain( + "code=exchange_failed", + ); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "mcp_oauth_token_exchange", + tenantId: TENANT.id, + extra: { slug: "exa" }, + }); + report.mockRestore(); + } finally { + as.stop(); + } + }); + + test("a persist failure after a completed exchange is reported without leaking the token", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const as = startStubAuthorizationServer(); + try { + const failingApiCall: ApiCall = async (method, path) => { + if (method === "GET" && path.endsWith("/providers?inherited=false")) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + throw new Error("provider store unavailable"); + }; + const routes = createMcpOAuthRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + credentialCipher: createNoopCredentialCipher(), + apiCall: failingApiCall, + probe: async (): Promise => ({ + ok: true, + toolCount: 1, + }), + }); + const app = mountAs(routes); + + const callbackResponse = await runConnectFlow(app, as); + + expect(callbackResponse.headers.get("location") ?? "").toContain( + "code=setup_failed", + ); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "persist_mcp_oauth_connection", + tenantId: TENANT.id, + extra: { slug: "exa" }, + }); + const extra = report.mock.calls[0]?.[1]?.extra as + Record | undefined; + expect(JSON.stringify(extra)).not.toContain("token_for_"); + report.mockRestore(); + } finally { + as.stop(); + } + }); + test("callback stores an oauth_token credential with the issued refresh token and expiry", async () => { const as = startStubAuthorizationServer({ refreshToken: "refresh_abc123", diff --git a/packages/connections/src/mcp-oauth-routes.ts b/packages/connections/src/mcp-oauth-routes.ts index 2ea0f4dfa..998e6e43b 100644 --- a/packages/connections/src/mcp-oauth-routes.ts +++ b/packages/connections/src/mcp-oauth-routes.ts @@ -35,6 +35,7 @@ import { createConnectStateStore, randomToken } from "./pkce"; import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; import { mcpPresetBySlug } from "./mcp-presets"; import { probeMcpServer, type McpProbeResult } from "./mcp-probe"; +import { reportError } from "@corbits/error-sink"; import { listMcpProviders, providerName, @@ -159,7 +160,10 @@ async function fetchCapturingOAuthError( captured.code = code; } } catch { - // malformed JSON on an error response; classifier uses the thrown error + // report-error-ignore: CL-7247 — malformed JSON on an error response; + // mcpOAuthStartErrorCode's own message-sniffing fallback classifies + // the thrown error regardless, so this degrades to that path rather + // than losing information worth reporting on its own. } return response; } @@ -298,6 +302,11 @@ export function createMcpOAuthRoutes( } catch (cause) { const message = cause instanceof Error ? cause.message : String(cause); deps.log(`mcp oauth start failed for "${target.slug}": ${message}`); + reportError(cause, { + operation: "mcp_oauth_start", + tenantId: c.get("tenant").id, + extra: { slug: target.slug }, + }); return c.redirect( redirectPath(returnPath, { mcpOauth: target.slug, @@ -459,6 +468,13 @@ export function createMcpOAuthRoutes( deps.log( `mcp oauth token exchange failed for "${payload.slug}": ${message}`, ); + // Never widen extra beyond identifiers safe to print — the + // authorization `code` and any codeVerifier are in scope above. + reportError(cause, { + operation: "mcp_oauth_token_exchange", + tenantId: c.get("tenant").id, + extra: { slug: payload.slug }, + }); return c.redirect( redirectPath(returnPath, { mcpOauth: payload.slug, @@ -583,6 +599,13 @@ export function createMcpOAuthRoutes( deps.log( `mcp oauth connect setup failed for tenant ${tenant.id}, slug ${payload.slug}: ${message}`, ); + // Never widen extra beyond identifiers safe to print — the + // exchanged access/refresh tokens are in scope above. + reportError(cause, { + operation: "persist_mcp_oauth_connection", + tenantId: tenant.id, + extra: { slug: payload.slug }, + }); return c.redirect( redirectPath(returnPath, { mcpOauth: payload.slug, diff --git a/packages/connections/src/mcp-oauth.test.ts b/packages/connections/src/mcp-oauth.test.ts index 847b4c988..c246b5980 100644 --- a/packages/connections/src/mcp-oauth.test.ts +++ b/packages/connections/src/mcp-oauth.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; -import { createMcpOAuthProvider } from "./mcp-oauth"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; +import { createMcpOAuthProvider, refreshMcpOAuthTokens } from "./mcp-oauth"; describe("createMcpOAuthProvider", () => { test("clientMetadata includes scope only when it is passed", () => { @@ -34,3 +35,28 @@ describe("createMcpOAuthProvider", () => { }); }); }); + +describe("refreshMcpOAuthTokens", () => { + test("an unreachable server reports the failure and comes back ok: false", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const server = Bun.serve({ port: 0, fetch: () => new Response(null) }); + const serverUrl = `http://127.0.0.1:${String(server.port)}/mcp`; + server.stop(true); + + const result = await refreshMcpOAuthTokens({ + serverUrl, + tokens: { access_token: "at_1", token_type: "Bearer" }, + callbackUrl: "http://hub.test/callback", + clientName: "Corbits Workbench", + }); + + expect(result.ok).toBe(false); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "refresh_mcp_oauth_tokens", + extra: { serverUrl }, + }); + report.mockRestore(); + }); +}); diff --git a/packages/connections/src/mcp-oauth.ts b/packages/connections/src/mcp-oauth.ts index 53a9eceed..7d6ef7045 100644 --- a/packages/connections/src/mcp-oauth.ts +++ b/packages/connections/src/mcp-oauth.ts @@ -24,6 +24,7 @@ import type { OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { reportError } from "@corbits/error-sink"; export type McpOAuthSession = { clientInformation?: OAuthClientInformationMixed; @@ -149,6 +150,10 @@ export async function refreshMcpOAuthTokens(args: { try { result = await auth(provider, { serverUrl: args.serverUrl }); } catch (cause) { + reportError(cause, { + operation: "refresh_mcp_oauth_tokens", + extra: { serverUrl: args.serverUrl }, + }); return { ok: false, message: cause instanceof Error ? cause.message : String(cause), diff --git a/packages/connections/src/mcp-probe.test.ts b/packages/connections/src/mcp-probe.test.ts index c5d7a8e20..82d472142 100644 --- a/packages/connections/src/mcp-probe.test.ts +++ b/packages/connections/src/mcp-probe.test.ts @@ -3,7 +3,8 @@ // metadata behind it, and a 401 that does advertise RFC 9728/8414 metadata // (the OAuth-gated shape this probe is meant to recognize). Unpinned fetch // follows a 302 to another origin; the probe must not. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { probeMcpServer } from "./mcp-probe"; function startUnauthorizedServer(): { url: string; stop: () => void } { @@ -61,17 +62,24 @@ describe("probeMcpServer", () => { }); test("a plain 401 with no discoverable OAuth metadata reports a plain failure", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const stub = startUnauthorizedServer(); try { const result = await probeMcpServer(stub.url, undefined); expect(result.ok).toBe(false); if (!result.ok) expect(result.requiresOAuth).toBeUndefined(); + expect(report).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ operation: "probe_mcp_server" }), + ); } finally { stub.stop(); + report.mockRestore(); } }); test("a 401 backed by RFC 9728/8414 metadata reports requiresOAuth with the discovered authorization server", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const stub = startOAuthGatedServer(); try { const result = await probeMcpServer(stub.url, undefined); @@ -81,8 +89,13 @@ describe("probeMcpServer", () => { } else { throw new Error("expected requiresOAuth: true"); } + expect(report).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ operation: "probe_mcp_server" }), + ); } finally { stub.stop(); + report.mockRestore(); } }); diff --git a/packages/connections/src/mcp-probe.ts b/packages/connections/src/mcp-probe.ts index 187d56e85..32a24fce2 100644 --- a/packages/connections/src/mcp-probe.ts +++ b/packages/connections/src/mcp-probe.ts @@ -8,6 +8,7 @@ import { mcpOriginPinnedFetch } from "@corbits/credential-providers"; import { withMcpConnection, listMcpTools } from "@corbits/mcp-tools"; import { discoverOAuthServerInfo } from "@modelcontextprotocol/sdk/client/auth.js"; +import { reportError } from "@corbits/error-sink"; export type McpProbeResult = | { readonly ok: true; readonly toolCount: number } @@ -47,7 +48,15 @@ async function discoverOAuthRequirement( return info.authorizationServerMetadata !== undefined ? { authorizationServerUrl: info.authorizationServerUrl } : undefined; - } catch { + } catch (cause) { + // A missing discovery document is the expected negative result (most + // MCP servers aren't OAuth-gated) and degrades to it identically to a + // real discovery-transport failure — report so an actual outage here + // doesn't silently read as "this server doesn't do OAuth." + reportError(cause, { + operation: "discover_mcp_oauth_requirement", + extra: { origin: new URL(url).origin }, + }); return undefined; } } @@ -78,6 +87,10 @@ export async function probeMcpServer( try { parsedUrl = new URL(url); } catch { + // report-error-ignore: CL-7247 — a malformed URL here is a person's + // paste-in typo (the same "not a valid URL" outcome the UI already + // surfaces to them), never a system fault; there is nothing to fix in + // response to it. return { ok: false, message: `"${url}" is not a valid URL.` }; } if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { @@ -95,6 +108,10 @@ export async function probeMcpServer( cause instanceof Error ? `Could not connect to that MCP server: ${cause.message}` : `Could not connect to that MCP server: ${String(cause)}`; + reportError(cause, { + operation: "probe_mcp_server", + extra: { origin: parsedUrl.origin }, + }); if (looksUnauthorized(cause)) { const discovered = await discoverOAuthRequirement(url); if (discovered !== undefined) { diff --git a/packages/connections/src/mcp-server-routes.test.ts b/packages/connections/src/mcp-server-routes.test.ts index 9190646a5..4b5097d92 100644 --- a/packages/connections/src/mcp-server-routes.test.ts +++ b/packages/connections/src/mcp-server-routes.test.ts @@ -2,7 +2,8 @@ // (`apiCall`) and a stubbed probe, mirroring `routes.test.ts`'s own // bare-Hono/tenant-injecting-middleware setup. Nothing here touches the // real network or a real MCP server. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -241,6 +242,42 @@ describe("POST /", () => { expect(body.slug).toBe("notion-2"); }); + test("a storage failure after a successful probe reports it without leaking the token", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const failingApiCall: ApiCall = async (method, path) => { + if (method === "GET" && path.endsWith("/providers?inherited=false")) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + throw new Error("provider store unavailable"); + }; + const app = buildApp({ apiCall: failingApiCall }); + + const response = await app.request("/", { + method: "POST", + body: JSON.stringify({ + name: "Notion Workspace", + url: "https://mcp.notion.example/sse", + token: "secret-token", + }), + }); + + expect(response.status).toBe(500); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "persist_mcp_server_connection", + tenantId: TENANT.id, + }); + const extra = report.mock.calls[0]?.[1]?.extra as + Record | undefined; + expect(JSON.stringify(extra)).not.toContain("secret-token"); + report.mockRestore(); + }); + test("a failing probe never touches storage", async () => { const hub = fakeHub({}); const app = buildApp({ diff --git a/packages/connections/src/mcp-server-routes.ts b/packages/connections/src/mcp-server-routes.ts index 48b4e0527..8765e260f 100644 --- a/packages/connections/src/mcp-server-routes.ts +++ b/packages/connections/src/mcp-server-routes.ts @@ -34,6 +34,7 @@ import { } from "@corbits/credential-providers"; import { probeMcpServer } from "./mcp-probe"; import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; +import { reportError } from "@corbits/error-sink"; import { MCP_PRESETS, mcpPresetBySlug } from "./mcp-presets"; const ErrorEnvelope = (code: string, message: string) => ({ @@ -342,6 +343,13 @@ export function createMcpServerRoutes( deps.log( `mcp server connect failed for tenant ${tenant.id}, slug ${slug}: ${message}`, ); + // Never widen extra beyond identifiers safe to print — `cause` here + // can carry the pasted bearer token in scope above. + reportError(cause, { + operation: "persist_mcp_server_connection", + tenantId: tenant.id, + extra: { slug }, + }); return c.json( ErrorEnvelope( "connection_setup_failed", diff --git a/packages/connections/src/oauth-routes.test.ts b/packages/connections/src/oauth-routes.test.ts index af364b416..2d113add7 100644 --- a/packages/connections/src/oauth-routes.test.ts +++ b/packages/connections/src/oauth-routes.test.ts @@ -6,7 +6,8 @@ // themselves: state sealing/consuming, PKCE round-tripping, the // returnPath cookie, not_configured, rate limiting, and duplicate- // callback recovery — all driven purely off `ConnectorDescriptor.oauth`. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import type { AppEnv } from "@intx/hub-api"; import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; @@ -178,6 +179,12 @@ describe("sanitizeReturnPath", () => { ), ).toBe("/settings/connections"); }); + + test("malformed percent-encoding falls back to the default silently", () => { + expect(sanitizeReturnPath("%", defaultReturnPath, allowlist)).toBe( + defaultReturnPath, + ); + }); }); describe("GET /:connectorId/start", () => { @@ -677,6 +684,7 @@ describe("GET /:connectorId/callback", () => { }); test("a thrown store failure surfaces as setup_failed without leaking the key", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const lines: string[] = []; const app = connectRoutes({ log: (line) => lines.push(line), @@ -697,6 +705,16 @@ describe("GET /:connectorId/callback", () => { ); expect(redirect.searchParams.get("code")).toBe("setup_failed"); expect(lines.join("\n")).not.toContain("key-for-abc123"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "oauth_connect_setup", + extra: { connectorId: "widget" }, + }); + const extra = report.mock.calls[0]?.[1]?.extra as + Record | undefined; + expect(JSON.stringify(extra)).not.toContain("key-for-abc123"); + report.mockRestore(); }); describe("open-redirect regression: the return cookie is never trusted either", () => { diff --git a/packages/connections/src/oauth-routes.ts b/packages/connections/src/oauth-routes.ts index 2d6c1fc97..f481311d5 100644 --- a/packages/connections/src/oauth-routes.ts +++ b/packages/connections/src/oauth-routes.ts @@ -42,6 +42,7 @@ import { type ConnectStateStore, } from "./pkce"; import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; +import { reportError } from "@corbits/error-sink"; import type { ConnectorDescriptor } from "./descriptor"; import { CONNECTOR_REGISTRY } from "./registry"; @@ -102,6 +103,11 @@ export function sanitizeReturnPath( const decodedOnceMore = decodeURIComponent(candidate); if (decodedOnceMore !== candidate) candidate = decodedOnceMore; } catch { + // report-error-ignore: CL-7247 — a malformed percent-encoding here is + // untrusted, possibly adversarial redirect input; the function's own + // contract (see header) is to fail silently to the default path + // exactly like an absent `?return=` would, never to surface as an + // error for a value nothing should have sent in the first place. return defaultReturnPath; } @@ -625,6 +631,15 @@ export function createOAuthConnectRoutes( deps.log( `${connectorId} connect setup failed for user ${user.id}: ${message}`, ); + // Never widen `extra` beyond identifiers safe to print — `cause` here + // can carry the exchanged material (apiKey/refreshToken) or the + // connector's clientSecret in scope above; reportError's redaction + // covers the error object itself, not whatever this call adds to + // context. + reportError(cause, { + operation: "oauth_connect_setup", + extra: { connectorId, userId: user.id }, + }); return c.redirect( redirectPath(returnPath, connectorId, { outcome: "error", diff --git a/packages/connections/src/oauth-tenant-connect.test.ts b/packages/connections/src/oauth-tenant-connect.test.ts index 2d4539b3f..5fc5dc30a 100644 --- a/packages/connections/src/oauth-tenant-connect.test.ts +++ b/packages/connections/src/oauth-tenant-connect.test.ts @@ -3,7 +3,8 @@ // authorize -> callback -> credential-stored round trip against a fake // provider (mirroring `oauth-routes.test.ts`'s `fakeDescriptor`), and a // mismatched-state callback that must never reach persistence at all. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { TenantEnv } from "@intx/hub-api"; @@ -163,6 +164,40 @@ describe("createTenantConnectCredential, mounted through createOAuthConnectRoute ]); }); + test("a persist failure is reported and comes back as invalid-credential", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const connectCredential = createTenantConnectCredential({ + hubUrl: "https://bench.example.com", + log: () => undefined, + registry: { widget: WIDGET_CONNECTOR }, + ensureProviderFn: async () => { + throw new Error("provider store unavailable"); + }, + }); + + const result = await connectCredential({ + c: { + get: (key: string) => + key === "tenant" ? TENANT : key === "principal" ? PRINCIPAL : null, + } as never, + connectorId: "widget", + userId: "user_1", + userEmail: "user_1@example.com", + cookies: [], + apiKey: "key-for-abc123", + }); + + expect(result.kind).toBe("invalid-credential"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "persist_tenant_oauth_connection", + tenantId: TENANT.id, + extra: { connectorId: "widget" }, + }); + report.mockRestore(); + }); + test("a callback with no matching state never persists a credential", async () => { const { app, providers, credentials } = mountTenantScoped(); diff --git a/packages/connections/src/oauth-tenant-connect.ts b/packages/connections/src/oauth-tenant-connect.ts index 34c18e77e..550e18a4c 100644 --- a/packages/connections/src/oauth-tenant-connect.ts +++ b/packages/connections/src/oauth-tenant-connect.ts @@ -19,6 +19,7 @@ // path, which has nothing else vouching for the secret). import type { TenantEnv } from "@intx/hub-api"; import { createHubAPI } from "@workbench/hub-client"; +import { reportError } from "@corbits/error-sink"; import type { ConnectorDescriptor } from "./descriptor"; import type { ProviderHealthStore } from "./provider-health"; import { CONNECTOR_REGISTRY } from "./registry"; @@ -99,6 +100,11 @@ export function createTenantConnectCredential( deps.log( `oauth connect for ${args.connectorId} on tenant ${tenant.id} failed to persist: ${message}`, ); + reportError(cause, { + operation: "persist_tenant_oauth_connection", + tenantId: tenant.id, + extra: { connectorId: args.connectorId }, + }); return { kind: "invalid-credential", message }; } }; diff --git a/packages/connections/src/openrouter-connect.test.ts b/packages/connections/src/openrouter-connect.test.ts index 679b9bf96..54344b8a9 100644 --- a/packages/connections/src/openrouter-connect.test.ts +++ b/packages/connections/src/openrouter-connect.test.ts @@ -1,8 +1,8 @@ // Unit tests for the OpenRouter connector's code-for-key exchange, // driven entirely against a stubbed fetch -- no OpenRouter credentials // involved. -import { expect, test } from "bun:test"; - +import { expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { exchangeCodeForKey, OPENROUTER_KEY_EXCHANGE_URL, @@ -34,6 +34,7 @@ test("exchanges the code and verifier for the user-scoped API key", async () => }); test("a transport failure is reported honestly, never as a key", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const fetchImpl: ExchangeFetch = async () => { throw new Error("getaddrinfo ENOTFOUND"); }; @@ -47,7 +48,14 @@ test("a transport failure is reported honestly, never as a key", async () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.message).toContain("Could not reach OpenRouter"); + expect(result.message).toContain("getaddrinfo ENOTFOUND"); } + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "exchange_code_for_openrouter_key", + }); + report.mockRestore(); }); // CL-7235: an OpenRouter key endpoint that never answers used to leave diff --git a/packages/connections/src/openrouter-connect.ts b/packages/connections/src/openrouter-connect.ts index e87b09126..45008c3d4 100644 --- a/packages/connections/src/openrouter-connect.ts +++ b/packages/connections/src/openrouter-connect.ts @@ -7,6 +7,7 @@ // and never put in a URL. import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; import { postExchangeRequest, @@ -57,6 +58,7 @@ export async function exchangeCodeForKey( }), }); } catch (cause) { + reportError(cause, { operation: "exchange_code_for_openrouter_key" }); return { ok: false, message: diff --git a/packages/connections/src/pkce.ts b/packages/connections/src/pkce.ts index c5840284a..9817f9252 100644 --- a/packages/connections/src/pkce.ts +++ b/packages/connections/src/pkce.ts @@ -138,6 +138,12 @@ export function createConnectStateStore(args: { if (parsed instanceof type.errors) return undefined; envelope = parsed; } catch { + // report-error-ignore: CL-7247 — decrypt/parse failure here is the + // expected outcome for a tampered, expired-then-reused, or wrong- + // provider state, indistinguishable from malicious probing by + // design (see module header). Reporting it would create a + // decrypt-failure oracle and flood the sink with routine, + // non-actionable noise. return undefined; } diff --git a/packages/connections/src/probes.test.ts b/packages/connections/src/probes.test.ts index 6939000af..63ab58dc1 100644 --- a/packages/connections/src/probes.test.ts +++ b/packages/connections/src/probes.test.ts @@ -2,7 +2,8 @@ // real network calls, no real keys — a 401 must reject, a 2xx must // accept, matching `@workbench/hub-client/credential-test`'s own // contract for `testProviderCredential`. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import type { FetchLike } from "@workbench/hub-client/credential-test"; import { testExaCredential, @@ -34,6 +35,22 @@ describe("testGranolaCredential", () => { ); expect(result.ok).toBe(true); }); + + test("a transport failure is reported by displayName, never the key", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const fetchImpl: FetchLike = async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }; + const result = await testGranolaCredential("test-key", fetchImpl); + expect(result.ok).toBe(false); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "probe_credential", + extra: { displayName: "Granola" }, + }); + report.mockRestore(); + }); }); describe("testExaCredential", () => { diff --git a/packages/connections/src/probes.ts b/packages/connections/src/probes.ts index a1fb426d3..770fed12d 100644 --- a/packages/connections/src/probes.ts +++ b/packages/connections/src/probes.ts @@ -11,6 +11,7 @@ import type { CredentialTestResult, FetchLike, } from "@workbench/hub-client/credential-test"; +import { reportError } from "@corbits/error-sink"; const PROBE_TIMEOUT_MS = 5000; @@ -34,6 +35,10 @@ async function probe( if (init.body !== undefined) fetchArgs.body = init.body; response = await fetchImpl(url, fetchArgs); } catch (cause) { + reportError(cause, { + operation: "probe_credential", + extra: { displayName }, + }); return { ok: false, message: diff --git a/packages/connections/src/routes.test.ts b/packages/connections/src/routes.test.ts index e70679bd4..3e316fe74 100644 --- a/packages/connections/src/routes.test.ts +++ b/packages/connections/src/routes.test.ts @@ -5,7 +5,8 @@ // `packages/webhook-triggers/test/management-routes.test.ts`. A fake // registry stands in for `CONNECTOR_REGISTRY` so every probe outcome is // deterministic and nothing here ever touches the real network. -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; +import * as errorSink from "@corbits/error-sink"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; @@ -622,7 +623,8 @@ describe("POST /:connectorId/complete", () => { expect(body.error.code).toBe("connection_setup_failed"); }); - test("a storage failure after a good probe 500s", async () => { + test("a storage failure after a good probe 500s and reports without leaking the key", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); const app = buildApp({ ensureProviderFn: async () => { throw new Error("hub unreachable"); @@ -636,6 +638,16 @@ describe("POST /:connectorId/complete", () => { expect(response.status).toBe(500); const body = (await response.json()) as { error: { code: string } }; expect(body.error.code).toBe("connection_setup_failed"); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "persist_api_key_connection", + extra: { connectorId: "accepting-connector" }, + }); + const extra = report.mock.calls[0]?.[1]?.extra as + Record | undefined; + expect(JSON.stringify(extra)).not.toContain("good-key"); + report.mockRestore(); }); test("a rejected probe reports the connector needs_attention with a category, never the probe's own message", async () => { @@ -1241,4 +1253,43 @@ describe("onInferenceCredentialUsable hook", () => { expect(response.status).toBe(200); expect(events).toHaveLength(0); }); + + test("a resolved-catalog check failure is reported and never breaks the connect", async () => { + const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); + const events: unknown[] = []; + const routes = createConnectionRoutes({ + hubUrl: "http://hub.test", + requireGrant: allowAll, + log: () => {}, + registry: OLLAMA_REGISTRY, + ensureProviderFn: async () => "prv_1", + ensureCredentialFn: async () => "crd_1", + seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), + getResolvedCatalogFn: async () => { + throw new Error("hub unreachable"); + }, + onInferenceCredentialUsable: async (info) => { + events.push(info); + }, + }); + const app = mountAs(routes); + const response = await app.request("/ollama/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + apiKey: "https://home-mac-studio.tail87f5aa.ts.net", + }), + }); + + expect(response.status).toBe(200); + expect(events).toHaveLength(0); + expect(report).toHaveBeenCalledTimes(1); + expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); + expect(report.mock.calls[0]?.[1]).toMatchObject({ + operation: "check_resolved_catalog_after_connect", + tenantId: TENANT.id, + extra: { connectorId: "ollama" }, + }); + report.mockRestore(); + }); }); diff --git a/packages/connections/src/routes.ts b/packages/connections/src/routes.ts index a181e73a3..313049115 100644 --- a/packages/connections/src/routes.ts +++ b/packages/connections/src/routes.ts @@ -532,6 +532,11 @@ export function createConnectionRoutes( deps.log( `could not check tenant ${tenant.id}'s resolved catalog after connecting ${descriptor.id}; the bench stays as-is until its next reconcile: ${message}`, ); + reportError(cause, { + operation: "check_resolved_catalog_after_connect", + tenantId: tenant.id, + extra: { connectorId: descriptor.id }, + }); } } } @@ -546,6 +551,13 @@ export function createConnectionRoutes( deps.log( `connection setup failed for connector ${connectorId} on tenant ${tenant.id}: ${message}`, ); + // Never widen extra beyond identifiers safe to print — the pasted + // `parsed.apiKey` is in scope above. + reportError(cause, { + operation: "persist_api_key_connection", + tenantId: tenant.id, + extra: { connectorId }, + }); return c.json( ErrorEnvelope( "connection_setup_failed", diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts index 069a9df01..6693c2478 100644 --- a/packages/onboarding/src/bench-provisioning.ts +++ b/packages/onboarding/src/bench-provisioning.ts @@ -204,6 +204,8 @@ export function createBenchProvisioner( else holdOff(key); return outcome; } catch (cause) { + // report-error-ignore: CL-7234 routes this drain catch through + // reportError the way provision.ts already does const message = cause instanceof Error ? cause.message : String(cause); logError( `bench provisioning for tenant ${seed.tenantId} failed; its pending row stays for a retry: ${message}`, diff --git a/packages/onboarding/src/plant-env-credentials.ts b/packages/onboarding/src/plant-env-credentials.ts index e63111097..703a04314 100644 --- a/packages/onboarding/src/plant-env-credentials.ts +++ b/packages/onboarding/src/plant-env-credentials.ts @@ -327,6 +327,8 @@ export async function plantEnvProviderCredentials( }), ); } catch (cause) { + // report-error-ignore: CL-7234 routes this catch through + // reportError the way provision.ts already does const message = cause instanceof Error ? cause.message : String(cause); args.log( `env credential plant: ${provider} failed to backfill catalog: ${message}`, @@ -357,6 +359,8 @@ export async function plantEnvProviderCredentials( try { await runSeedCatalog(catalogSeedArgs(provider, { apiKey })); } catch (cause) { + // report-error-ignore: CL-7234 routes this catch through + // reportError the way provision.ts already does const message = cause instanceof Error ? cause.message : String(cause); args.log(`env credential plant: ${provider} failed to plant: ${message}`); outcomes.push({ provider, status: "failed", message }); diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 55b9783fb..28903cf28 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -870,6 +870,8 @@ export function createOnboardingRoutes( deps.benchProvisioner?.wake(); return c.json(status, 200); } catch (cause) { + // report-error-ignore: CL-7234 — reportOnboardingError itself needs + // to call reportError; tracked there rather than at each call site const envelope = reportOnboardingError(deps.logError ?? deps.log, { userAction: `complete-setup for user ${user.id}`, code: "complete_setup_failed", @@ -914,6 +916,8 @@ export function createOnboardingRoutes( return c.json(await provisioningStatus(cookies, tenant), 200); } catch (cause) { + // report-error-ignore: CL-7234 — reportOnboardingError itself needs + // to call reportError; tracked there rather than at each call site const envelope = reportOnboardingError(deps.logError ?? deps.log, { userAction: `provisioning status for user ${user.id}`, code: "provisioning_status_failed", diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt new file mode 100644 index 000000000..157efd028 --- /dev/null +++ b/scripts/checks/report-error-baseline.txt @@ -0,0 +1,279 @@ +# check:report-error debt ledger — NOT an allowlist. +# +# Every line below is a catch clause that already existed when +# check:report-error started enforcing the reportError convention. It is +# recorded here so new code is held to the rule immediately while the +# existing backlog is tracked instead of hidden. Each line names a real +# bug someone should still fix: route the catch through reportError, +# rethrow it, or (for a finding already in flight on a ticket) replace +# its line here with a report-error-ignore comment in the source instead. +# +# This file only ever shrinks: fixing an entry and regenerating removes +# its line; a line with no matching finding anymore fails the check +# until it's regenerated, so it can't rot silently. +# +# Regenerate after fixing (or newly opting out) entries: +# bun run scripts/checks/report-error.ts --write-baseline +# +# Format (tab-separated): \t\t +# `occurrence` is this evidence string's 1-based rank among matches in +# the same file — line numbers aren't used as the key because they +# drift as unrelated code around a catch changes. +apps/hub/src/bench-session.ts 1 const message = cause instanceof Error ? cause.message : String(cause); +apps/hub/src/credential-expiry-sweep.ts 1 log.error`credential expiry sweep failed: ${ +apps/hub/src/env-credential-plant.ts 1 session = undefined; +apps/hub/src/grant-allowance.ts 1 return null; +apps/hub/src/index.ts 1 email = ""; +apps/hub/src/index.ts 1 email = undefined; +apps/hub/src/index.ts 1 log.warn( +apps/hub/src/index.ts 1 sidecarAllocationLog.error`Sidecar allocation reconciliation failed: ${error instanceof Error ? error.message : String(error)}`; +apps/hub/src/mail-redelivery.ts 1 log.warn`could not wake ${recipient} ahead of redelivery: ${ +apps/hub/src/routine-launcher.ts 1 const reason = +apps/hub/src/routine-launcher.ts 1 const reason = err instanceof Error ? err.message : String(err); +apps/hub/src/routine-scheduler.ts 1 const reason = err instanceof Error ? err.message : String(err); +apps/hub/src/routine-scheduler.ts 1 log.error`marking routine ${claimed.id}'s failed fire also failed: ${ +apps/hub/src/routine-scheduler.ts 1 log.error`routine scheduler tick failed: ${ +apps/hub/src/shutdown.ts 1 return { kind: "failed", error }; +apps/sidecar/src/atomic-write.ts 1 logger.warn`parent-dir fsync failed for ${path}; durability is degraded but the file is renamed and fsynced — ${err instanceof Error ? err.message : String(err)}`; +apps/sidecar/src/concurrency.ts 1 failures.push({ item, error }); +apps/sidecar/src/conversation-state.ts 1 logger.warn`connector route for ${opts.agentKey} could not parse the inbound sender; leaving the thread unadvanced: ${cause instanceof Error ? cause.message : String(cause)}`; +apps/sidecar/src/originating-workbench.ts 1 return undefined; +apps/sidecar/src/shutdown.ts 1 return { kind: "failed", error }; +apps/sidecar/src/signing-keypair.ts 1 return false; +apps/sidecar/src/step-agent-tools.ts 1 logger.error`step plugin dispose failed during ${context}: ${cause instanceof Error ? cause.message : String(cause)}`; +apps/sidecar/src/step-agent-tools.ts 1 logger.error`step tool bundle dispose failed: ${cause instanceof Error ? cause.message : String(cause)}`; +apps/sidecar/src/step-agent-tools.ts 1 logger.warn`step credential capability dispose failed: ${cause instanceof Error ? cause.message : String(cause)}`; +apps/sidecar/src/tool-materialization.ts 1 if (!(hasCode(err) && err.code === "ENOENT")) { +apps/sidecar/src/tool-materialization.ts 1 logger.error`active-deploy-id degraded write also failed for ${activeIdFile}: ${fallback instanceof Error ? fallback.message : String(fallback)}; writing dirty marker so the next boot can reconcile`; +apps/sidecar/src/tool-materialization.ts 1 logger.error`active-deploy-id dirty marker write also failed for ${activeIdFile}.dirty: ${marker instanceof Error ? marker.message : String(marker)}; on-disk state will diverge from the recorded id for one boot cycle`; +apps/sidecar/src/tool-materialization.ts 1 logger.warn`active-deploy-id primary persist failed for ${activeIdFile}: ${primary instanceof Error ? primary.message : String(primary)}; attempting degraded write`; +apps/sidecar/src/tool-materialization.ts 1 logger.warn`audit-dir fsync failed for ${dir}; rejected-apply durability is degraded but the files are written — ${err instanceof Error ? err.message : String(err)}`; +apps/sidecar/src/tool-materialization.ts 1 logger.warn`fsync failed for ${filePath}; durability is degraded but the bytes are written — ${err instanceof Error ? err.message : String(err)}`; +apps/sidecar/src/tool-materialization.ts 1 logger.warn`parent-dir fsync failed for ${instanceDir} after deploy-id persist; deploy-id durability is degraded but the committed deploy is staged on disk — ${err instanceof Error ? err.message : String(err)}`; +apps/sidecar/src/workflow-deployment-record.ts 1 const reason = cause instanceof Error ? cause.message : String(cause); +apps/sidecar/src/workflow-deployment-record.ts 1 return undefined; +apps/sidecar/src/workflow-host-wiring/index.ts 1 const message = +apps/sidecar/src/workflow-host-wiring/index.ts 1 const reason = +apps/sidecar/src/workflow-host-wiring/index.ts 2 const reason = +apps/sidecar/src/workflow-host-wiring/supervisor.ts 1 const message = cause instanceof Error ? cause.message : String(cause); +apps/sidecar/src/workflow-probe-handler.ts 1 logger.debug`probe child ${String(handle.pid)} SIGKILL raised (already exited?): ${errorMessage(err)}`; +apps/sidecar/src/workflow-probe-handler.ts 1 logger.debug`probe child ${String(handle.pid)} SIGTERM raised (already exited?): ${errorMessage(err)}`; +apps/sidecar/src/workflow-probe-handler.ts 1 payload = { ok: false, error: enrichProbeError(err) }; +apps/sidecar/src/workflow-run-pack-client.ts 1 const msg = cause instanceof Error ? cause.message : String(cause); +apps/web/src/approval-actions.ts 1 if (cause instanceof ApiQueryError) { +apps/web/src/auth/quote-card.tsx 1 // localStorage unavailable (private mode / blocked) — rotation just +apps/web/src/auth/quote-card.tsx 1 return -1; +apps/web/src/bench-context.tsx 1 // A private-browsing tab with storage disabled loses persistence, not +apps/web/src/bench-context.tsx 1 return null; +apps/web/src/block-response-actions.ts 1 if (cause instanceof ChatApiError && cause.status === 403) { +apps/web/src/block-response-actions.ts 2 if (cause instanceof ChatApiError && cause.status === 403) { +apps/web/src/command-palette-recents.ts 1 // Storage disabled or full — recents just stop persisting this tab. +apps/web/src/command-palette-recents.ts 1 return null; +apps/web/src/connect-github-actions.ts 1 const message = +apps/web/src/connect-service-actions.ts 1 const message = +apps/web/src/connect-service-actions.ts 1 return { ok: false, message: "Couldn't connect. Try again." }; +apps/web/src/connect-service-actions.ts 2 const message = +apps/web/src/deployment-capabilities-api.ts 1 return { +apps/web/src/global-routines.ts 1 toast( +apps/web/src/global-routines.ts 2 toast( +apps/web/src/last-workbench.ts 1 // A private-browsing tab with storage disabled loses this signal — +apps/web/src/last-workbench.ts 1 return null; +apps/web/src/onboarding.ts 1 return { kind: "error" }; +apps/web/src/onboarding.ts 1 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; +apps/web/src/onboarding.ts 1 return { kind: "unknown" }; +apps/web/src/onboarding.ts 2 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; +apps/web/src/onboarding.ts 3 return { kind: "error", message: FALLBACK_ERROR_MESSAGE }; +apps/web/src/pages/agent-detail-page.tsx 1 // A handle collision is the one failure retrying can never clear, so +apps/web/src/pages/agent-detail-page.tsx 1 setLifecycle({ +apps/web/src/pages/agent-detail-page.tsx 1 setSave({ kind: "idle" }); +apps/web/src/pages/create-agent-panel.tsx 1 setDraftFailed(true); +apps/web/src/pages/create-agent-panel.tsx 1 setSubmitError( +apps/web/src/pages/create-skill-dialog.tsx 1 setServerError(cause instanceof Error ? cause.message : String(cause)); +apps/web/src/pages/library-page.tsx 1 setUploadError( +apps/web/src/pages/mission-control-page.tsx 1 toast( +apps/web/src/pages/new-workbench-picker.tsx 1 // The missing-setup-agent precondition reads identically whether +apps/web/src/pages/skill-detail-page.tsx 1 if (options.keepDraft) { +apps/web/src/pages/skill-detail-page.tsx 1 if (statusOf(cause) === 409) { +apps/web/src/pages/skill-detail-page.tsx 1 setActionError(describeApiError(cause, "reading that version")); +apps/web/src/pages/skill-detail-page.tsx 1 setActionError(describeApiError(cause, "saving that change")); +apps/web/src/pages/skills-page.tsx 1 setState({ status: "error", message: messageOf(cause) }); +apps/web/src/profile-relations.ts 1 return { +apps/web/src/session.ts 1 return { +apps/web/src/session.ts 2 return { +apps/web/src/session.ts 3 return { +apps/web/src/session.ts 4 return { +apps/web/src/settings-access.ts 1 return "denied"; +apps/web/src/shell/context-menu/items.tsx 1 toast("Couldn't copy the link"); +apps/web/src/shell/routine-panel.tsx 1 setSaveState("error"); +apps/web/src/shell/workbench-list.tsx 1 // Revert the optimistic title on failure; the list will refetch on +apps/web/src/shell/workbench-list.tsx 1 setPinned(!next); +packages/access-policy/src/policy.ts 1 return []; +packages/agent-directory-tools/src/tool.ts 1 // The agent was genuinely created — that half-success must never +packages/agent-directory-tools/src/tool.ts 1 if (err instanceof CreateAgentDefinitionError) { +packages/agent-directory-tools/src/tool.ts 1 return errorResult(call.id, err); +packages/agent-directory/src/definition-history.ts 1 return null; +packages/agent-directory/src/routes.ts 1 return c.json( +packages/agent-lifecycle/src/index.ts 1 log.error`lifecycle sweep failed to undeploy ${address}: ${ +packages/approvals/src/allowance.ts 1 deps.log( +packages/approvals/src/allowance.ts 1 return { outcome: "park", reason: "classification_failed" }; +packages/artifacts-hub/src/routes.ts 1 return c.json( +packages/artifacts-hub/src/template-library.ts 1 deps.log( +packages/artifacts-hub/src/workflow-routes.ts 1 return c.json( +packages/artifacts-hub/src/workflow-routes.ts 2 return c.json( +packages/capability-tools/src/tool.ts 1 if (err instanceof CapabilityOutOfInventoryError) { +packages/capability-tools/src/tool.ts 1 return errorResult(call.id, err); +packages/catalog-tools/src/tool.ts 1 return errorResult(call.id, err); +packages/catalog-tools/src/tool.ts 2 return errorResult(call.id, err); +packages/catalog-tools/src/tool.ts 3 return errorResult(call.id, err); +packages/chat-ui/src/composer.tsx 1 if (attachGenerationRef.current !== generation) return; +packages/chat-ui/src/default-agent-workbench.ts 1 return { +packages/chat-ui/src/invite-agent-dialog.tsx 1 setInviteError( +packages/chat-ui/src/invite-agent-dialog.tsx 1 setInviteError(CHAT_STRINGS.inviteAgentQuickCreateError); +packages/chat-ui/src/timeline.tsx 1 toast(CHAT_STRINGS.copyTextError); +packages/chat-ui/src/tool-activity.ts 1 return output; +packages/chat-ui/src/use-optimistic-sends.ts 1 if (cause instanceof ChatApiError && cause.status === 403) { +packages/chat-ui/src/use-thread-navigation.ts 1 toast(CHAT_STRINGS.forkThreadError); +packages/chat-ui/src/use-workbench-stream.ts 1 onEventRef.current(eventType, message.data); +packages/chat/src/platform-adapter.ts 1 continue; +packages/chat/src/platform-adapter.ts 1 wakeLogger.error`drift check for ${binding.roomAddress} (run ${live.run.id}) failed, leaving it as-is: ${ +packages/chat/src/platform-adapter.ts 1 wakeLogger.error`inference-source reconcile for ${binding.roomAddress} (run ${run.id}) failed, leaving it as-is: ${ +packages/chat/src/platform-adapter.ts 1 wakeLogger.error`pinned-tool-package reconcile for ${live.binding.roomAddress} (run ${live.run.id}) failed, leaving it as-is: ${ +packages/chat/src/platform-adapter.ts 1 wakeLogger.error`relaunch sweep: could not relaunch ${live.binding.roomAddress} (run ${live.run.id} is ${live.run.status}): ${ +packages/chat/src/routes.ts 1 log.error( +packages/chat/src/routes.ts 1 return c.json(ErrorEnvelope("not_found", "blob not found"), 404); +packages/chat/src/routes.ts 2 log.error( +packages/chat/src/turn-context.ts 1 contextLog.warn`failed to assemble turn context on workbench ${input.workbenchId}: ${ +packages/chat/src/workbench-service.ts 1 fanoutLog.error( +packages/chat/src/workbench-service.ts 1 greetingLog.error( +packages/chat/src/workbench-service.ts 1 mintAgentDmLog.error( +packages/chat/src/workbench-service.ts 1 provisionLog.error( +packages/chat/src/workbench-service.ts 1 removeLog.error( +packages/chat/src/workbench-service.ts 2 fanoutLog.error( +packages/code-review/src/report.ts 1 return { ok: false, reason: "the reply was not JSON" }; +packages/code-review/src/review-run.ts 1 return { reviewer, ok: false, reason: reasonOf(err) }; +packages/command-palette/src/recents.ts 1 // A full or disabled store (private browsing, quota) loses +packages/command-palette/src/recents.ts 1 // As above — loses persistence, not function. +packages/command-palette/src/recents.ts 1 return []; +packages/connections-tools/src/tool.ts 1 if (err instanceof NoOwnRoomError) { +packages/connections-tools/src/tool.ts 1 return errorResult(call.id, err); +packages/connections-tools/src/tool.ts 2 return errorResult(call.id, err); +packages/connections-tools/src/tool.ts 3 return errorResult(call.id, err); +packages/connections-tools/src/tool.ts 4 return errorResult(call.id, err); +packages/error-sink/src/index.ts 1 return context.refId ?? UNKNOWN_OPERATION; +packages/folded-run-one-shot/src/one-shot-reply.ts 1 log.error`one-shot run ${triggerAddress}: undeploy failed during teardown (${reason}): ${ +packages/folded-run-one-shot/src/one-shot-reply.ts 1 void settle("planning-run-send-failed", () => { +packages/folded-runs/src/mail.ts 1 lastError = err; +packages/folded-runs/src/mail.ts 1 return { ok: false, error: err, attempts: attemptsUsed }; +packages/github-tools/src/pull-request-tools.ts 1 return errorResult(call.id, failureMessage(err)); +packages/github-tools/src/pull-request-tools.ts 1 return null; +packages/github-tools/src/pull-request-tools.ts 2 return errorResult(call.id, failureMessage(err)); +packages/github-tools/src/tool.ts 1 return null; +packages/github-tools/src/tool.ts 1 return { +packages/granola-tools/src/tool.ts 1 return null; +packages/granola-tools/src/tool.ts 1 return { +packages/granola-tools/src/tool.ts 2 return { +packages/hub-client/src/credential-test.ts 1 // Not JSON, or didn't match either shape — fall through to the +packages/hub-client/src/credential-test.ts 1 return []; +packages/hub-client/src/credential-test.ts 1 return undefined; +packages/hub-client/src/credential-test.ts 1 return { +packages/hub-client/src/workflow-push.ts 1 existing = null; +packages/inbox/src/routes.ts 1 publishLog.error( +packages/interaction-tools/src/tool.ts 1 if (err instanceof NoOwnChannelError) { +packages/jimmy-agent/src/gif-search-tool.ts 1 return false; +packages/jimmy-agent/src/gif-search-tool.ts 1 return null; +packages/jimmy-agent/src/gif-search-tool.ts 1 return { +packages/linear-tools/src/tool.ts 1 return null; +packages/linear-tools/src/tool.ts 1 return { +packages/longevity-sim/src/cli.ts 1 const detail = +packages/longevity-sim/src/engine.ts 1 defects.push({ +packages/longevity-sim/src/engine.ts 2 defects.push({ +packages/longevity-sim/src/engine.ts 3 defects.push({ +packages/manus-tools/src/tool.ts 1 return errorResult(call.id, err); +packages/manus-tools/src/tool.ts 1 return null; +packages/manus-tools/src/tool.ts 2 return errorResult(call.id, err); +packages/manus-tools/src/tool.ts 3 return errorResult(call.id, err); +packages/manus-tools/src/tool.ts 4 return errorResult(call.id, err); +packages/manus-tools/src/tool.ts 5 return errorResult(call.id, err); +packages/manus-tools/src/tool.ts 6 return errorResult(call.id, err); +packages/mcp-tools/src/tool.ts 1 return errorResult( +packages/mcp-tools/src/tool.ts 1 return errorResult(call.id, err); +packages/mcp-tools/src/tool.ts 1 return { +packages/mcp-tools/src/tool.ts 2 return errorResult(call.id, err); +packages/mcp-tools/src/tool.ts 3 return errorResult(call.id, err); +packages/mcp-tools/src/tool.ts 4 return errorResult(call.id, err); +packages/memory-hub/src/workflow-routes.ts 1 return c.json( +packages/memory-hub/src/workflow-routes.ts 2 return c.json( +packages/memory-tools/src/tool.ts 1 if (err instanceof MemoryUnavailableError) { +packages/memory-tools/src/tool.ts 2 if (err instanceof MemoryUnavailableError) { +packages/memory-tools/src/tool.ts 3 if (err instanceof MemoryUnavailableError) { +packages/mocks/src/ollama/capture.ts 1 return raw; +packages/ollama-adapter/src/inline-tool-json.ts 1 return false; +packages/ollama-adapter/src/inline-tool-json.ts 1 return { kind: "incomplete" }; +packages/onboarding/src/pending-seed.ts 1 return drop(); +packages/onboarding/src/routes.ts 1 // Neither `ProvisionError` nor `CliError` messages are safe to show +packages/onboarding/src/routes.ts 1 const message = cause instanceof Error ? cause.message : String(cause); +packages/onboarding/src/routes.ts 1 if (cause instanceof ProvisionError) { +packages/onboarding/src/routes.ts 1 return c.json( +packages/presence/src/artifact-persistence.ts 1 deps.onSnapshotError?.(key, error); +packages/presence/src/client.ts 1 // A malformed update is dropped rather than crashing the client; +packages/presence/src/client.ts 1 return []; +packages/presence/src/client.ts 1 return undefined; +packages/presence/src/client.ts 2 return undefined; +packages/presence/src/routes.ts 1 return c.json( +packages/reddit-tools/src/tool.ts 1 return null; +packages/reddit-tools/src/tool.ts 1 return { +packages/reddit-tools/src/tool.ts 2 return { +packages/routines-tools/src/tool.ts 1 return errorResult(call.id, err); +packages/routines-tools/src/tool.ts 1 return once; +packages/routines-tools/src/tool.ts 1 return value; +packages/routines-tools/src/tool.ts 2 return errorResult(call.id, err); +packages/routines-tools/src/tool.ts 3 return errorResult(call.id, err); +packages/routines-tools/src/tool.ts 4 return errorResult(call.id, err); +packages/routines/src/cron.ts 1 return false; +packages/routines/src/cron.ts 2 return false; +packages/routines/src/routes.ts 1 log.error( +packages/routines/src/routes.ts 1 return c.json( +packages/routines/src/routes.ts 2 log.error( +packages/routines/src/schedule-language.ts 1 return null; +packages/sandbox-sidecar/src/provisioner.ts 1 log.error`failed to sweep obsolete unit ${externalRef} for allocation ${allocationId}: ${ +packages/sandbox-sidecar/src/provisioner.ts 1 return rejected(...classify(error, "destroy_unit_failed")); +packages/sandbox-sidecar/src/provisioner.ts 1 return rejected(...classify(error, "start_unit_failed")); +packages/scout-agent/src/artifact-tool.ts 1 return { +packages/scout-agent/src/artifact-tool.ts 2 return { +packages/settings-ui/src/account-section.tsx 1 toast(SETTINGS_STRINGS.accountEmailCopyError); +packages/skills-tools/src/tool.ts 1 return errorResult(call.id, err); +packages/skills-tools/src/tool.ts 2 return errorResult(call.id, err); +packages/skills-tools/src/tool.ts 3 return errorResult(call.id, err); +packages/skills-tools/src/tool.ts 4 return errorResult(call.id, err); +packages/skills-tools/src/tool.ts 5 return errorResult(call.id, err); +packages/skills/src/asset-history.ts 1 return []; +packages/skills/src/hub-asset-store.ts 1 return null; +packages/skills/src/registry.ts 1 contentErrorToRegistryError(cause); +packages/skills/src/registry.ts 2 contentErrorToRegistryError(cause); +packages/slack-tag/src/principal-resolver.ts 1 log.error("Auto-provision failed for {email}: {error}", { +packages/slack-tag/src/slack-channel-name.ts 1 log.warn("conversations.info lookup failed for {channel}: {error}", { +packages/slack-tag/src/thread-state.ts 1 log.error( +packages/tool-registry-publish/src/freshness-check.ts 1 log.error`${err instanceof Error ? err.message : String(err)}`; +packages/tools-skills/src/tool.ts 1 return errorResult(call.id, err); +packages/tools-skills/src/tool.ts 2 return errorResult(call.id, err); +packages/tools-skills/src/tool.ts 3 return errorResult(call.id, err); +packages/turn-artifacts/src/index.ts 1 return []; +packages/url-path/src/decoded-or-null.ts 1 return null; +packages/web-search-tools/src/tool.ts 1 return null; +packages/web-search-tools/src/tool.ts 1 return { +packages/webhook-triggers/src/ingress-routes.ts 1 return c.json( +packages/webhook-triggers/src/signature.ts 1 return false; +packages/workflow-catalog/src/connect-github-routes.ts 1 // A repoId `state.repos` doesn't carry means the card's own +packages/workflow-catalog/src/connect-github-routes.ts 1 // The GitHub client's own errors (transport, HTTP status, shape +packages/workflow-catalog/src/template-block-routes.ts 1 const message = cause instanceof Error ? cause.message : String(cause); +workflows/attio-task-agent/src/finalize-tool.ts 1 return { +workflows/collateral-generation/src/finalize-tool.ts 1 return { +workflows/diligence-brief/src/finalize-tool.ts 1 return { +workflows/exa-topic-watch/src/finalize-tool.ts 1 return { +workflows/granola-call/src/finalize-tool.ts 1 return { +workflows/last-30-days-research/src/finalize-tool.ts 1 return { +workflows/morning-brief/src/finalize-tool.ts 1 return { +workflows/pain-point-collateral/src/finalize-tool.ts 1 return { +workflows/process-granola-call/src/finalize-tool.ts 1 return { +workflows/reddit-opportunity-scanner/src/finalize-tool.ts 1 return { +workflows/reddit-opportunity-scanner/src/finalize-tool.ts 2 return { diff --git a/scripts/checks/report-error.ts b/scripts/checks/report-error.ts new file mode 100644 index 000000000..fd83ae671 --- /dev/null +++ b/scripts/checks/report-error.ts @@ -0,0 +1,575 @@ +// check:report-error — AGENTS.md requires every caught error to reach +// reportError(...) from @corbits/error-sink: never a bare `catch {}`, +// never a toast alone, because reportError attaches operation/tenant/ +// room/agent context and a refId a person can quote to support, and +// redacts secrets before anything reaches a log sink. This check is what +// makes that rule real instead of prose sitting in a section titled +// "Conventions a check enforces" — like every script in this directory, +// it is a heuristic over source text, not proof, and a failure here is a +// claim to go verify, not a verdict. +// +// Each file is parsed with the TypeScript compiler API (already a repo +// dependency — reaching for it beats hand-rolling a brace/string matcher +// that will eventually misparse a template literal) and every `catch` +// clause is inspected. A clause passes if its body calls the file's own +// `reportError` import from `@corbits/error-sink` (bare or aliased, or +// via a `* as ns` namespace import), contains a `throw` that isn't nested +// inside another function or class body (a conditional rethrow is still a +// rethrow; a `throw` queued inside a `setTimeout` callback is not — it +// never propagates from this catch), or carries the opt-out marker below; +// anything else is a finding. Matching the import binding rather than the +// bare name `reportError` means an unrelated local function that happens +// to share the name is still flagged, and an aliased import (`import { +// reportError as report }`) is still recognized. +// +// This is still text-level triage, not control-flow analysis: a catch +// that calls a helper which calls reportError three frames down will +// false-positive, and only `try { } catch { }` statements are walked — a +// bare `.catch(...)` promise handler is out of scope until there's real +// evidence it's worth the extra surface. +// +// Deliberate exceptions already tracked by their own ticket get a narrow, +// greppable, justified opt-out: a comment containing `report-error-ignore:` +// followed by a reason, placed on the line the `catch` itself starts on or +// anywhere inside its body. There is no blanket per-file allowlist — every +// exception states its own reason next to the code it excuses. Use this +// only for a finding already in flight on a named ticket, never to clear a +// backlog entry — that's what the baseline below is for. +// +// A brand-new invariant introduced against an existing codebase can't +// demand the whole tree comply on day one, so the rest of the repo's +// findings — everything not carrying an opt-out — are recorded in +// scripts/checks/report-error-baseline.txt, a debt ledger, not an +// allowlist: every line in it is a bug someone should still fix. It is +// not a way to keep a finding quiet forever. The gate this check runs is: +// +// - A finding not in the baseline always fails — that's a regression. +// - A finding in the baseline fails too if this change's diff touches +// its catch clause's line — not merely its file, since a change that +// edits one function shouldn't be forced to also clean up unrelated +// debt elsewhere in the same file (this check's own introducing PR is +// the clearest example: its only edit to a file with baselined debt +// is a report-error-ignore comment nowhere near it). Fix the debt or +// give it its own report-error-ignore rather than let it ride along. +// - A baseline entry with no matching finding anymore fails, so a fix +// forces the baseline to shrink instead of quietly going stale. +// +// This follows check:tool-package-freshness's own precedent for scoping a +// new invariant to what changed rather than retroactively flagging +// pre-existing state: CI passes CHECK_BASE_REF; locally this falls back to +// the merge base with origin/main; with neither available the touched-file +// half of the gate no-ops (new-vs-baseline enforcement still applies). +// +// Regenerate the baseline after fixing (or newly opting out) entries: +// bun run scripts/checks/report-error.ts --write-baseline +import { spawnSync } from "node:child_process"; +import { Glob } from "bun"; +import path from "node:path"; +import ts from "typescript"; +import { + emptyReport, + reportAndExit, + rootFromArgs, + type CheckReport, +} from "./lib/repo"; +import { resolveBaseRef } from "./tool-package-freshness"; + +const SCAN_DIRS = ["apps", "packages", "workflows"]; +const IGNORE_MARKER_PATTERN = /report-error-ignore:\s*(\S.*)/; +const ERROR_SINK_MODULE = "@corbits/error-sink"; +export const BASELINE_PATH = "scripts/checks/report-error-baseline.txt"; + +const BASELINE_HEADER = [ + "# check:report-error debt ledger — NOT an allowlist.", + "#", + "# Every line below is a catch clause that already existed when", + "# check:report-error started enforcing the reportError convention. It is", + "# recorded here so new code is held to the rule immediately while the", + "# existing backlog is tracked instead of hidden. Each line names a real", + "# bug someone should still fix: route the catch through reportError,", + "# rethrow it, or (for a finding already in flight on a ticket) replace", + "# its line here with a report-error-ignore comment in the source instead.", + "#", + "# This file only ever shrinks: fixing an entry and regenerating removes", + "# its line; a line with no matching finding anymore fails the check", + "# until it's regenerated, so it can't rot silently.", + "#", + "# Regenerate after fixing (or newly opting out) entries:", + "# bun run scripts/checks/report-error.ts --write-baseline", + "#", + "# Format (tab-separated): \\t\\t", + "# `occurrence` is this evidence string's 1-based rank among matches in", + "# the same file — line numbers aren't used as the key because they", + "# drift as unrelated code around a catch changes.", + "", +].join("\n"); + +export interface ScannedFile { + readonly relPath: string; + readonly contents: string; +} + +function isExcludedPath(relPath: string): boolean { + if (relPath.includes("node_modules/")) return true; + if (relPath.includes("/dist/") || relPath.startsWith("dist/")) return true; + if (relPath.includes("/vendor/") || relPath.startsWith("vendor/")) { + return true; + } + if (relPath.includes("/test/") || relPath.startsWith("test/")) return true; + if (/\.(test|spec)\.tsx?$/.test(relPath)) return true; + return false; +} + +export async function scanFiles( + root: string, + dirs: readonly string[], +): Promise { + const files: string[] = []; + for (const dir of dirs) { + const glob = new Glob(`${dir}/**/*.{ts,tsx}`); + for await (const file of glob.scan({ cwd: root, dot: false })) { + if (isExcludedPath(file)) continue; + files.push(file); + } + } + return files; +} + +export interface ReportErrorBindings { + /** Local names bound to the named `reportError` export, e.g. from + * `import { reportError }` or `import { reportError as report }`. */ + readonly localNames: ReadonlySet; + /** Local names bound to a `* as ns` namespace import of the module, + * so `ns.reportError(...)` is recognized too. */ + readonly namespaceNames: ReadonlySet; +} + +/** + * Finds this file's own binding(s) for `@corbits/error-sink`'s + * `reportError` export. Matching against these bindings — rather than + * the bare identifier `reportError` — means an unrelated local function + * that happens to share the name doesn't pass, and an aliased import + * still does. + */ +export function findReportErrorBindings( + sourceFile: ts.SourceFile, +): ReportErrorBindings { + const localNames = new Set(); + const namespaceNames = new Set(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + if (!ts.isStringLiteral(statement.moduleSpecifier)) continue; + if (statement.moduleSpecifier.text !== ERROR_SINK_MODULE) continue; + const bindings = statement.importClause?.namedBindings; + if (bindings === undefined) continue; + if (ts.isNamespaceImport(bindings)) { + namespaceNames.add(bindings.name.text); + continue; + } + for (const element of bindings.elements) { + const importedName = (element.propertyName ?? element.name).text; + if (importedName === "reportError") localNames.add(element.name.text); + } + } + return { localNames, namespaceNames }; +} + +function callsReportError( + node: ts.Node, + bindings: ReportErrorBindings, +): boolean { + let found = false; + const visit = (n: ts.Node): void => { + if (found) return; + if (ts.isCallExpression(n)) { + const callee = n.expression; + if (ts.isIdentifier(callee) && bindings.localNames.has(callee.text)) { + found = true; + return; + } + if ( + ts.isPropertyAccessExpression(callee) && + callee.name.text === "reportError" && + ts.isIdentifier(callee.expression) && + bindings.namespaceNames.has(callee.expression.text) + ) { + found = true; + return; + } + } + ts.forEachChild(n, visit); + }; + visit(node); + return found; +} + +/** + * A nested function or class body's own control flow doesn't run + * synchronously as part of the catch: a `throw` inside a `setTimeout` + * callback or an unrelated closure never rethrows the caught error, it + * schedules an unhandleable exception on a later tick (or throws over + * unrelated data entirely) — so the search doesn't descend into one. + */ +function containsThrow(node: ts.Node): boolean { + let found = false; + const visit = (n: ts.Node): void => { + if (found) return; + if (ts.isThrowStatement(n)) { + found = true; + return; + } + if ( + ts.isFunctionLike(n) || + ts.isClassDeclaration(n) || + ts.isClassExpression(n) + ) { + return; + } + ts.forEachChild(n, visit); + }; + visit(node); + return found; +} + +/** + * Searches the whole enclosing try statement, not just the catch clause: + * the natural place to write "the next line is a deliberate empty catch" + * is the line above it, which sits inside the try block's trailing + * trivia rather than the catch clause's own text. + */ +function findIgnoreReason( + sourceFile: ts.SourceFile, + clause: ts.CatchClause, +): string | undefined { + const scope = ts.isTryStatement(clause.parent) ? clause.parent : clause; + const fullText = scope.getFullText(sourceFile); + const match = IGNORE_MARKER_PATTERN.exec(fullText); + return match?.[1]; +} + +function firstNonEmptyLine(text: string): string { + const lines = text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line !== "{" && line !== "}"); + return lines[0] ?? "{}"; +} + +function lineOf(sourceFile: ts.SourceFile, node: ts.Node): number { + return ( + sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1 + ); +} + +export interface Finding { + readonly relPath: string; + readonly line: number; + readonly evidence: string; +} + +export interface ScanResult { + readonly findings: readonly Finding[]; + readonly clauseCount: number; + readonly compliantCount: number; + readonly optedOutCount: number; + readonly optedOutNotes: readonly string[]; +} + +/** Walks every file's catch clauses once, classifying each as opted out, + * compliant, or a finding — independent of baseline/diff gating. */ +export function scanForFindings(files: readonly ScannedFile[]): ScanResult { + const findings: Finding[] = []; + const optedOutNotes: string[] = []; + let clauseCount = 0; + let compliantCount = 0; + let optedOutCount = 0; + + for (const { relPath, contents } of files) { + const sourceFile = ts.createSourceFile( + relPath, + contents, + ts.ScriptTarget.Latest, + true, + relPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const bindings = findReportErrorBindings(sourceFile); + + const visit = (node: ts.Node): void => { + if (ts.isCatchClause(node)) { + clauseCount += 1; + const line = lineOf(sourceFile, node); + const bodyText = node.block.getText(sourceFile); + const ignoreReason = findIgnoreReason(sourceFile, node); + + if (ignoreReason !== undefined) { + optedOutCount += 1; + optedOutNotes.push( + `${relPath}:${line}: catch opted out (${ignoreReason})`, + ); + } else if ( + callsReportError(node.block, bindings) || + containsThrow(node.block) + ) { + compliantCount += 1; + } else { + findings.push({ + relPath, + line, + evidence: firstNonEmptyLine(bodyText), + }); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + } + + return { + findings, + clauseCount, + compliantCount, + optedOutCount, + optedOutNotes, + }; +} + +function violationMessage(finding: Finding): string { + return ( + `${finding.relPath}:${finding.line}: catch neither calls ` + + `reportError(...) from @corbits/error-sink nor rethrows — body ` + + `starts with "${finding.evidence}". Report it through reportError, ` + + `rethrow it, or add a "report-error-ignore: " comment on ` + + `the catch or in its body if this is a deliberate exception.` + ); +} + +export function baselineKey( + relPath: string, + occurrence: number, + evidence: string, +): string { + return `${relPath}\t${occurrence}\t${evidence}`; +} + +/** + * Assigns each finding its 1-based occurrence among findings sharing the + * same (relPath, evidence) pair, in scan order, and keys it — the stable + * identity a baseline entry keys on, since raw line numbers drift as a + * file is edited elsewhere. + */ +export function keyFindings( + findings: readonly Finding[], +): Map { + const seen = new Map(); + const keyed = new Map(); + for (const finding of findings) { + const seenKey = `${finding.relPath} ${finding.evidence}`; + const occurrence = (seen.get(seenKey) ?? 0) + 1; + seen.set(seenKey, occurrence); + keyed.set( + baselineKey(finding.relPath, occurrence, finding.evidence), + finding, + ); + } + return keyed; +} + +export function parseBaseline(text: string): Set { + const keys = new Set(); + for (const rawLine of text.split("\n")) { + const line = rawLine.replace(/\r$/, ""); + if (line.trim().length === 0 || line.startsWith("#")) continue; + keys.add(line); + } + return keys; +} + +export function serializeBaseline(keys: Iterable): string { + return BASELINE_HEADER + [...keys].sort().join("\n") + "\n"; +} + +/** An inclusive range of line numbers (in the current, post-change file) + * that a diff hunk added or left as context. */ +export interface ChangedRange { + readonly start: number; + readonly end: number; +} + +export interface AuditOptions { + readonly baseline: ReadonlySet; + /** + * Changed line ranges per repo-relative path, keyed to the current + * (post-change) file's own line numbers. Undefined when no base ref is + * available — the ratchet no-ops in that case, but new-vs-baseline + * enforcement (and stale-entry detection) still runs. + * + * This is deliberately line-range, not whole-file: a change that edits + * one function in a large file shouldn't be forced to also clean up + * unrelated pre-existing debt elsewhere in that same file — including, + * notably, this very check's own PR, whose only edit to a file with + * baselined debt is adding a report-error-ignore comment nowhere near + * it. + */ + readonly changedLines?: ReadonlyMap; +} + +function isLineChanged( + changedLines: AuditOptions["changedLines"], + relPath: string, + line: number, +): boolean { + const ranges = changedLines?.get(relPath); + if (ranges === undefined) return false; + return ranges.some((range) => line >= range.start && line <= range.end); +} + +export function auditReportError( + files: readonly ScannedFile[], + options: AuditOptions, +): CheckReport { + const report = emptyReport(); + const scan = scanForFindings(files); + const keyed = keyFindings(scan.findings); + + let newCount = 0; + let baselinedCount = 0; + for (const [key, finding] of keyed) { + const inBaseline = options.baseline.has(key); + if (!inBaseline) { + newCount += 1; + report.violations.push(violationMessage(finding)); + continue; + } + baselinedCount += 1; + if (isLineChanged(options.changedLines, finding.relPath, finding.line)) { + report.violations.push( + `${violationMessage(finding)} This change's diff touches this ` + + `catch, so its baselined report-error debt must be fixed or ` + + `given its own report-error-ignore comment rather than left ` + + `in the baseline.`, + ); + } + } + + const staleKeys = [...options.baseline].filter((key) => !keyed.has(key)); + for (const key of staleKeys) { + report.violations.push( + `${BASELINE_PATH}: stale entry "${key}" no longer matches a real ` + + `finding — regenerate with "bun run scripts/checks/` + + `report-error.ts --write-baseline" so the baseline only shrinks.`, + ); + } + + report.notes.push(...scan.optedOutNotes); + report.notes.push( + `${scan.clauseCount} catch clause(s) scanned: ${scan.compliantCount} ` + + `compliant, ${scan.optedOutCount} opted out, ${baselinedCount} ` + + `baselined pre-existing, ${newCount} new finding(s), ` + + `${staleKeys.length} stale baseline entrie(s)`, + ); + return report; +} + +function git(root: string, args: readonly string[]): string | undefined { + const result = spawnSync("git", [...args], { cwd: root, encoding: "utf8" }); + return result.status === 0 ? result.stdout.trim() : undefined; +} + +const DIFF_FILE_PATTERN = /^\+\+\+ b\/(.+)$/; +const DIFF_HUNK_PATTERN = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/; + +/** + * Parses a zero-context unified diff (`git diff --unified=0`) into the + * changed line ranges of each file, in that file's post-change line + * numbers — the same numbering `lineOf` uses when scanning the current + * tree. A hunk that only deletes lines (`+c,0`) touches nothing in the + * new file and is skipped. + */ +export function parseChangedRanges( + diffText: string, +): Map { + const ranges = new Map(); + let currentFile: string | undefined; + for (const line of diffText.split("\n")) { + const fileMatch = DIFF_FILE_PATTERN.exec(line); + if (fileMatch?.[1] !== undefined) { + currentFile = fileMatch[1]; + continue; + } + const hunkMatch = DIFF_HUNK_PATTERN.exec(line); + if (hunkMatch === null || currentFile === undefined) continue; + const start = Number(hunkMatch[1]); + const count = hunkMatch[2] !== undefined ? Number(hunkMatch[2]) : 1; + if (count === 0) continue; + const list = ranges.get(currentFile) ?? []; + list.push({ start, end: start + count - 1 }); + ranges.set(currentFile, list); + } + return ranges; +} + +async function readFiles(root: string): Promise { + const relPaths = await scanFiles(root, SCAN_DIRS); + return Promise.all( + relPaths.map(async (relPath) => ({ + relPath, + contents: await Bun.file(path.join(root, relPath)).text(), + })), + ); +} + +async function writeBaseline(root: string): Promise { + const files = await readFiles(root); + const scan = scanForFindings(files); + const keyed = keyFindings(scan.findings); + await Bun.write( + path.join(root, BASELINE_PATH), + serializeBaseline(keyed.keys()), + ); + console.log( + `check:report-error: wrote ${keyed.size} entrie(s) to ${BASELINE_PATH}`, + ); +} + +async function main(): Promise { + const args = Bun.argv.slice(2); + const root = rootFromArgs(args); + + if (args.includes("--write-baseline")) { + await writeBaseline(root); + return; + } + + const files = await readFiles(root); + + const baselineFile = Bun.file(path.join(root, BASELINE_PATH)); + const baseline = (await baselineFile.exists()) + ? parseBaseline(await baselineFile.text()) + : new Set(); + + const baseRef = resolveBaseRef(root, process.env["CHECK_BASE_REF"]); + const changedLines = + baseRef === undefined + ? undefined + : parseChangedRanges( + git(root, ["diff", "--unified=0", `${baseRef}...HEAD`]) ?? "", + ); + + const report = auditReportError(files, { + baseline, + ...(changedLines !== undefined ? { changedLines } : {}), + }); + if (baseRef === undefined) { + report.notes.push( + "no base ref (no origin/main, no CHECK_BASE_REF); skipping the " + + "touched-line ratchet — CI supplies the base ref for the " + + "authoritative run. New-vs-baseline enforcement still applies.", + ); + } + report.notes.push( + `scanned ${files.length} file(s) under ${SCAN_DIRS.join(", ")}`, + ); + reportAndExit("check:report-error", report); +} + +if (import.meta.main) await main(); diff --git a/scripts/checks/test/report-error.test.ts b/scripts/checks/test/report-error.test.ts new file mode 100644 index 000000000..d6ba687ef --- /dev/null +++ b/scripts/checks/test/report-error.test.ts @@ -0,0 +1,504 @@ +import { expect, test } from "bun:test"; +import { + auditReportError, + baselineKey, + parseBaseline, + parseChangedRanges, + serializeBaseline, +} from "../report-error"; + +const NO_BASELINE = { baseline: new Set() }; +const BASELINE_REGENERATE_HINT = "--write-baseline"; + +test("a catch that calls reportError passes", () => { + const report = auditReportError( + [ + { + relPath: "packages/chat/src/thing.ts", + contents: [ + `import { reportError } from "@corbits/error-sink";`, + `try {`, + ` doWork();`, + `} catch (error) {`, + ` reportError(error, { operation: "thing" });`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toEqual([]); +}); + +test("a catch that calls reportError through a namespace import passes", () => { + const report = auditReportError( + [ + { + relPath: "packages/chat/src/thing.ts", + contents: [ + `import * as errorSink from "@corbits/error-sink";`, + `try {`, + ` doWork();`, + `} catch (error) {`, + ` errorSink.reportError(error, { operation: "thing" });`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toEqual([]); +}); + +test("a catch that calls an aliased reportError import passes", () => { + const report = auditReportError( + [ + { + relPath: "packages/chat/src/thing.ts", + contents: [ + `import { reportError as report } from "@corbits/error-sink";`, + `try {`, + ` doWork();`, + `} catch (error) {`, + ` report(error, { operation: "thing" });`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toEqual([]); +}); + +test("a catch that calls an unrelated local function named reportError is a violation", () => { + const report = auditReportError( + [ + { + relPath: "packages/chat/src/thing.ts", + contents: [ + `function reportError(message: string) {`, + ` console.log(message);`, + `}`, + `try {`, + ` doWork();`, + `} catch (error) {`, + ` reportError("failed");`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toHaveLength(1); +}); + +test("a throw queued inside a nested callback is a violation, not a rethrow", () => { + const report = auditReportError( + [ + { + relPath: "apps/hub/src/stream.ts", + contents: [ + `try {`, + ` doWork();`, + `} catch (error) {`, + ` setTimeout(() => {`, + ` throw error;`, + ` }, 0);`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toHaveLength(1); +}); + +test("a catch that rethrows passes", () => { + const report = auditReportError( + [ + { + relPath: "packages/chat/src/thing.ts", + contents: [ + `try {`, + ` doWork();`, + `} catch (error) {`, + ` throw error;`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toEqual([]); +}); + +test("a catch that conditionally rethrows passes", () => { + const report = auditReportError( + [ + { + relPath: "packages/chat/src/thing.ts", + contents: [ + `try {`, + ` doWork();`, + `} catch (error) {`, + ` if (isFatal(error)) throw error;`, + ` cache.clear();`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toEqual([]); +}); + +test("a bare catch {} is a violation", () => { + const report = auditReportError( + [ + { + relPath: "apps/hub/src/stream.ts", + contents: [`try {`, ` doWork();`, `} catch {}`].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("apps/hub/src/stream.ts:3"); +}); + +test("a catch that only logs, without reportError or rethrow, is a violation", () => { + const report = auditReportError( + [ + { + relPath: "packages/onboarding/src/routes.ts", + contents: [ + `try {`, + ` doWork();`, + `} catch (error) {`, + ` console.error(error);`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("console.error(error)"); +}); + +test("a catch with the opt-out marker in its body passes with a note", () => { + const report = auditReportError( + [ + { + relPath: "packages/onboarding/src/plant-env-credentials.ts", + contents: [ + `try {`, + ` doWork();`, + `} catch (error) {`, + ` // report-error-ignore: CL-7234 tracked separately`, + ` console.error(error);`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toEqual([]); + expect( + report.notes.some((n) => n.includes("CL-7234 tracked separately")), + ).toBe(true); +}); + +test("a catch with the opt-out marker on its own line passes", () => { + const report = auditReportError( + [ + { + relPath: "apps/hub/src/stream.ts", + contents: [ + `try {`, + ` doWork();`, + ` // report-error-ignore: CL-7197 fixed by a concurrent lane`, + `} catch {}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toEqual([]); +}); + +test("an opted-out catch is never a candidate for the baseline", () => { + // Even with an empty baseline, a ticketed opt-out never appears as a + // "new finding" violation — it's a separate mechanism entirely. + const report = auditReportError( + [ + { + relPath: "apps/hub/src/stream.ts", + contents: [ + `try {`, + ` doWork();`, + ` // report-error-ignore: CL-7197 fixed by a concurrent lane`, + `} catch {}`, + ].join("\n"), + }, + ], + { + baseline: new Set(), + changedLines: new Map([ + ["apps/hub/src/stream.ts", [{ start: 1, end: 4 }]], + ]), + }, + ); + expect(report.violations).toEqual([]); +}); + +test("reports every violation across multiple files, not just the first", () => { + const report = auditReportError( + [ + { + relPath: "a.ts", + contents: [`try {`, ` x();`, `} catch {}`].join("\n"), + }, + { + relPath: "b.ts", + contents: [`try {`, ` x();`, `} catch {}`].join("\n"), + }, + { + relPath: "c.ts", + contents: [ + `import { reportError } from "@corbits/error-sink";`, + `try {`, + ` x();`, + `} catch (e) {`, + ` reportError(e, {});`, + `}`, + ].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toHaveLength(2); +}); + +test("a file with no catch clauses passes with only the summary note", () => { + const report = auditReportError( + [{ relPath: "packages/chat/src/pure.ts", contents: "export const x = 1;" }], + NO_BASELINE, + ); + expect(report.violations).toEqual([]); + expect(report.notes).toHaveLength(1); + expect(report.notes[0]).toContain("0 catch clause(s) scanned"); +}); + +test("the summary note counts compliant, opted-out, baselined, new, and stale entries", () => { + const report = auditReportError( + [ + { + relPath: "a.ts", + contents: [ + `import { reportError } from "@corbits/error-sink";`, + `try {`, + ` x();`, + `} catch (e) {`, + ` reportError(e, {});`, + `}`, + ].join("\n"), + }, + { + relPath: "b.ts", + contents: [ + `try {`, + ` x();`, + ` // report-error-ignore: CL-0000 example`, + `} catch {}`, + ].join("\n"), + }, + { + relPath: "c.ts", + contents: [`try {`, ` x();`, `} catch {}`].join("\n"), + }, + ], + NO_BASELINE, + ); + expect(report.violations).toHaveLength(1); + expect(report.notes.at(-1)).toContain( + "3 catch clause(s) scanned: 1 compliant, 1 opted out, 0 baselined " + + "pre-existing, 1 new finding(s), 0 stale baseline entrie(s)", + ); +}); + +test("a finding already in the baseline passes when its file isn't touched", () => { + const evidence = "return null;"; + const key = baselineKey("c.ts", 1, evidence); + const report = auditReportError( + [ + { + relPath: "c.ts", + contents: [`try {`, ` x();`, `} catch {`, ` return null;`, `}`].join( + "\n", + ), + }, + ], + { baseline: new Set([key]) }, + ); + expect(report.violations).toEqual([]); +}); + +test("a finding not in the baseline is a new violation even with other entries present", () => { + const report = auditReportError( + [ + { + relPath: "c.ts", + contents: [`try {`, ` x();`, `} catch {`, ` return null;`, `}`].join( + "\n", + ), + }, + { + relPath: "other.ts", + contents: [`try {`, ` x();`, `} catch {`, ` return null;`, `}`].join( + "\n", + ), + }, + ], + { baseline: new Set([baselineKey("other.ts", 1, "return null;")]) }, + ); + // other.ts's finding matches the baseline and passes; c.ts's identical + // evidence in a different, non-baselined file still fails as new. + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("c.ts"); +}); + +test("a baselined finding whose line the diff touches still fails, forcing cleanup", () => { + const evidence = "return null;"; + const key = baselineKey("c.ts", 1, evidence); + const report = auditReportError( + [ + { + relPath: "c.ts", + contents: [`try {`, ` x();`, `} catch {`, ` return null;`, `}`].join( + "\n", + ), + }, + ], + { + baseline: new Set([key]), + changedLines: new Map([["c.ts", [{ start: 3, end: 3 }]]]), + }, + ); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("diff touches this catch"); +}); + +test("a baselined finding elsewhere in a touched file passes — the ratchet is line-scoped, not file-scoped", () => { + const evidence = "return null;"; + const key = baselineKey("c.ts", 1, evidence); + const report = auditReportError( + [ + { + relPath: "c.ts", + contents: [`try {`, ` x();`, `} catch {`, ` return null;`, `}`].join( + "\n", + ), + }, + ], + { + baseline: new Set([key]), + // The diff touches line 20 of this file, nowhere near the catch on + // line 3 — exactly this check's own case of adding an unrelated + // report-error-ignore comment elsewhere in a debt-carrying file. + changedLines: new Map([["c.ts", [{ start: 20, end: 21 }]]]), + }, + ); + expect(report.violations).toEqual([]); +}); + +test("a stale baseline entry with no matching finding fails", () => { + const report = auditReportError( + [{ relPath: "c.ts", contents: "export const x = 1;" }], + { baseline: new Set([baselineKey("c.ts", 1, "return null;")]) }, + ); + expect(report.violations).toHaveLength(1); + expect(report.violations[0]).toContain("stale entry"); + expect(report.violations[0]).toContain(BASELINE_REGENERATE_HINT); +}); + +test("fixing one of two identical-evidence findings in a file doesn't false-pass the other", () => { + // Both catches share the same evidence text; the second must key + // differently from the first so removing one doesn't silently drop + // baseline coverage for the other. + const contents = [ + `try {`, + ` a();`, + `} catch {`, + ` return null;`, + `}`, + `try {`, + ` b();`, + `} catch {`, + ` return null;`, + `}`, + ].join("\n"); + const report = auditReportError([{ relPath: "c.ts", contents }], { + baseline: new Set([baselineKey("c.ts", 1, "return null;")]), + }); + expect(report.violations).toHaveLength(1); +}); + +test("parseBaseline ignores comments and blank lines", () => { + const text = [ + "# a comment", + "", + "a.ts\t1\treturn null;", + " ", + "b.ts\t1\treturn undefined;", + ].join("\n"); + const keys = parseBaseline(text); + expect(keys).toEqual( + new Set(["a.ts\t1\treturn null;", "b.ts\t1\treturn undefined;"]), + ); +}); + +test("serializeBaseline sorts entries and includes the debt-ledger header", () => { + const text = serializeBaseline(["b.ts\t1\tx", "a.ts\t1\ty"]); + expect(text).toContain("debt ledger — NOT an allowlist"); + expect(text).toContain("--write-baseline"); + const body = text + .split("\n") + .filter((line) => !line.startsWith("#") && line.trim().length > 0); + expect(body).toEqual(["a.ts\t1\ty", "b.ts\t1\tx"]); +}); + +test("parseChangedRanges extracts each file's added/context line ranges from a zero-context diff", () => { + const diff = [ + "diff --git a/a.ts b/a.ts", + "index 1111111..2222222 100644", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -10,0 +11,2 @@ function f() {", + "+ line11();", + "+ line12();", + "diff --git a/b.ts b/b.ts", + "index 3333333..4444444 100644", + "--- a/b.ts", + "+++ b/b.ts", + "@@ -5 +5 @@ function g() {", + "-old();", + "+new();", + ].join("\n"); + const ranges = parseChangedRanges(diff); + expect(ranges.get("a.ts")).toEqual([{ start: 11, end: 12 }]); + expect(ranges.get("b.ts")).toEqual([{ start: 5, end: 5 }]); +}); + +test("parseChangedRanges skips a pure-deletion hunk, which touches nothing in the new file", () => { + const diff = [ + "diff --git a/a.ts b/a.ts", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -5,2 +4,0 @@ function f() {", + "-removed1();", + "-removed2();", + ].join("\n"); + const ranges = parseChangedRanges(diff); + expect(ranges.get("a.ts") ?? []).toEqual([]); +});