From 0fbf3e4eb749a5324b85bab335231ebba46a1352 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 01:11:50 +0300 Subject: [PATCH 01/13] (rules) Unwrap satisfies expressions on yielded loop steps --- src/rules/no-yield-await-in-loop.ts | 9 +------ tests/rules/no-yield-await-in-loop.test.ts | 29 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/rules/no-yield-await-in-loop.ts b/src/rules/no-yield-await-in-loop.ts index f28b2d2..7d28546 100644 --- a/src/rules/no-yield-await-in-loop.ts +++ b/src/rules/no-yield-await-in-loop.ts @@ -589,14 +589,7 @@ export default createRule({ return; } - let arg: TSESTree.Node = node.argument; - while ( - arg.type === AST_NODE_TYPES.TSAsExpression || - arg.type === AST_NODE_TYPES.TSTypeAssertion || - arg.type === AST_NODE_TYPES.TSNonNullExpression - ) { - arg = arg.expression; - } + const arg = unwrapTypeOnly(node.argument); if (arg.type !== AST_NODE_TYPES.CallExpression) { return; diff --git a/tests/rules/no-yield-await-in-loop.test.ts b/tests/rules/no-yield-await-in-loop.test.ts index 8c2df6b..d20ddbd 100644 --- a/tests/rules/no-yield-await-in-loop.test.ts +++ b/tests/rules/no-yield-await-in-loop.test.ts @@ -204,6 +204,17 @@ const run = cancAsync(function* (ids: string[]) { state!.cur = yield* cancAwait(load(id, state)); } });`, + + // Accumulator dependency preserved with satisfies expression + `import { cancAsync, cancAwait } from '@cancjs/coroutine'; +declare function step(acc: number, id: string): Promise; +const run = cancAsync(function* (ids: string[]) { + let acc = 0; + for (const id of ids) { + acc = yield* (cancAwait(step(acc, id)) satisfies Generator); + } + return acc; +});`, ], invalid: [ { @@ -311,6 +322,24 @@ const run = cancAsync(function* (ids: string[]) { ], }, + // Satisfies-wrapped cancAwait call with loop var + { + code: `import { cancAsync, cancAwait } from '@cancjs/coroutine'; +declare function load(id: string): Promise; +const run = cancAsync(function* (ids: string[]) { + for (const id of ids) { + yield* (cancAwait(load(id)) satisfies Generator); + } +});`, + errors: [ + { + messageId: 'serializedSteps' as const, + line: 5, + column: 5, + }, + ], + }, + // 6. Member write to an unrelated object stays independent { code: `import { cancAsync, cancAwait } from '@cancjs/coroutine'; From f9b09dd017af14c83ffd47a12439da03a30dfc99 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 01:23:33 +0300 Subject: [PATCH 02/13] (util) Match require destructuring with default values in factories --- src/util/coroutine-factories.ts | 13 +++++---- tests/coroutine-factories.test.ts | 48 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/util/coroutine-factories.ts b/src/util/coroutine-factories.ts index 7f6a066..0dac9ed 100644 --- a/src/util/coroutine-factories.ts +++ b/src/util/coroutine-factories.ts @@ -299,14 +299,15 @@ function exportedNameOf(id: TSESTree.Identifier, context: Context, source: strin } if (declarator.id.type === AST_NODE_TYPES.ObjectPattern) { for (const prop of declarator.id.properties) { - if ( - prop.type === AST_NODE_TYPES.Property && - prop.value.type === AST_NODE_TYPES.Identifier && - prop.value.name === id.name - ) { + if (prop.type !== AST_NODE_TYPES.Property) { + continue; + } + const target = prop.value.type === AST_NODE_TYPES.AssignmentPattern ? prop.value.left : prop.value; + if (target.type === AST_NODE_TYPES.Identifier && target.name === id.name) { return ( !prop.computed && prop.key.type === AST_NODE_TYPES.Identifier ? prop.key.name - : prop.key.type === AST_NODE_TYPES.Literal && typeof prop.key.value === 'string' ? prop.key.value + : !prop.computed && prop.key.type === AST_NODE_TYPES.Literal && typeof prop.key.value === 'string' ? + prop.key.value : undefined ); } diff --git a/tests/coroutine-factories.test.ts b/tests/coroutine-factories.test.ts index 6b25e25..ec7c386 100644 --- a/tests/coroutine-factories.test.ts +++ b/tests/coroutine-factories.test.ts @@ -85,6 +85,54 @@ const rows: Row[] = [ options: addAsync([reactEntry]), expected: 'cancAsync', }, + { + name: 'require destructure with default value', + code: `const { useX = fallback } = require('my-lib'); useX(function* () { PROBE; });`, + options: addAsync([{ name: 'useX', source: 'my-lib' }]), + expected: 'cancAsync', + }, + { + name: 'aliased require destructure with default value', + code: `const { useX: localX = fallback } = require('my-lib'); localX(function* () { PROBE; });`, + options: addAsync([{ name: 'useX', source: 'my-lib' }]), + expected: 'cancAsync', + }, + { + name: 'require destructure with default value from wrong source', + code: `const { useX = fallback } = require('other-lib'); useX(function* () { PROBE; });`, + options: addAsync([{ name: 'useX', source: 'my-lib' }]), + expected: null, + }, + { + name: 'computed key require destructure with default value', + code: `const { [dynamic]: localX = fallback } = require('my-lib'); localX(function* () { PROBE; });`, + options: addAsync([{ name: 'useX', source: 'my-lib' }]), + expected: null, + }, + { + name: 'computed key identifier matching factory name with default value', + code: `const { [useX]: localX = fallback } = require('my-lib'); localX(function* () { PROBE; });`, + options: addAsync([{ name: 'useX', source: 'my-lib' }]), + expected: null, + }, + { + name: 'computed string literal key with default value', + code: `const { ['useX']: localX = fallback } = require('my-lib'); localX(function* () { PROBE; });`, + options: addAsync([{ name: 'useX', source: 'my-lib' }]), + expected: null, + }, + { + name: 'gen require destructure with default value', + code: `const { useX: localX = fallback } = require('my-lib'); localX(function* () { PROBE; });`, + options: { additionalGenCoroutineFactories: [{ name: 'useX', source: 'my-lib' }] }, + expected: 'cancGenAsync', + }, + { + name: 'string literal key require destructure with default value', + code: `const { 'useX': localX = fallback } = require('my-lib'); localX(function* () { PROBE; });`, + options: addAsync([{ name: 'useX', source: 'my-lib' }]), + expected: 'cancAsync', + }, { name: 'object entry through a default import', code: `import effect from '@cancjs/react'; effect(function* () { PROBE; });`, From d183e6f952e254cf10432b93fe36fd8d00f49702 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 01:15:01 +0300 Subject: [PATCH 03/13] (rules) Evaluate shield option keys in last-wins order --- src/rules/executor-cancel-wiring.ts | 39 +++++++++------------- tests/rules/executor-cancel-wiring.test.ts | 33 ++++++++++++++++++ 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/src/rules/executor-cancel-wiring.ts b/src/rules/executor-cancel-wiring.ts index 3edc7d7..7221861 100644 --- a/src/rules/executor-cancel-wiring.ts +++ b/src/rules/executor-cancel-wiring.ts @@ -44,32 +44,25 @@ function isShielded(node: TSESTree.NewExpression, context: TSESLint.RuleContext< return false; } - let foundShieldIndex = -1; - for (let i = 0; i < objExpr.properties.length; i++) { - const prop = objExpr.properties[i]; - if ( - prop.type === AST_NODE_TYPES.Property && - !prop.computed && - ((prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === 'shield') || - (prop.key.type === AST_NODE_TYPES.Literal && prop.key.value === 'shield')) && - prop.value.type === AST_NODE_TYPES.Literal && - prop.value.value === true - ) { - foundShieldIndex = i; + let shielded = false; + for (const prop of objExpr.properties) { + if (prop.type === AST_NODE_TYPES.SpreadElement) { + shielded = false; + continue; } - } - - if (foundShieldIndex === -1) { - return false; - } - - for (let i = foundShieldIndex + 1; i < objExpr.properties.length; i++) { - if (objExpr.properties[i].type === AST_NODE_TYPES.SpreadElement) { - return false; + if (prop.computed) { + shielded = false; + continue; + } + const isShieldKey = + (prop.key.type === AST_NODE_TYPES.Identifier && prop.key.name === 'shield') || + (prop.key.type === AST_NODE_TYPES.Literal && prop.key.value === 'shield'); + if (isShieldKey) { + const val = unwrapTypeOnly(prop.value); + shielded = val.type === AST_NODE_TYPES.Literal && val.value === true; } } - - return true; + return shielded; } export default createRule({ diff --git a/tests/rules/executor-cancel-wiring.test.ts b/tests/rules/executor-cancel-wiring.test.ts index b838ff0..cf5fe7f 100644 --- a/tests/rules/executor-cancel-wiring.test.ts +++ b/tests/rules/executor-cancel-wiring.test.ts @@ -101,6 +101,22 @@ const opts = { shield: true } as const; new CancelablePromise((resolve, reject) => { save('a').then(resolve, reject); }, opts);`, + `import CancelablePromise from '@cancjs/promise'; +declare function save(id: string): Promise; +new CancelablePromise((resolve, reject) => { + save('a').then(resolve, reject); +}, { shield: false, shield: true });`, + `import CancelablePromise from '@cancjs/promise'; +declare function save(id: string): Promise; +declare const rest: Record; +new CancelablePromise((resolve, reject) => { + save('a').then(resolve, reject); +}, { ...rest, shield: true as const });`, + `import CancelablePromise from '@cancjs/promise'; +declare function save(id: string): Promise; +new CancelablePromise((resolve, reject) => { + save('a').then(resolve, reject); +}, { shield: true, label: 'op' });`, ], invalid: [ { @@ -199,6 +215,23 @@ new CancelablePromise((resolve, reject) => { }, { shield: true, ...rest });`, errors: [{ messageId: 'noCancelWiring' as const, line: 4, column: 23 }], }, + { + code: `import CancelablePromise from '@cancjs/promise'; +declare function save(id: string): Promise; +new CancelablePromise((resolve, reject) => { + save('a').then(resolve, reject); +}, { shield: true, shield: false });`, + errors: [{ messageId: 'noCancelWiring' as const, line: 3, column: 23 }], + }, + { + code: `import CancelablePromise from '@cancjs/promise'; +declare function save(id: string): Promise; +declare const key: string; +new CancelablePromise((resolve, reject) => { + save('a').then(resolve, reject); +}, { shield: true, [key]: false });`, + errors: [{ messageId: 'noCancelWiring' as const, line: 4, column: 23 }], + }, ], }; From b1ba56e4bb5b1db3c828986ffaec4efcc4c55450 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 01:55:09 +0300 Subject: [PATCH 04/13] (util) Keep intersected failure members intact beside unlisted marker --- src/util/failure-types.ts | 59 ++++++++++++++++++++++--- tests/failure-types.test.ts | 8 ++++ tests/rules/exhaustive-catch.test.ts | 56 +++++++++++++++++++++++ tests/rules/no-failure-widening.test.ts | 37 ++++++++++++++++ 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/src/util/failure-types.ts b/src/util/failure-types.ts index d94a9b7..8b7f8e4 100644 --- a/src/util/failure-types.ts +++ b/src/util/failure-types.ts @@ -138,6 +138,10 @@ export function isUnlistedMarker(type: ts.Type, checker: ts.TypeChecker): boolea return Boolean(memberType.flags & ts.TypeFlags.BooleanLiteral) && checker.typeToString(memberType) === 'true'; } +function stripUnlistedConstituents(type: ts.Type, checker: ts.TypeChecker): ts.Type[] { + return type.isIntersection() ? type.types.filter((c) => !isUnlistedMarker(c, checker)) : [type]; +} + /** Whether a branch is a promise or thenable, which claims an open failure set of its own. */ function isThenableType(type: ts.Type, checker: ts.TypeChecker): boolean { const sym = checker.getPropertyOfType(checker.getApparentType(type), 'then'); @@ -227,9 +231,13 @@ function extractFromFailureType(failureType: ts.Type, checker: ts.TypeChecker): const nonMarkers = part.types.filter((c) => !isUnlistedMarker(c, checker)); if (nonMarkers.length < part.types.length) { open = true; - for (const nm of nonMarkers) { - if (!isCancelErrorType(nm, checker)) { - listed.push(nm); + if (nonMarkers.length === 1) { + if (!isCancelErrorType(nonMarkers[0], checker)) { + listed.push(nonMarkers[0]); + } + } else if (nonMarkers.length > 1) { + if (!isCancelErrorType(part, checker) && nonMarkers.every((nm) => !isCancelErrorType(nm, checker))) { + listed.push(part); } } } else if (!isCancelErrorType(part, checker)) { @@ -376,6 +384,33 @@ export function isClassOrSubclass(t: ts.Type, h: ts.Type, checker: ts.TypeChecke if (t === h) { return true; } + if (h.isIntersection()) { + const hBrand = getBrandName(h, checker); + if (hBrand !== undefined && getBrandName(t, checker) !== hBrand) { + return false; + } + const hParts = stripUnlistedConstituents(h, checker); + const tParts = stripUnlistedConstituents(t, checker); + return ( + hParts.length > 0 && + hParts.every((hc) => + tParts.some( + (tc) => + isClassOrSubclass(tc, hc, checker) || + (!(hc.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) && + hc.getSymbol()?.name !== 'Error' && + hc.getSymbol()?.name !== 'Object' && + checker.isTypeAssignableTo(tc, hc)), + ), + ) + ); + } else if (t.isIntersection()) { + const tBrand = getBrandName(t, checker); + if (tBrand !== undefined && getBrandName(h, checker) !== tBrand) { + return false; + } + return stripUnlistedConstituents(t, checker).some((tc) => isClassOrSubclass(tc, h, checker)); + } const symT = (t as { symbol?: ts.Symbol }).symbol; const symH = (h as { symbol?: ts.Symbol }).symbol; if (symT && symH && symT === symH) { @@ -639,11 +674,25 @@ export function formatTypeNames(types: ts.Type[], checker: ts.TypeChecker): stri return match[1]; } } + if (t.isIntersection() && t.types.some((c) => isUnlistedMarker(c, checker))) { + return stripUnlistedConstituents(t, checker) + .map((c) => formatTypeNames([c], checker)) + .join(' & '); + } return checker.typeToString(t); }) .join(', '); } +function strippedTypeString(t: ts.Type, checker: ts.TypeChecker): string { + if (t.isIntersection() && t.types.some((c) => isUnlistedMarker(c, checker))) { + return stripUnlistedConstituents(t, checker) + .map((c) => formatTypeNames([c], checker)) + .join(' & '); + } + return checker.typeToString(t); +} + const DISCRIMINANT_PROPERTIES = new Set(['kind', 'name']); /** @@ -872,7 +921,7 @@ export function analyzeCatchFailures( for (const member of failures.listed) { if (!isCancelErrorType(member, checker, settings)) { - const id = member.getSymbol()?.name ?? checker.typeToString(member); + const id = member.getSymbol()?.name ?? strippedTypeString(member, checker); if (!allListedMap.has(id)) { allListedMap.set(id, member); } @@ -898,7 +947,7 @@ export function analyzeCatchFailures( isClassOrSubclass(t, h, checker) || (symbol && symbol === h.getSymbol()) || (symbol?.name && symbol.name === h.getSymbol()?.name) || - checker.typeToString(t) === checker.typeToString(h), + strippedTypeString(t, checker) === strippedTypeString(h, checker), ); }; diff --git a/tests/failure-types.test.ts b/tests/failure-types.test.ts index 2562b09..f2af73a 100644 --- a/tests/failure-types.test.ts +++ b/tests/failure-types.test.ts @@ -219,6 +219,8 @@ export interface UnlistedCopy { readonly _cancUnlisted: true } export interface UnlistedWrongLiteral { readonly _cancUnlisted: false } export type CopyOpen = CancelablePromise; export type IntersectionOpen = CancelablePromise; +export class ErrorA extends Error {} +export type ThreeConstituents = CancelablePromise; export type OrNull = CancelablePromise | null; `, }; @@ -274,6 +276,12 @@ export type OrNull = CancelablePromise | null; expect(res.listed.map((t) => virtualChecker.typeToString(t))).toEqual(['Unlisted']); }); + it('marker on a 3+ constituent intersection keeps constituents intact and sets open true', () => { + const res = getFailureMembers(virtualType('b3-unlisted.ts', 'ThreeConstituents'), virtualChecker); + expect(res.open).toBe(true); + expect(res.listed.length).toBe(1); + }); + it('union filter: a null branch does not make a carrier union undefined', () => { const res = getFailureMembers(virtualType('b3-unlisted.ts', 'OrNull'), virtualChecker); expect(res.defined).toBe(true); diff --git a/tests/rules/exhaustive-catch.test.ts b/tests/rules/exhaustive-catch.test.ts index 9ba21c8..ea88f2f 100644 --- a/tests/rules/exhaustive-catch.test.ts +++ b/tests/rules/exhaustive-catch.test.ts @@ -128,6 +128,20 @@ export const run = cancAsync(function* () { });`; } +function openThreeCatch(catchBody: string): string { + return `import { cancAsync, cancAwait } from '@cancjs/coroutine'; +import type CancelablePromise from '@cancjs/promise'; +class ErrorA extends Error { readonly kind = 'ErrorA'; } +declare function getOpen(): CancelablePromise; +export const run = cancAsync(function* () { + try { + yield* cancAwait(getOpen()); + } catch (e) { + ${catchBody} + } +});`; +} + function nestedCatch(innerBody: string, outerBody: string): string { return `import { cancAsync, cancAwait } from '@cancjs/coroutine'; import type CancelablePromise from '@cancjs/promise'; @@ -564,6 +578,13 @@ export const run = cancAsync(function* () { });`, options: [{ exclude: ['AssertionError'] }], }, + // Open 3+ constituent intersection with fallback disabled + { + code: openThreeCatch(`if (e instanceof ErrorA) { + console.log(e); + }`), + options: [{ requireUnlistedFallback: false }], + }, ], invalid: [ // Discriminant switch missing a case drops the unmatched failure @@ -1156,6 +1177,41 @@ cancAsync(function* () { output: null, errors: [{ messageId: 'unhandledFailure' as const, line: 12, data: { names: 'TimeoutError' } }], }, + // Open 3+ constituent intersection requires fallback under default options + { + code: openThreeCatch(`if (e instanceof ErrorA) { + console.log(e); + }`), + output: null, + errors: [ + { + messageId: 'missingUnlistedFallback', + line: 8, + suggestions: [ + { + messageId: 'missingUnlistedFallback', + output: withThrow( + openThreeCatch(`if (e instanceof ErrorA) { + console.log(e); + }`), + ), + }, + ], + }, + ], + }, + // Unguarded open 3+ constituent intersection reports unhandledFailure without marker + { + code: openThreeCatch(`console.log(e);`), + output: null, + errors: [ + { + messageId: 'unhandledFailure', + line: 8, + data: { names: 'ErrorA & { readonly meta: string; }' }, + }, + ], + }, ], }); diff --git a/tests/rules/no-failure-widening.test.ts b/tests/rules/no-failure-widening.test.ts index 083388a..5d0f766 100644 --- a/tests/rules/no-failure-widening.test.ts +++ b/tests/rules/no-failure-widening.test.ts @@ -71,6 +71,16 @@ describe('no-failure-widening (typed lane)', () => { declare const p: CancelablePromise; export const open = p as CancelablePromise;`, + // Intersected failure member beside unlisted marker kept intact + `import type CancelablePromise from '@cancjs/promise'; + class ErrorA extends Error {} + declare const p: CancelablePromise; + export const keptExact = p as CancelablePromise;`, + `import type CancelablePromise from '@cancjs/promise'; + class ErrorA extends Error {} + declare const p: CancelablePromise; + export const keptSub = p as CancelablePromise;`, + // Variable annotation default: allowVariableAnnotationWidening: true (silent) `import type CancelablePromise from '@cancjs/promise'; class UserNotFoundError extends Error {} @@ -393,6 +403,33 @@ export const u = withFailure()(getUser());`, }, ], }, + + // 13. Intersected failure member widening tests + { + code: `import type CancelablePromise from '@cancjs/promise'; +class ErrorA extends Error {} +class ErrorB extends Error {} +declare const p: CancelablePromise; +export const c = p as CancelablePromise;`, + output: null, + errors: [{ messageId: 'failureWidening', line: 5, data: { position: 'cast' } }], + }, + { + code: `import type CancelablePromise from '@cancjs/promise'; +class ErrorA extends Error {} +declare const p: CancelablePromise; +export const c = p as CancelablePromise;`, + output: null, + errors: [{ messageId: 'failureWidening', line: 4, data: { position: 'cast' } }], + }, + { + code: `import type CancelablePromise from '@cancjs/promise'; +class ErrorA extends Error {} +declare const q: CancelablePromise; +export const c = q as CancelablePromise;`, + output: null, + errors: [{ messageId: 'failureWidening', line: 4, data: { position: 'cast' } }], + }, ], }); }); From e9752b24812e73731e3dd24e3682c96d77434110 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 01:33:24 +0300 Subject: [PATCH 05/13] (util) Detect handler exits inside switch, try, and loop blocks --- src/util/catch-flow.ts | 59 ++++++++------ tests/catch-flow.test.ts | 93 +++++++++++++++++++++ tests/rules/no-swallowed-cancel.test.ts | 104 ++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 24 deletions(-) diff --git a/src/util/catch-flow.ts b/src/util/catch-flow.ts index fa75295..db74c29 100644 --- a/src/util/catch-flow.ts +++ b/src/util/catch-flow.ts @@ -712,7 +712,14 @@ function statementsAllLogOrReturn(statements: TSESTree.Statement[]): boolean { return true; } -function containsExitWithoutRethrow(node: TSESTree.Node, paramName: string, context: Context): boolean { +function containsHandlerExit( + node: TSESTree.Node, + paramName: string, + context: Context, + inSwitch: boolean, + inLoop: boolean, + labelsInsideCatch: ReadonlySet, +): boolean { if ( node.type === AST_NODE_TYPES.FunctionDeclaration || node.type === AST_NODE_TYPES.FunctionExpression || @@ -723,16 +730,26 @@ function containsExitWithoutRethrow(node: TSESTree.Node, paramName: string, cont if (node.type === AST_NODE_TYPES.ReturnStatement) { return !returnsRejection(node, paramName, context); } - if (node.type === AST_NODE_TYPES.BreakStatement || node.type === AST_NODE_TYPES.ContinueStatement) { - return true; + if (node.type === AST_NODE_TYPES.BreakStatement) { + return node.label !== null ? !labelsInsideCatch.has(node.label.name) : !inSwitch && !inLoop; + } + if (node.type === AST_NODE_TYPES.ContinueStatement) { + return node.label !== null ? !labelsInsideCatch.has(node.label.name) : !inLoop; + } + if (node.type === AST_NODE_TYPES.LabeledStatement) { + const nextLabels = new Set(labelsInsideCatch); + nextLabels.add(node.label.name); + return containsHandlerExit(node.body, paramName, context, inSwitch, inLoop, nextLabels); } if (node.type === AST_NODE_TYPES.BlockStatement) { - return node.body.some((s) => containsExitWithoutRethrow(s, paramName, context)); + return node.body.some((s) => containsHandlerExit(s, paramName, context, inSwitch, inLoop, labelsInsideCatch)); } if (node.type === AST_NODE_TYPES.IfStatement) { return ( - containsExitWithoutRethrow(node.consequent, paramName, context) || - (node.alternate ? containsExitWithoutRethrow(node.alternate, paramName, context) : false) + containsHandlerExit(node.consequent, paramName, context, inSwitch, inLoop, labelsInsideCatch) || + (node.alternate ? + containsHandlerExit(node.alternate, paramName, context, inSwitch, inLoop, labelsInsideCatch) + : false) ); } if ( @@ -742,35 +759,29 @@ function containsExitWithoutRethrow(node: TSESTree.Node, paramName: string, cont node.type === AST_NODE_TYPES.ForInStatement || node.type === AST_NODE_TYPES.ForOfStatement ) { - return containsExitWithoutRethrow(node.body, paramName, context); + return containsHandlerExit(node.body, paramName, context, inSwitch, true, labelsInsideCatch); } if (node.type === AST_NODE_TYPES.SwitchStatement) { - return node.cases.some((c) => c.consequent.some((s) => containsExitWithoutRethrow(s, paramName, context))); + return node.cases.some((c) => + c.consequent.some((s) => containsHandlerExit(s, paramName, context, true, inLoop, labelsInsideCatch)), + ); } if (node.type === AST_NODE_TYPES.TryStatement) { return ( - containsExitWithoutRethrow(node.block, paramName, context) || - (node.handler ? containsExitWithoutRethrow(node.handler.body, paramName, context) : false) || - (node.finalizer ? containsExitWithoutRethrow(node.finalizer, paramName, context) : false) + containsHandlerExit(node.block, paramName, context, inSwitch, inLoop, labelsInsideCatch) || + (node.handler ? + containsHandlerExit(node.handler.body, paramName, context, inSwitch, inLoop, labelsInsideCatch) + : false) || + (node.finalizer ? + containsHandlerExit(node.finalizer, paramName, context, inSwitch, inLoop, labelsInsideCatch) + : false) ); } return false; } function statementCanExitWithoutRethrow(stmt: TSESTree.Statement, paramName: string, context: Context): boolean { - if (stmt.type === AST_NODE_TYPES.ReturnStatement) { - return !returnsRejection(stmt, paramName, context); - } - if (stmt.type === AST_NODE_TYPES.BreakStatement || stmt.type === AST_NODE_TYPES.ContinueStatement) { - return true; - } - if (stmt.type === AST_NODE_TYPES.IfStatement) { - return ( - containsExitWithoutRethrow(stmt.consequent, paramName, context) || - (stmt.alternate ? containsExitWithoutRethrow(stmt.alternate, paramName, context) : false) - ); - } - return false; + return containsHandlerExit(stmt, paramName, context, false, false, new Set()); } /** diff --git a/tests/catch-flow.test.ts b/tests/catch-flow.test.ts index 8c95da2..5b39743 100644 --- a/tests/catch-flow.test.ts +++ b/tests/catch-flow.test.ts @@ -402,6 +402,99 @@ catalogSearch.catch(() => 0);`; expect(fallbackAnalysis.compliant).toBe(false); expect(fallbackAnalysis.via).toBeNull(); }); + + it('rejects switch return before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +declare const code: number; +try {} catch (e) { + switch (code) { case 1: return 1; } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('rejects try-finally return before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +declare function cleanup(): void; +try {} catch (e) { + try { return 1; } finally { cleanup(); } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('rejects loop return before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +declare const items: number[]; +try {} catch (e) { + for (const item of items) { if (item) return item; } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('rejects break to outer label before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +declare const xs: number[]; +declare const code: number; +outer: for (const x of xs) { + try {} catch (e) { + switch (code) { case 1: break outer; } + if (isCancelError(e)) throw e; + toast(e); + } +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('passes switch with break before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +declare const code: number; +declare function log(n: number): void; +try {} catch (e) { + switch (code) { case 1: log(1); break; default: log(2); } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); + + it('passes loop with continue and break before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +declare const items: number[]; +declare const stop: number; +declare function log(n: number): void; +try {} catch (e) { + for (const item of items) { if (!item) continue; if (item === stop) break; log(item); } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); + + it('passes switch with rejecting return in catch callback', () => { + const code = `${IMPORT_PREAMBLE} +declare const code: number; +catalogSearch.catch((e) => { + switch (code) { case 1: return Promise.reject(e); } + if (isCancelError(e)) throw e; + toast(e); +});`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); }); type ProbeMessages = diff --git a/tests/rules/no-swallowed-cancel.test.ts b/tests/rules/no-swallowed-cancel.test.ts index ffa1afb..9f24d48 100644 --- a/tests/rules/no-swallowed-cancel.test.ts +++ b/tests/rules/no-swallowed-cancel.test.ts @@ -460,6 +460,47 @@ cancAsync(function* () { toast(e); return yield* cancThrow(e); } +});`, + }, + { + name: 'switch with break before cancel guard does not exit handler', + code: `${HEAD} +declare const code: number; +declare function log(n: number): void; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + switch (code) { case 1: log(1); break; default: log(2); } + if (isCancelError(e)) throw e; + toast(e); + } +});`, + }, + { + name: 'loop with continue and break before cancel guard does not exit handler', + code: `${HEAD} +declare const items: number[]; +declare const stop: number; +declare function log(n: number): void; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + for (const item of items) { if (!item) continue; if (item === stop) break; log(item); } + if (isCancelError(e)) throw e; + toast(e); + } +});`, + }, + { + name: 'switch with rejecting return in catch callback does not exit without rethrow', + code: `${HEAD} +declare const code: number; +new CancelablePromise((resolve) => resolve(1)).catch((e) => { + switch (code) { case 1: return Promise.reject(e); } + if (isCancelError(e)) throw e; + toast(e); });`, }, ]; @@ -524,6 +565,69 @@ cancAsync(function* () { });`, errors: [{ messageId: 'swallowedCancel' }], }, + { + name: 'switch return before cancel guard leaves cancel swallowed', + code: `${HEAD} +declare const code: number; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + switch (code) { case 1: return 1; } + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 16, column: 5 }], + }, + { + name: 'try-finally return before cancel guard leaves cancel swallowed', + code: `${HEAD} +declare function cleanup(): void; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + try { return 1; } finally { cleanup(); } + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 16, column: 5 }], + }, + { + name: 'loop return before cancel guard leaves cancel swallowed', + code: `${HEAD} +declare const items: number[]; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + for (const item of items) { if (item) return item; } + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 16, column: 5 }], + }, + { + name: 'break to outer label before cancel guard leaves cancel swallowed', + code: `${HEAD} +declare const xs: number[]; +declare const code: number; +cancAsync(function* () { + outer: for (const x of xs) { + try { + yield* cancAwait(load()); + } catch (e) { + switch (code) { case 1: break outer; } + if (isCancelError(e)) throw e; + toast(e); + } + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 18, column: 7 }], + }, { name: 'boundary in only one branch leaves other branch unguarded', code: boundaryOneBranch, From acd71959d1934ec4fd20fd709c23c8b0ca317bb8 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 02:01:37 +0300 Subject: [PATCH 06/13] (util) Allow disjoint error guards to return before a cancel guard --- src/util/catch-flow.ts | 264 +++++++++++++++++++++++- tests/catch-flow.test.ts | 169 +++++++++++++++ tests/rules/no-swallowed-cancel.test.ts | 243 +++++++++++++++++++++- 3 files changed, 665 insertions(+), 11 deletions(-) diff --git a/src/util/catch-flow.ts b/src/util/catch-flow.ts index db74c29..2bb767c 100644 --- a/src/util/catch-flow.ts +++ b/src/util/catch-flow.ts @@ -1,5 +1,6 @@ import type { TSESLint, TSESTree } from '@typescript-eslint/utils'; import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import ts from 'typescript'; import { isGlobalPromiseIdent } from './adoption.js'; import { @@ -13,6 +14,7 @@ import { } from './canc-imports.js'; import { getCancServices, isCancelableExpression, isCancelableType } from './canc-types.js'; import { unwrapTypeOnly } from './executor.js'; +import { isCancelErrorType } from './failure-types.js'; import type { ResolvedCancSettings } from './settings.js'; export interface CatchSite { @@ -712,6 +714,201 @@ function statementsAllLogOrReturn(statements: TSESTree.Statement[]): boolean { return true; } +const KNOWN_NON_CANCEL_GUARDS = [ + 'isTimeoutError', + 'isAbortError', + 'isSupersededError', + 'isBreakError', + 'isProcessExitError', + 'isProcessSpawnError', + 'isJsonParseError', + 'isNotImplementedError', +] as const; + +function isDisjointLiteralVal(propName: string, literalNode: TSESTree.Node): boolean { + const unwrapped = unwrapTypeOnly(literalNode); + if (unwrapped.type !== AST_NODE_TYPES.Literal) { + return false; + } + const val = unwrapped.value; + if (propName === 'name') { + return typeof val === 'string' && val !== 'CancelError' && val !== 'Error'; + } + if (propName === 'kind' || propName === 'code' || propName === 'tag' || propName === '_tag') { + return ( + (typeof val === 'string' && + val !== 'CancelError' && + val !== 'cancel' && + val !== 'canceled' && + val !== 'cancelled') || + typeof val === 'number' + ); + } + return false; +} + +function isDisjointMemberLiteral(memberNode: TSESTree.Node, literalNode: TSESTree.Node, paramName: string): boolean { + const member = unwrapTypeOnly(memberNode); + if (member.type !== AST_NODE_TYPES.MemberExpression || member.computed) { + return false; + } + if (member.property.type !== AST_NODE_TYPES.Identifier) { + return false; + } + const obj = unwrapTypeOnly(member.object); + if (obj.type !== AST_NODE_TYPES.Identifier || obj.name !== paramName) { + return false; + } + return isDisjointLiteralVal(member.property.name, literalNode); +} + +function isDisjointErrorCtor(ctorNode: TSESTree.Expression, context: Context): boolean { + const ctor = unwrapTypeOnly(ctorNode); + if (refersTo(ctor, context, 'CancelError')) { + return false; + } + if ( + ctor.type === AST_NODE_TYPES.Identifier && + (ctor.name === 'CancelError' || ctor.name === 'Error' || ctor.name === 'Object') + ) { + return false; + } + if (ctor.type === AST_NODE_TYPES.MemberExpression && !ctor.computed) { + if (ctor.property.type === AST_NODE_TYPES.Identifier) { + if (ctor.property.name === 'CancelError') { + return false; + } + const obj = unwrapTypeOnly(ctor.object); + if ( + obj.type === AST_NODE_TYPES.Identifier && + (obj.name === 'globalThis' || obj.name === 'window' || obj.name === 'global') && + (ctor.property.name === 'Error' || ctor.property.name === 'Object') + ) { + return false; + } + } + } + const services = getCancServices(context); + if (services?.program) { + const checker = services.program.getTypeChecker(); + const tsNode = services.esTreeNodeToTSNodeMap.get(ctor); + const ctorType = checker.getTypeAtLocation(tsNode); + const sigs = ctorType.getConstructSignatures(); + return ( + sigs.length > 0 && + sigs.every((sig) => { + const inst = sig.getReturnType(); + return ( + !(inst.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) && + !isCancelErrorType(inst, checker) && + inst.getSymbol()?.name !== 'Error' && + inst.getSymbol()?.name !== 'Object' + ); + }) + ); + } + return ( + ctor.type === AST_NODE_TYPES.Identifier || + (ctor.type === AST_NODE_TYPES.MemberExpression && + !ctor.computed && + ctor.property.type === AST_NODE_TYPES.Identifier) + ); +} + +function isDisjointNonCancelGuard(exprNode: TSESTree.Expression, paramName: string, context: Context): boolean { + const expr = unwrapTypeOnly(exprNode); + if (expr.type === AST_NODE_TYPES.UnaryExpression) { + return false; + } + if (expr.type === AST_NODE_TYPES.LogicalExpression) { + if (expr.operator === '||') { + return ( + isDisjointNonCancelGuard(expr.left, paramName, context) && + isDisjointNonCancelGuard(expr.right, paramName, context) + ); + } + if (expr.operator === '&&') { + return ( + isDisjointNonCancelGuard(expr.left, paramName, context) || + isDisjointNonCancelGuard(expr.right, paramName, context) + ); + } + return false; + } + if (expr.type === AST_NODE_TYPES.BinaryExpression) { + if (expr.operator === 'instanceof') { + const left = unwrapTypeOnly(expr.left); + if (left.type === AST_NODE_TYPES.Identifier && left.name === paramName) { + return isDisjointErrorCtor(expr.right, context); + } + return false; + } + if (expr.operator === '===') { + return ( + isDisjointMemberLiteral(expr.left, expr.right, paramName) || + isDisjointMemberLiteral(expr.right, expr.left, paramName) + ); + } + return false; + } + if (expr.type === AST_NODE_TYPES.CallExpression) { + if (expr.arguments.length >= 1 && expr.arguments[0]?.type !== AST_NODE_TYPES.SpreadElement) { + const firstArg = unwrapTypeOnly(expr.arguments[0]); + if (firstArg.type === AST_NODE_TYPES.Identifier && firstArg.name === paramName) { + for (const g of KNOWN_NON_CANCEL_GUARDS) { + if (refersTo(expr.callee, context, g)) { + return true; + } + } + if ( + refersTo(expr.callee, context, 'isErrorOf') && + expr.arguments.length >= 2 && + expr.arguments[1]?.type !== AST_NODE_TYPES.SpreadElement + ) { + return isDisjointErrorCtor(expr.arguments[1], context); + } + if ( + expr.callee.type === AST_NODE_TYPES.CallExpression && + refersTo(expr.callee.callee, context, 'createIsError') && + expr.callee.arguments.length >= 1 && + expr.callee.arguments[0]?.type !== AST_NODE_TYPES.SpreadElement + ) { + return isDisjointErrorCtor(expr.callee.arguments[0], context); + } + const services = getCancServices(context); + if (services?.program) { + const checker = services.program.getTypeChecker(); + const tsCall = services.esTreeNodeToTSNodeMap.get(expr); + if (ts.isCallExpression(tsCall)) { + const sig = checker.getResolvedSignature(tsCall); + const pred = sig ? checker.getTypePredicateOfSignature(sig) : undefined; + if ( + pred && + (pred.kind === ts.TypePredicateKind.Identifier || pred.kind === ts.TypePredicateKind.AssertsIdentifier) && + pred.parameterIndex === 0 && + pred.type + ) { + const parts = pred.type.isUnion() ? pred.type.types : [pred.type]; + return ( + parts.length > 0 && + parts.every( + (p) => + !(p.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) && + !isCancelErrorType(p, checker) && + p.getSymbol()?.name !== 'Error' && + p.getSymbol()?.name !== 'Object', + ) + ); + } + } + } + } + } + return false; + } + return false; +} + function containsHandlerExit( node: TSESTree.Node, paramName: string, @@ -745,12 +942,16 @@ function containsHandlerExit( return node.body.some((s) => containsHandlerExit(s, paramName, context, inSwitch, inLoop, labelsInsideCatch)); } if (node.type === AST_NODE_TYPES.IfStatement) { - return ( - containsHandlerExit(node.consequent, paramName, context, inSwitch, inLoop, labelsInsideCatch) || - (node.alternate ? + const isDisjoint = isDisjointNonCancelGuard(node.test, paramName, context); + const consequentExits = + isDisjoint ? false : ( + containsHandlerExit(node.consequent, paramName, context, inSwitch, inLoop, labelsInsideCatch) + ); + const alternateExits = + node.alternate ? containsHandlerExit(node.alternate, paramName, context, inSwitch, inLoop, labelsInsideCatch) - : false) - ); + : false; + return consequentExits || alternateExits; } if ( node.type === AST_NODE_TYPES.WhileStatement || @@ -762,9 +963,56 @@ function containsHandlerExit( return containsHandlerExit(node.body, paramName, context, inSwitch, true, labelsInsideCatch); } if (node.type === AST_NODE_TYPES.SwitchStatement) { - return node.cases.some((c) => - c.consequent.some((s) => containsHandlerExit(s, paramName, context, true, inLoop, labelsInsideCatch)), - ); + const disc = unwrapTypeOnly(node.discriminant); + const isTrueDisc = disc.type === AST_NODE_TYPES.Literal && disc.value === true; + const memberProp = + ( + disc.type === AST_NODE_TYPES.MemberExpression && + !disc.computed && + disc.property.type === AST_NODE_TYPES.Identifier && + ['name', 'kind', 'code', 'tag', '_tag'].includes(disc.property.name) && + unwrapTypeOnly(disc.object).type === AST_NODE_TYPES.Identifier && + (unwrapTypeOnly(disc.object) as TSESTree.Identifier).name === paramName + ) ? + disc.property.name + : null; + + const isCaseDisjoint = (c: TSESTree.SwitchCase): boolean => { + if (c.test === null) { + return false; + } + if (isTrueDisc) { + return isDisjointNonCancelGuard(c.test, paramName, context); + } + if (memberProp !== null) { + return isDisjointLiteralVal(memberProp, c.test); + } + return false; + }; + + for (let idx = 0; idx < node.cases.length; idx++) { + const caseItem = node.cases[idx]; + if (isCaseDisjoint(caseItem)) { + continue; + } + const runStmts: TSESTree.Statement[] = []; + for (let j = idx; j < node.cases.length; j++) { + const targetCase = node.cases[j]; + runStmts.push(...targetCase.consequent); + const last = targetCase.consequent.at(-1); + if ( + last?.type === AST_NODE_TYPES.BreakStatement || + last?.type === AST_NODE_TYPES.ReturnStatement || + last?.type === AST_NODE_TYPES.ThrowStatement + ) { + break; + } + } + if (runStmts.some((s) => containsHandlerExit(s, paramName, context, true, inLoop, labelsInsideCatch))) { + return true; + } + } + return false; } if (node.type === AST_NODE_TYPES.TryStatement) { return ( diff --git a/tests/catch-flow.test.ts b/tests/catch-flow.test.ts index 5b39743..b75e5f6 100644 --- a/tests/catch-flow.test.ts +++ b/tests/catch-flow.test.ts @@ -495,6 +495,175 @@ catalogSearch.catch((e) => { expect(analysis.compliant).toBe(true); expect(analysis.via).toBe('guard'); }); + + it('passes disjoint error ctor return before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +class NotFoundError extends Error {} +try {} catch (e) { + if (e instanceof NotFoundError) return null; + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); + + it('passes known non-cancel brand guards before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +import { isTimeoutError, isAbortError } from '@cancjs/toolbox'; +try {} catch (e) { + if (isTimeoutError(e)) return null; + if (isAbortError(e)) return null; + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); + + it('passes isErrorOf and createIsError guards before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +import { isErrorOf } from '@cancjs/toolbox'; +class NotFoundError extends Error {} +try {} catch (e) { + if (isErrorOf(e, NotFoundError)) return null; + if (createIsError(NotFoundError)(e)) return null; + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); + + it('passes name and kind discriminant equality checks before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +try {} catch (e) { + if ((e as Error).name === 'NotFoundError') return null; + if ((e as { kind?: string }).kind === 'NotFound') return null; + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); + + it('passes logical combinations of disjoint error guards before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +import { isTimeoutError } from '@cancjs/toolbox'; +class NotFoundError extends Error {} +class RateLimitError extends Error { retryable = true; } +try {} catch (e) { + if (e instanceof NotFoundError || isTimeoutError(e)) return null; + else if (e instanceof RateLimitError && e.retryable) return null; + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); + + it('passes switch on discriminant before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +try {} catch (e) { + switch ((e as { kind?: string }).kind) { + case 'NotFound': + return null; + } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + expect(analysis.via).toBe('guard'); + }); + + it('rejects general Error instanceof before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +try {} catch (e) { + if (e instanceof Error) return null; + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('rejects CancelError instanceof before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +import { CancelError } from '@cancjs/promise'; +try {} catch (e) { + if (e instanceof CancelError) return null; + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('rejects negated disjoint guard before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +class NotFoundError extends Error {} +try {} catch (e) { + if (!(e instanceof NotFoundError)) return null; + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('rejects alternate return after disjoint guard before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +class NotFoundError extends Error {} +try {} catch (e) { + if (e instanceof NotFoundError) { + toast(e); + } else { + return null; + } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('rejects switch default return before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +try {} catch (e) { + switch ((e as { kind?: string }).kind) { + case 'NotFound': + toast(e); + break; + default: + return null; + } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('rejects switch fallthrough from CancelError case before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +try {} catch (e) { + switch ((e as Error).name) { + case 'CancelError': + case 'NotFoundError': + return null; + } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); }); type ProbeMessages = diff --git a/tests/rules/no-swallowed-cancel.test.ts b/tests/rules/no-swallowed-cancel.test.ts index 9f24d48..5adccf5 100644 --- a/tests/rules/no-swallowed-cancel.test.ts +++ b/tests/rules/no-swallowed-cancel.test.ts @@ -501,6 +501,99 @@ new CancelablePromise((resolve) => resolve(1)).catch((e) => { switch (code) { case 1: return Promise.reject(e); } if (isCancelError(e)) throw e; toast(e); +});`, + }, + { + name: 'disjoint class guard returns before cancel guard', + code: `${HEAD} +class NotFoundError extends Error {} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (e instanceof NotFoundError) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + }, + { + name: 'disjoint brand guards return before cancel guard', + code: `${HEAD} +import { isTimeoutError, isAbortError } from '@cancjs/toolbox'; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (isTimeoutError(e)) return null; + if (isAbortError(e)) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + }, + { + name: 'isErrorOf guard returns before cancel guard', + code: `${HEAD} +import { isErrorOf } from '@cancjs/toolbox'; +class NotFoundError extends Error {} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (isErrorOf(e, NotFoundError)) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + }, + { + name: 'name and kind discriminant equality checks return before cancel guard', + code: `${HEAD} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if ((e as Error).name === 'NotFoundError') return null; + if ((e as { kind?: string }).kind === 'NotFound') return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + }, + { + name: 'chained else-if and logical disjunction error guards return before cancel guard', + code: `${HEAD} +import { isTimeoutError } from '@cancjs/toolbox'; +class NotFoundError extends Error {} +class RateLimitError extends Error { + retryable = true; +} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (e instanceof NotFoundError || isTimeoutError(e)) return null; + else if (e instanceof RateLimitError && e.retryable) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + }, + { + name: 'switch on discriminant before cancel guard', + code: `${HEAD} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + switch ((e as { kind?: string }).kind) { + case 'NotFound': + return null; + } + if (isCancelError(e)) throw e; + toast(e); + } });`, }, ]; @@ -628,6 +721,108 @@ cancAsync(function* () { });`, errors: [{ messageId: 'swallowedCancel', line: 18, column: 7 }], }, + { + name: 'instanceof Error before cancel guard still reports swallowed cancel', + code: `${HEAD} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (e instanceof Error) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 15, column: 5 }], + }, + { + name: 'instanceof CancelError return before cancel guard still reports swallowed cancel', + code: `${HEAD} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (e instanceof CancelError) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 15, column: 5 }], + }, + { + name: 'negated guard return before cancel guard still reports swallowed cancel', + code: `${HEAD} +class NotFoundError extends Error {} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (!(e instanceof NotFoundError)) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 16, column: 5 }], + }, + { + name: 'alternate return after disjoint guard still reports swallowed cancel', + code: `${HEAD} +class NotFoundError extends Error {} +declare function log(err: unknown): void; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (e instanceof NotFoundError) { + log(e); + } else { + return null; + } + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 17, column: 5 }], + }, + { + name: 'switch default return before cancel guard still reports swallowed cancel', + code: `${HEAD} +declare function log(err: unknown): void; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + switch ((e as { kind?: string }).kind) { + case 'NotFound': + log(e); + break; + default: + return null; + } + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 16, column: 5 }], + }, + { + name: 'switch fallthrough to return from CancelError case still reports swallowed cancel', + code: `${HEAD} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + switch ((e as Error).name) { + case 'CancelError': + case 'NotFoundError': + return null; + } + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 15, column: 5 }], + }, { name: 'boundary in only one branch leaves other branch unguarded', code: boundaryOneBranch, @@ -896,20 +1091,62 @@ const corpusValid: ValidTestCase[] = validFiles.map((file) => ({ code: corpus(file), })); -const typedInvalid = [...corpusInvalid, ...corpusTypeOnlyInvalid, ...inlineInvalid, ...typeOnlyInvalid]; +const typeOnlyValid: ValidTestCase[] = [ + { + name: 'typed user type-predicate disjoint guard returns before cancel guard', + code: `${HEAD} +class NotFoundError extends Error {} +declare function isNotFound(err: unknown): err is NotFoundError; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (isNotFound(e)) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + }, +]; + +const typedPredicateInvalid: InvalidTestCase[] = [ + { + name: 'typed user predicate narrowing to general Error before cancel guard', + code: `${HEAD} +declare function isAnyErr(x: unknown): x is Error; +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + if (isAnyErr(e)) return null; + if (isCancelError(e)) throw e; + toast(e); + } +});`, + errors: [{ messageId: 'swallowedCancel', line: 16, column: 5 }], + }, +]; + +const typedInvalid = [ + ...corpusInvalid, + ...corpusTypeOnlyInvalid, + ...inlineInvalid, + ...typeOnlyInvalid, + ...typedPredicateInvalid, +]; const untypedSilent = [...corpusTypeOnlyInvalid, ...typeOnlyInvalid]; describe('canc/no-swallowed-cancel', () => { it('covers every corpus file and at least 20 cases', () => { const covered = [...corpusInvalid, ...corpusTypeOnlyInvalid].length; expect(covered).toBe(invalidFiles.length); - const total = typedInvalid.length + corpusValid.length + inlineValid.length; + const total = typedInvalid.length + corpusValid.length + inlineValid.length + typeOnlyValid.length; console.log(`no-swallowed-cancel typed cases: ${total} (${typedInvalid.length} invalid)`); expect(total).toBeGreaterThanOrEqual(20); }); ruleTester({ types: true }).run(RULE_ID, rule, { - valid: [...corpusValid, ...inlineValid], + valid: [...corpusValid, ...inlineValid, ...typeOnlyValid], invalid: typedInvalid, }); From f453711356724d508cf98796977309dbdb0421a3 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 02:11:24 +0300 Subject: [PATCH 07/13] (rules) Route inner catch trailing rethrows through keptGuardTypes --- src/rules/exhaustive-catch.ts | 23 +++++++----------- tests/rules/exhaustive-catch.test.ts | 35 ++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/rules/exhaustive-catch.ts b/src/rules/exhaustive-catch.ts index 0b4ff69..bbbde17 100644 --- a/src/rules/exhaustive-catch.ts +++ b/src/rules/exhaustive-catch.ts @@ -93,29 +93,22 @@ function innerHandledTypes(handler: TSESTree.CatchClause, context: Context): ts. if (rethrowAt === -1) { return keptGuardTypes(body, bindingName, context); } - const handled: ts.Type[] = []; - for (const stmt of body.slice(0, rethrowAt)) { - if ( - stmt.type === AST_NODE_TYPES.IfStatement && - !stmt.alternate && - isRecognizedGuard(stmt.test, bindingName, context) && - endsWithExit(stmt.consequent) - ) { - // the collector walks any node; only the guard test names the kept types - handled.push(...collectHandledCatchTypes(stmt.test, bindingName, context)); - } - } - return handled; + return keptGuardTypes(body.slice(0, rethrowAt), bindingName, context, true); } /** * Guard types of the branches that keep the binding: the branch never rethrows it, and either exits or no later * statement can rethrow it. */ -function keptGuardTypes(body: TSESTree.Statement[], bindingName: string, context: Context): ts.Type[] { +function keptGuardTypes( + body: TSESTree.Statement[], + bindingName: string, + context: Context, + trailingRethrow = false, +): ts.Type[] { const handled: ts.Type[] = []; body.forEach((stmt, i) => { - const laterRethrow = body.slice(i + 1).some((s) => subtreeRethrows(s, bindingName, context)); + const laterRethrow = trailingRethrow || body.slice(i + 1).some((s) => subtreeRethrows(s, bindingName, context)); const keep = ( test: TSESTree.Expression, branch: TSESTree.Statement[], diff --git a/tests/rules/exhaustive-catch.test.ts b/tests/rules/exhaustive-catch.test.ts index ea88f2f..b662393 100644 --- a/tests/rules/exhaustive-catch.test.ts +++ b/tests/rules/exhaustive-catch.test.ts @@ -208,6 +208,11 @@ const s3LaterConditionalRethrow = nestedCatch( outerLogB, ).replace('export const run', 'declare const flag: boolean;\nexport const run'); +const s3ConditionalRethrow = nestedCatch( + "if (inner instanceof ErrorA) { if (Math.random() > 0.5) throw inner; return 'a'; } throw inner;", + 'if (e instanceof ErrorB) { console.log(e); }', +); + const typedTester = ruleTester({ types: true }); const untypedTester = ruleTester({ types: false }); @@ -448,6 +453,16 @@ export const run = cancAsync(function* () { throw inner;`, outerLogB, ), + // Inner if and else if chain handles both errors before trailing rethrow + nestedCatch( + "if (inner instanceof ErrorA) { return 'a'; } else if (inner instanceof ErrorB) { return 'b'; } throw inner;", + 'console.log(e);', + ), + // Inner switch handles both errors before trailing rethrow + nestedCatch( + "switch (true) { case inner instanceof ErrorA: return 'a'; case inner instanceof ErrorB: return 'b'; } throw inner;", + 'console.log(e);', + ), // Valid corpus twins codeValid14, codeValid15, @@ -995,6 +1010,26 @@ export const run = cancAsync(function* () { }, ], }, + // Inner guard branch with conditional rethrow passes ErrorA to outer catch + { + code: s3ConditionalRethrow, + output: null, + errors: [ + { + messageId: 'noRethrow', + line: 13, + column: 5, + data: { names: 'ErrorA', handled: 'ErrorB' }, + suggestions: [ + { + messageId: 'noRethrow', + data: { names: 'ErrorA', handled: 'ErrorB' }, + output: withThrow(s3ConditionalRethrow), + }, + ], + }, + ], + }, // 8. Inner try/catch yield throws to outer catch { code: `import { cancAsync, cancAwait } from '@cancjs/coroutine'; From d38621a90581b1db7c33c0751982ddf173090126 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 02:29:46 +0300 Subject: [PATCH 08/13] (util) Filter handled discriminant switch types by kept case labels --- src/util/failure-types.ts | 26 ++++++++- tests/failure-types.test.ts | 103 ++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/util/failure-types.ts b/src/util/failure-types.ts index 8b7f8e4..5903c77 100644 --- a/src/util/failure-types.ts +++ b/src/util/failure-types.ts @@ -704,6 +704,7 @@ function switchCaseTypes( bindingName: string, checker: ts.TypeChecker, nodeMap: NodeMap, + allowedLabels?: ReadonlySet, ): ts.Type[] { const discriminant = node.discriminant; if ( @@ -724,7 +725,11 @@ function switchCaseTypes( } const labels = new Set(); for (const c of node.cases) { - if (c.test?.type === AST_NODE_TYPES.Literal && typeof c.test.value === 'string') { + if ( + c.test?.type === AST_NODE_TYPES.Literal && + typeof c.test.value === 'string' && + (!allowedLabels || allowedLabels.has(c.test.value)) + ) { labels.add(c.test.value); } } @@ -741,6 +746,25 @@ function switchCaseTypes( }); } +export function collectHandledSwitchLabels( + node: TSESTree.SwitchStatement, + bindingName: string, + allowedLabels: ReadonlySet, + context: Context, +): ts.Type[] { + const services = getCancServices(context); + if (!services?.program || allowedLabels.size === 0) { + return []; + } + return switchCaseTypes( + node, + bindingName, + services.program.getTypeChecker(), + services.esTreeNodeToTSNodeMap, + allowedLabels, + ); +} + export function collectHandledCatchTypes(root: TSESTree.Node, bindingName: string, context: Context): ts.Type[] { const services = getCancServices(context); if (!services?.program) { diff --git a/tests/failure-types.test.ts b/tests/failure-types.test.ts index f2af73a..3905dd9 100644 --- a/tests/failure-types.test.ts +++ b/tests/failure-types.test.ts @@ -9,6 +9,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { collectHandledCatchTypes, + collectHandledSwitchLabels, getFailureMembers, getProgramFailureSymbols, isCancelErrorCtorNode, @@ -515,6 +516,108 @@ class ErrorB extends Error {}`, }); }); + describe('collectHandledSwitchLabels', () => { + it('returns only the union constituents whose kind is in allowedLabels', () => { + const kindCode = `class ErrorA extends Error { readonly kind = 'ErrorA'; } +class ErrorB extends Error { readonly kind = 'ErrorB'; } +class ErrorC extends Error { readonly kind = 'ErrorC'; } +export function probe(): void { + try { + throw new Error('x'); + } catch (e) { + switch ((e as ErrorA | ErrorB | ErrorC).kind) { + case 'ErrorA': + break; + case 'ErrorB': + break; + case 'ErrorC': + break; + } + } +} +`; + const { program: probeProgram, dir } = createVirtualProgram({ 'switch-labels-probe.ts': kindCode }); + const filePath = path.resolve(dir, 'src', 'switch-labels-probe.ts'); + const { ast, services } = parseAndGenerateServices(kindCode, { + filePath, + programs: [probeProgram], + range: true, + loc: true, + }); + const fn = ast.body.find((n) => n.type === AST_NODE_TYPES.ExportNamedDeclaration); + const decl = fn?.type === AST_NODE_TYPES.ExportNamedDeclaration ? fn.declaration : null; + let switchStmt: TSESTree.SwitchStatement | undefined; + if (decl?.type === AST_NODE_TYPES.FunctionDeclaration) { + const tryStmt = decl.body.body[0]; + if (tryStmt.type === AST_NODE_TYPES.TryStatement && tryStmt.handler) { + const first = tryStmt.handler.body.body[0]; + if (first?.type === AST_NODE_TYPES.SwitchStatement) { + switchStmt = first; + } + } + } + if (!switchStmt) { + throw new Error('switch statement not found'); + } + const context = { + sourceCode: { ast, parserServices: services }, + settings: {}, + } as unknown as TSESLint.RuleContext; + const types = collectHandledSwitchLabels(switchStmt, 'e', new Set(['ErrorA', 'ErrorC']), context); + expect(types.map((t) => t.getSymbol()?.name ?? '?')).toEqual(['ErrorA', 'ErrorC']); + + const empty = collectHandledSwitchLabels(switchStmt, 'e', new Set(), context); + expect(empty).toEqual([]); + }); + + it('returns only the union constituents whose name literal is in allowedLabels', () => { + const nameCode = `class ErrorA extends Error { readonly name = 'ErrorA' as const; } +class ErrorB extends Error { readonly name = 'ErrorB' as const; } +export function probe(): void { + try { + throw new Error('x'); + } catch (e) { + switch ((e as ErrorA | ErrorB).name) { + case 'ErrorA': + break; + case 'ErrorB': + break; + } + } +} +`; + const { program: probeProgram, dir } = createVirtualProgram({ 'switch-name-labels-probe.ts': nameCode }); + const filePath = path.resolve(dir, 'src', 'switch-name-labels-probe.ts'); + const { ast, services } = parseAndGenerateServices(nameCode, { + filePath, + programs: [probeProgram], + range: true, + loc: true, + }); + const fn = ast.body.find((n) => n.type === AST_NODE_TYPES.ExportNamedDeclaration); + const decl = fn?.type === AST_NODE_TYPES.ExportNamedDeclaration ? fn.declaration : null; + let switchStmt: TSESTree.SwitchStatement | undefined; + if (decl?.type === AST_NODE_TYPES.FunctionDeclaration) { + const tryStmt = decl.body.body[0]; + if (tryStmt.type === AST_NODE_TYPES.TryStatement && tryStmt.handler) { + const first = tryStmt.handler.body.body[0]; + if (first?.type === AST_NODE_TYPES.SwitchStatement) { + switchStmt = first; + } + } + } + if (!switchStmt) { + throw new Error('switch statement not found'); + } + const context = { + sourceCode: { ast, parserServices: services }, + settings: {}, + } as unknown as TSESLint.RuleContext; + const types = collectHandledSwitchLabels(switchStmt, 'e', new Set(['ErrorB']), context); + expect(types.map((t) => t.getSymbol()?.name ?? '?')).toEqual(['ErrorB']); + }); + }); + describe('isCancelErrorCtorNode', () => { it('returns true for NewExpression referring to CancelError and false for other callees', () => { const mockContext = { From 4734988d6460ffb0d29a5a2f96a63e124ff249ec Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 02:37:14 +0300 Subject: [PATCH 09/13] (rules) Keep nested discriminant switch types and test fallthrough --- src/rules/exhaustive-catch.ts | 32 +++++++++++++++++++++------- tests/rules/exhaustive-catch.test.ts | 28 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/rules/exhaustive-catch.ts b/src/rules/exhaustive-catch.ts index bbbde17..b10967c 100644 --- a/src/rules/exhaustive-catch.ts +++ b/src/rules/exhaustive-catch.ts @@ -14,7 +14,7 @@ import { import createRule from '../util/create-rule.js'; import { unwrapTypeOnly } from '../util/executor.js'; import { analyzeCatchSteps, type CatchStep, isTypePredicateCall } from '../util/failure-checker-exhaustive-catch.js'; -import { collectHandledCatchTypes } from '../util/failure-types.js'; +import { collectHandledCatchTypes, collectHandledSwitchLabels } from '../util/failure-types.js'; export type MessageIds = 'unhandledFailure' | 'noRethrow' | 'missingUnlistedFallback'; @@ -141,13 +141,29 @@ function keptGuardTypes( branch = branch.alternate; } } else if (stmt.type === AST_NODE_TYPES.SwitchStatement) { - const guarded = hasGuardCases(stmt, bindingName, context); - stmt.cases.forEach((c, at) => { - if (c.test) { - // a case without break falls through, so the following cases run as part of it - keep(c.test, fallThroughBody(stmt.cases.slice(at)), guarded, isReturn); - } - }); + if (isBindingMember(stmt.discriminant, bindingName)) { + const keptLabels = new Set(); + stmt.cases.forEach((c, at) => { + if (c.test?.type === AST_NODE_TYPES.Literal && typeof c.test.value === 'string') { + const branch = fallThroughBody(stmt.cases.slice(at)); + const last = branch.at(-1); + if ( + !branch.some((s) => subtreeRethrows(s, bindingName, context)) && + (!laterRethrow || (last !== undefined && isReturn(last))) + ) { + keptLabels.add(c.test.value); + } + } + }); + handled.push(...collectHandledSwitchLabels(stmt, bindingName, keptLabels, context)); + } else { + const guarded = hasGuardCases(stmt, bindingName, context); + stmt.cases.forEach((c, at) => { + if (c.test) { + keep(c.test, fallThroughBody(stmt.cases.slice(at)), guarded, isReturn); + } + }); + } } }); return handled; diff --git a/tests/rules/exhaustive-catch.test.ts b/tests/rules/exhaustive-catch.test.ts index b662393..52000d3 100644 --- a/tests/rules/exhaustive-catch.test.ts +++ b/tests/rules/exhaustive-catch.test.ts @@ -463,6 +463,16 @@ export const run = cancAsync(function* () { "switch (true) { case inner instanceof ErrorA: return 'a'; case inner instanceof ErrorB: return 'b'; } throw inner;", 'console.log(e);', ), + // Inner discriminant switch handles both errors without rethrow + nestedCatch( + "switch ((inner as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; case 'ErrorB': return 'b'; }", + 'console.log(e);', + ), + // Inner discriminant switch fall-through to return before trailing rethrow handles both + nestedCatch( + "switch ((inner as ErrorA | ErrorB).kind) { case 'ErrorA': case 'ErrorB': return 'ok'; } throw inner;", + 'console.log(e);', + ), // Valid corpus twins codeValid14, codeValid15, @@ -1055,6 +1065,24 @@ export const run = cancAsync(function* () { }, ], }, + // Nested discriminant switch with fall-through to rethrow + { + code: nestedCatch( + "switch ((inner as ErrorA | ErrorB).kind) { case 'ErrorA': case 'ErrorB': throw inner; }", + 'console.log(e);', + ), + output: null, + errors: [{ messageId: 'unhandledFailure', data: { names: 'ErrorA, ErrorB' } }], + }, + // Nested switch(true) with fall-through to rethrow + { + code: nestedCatch( + 'switch (true) { case inner instanceof ErrorA: case inner instanceof ErrorB: throw inner; }', + 'console.log(e);', + ), + output: null, + errors: [{ messageId: 'unhandledFailure', data: { names: 'ErrorA, ErrorB' } }], + }, ], }); }); From 755b39210bf988ee1008490fe6f4651c5c28bde4 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 02:57:41 +0300 Subject: [PATCH 10/13] (rules) Exempt guarded switch exits before a trailing catch rethrow --- src/rules/exhaustive-catch.ts | 8 ++++ tests/rules/exhaustive-catch.test.ts | 57 +++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/rules/exhaustive-catch.ts b/src/rules/exhaustive-catch.ts index b10967c..17e0825 100644 --- a/src/rules/exhaustive-catch.ts +++ b/src/rules/exhaustive-catch.ts @@ -433,6 +433,14 @@ function hasSwallowingExit(node: TSESTree.Node, bindingName: string, context: Co // an exit under a guard leaves only for the errors the guard matches return node.alternate ? hasSwallowingExit(node.alternate, bindingName, context, nested) : false; } + if (node.type === AST_NODE_TYPES.SwitchStatement && hasGuardCases(node, bindingName, context)) { + const defaultCase = node.cases.find((c) => c.test === null); + if (!defaultCase) { + return false; + } + const defaultAt = node.cases.indexOf(defaultCase); + return fallThroughBody(node.cases.slice(defaultAt)).some((s) => hasSwallowingExit(s, bindingName, context, true)); + } const childNested = nested || LOOP_OR_SWITCH.has(node.type); let found = false; forEachChild(node, (child) => { diff --git a/tests/rules/exhaustive-catch.test.ts b/tests/rules/exhaustive-catch.test.ts index 52000d3..b002c0c 100644 --- a/tests/rules/exhaustive-catch.test.ts +++ b/tests/rules/exhaustive-catch.test.ts @@ -44,7 +44,7 @@ class ErrorB extends Error { readonly kind = 'ErrorB'; } declare function getUnion(): CancelablePromise; declare function log(e: unknown): boolean; declare const flag: boolean; -declare const mode: string; +declare const mode: string; declare const otherFlag: number; export const run = cancAsync(function* () { try { yield* cancAwait(getUnion()); @@ -610,6 +610,22 @@ export const run = cancAsync(function* () { }`), options: [{ requireUnlistedFallback: false }], }, + // Guarded switch on error discriminant before trailing rethrow + unionCatch("switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; } throw e;"), + // Guarded switch(true) before trailing rethrow + unionCatch("switch (true) { case e instanceof ErrorA: return 'a'; } throw e;"), + // Open-set step reachable fallback for unlisted errors + `import { cancAsync, cancAwait } from '@cancjs/coroutine'; +import type CancelablePromise from '@cancjs/promise'; +class ErrorA extends Error { readonly kind = 'ErrorA'; } +declare function getOpen(): CancelablePromise; +export const run = cancAsync(function* () { + try { + yield* cancAwait(getOpen()); + } catch (e) { + switch ((e as ErrorA).kind) { case 'ErrorA': return 'a'; } throw e; + } +});`, ], invalid: [ // Discriminant switch missing a case drops the unmatched failure @@ -1275,6 +1291,45 @@ cancAsync(function* () { }, ], }, + // Guarded switch with default return swallows unhandled failure + { + code: unionCatch( + "switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; default: return 'other'; } throw e;", + ), + output: null, + errors: [ + { + messageId: 'noRethrow', + line: 13, + column: 5, + data: { names: 'ErrorB', handled: 'ErrorA' }, + suggestions: [ + { + messageId: 'noRethrow', + data: { names: 'ErrorB', handled: 'ErrorA' }, + output: withThrow( + unionCatch( + "switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; default: return 'other'; } throw e;", + ), + ), + }, + ], + }, + ], + }, + // Unguarded switch before throw e can exit for any error + { + code: unionCatch("switch (otherFlag) { case 1: return 'a'; } throw e;"), + output: null, + errors: [ + { + messageId: 'unhandledFailure', + line: 13, + column: 5, + data: { names: 'ErrorA, ErrorB' }, + }, + ], + }, ], }); From 75d35b3a60ccc121ad200a7d1c68e6db9e07e949 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 13:39:14 +0300 Subject: [PATCH 11/13] (util) Unwrap block-scoped switch exits in catch-flow slicing --- src/util/catch-flow.ts | 6 ++- tests/catch-flow.test.ts | 72 +++++++++++++++++++++++++ tests/rules/no-swallowed-cancel.test.ts | 22 ++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/util/catch-flow.ts b/src/util/catch-flow.ts index 2bb767c..e3e9473 100644 --- a/src/util/catch-flow.ts +++ b/src/util/catch-flow.ts @@ -999,9 +999,13 @@ function containsHandlerExit( for (let j = idx; j < node.cases.length; j++) { const targetCase = node.cases[j]; runStmts.push(...targetCase.consequent); - const last = targetCase.consequent.at(-1); + let last = targetCase.consequent.at(-1); + while (last?.type === AST_NODE_TYPES.BlockStatement) { + last = last.body.at(-1); + } if ( last?.type === AST_NODE_TYPES.BreakStatement || + last?.type === AST_NODE_TYPES.ContinueStatement || last?.type === AST_NODE_TYPES.ReturnStatement || last?.type === AST_NODE_TYPES.ThrowStatement ) { diff --git a/tests/catch-flow.test.ts b/tests/catch-flow.test.ts index b75e5f6..455e650 100644 --- a/tests/catch-flow.test.ts +++ b/tests/catch-flow.test.ts @@ -660,6 +660,78 @@ try {} catch (e) { } if (isCancelError(e)) throw e; toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(false); + }); + + it('accepts switch case with block-scoped break before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +try {} catch (e) { + switch ((e as Error).name) { + case 'CancelError': { + const msg = (e as Error).message; + toast(msg); + break; + } + case 'NotFoundError': + return null; + } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + + const nestedCode = `${IMPORT_PREAMBLE} +try {} catch (e) { + switch ((e as Error).name) { + case 'CancelError': { + { + break; + } + } + case 'NotFoundError': + return null; + } + if (isCancelError(e)) throw e; + toast(e); +}`; + const nestedAnalysis = parseAndAnalyze(nestedCode); + expect(nestedAnalysis.compliant).toBe(true); + }); + + it('accepts switch case with loop-wrapped continue before cancel guard', () => { + const code = `${IMPORT_PREAMBLE} +try {} catch (e) { + for (const attempt of [1, 2]) { + switch ((e as Error).name) { + case 'CancelError': { + continue; + } + case 'NotFoundError': + return null; + } + } + if (isCancelError(e)) throw e; + toast(e); +}`; + const analysis = parseAndAnalyze(code); + expect(analysis.compliant).toBe(true); + }); + + it('rejects switch case block without trailing jump falling through to return', () => { + const code = `${IMPORT_PREAMBLE} +try {} catch (e) { + switch ((e as Error).name) { + case 'CancelError': { + toast(e); + } + case 'NotFoundError': + return null; + } + if (isCancelError(e)) throw e; + toast(e); }`; const analysis = parseAndAnalyze(code); expect(analysis.compliant).toBe(false); diff --git a/tests/rules/no-swallowed-cancel.test.ts b/tests/rules/no-swallowed-cancel.test.ts index 5adccf5..da042d6 100644 --- a/tests/rules/no-swallowed-cancel.test.ts +++ b/tests/rules/no-swallowed-cancel.test.ts @@ -594,6 +594,28 @@ cancAsync(function* () { if (isCancelError(e)) throw e; toast(e); } +});`, + }, + { + name: 'switch with block-scoped break before cancel guard', + code: `${HEAD} +cancAsync(function* () { + try { + yield* cancAwait(load()); + } catch (e) { + switch ((e as Error).name) { + case 'CancelError': { + const msg = (e as Error).message; + toast(msg); + break; + } + case 'NotFoundError': { + return null; + } + } + if (isCancelError(e)) throw e; + toast(e); + } });`, }, ]; From f64f59a638ea61f3b324265d78e24d63cbf06bea Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 13:52:25 +0300 Subject: [PATCH 12/13] (rules) Unwrap block-scoped switch cases in exhaustive-catch --- src/rules/exhaustive-catch.ts | 19 ++- tests/rules/exhaustive-catch.test.ts | 197 +++++++++++++++++---------- 2 files changed, 138 insertions(+), 78 deletions(-) diff --git a/src/rules/exhaustive-catch.ts b/src/rules/exhaustive-catch.ts index 17e0825..67f0663 100644 --- a/src/rules/exhaustive-catch.ts +++ b/src/rules/exhaustive-catch.ts @@ -169,15 +169,26 @@ function keptGuardTypes( return handled; } +function trailingStmt(stmt: TSESTree.Statement | undefined): TSESTree.Statement | undefined { + let cur = stmt; + while (cur?.type === AST_NODE_TYPES.BlockStatement) { + cur = cur.body.at(-1); + } + return cur; +} + /** Statements a switch case runs, through the following cases until one ends with a jump. */ function fallThroughBody(cases: TSESTree.SwitchCase[]): TSESTree.Statement[] { const run: TSESTree.Statement[] = []; for (const c of cases) { run.push(...c.consequent); - const last = c.consequent.at(-1); + const last = trailingStmt(c.consequent.at(-1)); if ( last && - (last.type === AST_NODE_TYPES.BreakStatement || isReturn(last) || last.type === AST_NODE_TYPES.ThrowStatement) + (last.type === AST_NODE_TYPES.BreakStatement || + last.type === AST_NODE_TYPES.ContinueStatement || + last.type === AST_NODE_TYPES.ReturnStatement || + last.type === AST_NODE_TYPES.ThrowStatement) ) { break; } @@ -186,7 +197,7 @@ function fallThroughBody(cases: TSESTree.SwitchCase[]): TSESTree.Statement[] { } function isReturn(stmt: TSESTree.Statement): boolean { - return stmt.type === AST_NODE_TYPES.ReturnStatement; + return trailingStmt(stmt)?.type === AST_NODE_TYPES.ReturnStatement; } /** Whether the binding is rethrown anywhere under the statement, outside nested functions. */ @@ -207,7 +218,7 @@ function subtreeRethrows(stmt: TSESTree.Statement, bindingName: string, context: } function endsWithExit(stmt: TSESTree.Statement): boolean { - const last = stmt.type === AST_NODE_TYPES.BlockStatement ? stmt.body.at(-1) : stmt; + const last = trailingStmt(stmt); return ( last?.type === AST_NODE_TYPES.ReturnStatement || last?.type === AST_NODE_TYPES.BreakStatement || diff --git a/tests/rules/exhaustive-catch.test.ts b/tests/rules/exhaustive-catch.test.ts index b002c0c..f44e9d2 100644 --- a/tests/rules/exhaustive-catch.test.ts +++ b/tests/rules/exhaustive-catch.test.ts @@ -626,6 +626,29 @@ export const run = cancAsync(function* () { switch ((e as ErrorA).kind) { case 'ErrorA': return 'a'; } throw e; } });`, + // Unbraced partial nested discriminant switch keeps unhandled outer errors + nestedCatch( + "switch ((inner as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; } throw inner;", + 'if (e instanceof ErrorB) { console.log(e); }', + ), + // Block-scoped partial nested discriminant switch keeps unhandled outer errors + nestedCatch( + "switch ((inner as ErrorA | ErrorB).kind) { case 'ErrorA': { const out = 'a'; return out; } } throw inner;", + 'if (e instanceof ErrorB) { console.log(e); }', + ), + // Block-scoped nested switch(true) keeps unhandled outer errors + nestedCatch( + "switch (true) { case inner instanceof ErrorA: { { return 'a'; } } } throw inner;", + 'if (e instanceof ErrorB) { console.log(e); }', + ), + // Block-scoped break in default before returning case + unionCatch( + "switch ((e as ErrorA | ErrorB).kind) { default: { break; } case 'ErrorA': { return 'a'; } } throw e;", + ), + // Loop-enclosed block-scoped continue in default before returning case + unionCatch( + "for (const k of [1]) { switch ((e as ErrorA | ErrorB).kind) { default: { void k; continue; } case 'ErrorA': return 'a'; } } throw e;", + ), ], invalid: [ // Discriminant switch missing a case drops the unmatched failure @@ -1099,6 +1122,106 @@ export const run = cancAsync(function* () { output: null, errors: [{ messageId: 'unhandledFailure', data: { names: 'ErrorA, ErrorB' } }], }, + // Open 3+ constituent intersection requires fallback under default options + { + code: openThreeCatch(`if (e instanceof ErrorA) { + console.log(e); + }`), + output: null, + errors: [ + { + messageId: 'missingUnlistedFallback', + line: 8, + suggestions: [ + { + messageId: 'missingUnlistedFallback', + output: withThrow( + openThreeCatch(`if (e instanceof ErrorA) { + console.log(e); + }`), + ), + }, + ], + }, + ], + }, + // Unguarded open 3+ constituent intersection reports unhandledFailure without marker + { + code: openThreeCatch(`console.log(e);`), + output: null, + errors: [ + { + messageId: 'unhandledFailure', + line: 8, + data: { names: 'ErrorA & { readonly meta: string; }' }, + }, + ], + }, + // Guarded switch with default return swallows unhandled failure + { + code: unionCatch( + "switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; default: return 'other'; } throw e;", + ), + output: null, + errors: [ + { + messageId: 'noRethrow', + line: 13, + column: 5, + data: { names: 'ErrorB', handled: 'ErrorA' }, + suggestions: [ + { + messageId: 'noRethrow', + data: { names: 'ErrorB', handled: 'ErrorA' }, + output: withThrow( + unionCatch( + "switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; default: return 'other'; } throw e;", + ), + ), + }, + ], + }, + ], + }, + // Unguarded switch before throw e can exit for any error + { + code: unionCatch("switch (otherFlag) { case 1: return 'a'; } throw e;"), + output: null, + errors: [ + { + messageId: 'unhandledFailure', + line: 13, + column: 5, + data: { names: 'ErrorA, ErrorB' }, + }, + ], + }, + // Guarded switch with block-scoped default return swallows unhandled failure + { + code: unionCatch( + "switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': { return 'a'; } default: { return 'other'; } } throw e;", + ), + output: null, + errors: [ + { + messageId: 'noRethrow', + line: 13, + column: 5, + data: { names: 'ErrorB', handled: 'ErrorA' }, + suggestions: [ + { + messageId: 'noRethrow', + data: { names: 'ErrorB', handled: 'ErrorA' }, + output: withThrow( + unionCatch( + "switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': { return 'a'; } default: { return 'other'; } } throw e;", + ), + ), + }, + ], + }, + ], + }, ], }); }); @@ -1256,80 +1379,6 @@ cancAsync(function* () { output: null, errors: [{ messageId: 'unhandledFailure' as const, line: 12, data: { names: 'TimeoutError' } }], }, - // Open 3+ constituent intersection requires fallback under default options - { - code: openThreeCatch(`if (e instanceof ErrorA) { - console.log(e); - }`), - output: null, - errors: [ - { - messageId: 'missingUnlistedFallback', - line: 8, - suggestions: [ - { - messageId: 'missingUnlistedFallback', - output: withThrow( - openThreeCatch(`if (e instanceof ErrorA) { - console.log(e); - }`), - ), - }, - ], - }, - ], - }, - // Unguarded open 3+ constituent intersection reports unhandledFailure without marker - { - code: openThreeCatch(`console.log(e);`), - output: null, - errors: [ - { - messageId: 'unhandledFailure', - line: 8, - data: { names: 'ErrorA & { readonly meta: string; }' }, - }, - ], - }, - // Guarded switch with default return swallows unhandled failure - { - code: unionCatch( - "switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; default: return 'other'; } throw e;", - ), - output: null, - errors: [ - { - messageId: 'noRethrow', - line: 13, - column: 5, - data: { names: 'ErrorB', handled: 'ErrorA' }, - suggestions: [ - { - messageId: 'noRethrow', - data: { names: 'ErrorB', handled: 'ErrorA' }, - output: withThrow( - unionCatch( - "switch ((e as ErrorA | ErrorB).kind) { case 'ErrorA': return 'a'; default: return 'other'; } throw e;", - ), - ), - }, - ], - }, - ], - }, - // Unguarded switch before throw e can exit for any error - { - code: unionCatch("switch (otherFlag) { case 1: return 'a'; } throw e;"), - output: null, - errors: [ - { - messageId: 'unhandledFailure', - line: 13, - column: 5, - data: { names: 'ErrorA, ErrorB' }, - }, - ], - }, ], }); From 1eb9bbdb0ec9847143e2b904371ed3a4f1006221 Mon Sep 17 00:00:00 2001 From: bisubus Date: Tue, 3 Nov 2026 13:59:28 +0300 Subject: [PATCH 13/13] (util) Deduplicate carrier union failures after stripping unlisted marker --- src/util/failure-types.ts | 10 +++++++--- tests/failure-types.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/util/failure-types.ts b/src/util/failure-types.ts index 5903c77..37e622c 100644 --- a/src/util/failure-types.ts +++ b/src/util/failure-types.ts @@ -302,7 +302,7 @@ export function getFailureMembers( const listedMap = new Map(); for (const r of results) { for (const t of r.listed) { - const id = checker.typeToString(t); + const id = strippedTypeString(t, checker); if (!listedMap.has(id)) { listedMap.set(id, t); } @@ -687,7 +687,7 @@ export function formatTypeNames(types: ts.Type[], checker: ts.TypeChecker): stri function strippedTypeString(t: ts.Type, checker: ts.TypeChecker): string { if (t.isIntersection() && t.types.some((c) => isUnlistedMarker(c, checker))) { return stripUnlistedConstituents(t, checker) - .map((c) => formatTypeNames([c], checker)) + .map((c) => checker.typeToString(c)) .join(' & '); } return checker.typeToString(t); @@ -922,7 +922,11 @@ export function analyzeCatchFailures( failures.open = true; } for (const m of arg0Failures.listed) { - if (!failures.listed.some((existing) => checker.typeToString(existing) === checker.typeToString(m))) { + if ( + !failures.listed.some( + (existing) => strippedTypeString(existing, checker) === strippedTypeString(m, checker), + ) + ) { failures.listed.push(m); } } diff --git a/tests/failure-types.test.ts b/tests/failure-types.test.ts index 3905dd9..67fcdc3 100644 --- a/tests/failure-types.test.ts +++ b/tests/failure-types.test.ts @@ -10,6 +10,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { collectHandledCatchTypes, collectHandledSwitchLabels, + formatTypeNames, getFailureMembers, getProgramFailureSymbols, isCancelErrorCtorNode, @@ -222,6 +223,7 @@ export type CopyOpen = CancelablePromise; export type IntersectionOpen = CancelablePromise; export class ErrorA extends Error {} export type ThreeConstituents = CancelablePromise; +export type ThreeUnion = CancelablePromise | CancelablePromise; export type OrNull = CancelablePromise | null; `, }; @@ -283,6 +285,14 @@ export type OrNull = CancelablePromise | null; expect(res.listed.length).toBe(1); }); + it('union of listed and unlisted 3+ constituent intersections deduplicates listed members', () => { + const res = getFailureMembers(virtualType('b3-unlisted.ts', 'ThreeUnion'), virtualChecker); + expect(res.defined).toBe(true); + expect(res.open).toBe(true); + expect(res.listed.length).toBe(1); + expect(formatTypeNames(res.listed, virtualChecker)).toBe('ErrorA & { readonly meta: string; }'); + }); + it('union filter: a null branch does not make a carrier union undefined', () => { const res = getFailureMembers(virtualType('b3-unlisted.ts', 'OrNull'), virtualChecker); expect(res.defined).toBe(true);