From 06caa2f61b04286635cdffbdc7de12b3b60ecd76 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 02:53:10 +0200 Subject: [PATCH 1/2] chore: adopt @unthrown/oxlint recommended rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register the unthrown oxlint plugin in .oxlintrc.json and enable the recommended rule set as errors: no-ambiguous-error-type, no-catch-all-pattern, no-unhandled-result, prefer-async-result (the two opt-ins, no-throw and prefer-ensure, stay off). The package rides the catalog and is excluded from the supply-chain maturity gate as a first-party btravstack library. Violations fixed: - prefer-async-result (3): the internal never-rejecting async work thunks in TypedAmqpClient.create, TypedAmqpWorker.create, and AmqpClient.close were annotated Promise>. Dropped the redundant return annotations — inference yields the same type, and the thunks are immediately adopted by fromSafePromise, so no raw Promise crosses an API boundary. Removed the now-unused Result type import in amqp-client.ts. - no-ambiguous-error-type, no-catch-all-pattern, no-unhandled-result: zero findings. No disables were needed. Co-Authored-By: Claude Fable 5 --- .oxlintrc.json | 9 ++++++++- package.json | 1 + packages/client/src/client.ts | 2 +- packages/core/src/amqp-client.ts | 11 ++--------- packages/worker/src/worker.ts | 2 +- pnpm-lock.yaml | 23 +++++++++++++++++++++++ pnpm-workspace.yaml | 2 ++ 7 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index 10192cb6..bb52756e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,4 +1,11 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "extends": ["./node_modules/@btravstack/oxlint/base.json"] + "extends": ["./node_modules/@btravstack/oxlint/base.json"], + "jsPlugins": [{ "name": "unthrown", "specifier": "@unthrown/oxlint" }], + "rules": { + "unthrown/no-ambiguous-error-type": "error", + "unthrown/no-catch-all-pattern": "error", + "unthrown/no-unhandled-result": "error", + "unthrown/prefer-async-result": "error" + } } diff --git a/package.json b/package.json index f67893c7..fea5a2b3 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@btravstack/oxlint": "catalog:", "@changesets/cli": "catalog:", "@commitlint/cli": "catalog:", + "@unthrown/oxlint": "catalog:", "knip": "catalog:", "lefthook": "catalog:", "oxfmt": "catalog:", diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 8988bcf9..2cbb6e9d 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -241,7 +241,7 @@ export class TypedAmqpClient { .waitForConnectionReady() .flatMap(() => client.setupReplyConsumerIfNeeded()); - const inner = (async (): Promise, never>> => { + const inner = (async () => { const setupResult = await setup; if (!setupResult.isOk()) { const closeResult = await client.close(); diff --git a/packages/core/src/amqp-client.ts b/packages/core/src/amqp-client.ts index fa7d1724..eaa61625 100644 --- a/packages/core/src/amqp-client.ts +++ b/packages/core/src/amqp-client.ts @@ -7,14 +7,7 @@ import type { CreateChannelOpts, } from "amqp-connection-manager"; import type { Channel, ConsumeMessage, Options } from "amqplib"; -import { - fromPromise, - fromSafePromise, - fromSafeThrowable, - Ok, - type AsyncResult, - type Result, -} from "unthrown"; +import { fromPromise, fromSafePromise, fromSafeThrowable, Ok, type AsyncResult } from "unthrown"; import { ConnectionManagerSingleton, type ConnectionLease } from "./connection-manager.js"; import { TechnicalError } from "./errors.js"; @@ -469,7 +462,7 @@ export class AmqpClient { close(): AsyncResult { if (this.closing) return this.closing; - const inner = (async (): Promise> => { + const inner = (async () => { const channelResult = await fromPromise( this.channelWrapper.close(), (error: unknown, defect) => defect(new TechnicalError("Failed to close channel", error)), diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index 91f9b964..b6cd791d 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -524,7 +524,7 @@ export class TypedAmqpWorker { // If setup fails, release the AmqpClient's connection ref-count and cancel // any consumers that registered before the failure, so a failed create() // does not leak. - const inner = (async (): Promise, never>> => { + const inner = (async () => { const setupResult = await setup; if (!setupResult.isOk()) { const closeResult = await worker.close(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8799a5d..bfa79108 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ catalogs: '@standard-schema/spec': specifier: 1.1.0 version: 1.1.0 + '@unthrown/oxlint': + specifier: 5.0.0 + version: 5.0.0 '@unthrown/vitest': specifier: 5.0.0 version: 5.0.0 @@ -167,6 +170,9 @@ importers: '@commitlint/cli': specifier: 'catalog:' version: 21.2.1(@types/node@26.1.1)(conventional-commits-parser@7.0.1)(typescript@6.0.3) + '@unthrown/oxlint': + specifier: 'catalog:' + version: 5.0.0(oxlint@1.74.0) knip: specifier: 'catalog:' version: 6.27.0 @@ -2080,6 +2086,10 @@ packages: cpu: [x64] os: [win32] + '@oxlint/plugins@1.75.0': + resolution: {integrity: sha512-dNQBRuvkeecm9nxi1cXRxXA1oAyqJqHof6cdnjY/WDBU1nZ9V07rp9X9gG7+JgShrjJW4VR2dTo7t2EcX4XD8g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2669,6 +2679,12 @@ packages: '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@unthrown/oxlint@5.0.0': + resolution: {integrity: sha512-omNWxGLFYI3DcBzvGtnDDSTG4epZOdOi7XuAxLkjDE8EyqVA6qFJ/gtJOeFEoZP6aNogYuMvh5hAlGNn8u7cEA==} + engines: {node: '>=20'} + peerDependencies: + oxlint: ^1.69.0 + '@unthrown/vitest@5.0.0': resolution: {integrity: sha512-YbyqZE+JnPVMkDGRY+cFUY27TEbp7O+mLMF5Clf6zxneTEoU6du0G/xnjQNd/R/Gib8jyfuuLL3tDzwF6YhJiA==} engines: {node: '>=20'} @@ -6824,6 +6840,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true + '@oxlint/plugins@1.75.0': {} + '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': @@ -7407,6 +7425,11 @@ snapshots: '@ungap/structured-clone@1.3.1': {} + '@unthrown/oxlint@5.0.0(oxlint@1.74.0)': + dependencies: + '@oxlint/plugins': 1.75.0 + oxlint: 1.74.0 + '@unthrown/vitest@5.0.0(unthrown@5.0.0)(vitest@4.1.10)': dependencies: unthrown: 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 54a862e6..16a12e9a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -28,6 +28,7 @@ catalog: "@orpc/zod": 1.14.8 "@standard-schema/spec": 1.1.0 "@types/node": 26.1.1 + "@unthrown/oxlint": 5.0.0 "@unthrown/vitest": 5.0.0 "@vitest/coverage-v8": 4.1.10 amqp-connection-manager: 5.0.0 @@ -124,6 +125,7 @@ minimumReleaseAgeExclude: - "@btravstack/tsconfig" - "@btravstack/typedoc" - unthrown + - "@unthrown/oxlint" - "@unthrown/vitest" # Temporary: fast-uri 3.1.4 (the GHSA-v2hh-gcrm-f6hx override above) is younger # than the 7-day cutoff. Remove once 3.1.4 is 7 days old (published 2026-07-19). From 06db7776f182bad33b62db84af7f84e2bf6110e4 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 30 Jul 2026 10:16:19 +0200 Subject: [PATCH 2/2] chore: enable all @unthrown/oxlint rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn on the two opt-in rules (unthrown/no-throw, unthrown/prefer-ensure) on top of the recommended set. prefer-ensure reported zero violations; the 40 no-throw sites are all deliberate — fail-fast declaration-time config validation in the builders/handlers, defect-channel routing inside fromSafeThrowable/combinator thunks, and vitest fixtures — and now carry targeted oxlint-disable comments naming their reason. Test files get an oxlintrc override turning no-throw off (the other rules stay on). Also reword the import comment in tests/src/rabbitmq-config.spec.ts: the global-setup import is a runtime side-effect import (it executes top-level module code and loads testcontainers), not a type-only one (review feedback on PR #613). Add the changeset the ci/Changeset check was failing over: the branch touches package sources (annotation cleanup + lint comments) with no changeset since main — a patch entry now covers the six publishable packages (fixed group), no runtime behavior change. Co-Authored-By: Claude Fable 5 --- .changeset/enable-all-unthrown-oxlint-rules.md | 10 ++++++++++ .oxlintrc.json | 14 ++++++++++++-- docs/.vitepress/config.ts | 1 + packages/asyncapi/src/index.ts | 1 + packages/client/src/client.ts | 3 +++ packages/contract/src/builder/contract.ts | 2 ++ packages/contract/src/builder/exchange.ts | 1 + packages/contract/src/builder/queue.ts | 6 ++++++ packages/contract/src/builder/ttl-backoff.ts | 1 + packages/contract/src/builder/validate.ts | 3 +++ packages/core/src/amqp-client.ts | 4 ++++ packages/core/src/setup.ts | 4 ++++ packages/testing/src/extension.ts | 4 ++++ packages/worker/src/decompression.ts | 1 + packages/worker/src/handlers.ts | 4 ++++ packages/worker/src/retry.ts | 2 ++ packages/worker/src/worker.ts | 3 +++ tests/src/rabbitmq-config.spec.ts | 6 ++++-- 18 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 .changeset/enable-all-unthrown-oxlint-rules.md diff --git a/.changeset/enable-all-unthrown-oxlint-rules.md b/.changeset/enable-all-unthrown-oxlint-rules.md new file mode 100644 index 00000000..5d2e0b0d --- /dev/null +++ b/.changeset/enable-all-unthrown-oxlint-rules.md @@ -0,0 +1,10 @@ +--- +"@amqp-contract/contract": patch +"@amqp-contract/core": patch +"@amqp-contract/client": patch +"@amqp-contract/worker": patch +"@amqp-contract/asyncapi": patch +"@amqp-contract/testing": patch +--- + +Internal cleanup from enabling all `@unthrown/oxlint` rules (`no-throw` and `prefer-ensure` on top of the recommended set): redundant `Promise>` return annotations dropped in favor of inference, and every deliberate `throw` site now carries a targeted lint disable naming its reason. No runtime behavior change. diff --git a/.oxlintrc.json b/.oxlintrc.json index bb52756e..72a3034b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -5,7 +5,17 @@ "rules": { "unthrown/no-ambiguous-error-type": "error", "unthrown/no-catch-all-pattern": "error", + "unthrown/no-throw": "error", "unthrown/no-unhandled-result": "error", - "unthrown/prefer-async-result": "error" - } + "unthrown/prefer-async-result": "error", + "unthrown/prefer-ensure": "error" + }, + "overrides": [ + { + "files": ["**/*.spec.ts", "**/*.test.ts", "**/__tests__/**"], + "rules": { + "unthrown/no-throw": "off" + } + } + ] } diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 047cd189..4a83b99f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -35,6 +35,7 @@ const parseDocsVersions = (raw: string): VersionMenu => { try { return JSON.parse(raw) as VersionMenu; } catch (cause) { + // oxlint-disable-next-line unthrown/no-throw -- build-time fail-fast: a broken DOCS_VERSIONS means the deploy workflow is broken throw new Error( `DOCS_VERSIONS is not valid JSON — expected {"current":…,"items":[…]}, got: ${raw}`, { cause }, diff --git a/packages/asyncapi/src/index.ts b/packages/asyncapi/src/index.ts index b8e3a0e0..6aca3c8e 100644 --- a/packages/asyncapi/src/index.ts +++ b/packages/asyncapi/src/index.ts @@ -638,6 +638,7 @@ export class AsyncAPIGenerator { `Configure schemaConverters (e.g. zodToJsonSchema) to generate accurate schemas.`; if (this.failOnMissingConverter) { + // oxlint-disable-next-line unthrown/no-throw -- deliberate fail-fast the caller opted into via failOnMissingConverter throw new Error(`AsyncAPIGenerator: ${message}`); } diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 2cbb6e9d..3393c390 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -76,6 +76,7 @@ const DIRECT_REPLY_TO = "amq.rabbitmq.reply-to"; */ function technicalDefect(error: TechnicalError): Result { return fromSafeThrowable((): never => { + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing inside the fromSafeThrowable thunk throw error; })(); } @@ -529,6 +530,7 @@ export class TypedAmqpClient { if (!published) { // A full write buffer / rejected message is an unexpected publish // failure → defect (the throw routes it there). + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing — the combinator adopts the throw as a Defect throw new TechnicalError( `Failed to publish message for publisher "${String(publisherName)}": Channel rejected the message (buffer full or other channel issue)`, ); @@ -777,6 +779,7 @@ export class TypedAmqpClient { .flatMap((published): Result => { if (!published) { // A full write buffer is an unexpected publish failure → defect. + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing — the combinator adopts the throw as a Defect throw new TechnicalError( `Failed to publish RPC request for "${rpcName}": channel buffer full`, ); diff --git a/packages/contract/src/builder/contract.ts b/packages/contract/src/builder/contract.ts index 51c3c372..29f228ee 100644 --- a/packages/contract/src/builder/contract.ts +++ b/packages/contract/src/builder/contract.ts @@ -60,6 +60,7 @@ function addResource( return; } if (!resourcesEqual(existing, value)) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error( `defineContract: ${kind} "${name}" was declared with conflicting definitions. ` + `Two ${kind}s sharing a name must be the exact same definition; ` + @@ -152,6 +153,7 @@ export function defineContract( if (inputConsumers && inputRpcs) { const collisions = Object.keys(inputConsumers).filter((name) => Object.hasOwn(inputRpcs, name)); if (collisions.length > 0) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error( `defineContract: name collision between consumers and rpcs — keys must be disjoint. Conflicting names: ${collisions.join(", ")}`, ); diff --git a/packages/contract/src/builder/exchange.ts b/packages/contract/src/builder/exchange.ts index 6213dd43..24ec0209 100644 --- a/packages/contract/src/builder/exchange.ts +++ b/packages/contract/src/builder/exchange.ts @@ -146,6 +146,7 @@ export function defineExchange( ]); const type = options?.type ?? "topic"; if (!["topic", "direct", "fanout", "headers"].includes(type)) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error( `Unknown exchange type "${String(type)}" for exchange "${name}". ` + "Allowed types: topic, direct, fanout, headers.", diff --git a/packages/contract/src/builder/queue.ts b/packages/contract/src/builder/queue.ts index 61bb0442..90ed3360 100644 --- a/packages/contract/src/builder/queue.ts +++ b/packages/contract/src/builder/queue.ts @@ -181,21 +181,26 @@ export function defineQueue(name: string, options?: DefineQueueOptions): QueueEn if (type === "quorum") { // Quorum queues do not support non-durable, exclusive, autoDelete, or maxPriority if (opts.durable === false) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error("Non-durable queues are not supported with quorum type."); } if (opts.exclusive !== undefined) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error("Exclusive queues are not supported with quorum type."); } if (opts.autoDelete !== undefined) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error("Auto-deleting queues are not supported with quorum type."); } if (opts.maxPriority !== undefined) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error("Priority queues are not supported with quorum type."); } } else { // Validate maxPriority if (opts.maxPriority !== undefined) { if (opts.maxPriority < 1 || opts.maxPriority > 255) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error( `Invalid maxPriority: ${opts.maxPriority}. Must be between 1 and 255. Recommended range: 1-10.`, ); @@ -209,6 +214,7 @@ export function defineQueue(name: string, options?: DefineQueueOptions): QueueEn if (inputRetry.mode === "immediate-requeue" || inputRetry.mode === "ttl-backoff") { if (inputRetry.maxRetries !== undefined) { if (inputRetry.maxRetries < 1 || !Number.isInteger(inputRetry.maxRetries)) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error( `Queue "${name}" uses ${inputRetry.mode} retry mode with invalid maxRetries: ${inputRetry.maxRetries}. Must be a positive integer.`, ); diff --git a/packages/contract/src/builder/ttl-backoff.ts b/packages/contract/src/builder/ttl-backoff.ts index ec386ec1..084e8d17 100644 --- a/packages/contract/src/builder/ttl-backoff.ts +++ b/packages/contract/src/builder/ttl-backoff.ts @@ -107,6 +107,7 @@ export function createTtlBackoffInfrastructure( ): TtlBackoffRetryInfrastructure { // Ensure queue retry mode is ttl-backoff if (queue.retry.mode !== "ttl-backoff") { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error throw new Error( `Queue ${queue.name} does not have ttl-backoff retry mode. Infrastructure can only be created for queues with ttl-backoff retry.`, ); diff --git a/packages/contract/src/builder/validate.ts b/packages/contract/src/builder/validate.ts index 35148c0d..e4bbb2cc 100644 --- a/packages/contract/src/builder/validate.ts +++ b/packages/contract/src/builder/validate.ts @@ -15,6 +15,7 @@ /** Throw unless `name` is a non-empty string (AMQP names must not be blank). */ export function _internal_assertNonEmptyName(kind: string, name: unknown): void { if (typeof name !== "string" || name.length === 0) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error (see module doc) throw new Error(`${kind} name must be a non-empty string, got ${JSON.stringify(name)}`); } } @@ -32,6 +33,7 @@ export function _internal_assertKnownKeys( if (bag === undefined) return; const unknown = Object.keys(bag).filter((key) => !allowed.includes(key)); if (unknown.length > 0) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error (see module doc) throw new Error( `Unknown option${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")} ` + `on ${kind} "${name}". Allowed options: ${allowed.join(", ")}.`, @@ -55,6 +57,7 @@ export function _internal_assertStandardSchema(kind: string, schema: unknown): v ? (standard as Record)["validate"] : undefined; if (typeof validate !== "function") { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error (see module doc) throw new Error( `${kind} must be a Standard Schema v1 (an object exposing "~standard".validate) — ` + "got a value without one. Zod, Valibot, and ArkType schemas all qualify.", diff --git a/packages/core/src/amqp-client.ts b/packages/core/src/amqp-client.ts index eaa61625..97dc3d95 100644 --- a/packages/core/src/amqp-client.ts +++ b/packages/core/src/amqp-client.ts @@ -57,6 +57,7 @@ function resolveConnectTimeoutMs(input: number | null | undefined): number | nul if (input === null) return null; if (input === undefined) return DEFAULT_CONNECT_TIMEOUT_MS; if (!Number.isFinite(input) || input <= 0) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast config error; surfaces as a Defect from the typed create() factories (see doc above) throw new TechnicalError( `Invalid connectTimeoutMs: expected a positive finite number of milliseconds, got ${String(input)}. Pass null to disable the timeout.`, ); @@ -292,6 +293,7 @@ export class AmqpClient { try { return Buffer.from(JSON.stringify(content)); } catch (error) { + // oxlint-disable-next-line unthrown/no-throw -- known-technical precondition throw in a plain helper, adopted by the fromSafeThrowable boundary at the call sites throw new TechnicalError("Failed to JSON-encode message content", error); } } @@ -375,6 +377,7 @@ export class AmqpClient { // A misconfigured prefetch is a programming fault, not a modeled // failure — surface it through the defect channel. return fromSafeThrowable((): string => { + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing inside the fromSafeThrowable thunk throw new TechnicalError( `Invalid prefetch: expected a non-negative integer ≤ 65535, got ${String(prefetch)}`, ); @@ -476,6 +479,7 @@ export class AmqpClient { if (channelResult.isDefect() && releaseResult.isDefect()) { // Both steps failed — combine their defect causes. The throw is // adopted by `fromSafePromise` below and re-emerges as a single Defect. + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing — adopted by the fromSafePromise boundary below throw new TechnicalError( "Failed to close channel and release connection", new AggregateError( diff --git a/packages/core/src/setup.ts b/packages/core/src/setup.ts index 0301a8a8..361721cf 100644 --- a/packages/core/src/setup.ts +++ b/packages/core/src/setup.ts @@ -49,6 +49,7 @@ export async function setupAmqpTopology( ); if (exchangeErrors.length > 0) { const names = exchangeErrors.map((e) => e.name).join(", "); + // oxlint-disable-next-line unthrown/no-throw -- plain async helper; the rejection is adopted as a Defect at the channel-setup boundary (documented @throws) throw new AggregateError( exchangeErrors.map(({ result }) => result.reason), `Failed to setup exchanges: ${names}`, @@ -65,6 +66,7 @@ export async function setupAmqpTopology( ); if (!exchangeExists) { + // oxlint-disable-next-line unthrown/no-throw -- plain async helper; the rejection is adopted as a Defect at the channel-setup boundary (documented @throws) throw new TechnicalError( `Queue "${queue.name}" references dead letter exchange "${dlxName}" which is not declared in the contract. ` + `Add the exchange to contract.exchanges to ensure it is created before the queue.`, @@ -120,6 +122,7 @@ export async function setupAmqpTopology( ); if (queueErrors.length > 0) { const names = queueErrors.map((e) => e.name).join(", "); + // oxlint-disable-next-line unthrown/no-throw -- plain async helper; the rejection is adopted as a Defect at the channel-setup boundary (documented @throws) throw new AggregateError( queueErrors.map(({ result }) => result.reason), `Failed to setup queues: ${names}`, @@ -162,6 +165,7 @@ export async function setupAmqpTopology( ); if (bindingErrors.length > 0) { const names = bindingErrors.map((e) => e.name).join(", "); + // oxlint-disable-next-line unthrown/no-throw -- plain async helper; the rejection is adopted as a Defect at the channel-setup boundary (documented @throws) throw new AggregateError( bindingErrors.map(({ result }) => result.reason), `Failed to setup bindings: ${names}`, diff --git a/packages/testing/src/extension.ts b/packages/testing/src/extension.ts index dd17f4f4..0305c6c9 100644 --- a/packages/testing/src/extension.ts +++ b/packages/testing/src/extension.ts @@ -159,6 +159,7 @@ export const it = vitestIt.extend({ options, ); if (!success) { + // oxlint-disable-next-line unthrown/no-throw -- vitest fixture — throwing is how a fixture fails the test throw new Error( `Failed to publish message to exchange "${exchange}" with routing key "${routingKey}"`, ); @@ -228,6 +229,7 @@ export const it = vitestIt.extend({ await vi.waitFor( () => { if (messages.length < count) { + // oxlint-disable-next-line unthrown/no-throw -- vitest fixture — throwing is how a fixture fails the test throw new Error(`Expected ${count} message(s) but only received ${messages.length}`); } }, @@ -277,6 +279,7 @@ async function createVhost() { const errorMessage = responseBody ? `Failed to create vhost '${namespace}': ${vhostResponse.status} - ${responseBody}` : `Failed to create vhost '${namespace}': ${vhostResponse.status}`; + // oxlint-disable-next-line unthrown/no-throw -- vitest fixture — throwing is how a fixture fails the test throw new Error(errorMessage, { cause: vhostResponse, }); @@ -305,6 +308,7 @@ async function deleteVhost(vhost: string) { const errorMessage = responseBody ? `Failed to delete vhost '${vhost}': ${vhostResponse.status} - ${responseBody}` : `Failed to delete vhost '${vhost}': ${vhostResponse.status}`; + // oxlint-disable-next-line unthrown/no-throw -- vitest fixture — throwing is how a fixture fails the test throw new Error(errorMessage, { cause: vhostResponse, }); diff --git a/packages/worker/src/decompression.ts b/packages/worker/src/decompression.ts index 6f135c20..dde5d405 100644 --- a/packages/worker/src/decompression.ts +++ b/packages/worker/src/decompression.ts @@ -47,6 +47,7 @@ export function decompressBuffer( if (!isSupportedEncoding(normalizedEncoding)) { return fromSafeThrowable((): Buffer => { + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing inside the fromSafeThrowable thunk throw new TechnicalError( `Unsupported content-encoding: "${contentEncoding}". ` + `Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. ` + diff --git a/packages/worker/src/handlers.ts b/packages/worker/src/handlers.ts index c132ce60..73149c19 100644 --- a/packages/worker/src/handlers.ts +++ b/packages/worker/src/handlers.ts @@ -50,6 +50,7 @@ function validateHandlerTargetExists( if (!isConsumer && !isRpc) { const available = formatAvailable(availableHandlerNames(contract)); + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error — worker creation fails before any connection is acquired throw new Error( `Handler target "${name}" not found in contract. Available consumers and RPCs: ${available}`, ); @@ -106,6 +107,7 @@ function validateHandlers( // JavaScript callers can pass null/undefined despite the type — surface a // clear error instead of the TypeError Object.keys would throw. if (handlers === null || typeof handlers !== "object") { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error — worker creation fails before any connection is acquired throw new Error( "declareHandlers requires a `handlers` object with one handler per `consumers` and `rpcs` entry", ); @@ -115,6 +117,7 @@ function validateHandlers( } const missing = missingHandlerNames(contract, handlers); if (missing.length > 0) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error — worker creation fails before any connection is acquired throw new Error( `Missing handlers for contract entries: ${missing.join(", ")}. ` + "Every `consumers` and `rpcs` key requires a handler.", @@ -122,6 +125,7 @@ function validateHandlers( } const invalid = invalidHandlerNames(contract, handlers); if (invalid.length > 0) { + // oxlint-disable-next-line unthrown/no-throw -- fail-fast declaration-time config error — worker creation fails before any connection is acquired throw new Error( `Handlers for contract entries are not functions: ${invalid.join(", ")}. ` + "Each handler must be a function or a [handler, options] tuple.", diff --git a/packages/worker/src/retry.ts b/packages/worker/src/retry.ts index 84d967ed..1d4f7788 100644 --- a/packages/worker/src/retry.ts +++ b/packages/worker/src/retry.ts @@ -185,6 +185,7 @@ function handleErrorTtlBackoff( // is a contract/setup bug, not a modeled failure — route it to the defect // channel. return fromSafeThrowable((): void => { + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing inside the fromSafeThrowable thunk throw new TechnicalError("Queue does not have TTL-backoff infrastructure"); })().toAsync(); } @@ -316,6 +317,7 @@ function publishForRetry( // ack the original — leave it un-ack'd so the broker / channel manager // can redeliver it once the channel recovers. The throw routes to the // defect channel (a publish infrastructure failure is unexpected). + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing — the combinator adopts the throw as a Defect throw new TechnicalError("Failed to publish message for retry (write buffer full)"); } diff --git a/packages/worker/src/worker.ts b/packages/worker/src/worker.ts index b6cd791d..5f1b41a4 100644 --- a/packages/worker/src/worker.ts +++ b/packages/worker/src/worker.ts @@ -144,6 +144,7 @@ function isHandlerTuple(entry: unknown): entry is [unknown, ConsumerOptions] { */ function technicalDefect(error: TechnicalError): AsyncResult { return fromSafeThrowable((): T => { + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing inside the fromSafeThrowable thunk throw error; })().toAsync(); } @@ -673,6 +674,7 @@ export class TypedAmqpWorker { if (result.issues) { // A schema-invalid incoming message is routed to the DLQ by the caller; // it is an infrastructure/producer fault, so surface it as a defect. + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing — the combinator adopts the throw as a Defect throw new TechnicalError( `${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues), @@ -1252,6 +1254,7 @@ export class TypedAmqpWorker { // handlerError as the defect cause. The throw becomes a `Defect`; a // routing *failure* (handleError defect) short-circuits before this and // leaves `messageHandled` false so the message is redelivered. + // oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing — the combinator adopts the throw as a Defect throw new TechnicalError( `Handler "${String(name)}" failed: ${handlerError.message}`, handlerError, diff --git a/tests/src/rabbitmq-config.spec.ts b/tests/src/rabbitmq-config.spec.ts index aeafaa89..90cea5e3 100644 --- a/tests/src/rabbitmq-config.spec.ts +++ b/tests/src/rabbitmq-config.spec.ts @@ -1,8 +1,10 @@ /** * Test to verify that RABBITMQ_IMAGE environment variable works correctly */ -// Type-only side effect: pulls in the `ProvidedContext` module augmentation -// declaring the __TESTCONTAINERS_RABBITMQ_*__ keys `inject()` reads. +// Runtime side-effect import (it executes the module's top-level code and +// loads testcontainers, though it starts no container by itself), kept for +// the `ProvidedContext` module augmentation declaring the +// __TESTCONTAINERS_RABBITMQ_*__ keys `inject()` reads. import "@amqp-contract/testing/global-setup"; import { describe, expect, inject, it } from "vitest";