Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/enable-all-unthrown-oxlint-rules.md
Original file line number Diff line number Diff line change
@@ -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<Result<...>>` 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.
19 changes: 18 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
{
"$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-throw": "error",
"unthrown/no-unhandled-result": "error",
"unthrown/prefer-async-result": "error",
"unthrown/prefer-ensure": "error"
},
"overrides": [
{
"files": ["**/*.spec.ts", "**/*.test.ts", "**/__tests__/**"],
"rules": {
"unthrown/no-throw": "off"
}
}
]
}
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@btravstack/oxlint": "catalog:",
"@changesets/cli": "catalog:",
"@commitlint/cli": "catalog:",
"@unthrown/oxlint": "catalog:",
"knip": "catalog:",
"lefthook": "catalog:",
"oxfmt": "catalog:",
Expand Down
1 change: 1 addition & 0 deletions packages/asyncapi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand Down
5 changes: 4 additions & 1 deletion packages/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const DIRECT_REPLY_TO = "amq.rabbitmq.reply-to";
*/
function technicalDefect(error: TechnicalError): Result<never, never> {
return fromSafeThrowable((): never => {
// oxlint-disable-next-line unthrown/no-throw -- deliberate defect-channel routing inside the fromSafeThrowable thunk
throw error;
})();
}
Expand Down Expand Up @@ -241,7 +242,7 @@ export class TypedAmqpClient<TContract extends ContractDefinition> {
.waitForConnectionReady()
.flatMap(() => client.setupReplyConsumerIfNeeded());

const inner = (async (): Promise<Result<TypedAmqpClient<TContract>, never>> => {
const inner = (async () => {
const setupResult = await setup;
if (!setupResult.isOk()) {
const closeResult = await client.close();
Expand Down Expand Up @@ -529,6 +530,7 @@ export class TypedAmqpClient<TContract extends ContractDefinition> {
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)`,
);
Expand Down Expand Up @@ -777,6 +779,7 @@ export class TypedAmqpClient<TContract extends ContractDefinition> {
.flatMap((published): Result<void, never> => {
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`,
);
Expand Down
2 changes: 2 additions & 0 deletions packages/contract/src/builder/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function addResource<T>(
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; ` +
Expand Down Expand Up @@ -152,6 +153,7 @@ export function defineContract<TContract extends ContractDefinitionInput>(
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(", ")}`,
);
Expand Down
1 change: 1 addition & 0 deletions packages/contract/src/builder/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
6 changes: 6 additions & 0 deletions packages/contract/src/builder/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
);
Expand All @@ -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.`,
);
Expand Down
1 change: 1 addition & 0 deletions packages/contract/src/builder/ttl-backoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
);
Expand Down
3 changes: 3 additions & 0 deletions packages/contract/src/builder/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
}
}
Expand All @@ -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(", ")}.`,
Expand All @@ -55,6 +57,7 @@ export function _internal_assertStandardSchema(kind: string, schema: unknown): v
? (standard as Record<string, unknown>)["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.",
Expand Down
15 changes: 6 additions & 9 deletions packages/core/src/amqp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -64,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.`,
);
Expand Down Expand Up @@ -299,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);
}
}
Expand Down Expand Up @@ -382,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)}`,
);
Expand Down Expand Up @@ -469,7 +465,7 @@ export class AmqpClient {
close(): AsyncResult<void, never> {
if (this.closing) return this.closing;

const inner = (async (): Promise<Result<void, never>> => {
const inner = (async () => {
const channelResult = await fromPromise(
this.channelWrapper.close(),
(error: unknown, defect) => defect(new TechnicalError("Failed to close channel", error)),
Expand All @@ -483,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(
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand All @@ -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.`,
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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}`,
Expand Down
4 changes: 4 additions & 0 deletions packages/testing/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export const it = vitestIt.extend<AmqpTestFixtures>({
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}"`,
);
Expand Down Expand Up @@ -228,6 +229,7 @@ export const it = vitestIt.extend<AmqpTestFixtures>({
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}`);
}
},
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
});
Expand Down
1 change: 1 addition & 0 deletions packages/worker/src/decompression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}. ` +
Expand Down
4 changes: 4 additions & 0 deletions packages/worker/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function validateHandlerTargetExists<TContract extends ContractDefinition>(

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}`,
);
Expand Down Expand Up @@ -106,6 +107,7 @@ function validateHandlers<TContract extends ContractDefinition>(
// 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",
);
Expand All @@ -115,13 +117,15 @@ function validateHandlers<TContract extends ContractDefinition>(
}
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.",
);
}
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.",
Expand Down
2 changes: 2 additions & 0 deletions packages/worker/src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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)");
}

Expand Down
Loading
Loading