Skip to content

eslint: no-core-error-then-process-exitcode — forward scan + top-level autofix - #47240

Merged
pelikhan merged 3 commits into
mainfrom
copilot/eslint-factory-fix-forward-scan
Jul 22, 2026
Merged

eslint: no-core-error-then-process-exitcode — forward scan + top-level autofix#47240
pelikhan merged 3 commits into
mainfrom
copilot/eslint-factory-fix-forward-scan

Conversation

Copilot AI commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

no-core-error-then-process-exitcode only checked the immediately adjacent statement after core.error(), silently missing the pattern when any intervening statement appeared. It also lacked the module-top-level autofix present in the sibling no-core-error-then-process-exit rule.

Detection

Rewrites checkStatements to scan forward from core.error() (inner for j = i+1 loop), stopping at core.setFailed() or any control-transfer statement — now catching:

core.error("deploy failed");
core.info("cleaning up");    // intervening statement no longer defeats detection
process.exitCode = 1;        // ← now flagged

Added isCoreSetFailedStatement and isControlTransferStatement helpers (copied from sibling). New valid cases: core.setFailed or return/throw/break between the pair stops the scan.

Autofix

safeToFix now includes enclosingFn === null (module top level), mirroring the sibling. At top level the fixer emits core.setFailed(msg); without return;; inside main() it appends return; as before. Non-adjacent pairs never get autofix.

Program-level export barrier

The Program handler segments the body at export/import declarations before calling checkStatements, so an export { … } between core.error and process.exitCode still suppresses the report (existing behavior preserved).

Tests

  • New invalid: core.error("x"); core.info("y"); process.exitCode = 1; (non-adjacent, no suggestion)
  • New valid: core.error("x"); core.setFailed("y"); process.exitCode = 1; and core.error("x"); return; process.exitCode = 1;
  • Updated: module-top-level and switch-case invalid cases now assert the autofix suggestion output

…scan + top-level autofix

- Add isCoreSetFailedStatement and isControlTransferStatement helpers
- Rewrite checkStatements to scan forward from core.error() (like sibling rule)
- Stop scan at core.setFailed() or control-transfer statements (return, throw, break, etc.)
- Non-adjacent pairs report without autofix suggestion
- Add module top-level autofix: enclosingFn === null → core.setFailed(args); no return;
- Update Program handler to split body at export/import declarations (preserving export-barrier behavior)
- Update rule docs to describe forward-scan semantics
- Add new valid tests: setFailed-stops-scan, control-transfer-stops-scan
- Add new invalid tests: non-adjacent pair, two-intervening-statements
- Update module-top-level and switch-case invalid tests to include autofix suggestions

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix detection issue with non-adjacent forward scan in ESLint rule eslint: no-core-error-then-process-exitcode — forward scan + top-level autofix Jul 22, 2026
Copilot AI requested a review from pelikhan July 22, 2026 08:36
@pelikhan
pelikhan marked this pull request as ready for review July 22, 2026 08:37
Copilot AI review requested due to automatic review settings July 22, 2026 08:37

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

Extends the ESLint rule to detect non-adjacent failure patterns and provide safe top-level fixes.

Changes:

  • Adds forward scanning with control-transfer barriers.
  • Enables module-level autofix suggestions.
  • Expands tests for scanning and autofixes.
Show a summary per file
File Description
no-core-error-then-process-exitcode.ts Implements scanning, barriers, and autofixes.
no-core-error-then-process-exitcode.test.ts Tests detection and suggestion behavior.

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: 1
  • Review effort level: Medium

Comment on lines +40 to +41
// throw between error and exitCode stops scanning
`function run() { core.error("x"); throw new Error("x"); process.exitCode = 1; }`,
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

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

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

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 92/100 — Excellent

Analyzed 11 test case(s): 11 design, 0 implementation, 0 violation(s).

📊 Metrics (11 test cases analyzed)
Metric Value
Analyzed 11 cases (all TypeScript/vitest)
✅ Design contracts 11 (100%)
⚠️ Implementation details 0 (0%)
Edge/error coverage 11 (100%)
Duplicate clusters 0
Test inflation 0.20:1 (↓23 test lines vs ↑114 prod lines) — ✅ Healthy
🚨 Violations 0
Test Case Category Coverage
core.error + process.exitCode at module level Behavioral Pattern detection + safe autofix
core.error + process.exitCode in main() Behavioral Function scope + autofix with return
core.error + process.exitCode in helper() Behavioral Unsafe autofix detection (no suggestion)
core.error + process.exitCode in switch case Behavioral Safe autofix in switch (module level)
core.setFailed stops scan Behavioral Scan termination logic
return stops scan (inside function) Behavioral Control transfer detection
throw stops scan Behavioral Control transfer detection
Non-adjacent pair (intervening statement) Behavioral Forward scanning (no autofix)
Two intervening statements Behavioral Multi-statement forward scan
Template literals in message Behavioral Message preservation during autofix
Multiple arguments (unsafe autofix) Behavioral Autofix safety (core.error has multiple args)
✨ Highlights

Coverage Summary:

  • ✅ Pattern detection across all scopes (module, main(), helper functions, switch cases)
  • ✅ Forward scanning with proper termination at control-transfer statements (return, throw, process.exit)
  • ✅ Scan termination at core.setFailed() boundary
  • ✅ Safe vs unsafe autofix distinction (adjacent + safe scope vs. non-adjacent or helper functions)
  • ✅ Edge cases: template literals, multiple arguments, intervening statements, exported functions, async

No Violations: Test file structure is correct (vitest with ESLint RuleTester), all assertions explicit, no mocks, no happy-path-only bias.

Verdict

Passed. 100% design tests (threshold: ≤30% implementation). Test suite comprehensively validates the forward-scan logic and autofix safety without overreaching. Well-calibrated test growth (0.20:1 ratio).

🧪 Test quality analysis by Test Quality Sentinel · haiku45 21.6 AIC · ⌖ 7.81 AIC · ⊞ 7K ·
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: 92/100. 100% design tests (threshold: 30% implementation). Test suite comprehensively validates the forward-scan logic and autofix safety without overreaching.

@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.

REQUEST_CHANGES — two medium-severity correctness gaps

The forward-scan rewrite is a solid improvement, but two issues need fixing before merge:

  1. Duplicate reports on consecutive calls (line 222) — multiple before one each trigger a report, producing conflicting autofix suggestions on the same node. Needs deduplication or equivalent.

  2. ** not recognized as a control-transfer barrier** (line 124) — inconsistency with which handles both computed and non-computed forms; causes false-positive reports when appears between the pair.

Other observations (non-blocking)
  • and test coverage gap (flagged in existing review comment #3628789116): the tests cover and but not // as barriers.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 43.5 AIC · ⌖ 4.93 AIC · ⊞ 5.6K
Comment /review to run again


// Stop scanning if setFailed already handles the failure or a control-transfer exits the block.
if (isCoreSetFailedStatement(candidate, sourceCode) || isControlTransferStatement(candidate)) {
break;

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.

Consecutive core.error calls produce duplicate reports on the same process.exitCode node: two core.error(...) statements before one process.exitCode = 1 will both report, generating two lint errors for a single assignment — and if both qualify for autofix, two conflicting fixers target the same node.

💡 Details and suggested fix

Example that currently produces two separate reports:

core.error("a");
core.error("b");
process.exitCode = 1;  // reported by BOTH i=0 and i=1

When i=0 finds the process.exitCode at j=2 (non-adjacent, no autofix), it breaks and the outer loop continues to i=1. i=1 then finds the same process.exitCode at j=2 (adjacent, now autofix-eligible), generating a second report on an already-reported node with a conflicting fixer.

Simplest fix — deduplicate by tracking already-reported process.exitCode nodes:

function checkStatements(stmts: readonly TSESTree.Statement[]): void {
  const reported = new WeakSet<TSESTree.Statement>();
  for (let i = 0; i < stmts.length - 1; i++) {
    ...
    if (isProcessExitCodeNonZero(candidate)) {
      if (!reported.has(candidate)) {
        reported.add(candidate);
        context.report({ ... });
      }
      break;
    }
  }
}

// process.exit(...) — any call, regardless of exit code
if (node.type === AST_NODE_TYPES.ExpressionStatement && node.expression.type === AST_NODE_TYPES.CallExpression) {
const callee = node.expression.callee;
if (

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.

isControlTransferStatement does not handle computed process["exit"]() calls: isCoreSetFailedStatement explicitly handles both computed and non-computed access, but isControlTransferStatement only matches process.exit(...), missing process["exit"](1).

💡 Details and suggested fix
// Currently only catches:
process.exit(1)

// Misses:
process["exit"](1)

This is an inconsistency with isCoreSetFailedStatement which handles both forms. A process["exit"]() between core.error and process.exitCode will not stop the scan, producing a false-positive report.

Fix: add the computed check, mirroring isCoreSetFailedStatement:

if (
  callee.type === AST_NODE_TYPES.MemberExpression &&
  callee.object.type === AST_NODE_TYPES.Identifier &&
  callee.object.name === "process" &&
  callee.property.type === AST_NODE_TYPES.Identifier &&
  callee.property.name === "exit" &&
  !callee.computed
) { ... }
// Add:
if (
  callee.type === AST_NODE_TYPES.MemberExpression &&
  callee.object.type === AST_NODE_TYPES.Identifier &&
  callee.object.name === "process" &&
  callee.computed &&
  callee.property.type === AST_NODE_TYPES.Literal &&
  callee.property.value === "exit"
) { return true; }

@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: forward scan + top-level autofix

The forward-scan logic and top-level autofix are a solid improvement. Program segmentation at export boundaries is correct, and the barrier helpers are clean.

One blocking correctness issue (see inline):

When two core.error(...) statements both precede process.exitCode = nonzero, the outer loop visits each independently — the inner scan finds the same process.exitCode node each time — producing duplicate diagnostics on the same source location. Fix by tracking reported process.exitCode nodes in a Set, or advancing i past the matched index after reporting.

The existing comment about missing barrier-branch tests (break, continue, process.exit) is also worth addressing.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 44.8 AIC · ⌖ 4.6 AIC · ⊞ 5K


// Scan forward for process.exitCode = nonzero, stopping at setFailed or control-transfer.
// Adjacent (j === i+1) keeps autofix; non-adjacent reports without suggestion.
for (let j = i + 1; j < stmts.length; j++) {

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.

Potential duplicate reports when two core.error calls precede process.exitCode.

The inner for j loop means every core.error in the segment independently scans forward and finds the same process.exitCode node. For input like:

core.error("a"); core.error("b"); process.exitCode = 1;

both i=0 and i=1 hit isProcessExitCodeNonZero(candidate) and report() fires twice on the same node, producing two diagnostics.

Consider tracking already-reported process.exitCode nodes in a Set and skipping if already reported, or break-ing the outer loop after a match is consumed.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 069ebdb. Added a reported = new WeakSet<TSESTree.Statement>() inside checkStatements; when the inner loop finds a process.exitCode node that's already in the set, it skips reporting and breaks. This prevents the second core.error from generating a conflicting diagnostic on the same node.

@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.

Skills-Based Review

Applied /tdd and /codebase-design — no blocking issues, but a few things to address.

📋 Key Themes & Highlights

Key Themes

  • Multi-core.error scenario untested: Two consecutive core.error() calls sharing the same process.exitCode node will both produce reports; the adjacent one applies an autofix that removes the exitCode, while the outer one (non-adjacent) is left with no actionable path. The behaviour should be tested and, if needed, guarded with a claimed-node set.
  • break/continue/process.exit stop-conditions untested: An existing comment (from a previous reviewer) correctly flags that these branches of isControlTransferStatement lack test coverage.

Positive Highlights

  • Forward-scan rewrite is clean and correctly mirrors the sibling rule.
  • Context-aware autofix (module top-level vs. inside main()) is a solid improvement.
  • Program-body segmentation at export/import boundaries preserves existing behaviour with a clear comment explaining why.
  • Helper names (isCoreSetFailedStatement, isControlTransferStatement) are consistent with the sibling rule — good domain coherence.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 44.7 AIC · ⌖ 4.9 AIC · ⊞ 6.7K
Comment /matt to run again


// Scan forward for process.exitCode = nonzero, stopping at setFailed or control-transfer.
// Adjacent (j === i+1) keeps autofix; non-adjacent reports without suggestion.
for (let j = i + 1; j < stmts.length; j++) {

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.

[/tdd] Two consecutive core.error() calls before the same process.exitCode will each produce their own report — the adjacent one gets an autofix that removes the exitCode, potentially leaving the first error call in an inconsistent state.

💡 Scenario + suggested fix
core.error("a");
core.error("b");
process.exitCode = 1;

The outer i loop reports twice: core.error("a") (non-adjacent, no autofix) and core.error("b") (adjacent, autofix that removes the exitCode). After applying the second suggestion, the first report can no longer be autofixed and leaves core.error("a") without any fix path.

Track which exitCode nodes have already been claimed, or add a test that documents the intended behavior for this pattern:

const claimedExitCodes = new Set<TSESTree.Statement>();
// inside the j loop, before reporting:
if (claimedExitCodes.has(candidate)) break;
claimedExitCodes.add(candidate);

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 069ebdb. Added the reported WeakSet deduplication — for core.error("a"); core.error("b"); process.exitCode = 1;, only one error is now reported (from the first core.error, non-adjacent, no autofix), and the second core.error's claim on the same exitCode node is suppressed.

break;
}

// Stop scanning if setFailed already handles the failure or a control-transfer exits the block.

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.

[/tdd] The autofix fixer removes candidate (the process.exitCode node), but when the pair is non-adjacent the break fires without a suggestion — however nothing prevents a future code path from calling fixer.remove(candidate) on the same node twice if the outer i loop reaches another core.error that is also adjacent to the same exitCode node. Adding a test with two separate adjacent core.error → same process.exitCode would lock down this invariant.

💡 Test scaffold
{
  // Two core.error() calls both adjacent to the same process.exitCode
  // — expected: one report per core.error(), only the closest one gets autofix
  code: `core.error("a"); core.error("b"); process.exitCode = 1;`,
  errors: [
    { messageId: "noCoreErrorThenProcessExitCode", suggestions: [] },
    { messageId: "noCoreErrorThenProcessExitCode", suggestions: [{ messageId: "replaceWithSetFailed", output: "..." }] },
  ],
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added in 069ebdb. The new invalid test case in the test file documents the deduplication invariant: core.error("a"); core.error("b"); process.exitCode = 1; produces exactly one error (from core.error("a"), non-adjacent, no autofix), preventing conflicting fixers on the same node.

function isControlTransferStatement(node: TSESTree.Statement): boolean {
// prettier-ignore
if (
node.type === AST_NODE_TYPES.ReturnStatement ||

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.

[/codebase-design] isCoreSetFailedStatement accepts sourceCode: SourceCode solely for the alias check, while isControlTransferStatement takes no such parameter. The asymmetry is fine functionally, but a brief comment noting why the signatures differ (alias resolution vs. pure syntax) would help the next reader who wonders if the omission was intentional.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 069ebdb. Updated the JSDoc for isCoreSetFailedStatement to note: "Accepts sourceCode for alias resolution via isCoreAliasIdentifier; contrast with isControlTransferStatement which is a pure syntax check and needs no source-code context."

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

…it], add test coverage for all barriers

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
@pelikhan
pelikhan merged commit b3dac30 into main Jul 22, 2026
@pelikhan
pelikhan deleted the copilot/eslint-factory-fix-forward-scan branch July 22, 2026 11:15
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.83.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[eslint-factory] no-core-error-then-process-exitcode: match sibling's non-adjacent forward scan (+ top-level autofix)

3 participants