Skip to content

eslint-factory: detect catch-variable aliases in require-error-cause-in-rethrow#43966

Merged
pelikhan merged 5 commits into
mainfrom
copilot/eslint-factory-fix-require-error-cause
Jul 7, 2026
Merged

eslint-factory: detect catch-variable aliases in require-error-cause-in-rethrow#43966
pelikhan merged 5 commits into
mainfrom
copilot/eslint-factory-fix-require-error-cause

Conversation

Copilot AI commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

require-error-cause-in-rethrow only tracked the catch parameter identifier, so common normalization (const err = caughtError) bypassed detection and allowed rethrows that dropped causal chains. It also missed references hidden behind logical/conditional expressions, which masked real cases such as the create_pull_request.cjs pattern.

  • Catch alias tracking (conservative)

    • Extend catch-frame analysis to recognize direct aliases in catch scope: const <alias> = <catchParam>.
    • Keep matching strict: initializer must be exactly the catch identifier; unrelated initializers are ignored.
    • Skip malformed/self-name alias declarations defensively.
  • Reference traversal expansion

    • Extend expression scanning to cover LogicalExpression and ConditionalExpression in addition to existing call/template/member/binary traversal.
    • This closes misses like ${message || String(err)} where the caught value is referenced indirectly.
  • Cause suggestion precision

    • When reporting, use the identifier actually referenced in the rethrow message (err vs catch param) so suggested fixes insert the correct { cause: <id> }.
  • Rule coverage updates

    • Added RuleTester cases for:
      • invalid aliased rethrow without cause,
      • valid aliased rethrow with { cause: err },
      • valid unrelated alias initializer,
      • valid reassigned alias (intentionally not tracked).
try {
  doSomething();
} catch (deleteError) {
  /** @type {any} */
  const err = deleteError;
  throw new Error(`Failed: ${message || String(err)}`); // now flagged
  // suggested: throw new Error(`Failed: ${message || String(err)}`, { cause: err });
}

Generated by 👨‍🍳 PR Sous Chef · 5.04 AIC · ⌖ 6.87 AIC · ⊞ 4.7K ·
Comment /souschef to run again

Copilot AI and others added 2 commits July 7, 2026 08:08
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix eslint-factory normalization idiom for error rethrow eslint-factory: detect catch-variable aliases in require-error-cause-in-rethrow Jul 7, 2026
Copilot AI requested a review from pelikhan July 7, 2026 08:13
@pelikhan pelikhan marked this pull request as ready for review July 7, 2026 08:17
Copilot AI review requested due to automatic review settings July 7, 2026 08:17
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer failed during the skills-based review.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

⚠️ PR Code Quality Reviewer failed during code quality review.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #43966 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the require-error-cause-in-rethrow ESLint rule so it detects common catch-variable aliasing patterns and finds references hidden behind additional expression forms, preventing rethrows that drop causal chains.

Changes:

  • Track direct const <alias> = <catchParam> aliases inside catch blocks and treat them as equivalent to the caught error for reference detection.
  • Expand message-expression traversal to include LogicalExpression and ConditionalExpression.
  • Improve suggestion precision by inserting { cause: <identifier actually referenced> } (alias vs catch param) and add RuleTester coverage for these scenarios.
Show a summary per file
File Description
eslint-factory/src/rules/require-error-cause-in-rethrow.ts Adds catch-alias collection and expands expression reference scanning; updates reporting/suggestions to use the referenced identifier.
eslint-factory/src/rules/require-error-cause-in-rethrow.test.ts Adds/updates RuleTester cases covering alias detection, logical-expression traversal, and non-tracked alias scenarios.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 2
  • Review effort level: Low

Comment on lines 212 to 220
context.report({
node,
messageId: "missingCause",
data: { catchVar: catchVarName },
data: { catchVar: causeIdentifier },
suggest: [
{
messageId: "addCause" as const,
data: { catchVar: catchVarName },
data: { catchVar: causeIdentifier },
fix(fixer) {
Comment on lines 80 to 90
function collectCatchAliases(catchBody: TSESTree.BlockStatement, catchVarName: string): Set<string> {
const aliases = new Set<string>();
for (const statement of catchBody.body) {
if (statement.type !== AST_NODE_TYPES.VariableDeclaration || statement.kind !== "const") continue;
for (const decl of statement.declarations) {
if (decl.id.type !== AST_NODE_TYPES.Identifier) continue;
if (!decl.init || decl.init.type !== AST_NODE_TYPES.Identifier) continue;
if (decl.init.name !== catchVarName) continue;
if (decl.id.name === catchVarName) continue;
aliases.add(decl.id.name);
}
@github-actions github-actions Bot mentioned this pull request Jul 7, 2026
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 83/100 — Excellent

Analyzed 4 test(s): 3 design, 1 implementation, 0 violation(s).

📊 Metrics (4 tests)
Metric Value
Analyzed 4 (Go: 0, JS/TS: 4)
✅ Design 3 (75%)
⚠️ Implementation 1 (25%)
Edge/error coverage 3 (75%)
Duplicate clusters 0
Inflation No (test:prod = 22:39 ≈ 0.56:1)
🚨 Violations 0

Note: The test file is require-error-cause-in-rethrow.test.ts (TypeScript/vitest). Build-tag rules apply to Go only and do not apply here.

| Test | File | Classification | Issues |
|---|---|---|
| Valid: alias from unrelated value should not flag | require-error-cause-in-rethrow.test.ts:44 | behavioral_contract | None |
| Valid: reassigned alias not tracked (intentional) | require-error-cause-in-rethrow.test.ts:46 | implementation_detail | Documents a known limitation |
| Valid: alias + cause passes | require-error-cause-in-rethrow.test.ts:48 | behavioral_contract | None |
| Invalid: aliased catch var detected + auto-fix | require-error-cause-in-rethrow.test.ts:152 | behavioral_contract | None |

Verdict

Passed. 25% implementation tests (threshold: 30%). The four new scenarios cleanly guard the alias-detection feature: two boundary non-flagging cases, one valid-alias-with-cause happy path, and one core detection case that also validates the auto-fix suggestion output.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

🧪 Test quality analysis by Test Quality Sentinel · 35 AIC · ⌖ 13.8 AIC · ⊞ 6.8K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 83/100 — Excellent. 25% implementation tests (threshold: 30%).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: eslint-factory — catch-variable alias detection

The changes are correct, well-scoped, and conservative. Core logic is sound:

  • collectCatchAliases — correctly limits alias collection to top-level const declarations with a direct catchParam initializer. Unrelated initializers and let/reassigned variables are intentionally excluded.
  • findTrackedIdentifierReference — cleanly refactored from boolean to name-returning. Correctly covers LogicalExpression and ConditionalExpression, closing the real-world miss described in the PR.
  • causeIdentifier propagation — using the actual referenced identifier in error messages and fix suggestions produces accurate autofix output.
  • Tests — cover the four key scenarios (unrelated alias valid, let/reassigned not tracked valid, alias+cause valid, alias+logical missing cause invalid with correct fix).

Two non-blocking suggestions left as inline comments.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 110.6 AIC · ⌖ 5.96 AIC · ⊞ 4.8K

function collectCatchAliases(catchBody: TSESTree.BlockStatement, catchVarName: string): Set<string> {
const aliases = new Set<string>();
for (const statement of catchBody.body) {
if (statement.type !== AST_NODE_TYPES.VariableDeclaration || statement.kind !== "const") continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: collectCatchAliases only walks top-level const declarations — aliases declared in nested blocks (if, nested try, for) are silently ignored. This conservative boundary is correct, but a short comment explaining the intent would prevent future contributors from inadvertently widening the scope. Consider:\n\nts\n// Only tracks const aliases at the top level of the catch block;\n// aliases inside nested blocks are intentionally not followed.\n\n\n@copilot please address this.

return findTrackedIdentifierReference(node.right, trackedNames);
}
if (node.type === AST_NODE_TYPES.ConditionalExpression) {
const testMatch = findTrackedIdentifierReference(node.test, trackedNames);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: The ConditionalExpression handler traverses all three sub-expressions (test, consequent, alternate). Scanning test means a condition like err ? ... : ... would return err as the causeIdentifier, but the actual error object itself doesn't appear in the message text — only in the guard. This could produce a slightly misleading suggestion in rare patterns. Consider restricting traversal to consequent and alternate only, mirroring the semantics of LogicalExpression (where only the values, not the condition, matter for the message). If the intent is intentionally broad, a comment explaining why test is included would help.\n\n@copilot please address this.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: two correctness issues need addressing before merge

The alias-tracking and expression-traversal additions are directionally correct, but the alias resolution is done by name only (no scope analysis), which creates a false positive when an inner-block const shadows the registered alias name. This can cause the rule to insert a wrong { cause: ... } suggestion that references a completely different object.

Blocking issues
  1. Alias shadowing → false positive (line ~89): collectCatchAliases registers a name globally for the entire catch scope. findTrackedIdentifierReference then matches that name anywhere in the throw message, including identifiers bound by inner-block const declarations that shadow the alias. The resulting suggestion inserts { cause: innerErr } when innerErr is not the caught error. Fix: use ESLint scope analysis to verify binding origin, or at minimum add a test case that proves the boundary is known.

  2. Nested-block alias → false negative (line ~83): Aliases declared inside if/for/while blocks within the catch body are never collected. If intentional (conservative design to avoid the scope-collision problem), document it with a comment so future maintainers don't "fix" it and reintroduce the collision.

Non-blocking observations
  • trackedNames is constructed even when catchVarName is "" (unreachable today due to the guard on line 189, but fragile). A comment or an assertion would make the invariant explicit.
  • UnaryExpression nodes (e.g., ${typeof err}, ${!err}) are not traversed by findTrackedIdentifierReference. This is a false negative edge-case, not a correctness regression (the old code had the same gap).

🔎 Code quality review by PR Code Quality Reviewer · 145.4 AIC · ⌖ 6.75 AIC · ⊞ 5.4K
Comment /review to run again

if (!decl.init || decl.init.type !== AST_NODE_TYPES.Identifier) continue;
if (decl.init.name !== catchVarName) continue;
if (decl.id.name === catchVarName) continue;
aliases.add(decl.id.name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive: inner-block const shadowing a registered alias causes the rule to fire on unrelated variables.

collectCatchAliases scans only the top-level const alias = catchVar declaration and adds the alias name to trackedNames. But findTrackedIdentifierReference then matches that name anywhere in the throw message — including in expressions that reference a different inner-block const that shadows the alias.

💡 Concrete false positive example
try { doSomething(); } catch (deleteError) {
  const err = deleteError;           // ← 'err' registered as alias of deleteError
  if (recoverable) {
    const err = new Error('fallback'); // ← inner-block shadow — different variable
    throw new Error(`Fallback: ${err}`); // ← rule incorrectly flags this
  }
}

The rule reports missingCause and suggests { cause: err }, but the err in the inner block is new Error('fallback'), not deleteError. There is no missing cause here.

The correct fix requires scope analysis: use ESLint's scope API to verify that the identifier's resolved binding actually points to the aliased declaration, not a shadowing one. Alternatively, document this gap with an explicit test case.

if (!isInsideCatchBody(node)) return;

const catchVarName = frame.varName;
const trackedNames = new Set<string>([catchVarName, ...frame.aliases]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty-string catchVarName is included in trackedNames, so any identifier named "" (impossible in practice, but also any future empty-sentinel path) would match legitimate identifiers — more importantly, every call with an empty-catch frame includes "" in the set, which is a wasted allocation.

More concretely: frame.varName is """" when the catch has no binding (catch {}or destructured). Line 189 already guards withif (!frame || !frame.varName) return;before reaching this line, so the empty-string-in-set is unreachable at runtime. But it is **fragile**: any future reordering of that guard would silently maketrackedNamescontain""`, which would match nothing harmful today but is not obviously correct.

💡 Suggested fix

Build the set only when catchVarName is non-empty, which is already guaranteed by the guard above. A comment noting the invariant would be clearest:

// frame.varName is guaranteed non-empty by the guard above.
const trackedNames = new Set<string>([catchVarName, ...frame.aliases]);

Or assert it explicitly:

if (!catchVarName) return; // defensive guard (already checked via frame.varName)

This documents the invariant and makes future refactors safe.

function collectCatchAliases(catchBody: TSESTree.BlockStatement, catchVarName: string): Set<string> {
const aliases = new Set<string>();
for (const statement of catchBody.body) {
if (statement.type !== AST_NODE_TYPES.VariableDeclaration || statement.kind !== "const") continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

collectCatchAliases only scans top-level const declarations — aliases declared inside nested blocks (if, for, try) inside the catch body are silently missed, creating a false negative.

💡 False negative example
try { doSomething(); } catch (deleteError) {
  if (condition) {
    const err = deleteError; // ← inside nested block, NOT at top-level
    throw new Error(`Failed: ${err}`); // ← rule does NOT flag this, but it should
  }
}

The check for (const statement of catchBody.body) iterates only the top-level statements of the BlockStatement. A const alias inside an if/for/while block body is never visited.

Whether this is intentional or not is worth documenting. If intentional (conservative design), a code comment should say so. If not intentional, the scan needs to recurse into nested blocks — though that significantly broadens what "alias" means and re-introduces the scope-collision problem noted in the other comment.

@pelikhan

pelikhan commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, make sure the branch is up to date with the base branch, and rerun checks.

Generated by 👨‍🍳 PR Sous Chef · 5.04 AIC · ⌖ 6.87 AIC · ⊞ 4.7K ·
Comment /souschef to run again

… verification

- Split `{{catchVar}}` into `{{catchParam}}` (catch clause name) and `{{refName}}`
  (the referenced identifier), so diagnostics like "inside catch (deleteError)"
  remain accurate when the reference is an alias `err`.
- Track alias `VariableDeclarator` nodes (Map instead of Set) and store
  `catchNode: CatchClause` in each frame so bindings can be verified.
- `verifyIdentifier` walks up the scope chain via `sourceCode.getScope()` and
  confirms the resolved `Variable.defs` points to the recorded catch-clause param
  or alias declarator, preventing false positives from shadowed names in inner blocks.
- Update tests: use `{ catchParam, refName }` data keys; add valid case for
  shadowed alias in nested block that must not be flagged.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-finisher skill

Done. Both review threads are addressed in the latest commit:

  1. Message placeholder precision (lines 212-220): Split {{catchVar}} into {{catchParam}} (the catch clause binding, e.g. deleteError) and {{refName}} (the identifier actually referenced, e.g. err). Diagnostics now correctly read "inside catch (deleteError) references err".

  2. Scope-safe binding verification (lines 80-90): CatchFrame.aliases now maps alias names to their VariableDeclarator nodes (instead of a Set<string>), and catchNode: CatchClause is stored in the frame. A verifyIdentifier closure walks sourceCode.getScope() up the scope chain to confirm each matched identifier resolves to the actual catch-parameter or alias declarator — preventing false positives from shadowed names in nested blocks (e.g., { const err = getFallbackError(); throw new Error(String(err)); } is now correctly ignored).

A test case for the shadowed alias scenario was added. All 159 tests pass.

Copilot AI commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, make sure the branch is up to date with the base branch, and rerun checks.

...

pr-finisher execution complete:

  • Reviews — Review threads from @copilot-pull-request-reviewer were addressed in commit df8761c (message placeholder split, scope-safe alias binding verification). Cannot verify resolution status via API (403 errors), but code changes are confirmed in place.
  • Checks (local) — All local validation passed: make fmt ✅, make lint ✅ (0 errors, 40 pre-existing warnings), make test-unit ✅, eslint-factory tests ✅ (159/159), make recompile ✅, make agent-report-progress
  • Checks (CI) — Stale after agent push. Agent cannot re-trigger CI; workflows will not run until a maintainer manually re-triggers (close/reopen PR, workflow_dispatch, or push).
  • Mergeable — Branch is up to date with main (0 commits behind, merge base afad433)

Actions taken: Validated all local checks, confirmed review fixes from df8761c are in place.

Hand-off: CI must be re-triggered by a maintainer (close/reopen PR, workflow_dispatch, or push from maintainer) before merge. All local validation is green and ready for CI confirmation.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🤖 PR Triage · Run §28868091227

Field Value
Category bug
Risk Low
Score 80 / 100
Action auto_merge

Score breakdown: Impact 40 · Urgency 25 · Quality 15

Rationale: Fixes require-error-cause-in-rethrow rule to detect catch-variable aliases and logical-expression references. Small footprint (+125/-33, 2 files), CI passing (skipped+success), bot-APPROVED (github-actions APPROVED). High-quality lint correctness fix — ready for auto_merge.

Labels: pr-type:bug pr-risk:low pr-priority:high pr-action:auto_merge

Generated by 🔧 PR Triage Agent · 119.4 AIC · ⌖ 6.65 AIC · ⊞ 5.4K ·

@pelikhan pelikhan merged commit 0121fe6 into main Jul 7, 2026
7 checks passed
@pelikhan pelikhan deleted the copilot/eslint-factory-fix-require-error-cause branch July 7, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

4 participants