Skip to content

[eslint-miner] eslint: add no-core-error-then-process-exitcode rule - #47054

Merged
pelikhan merged 3 commits into
mainfrom
eslint-miner/no-core-error-then-process-exitcode-4e52a7822d92bdee
Jul 21, 2026
Merged

pelikhan merged 3 commits into
mainfrom
eslint-miner/no-core-error-then-process-exitcode-4e52a7822d92bdee

Conversation

@github-actions

@github-actions github-actions Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a new ESLint rule gh-aw-custom/no-core-error-then-process-exitcode to the eslint-factory plugin. The rule flags the anti-pattern core.error(msg); process.exitCode = nonzero in GitHub Actions scripts and guides authors toward core.setFailed(msg), which correctly marks the action as failed and allows post-action cleanup hooks to run.

This is a sibling to the existing no-core-error-then-process-exit rule, targeting process.exitCode = nonzero assignments rather than process.exit(1) calls. Unlike process.exit(1), setting process.exitCode does not immediately halt execution — subsequent code continues running in a failed state.

Changes

File Change
eslint-factory/src/rules/no-core-error-then-process-exitcode.ts New rule implementation (168 lines)
eslint-factory/src/rules/no-core-error-then-process-exitcode.test.ts Comprehensive test suite (93 lines)
eslint-factory/src/index.ts Rule import and registration
eslint-factory/eslint.config.cjs Rule enabled at warn severity

Rule Behaviour

Flagged pattern:

core.error("fatal"); process.exitCode = 1;

Preferred replacement:

core.setFailed("fatal"); return;

Autofix scope (suggestion only): Available when the pattern appears inside a top-level main() function (named main at program scope, including export async function main) with a single non-spread argument. For other contexts (module scope, helper functions, SwitchCase branches, or multi-argument calls) the rule reports without a suggestion fix.

Valid patterns not flagged:

  • core.error(msg); process.exitCode = 0 — clean exit, not an error signal
  • process.exitCode = 1 without a preceding core.error — unrelated assignment
  • core.warning(msg); process.exitCode = 1 — non-error log methods
  • core.error(msg); process.exitCode = code — runtime value, unknown at lint time
  • core.error(msg); process.exit(1) — covered by the sibling rule
  • core.error(msg); process.exitCode += 1 — compound assignment operator

Detection

The rule uses @typescript-eslint/utils AST traversal across BlockStatement, SwitchCase, and Program node visitors. It resolves core aliases via the shared CORE_ALIASES set and isCoreAliasIdentifier helper, consistent with other rules in the plugin.

Generated by PR Description Updater for #47054 · sonnet46 39.6 AIC · ⌖ 4.59 AIC · ⊞ 4.7K · ◷

Detects the pattern core.error(msg) followed immediately by
process.exitCode = nonzero. This is a sibling to the existing
no-core-error-then-process-exit rule and closes the gap where
mcp_cli_bridge.cjs (and potentially new code) uses process.exitCode
assignment instead of process.exit().

Unlike process.exit(), process.exitCode = 1 does NOT halt execution
immediately — subsequent code continues to run in the failed state.
Prefer core.setFailed() which both marks the action as failed and
allows post-action cleanup hooks to run.

Observed in: mcp_cli_bridge.cjs (4 instances at lines 963, 1204,
1230, 1243, 1361, 1372).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added automation cookie Issue Monster Loves Cookies! eslint labels Jul 21, 2026
@pelikhan
pelikhan marked this pull request as ready for review July 21, 2026 09:59
Copilot AI review requested due to automatic review settings July 21, 2026 09:59
@github-actions

github-actions Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor Author

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

@github-actions

github-actions Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ 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 (0 additions detected).

@github-actions

github-actions Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor Author

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

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

Adds an ESLint rule detecting core.error() immediately followed by non-zero process.exitCode assignment.

Changes:

  • Implements and tests the new rule.
  • Registers and enables it as a warning.
Show a summary per file
File Description
eslint-factory/src/rules/no-core-error-then-process-exitcode.ts Implements detection and suggestions.
eslint-factory/src/rules/no-core-error-then-process-exitcode.test.ts Adds valid and invalid cases.
eslint-factory/src/index.ts Registers the rule.
eslint-factory/eslint.config.cjs Enables the rule.

Review details

Tip

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

Comments suppressed due to low confidence (1)

eslint-factory/src/rules/no-core-error-then-process-exitcode.ts:139

  • The matcher accepts any core.error(...) argument list, but core.error(message, properties) supports annotation metadata while core.setFailed(message) accepts only the message. Passing both arguments to setFailed silently drops file/line/title annotation properties. Only offer this suggestion when there is exactly one non-spread message argument; multi-argument calls can still be reported without an automatic suggestion, with a regression test for that case.
                      const errorCall = (current as TSESTree.ExpressionStatement).expression as TSESTree.CallExpression;
                      const args = errorCall.arguments.map(a => sourceCode.getText(a)).join(", ");
                      const callee = errorCall.callee as TSESTree.MemberExpression;
                      const objectName = sourceCode.getText(callee.object);
                      const replacement = enclosingFn !== null ? `${objectName}.setFailed(${args}); return;` : `${objectName}.setFailed(${args});`;
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Medium

const next = stmts[i + 1];
if (isCoreErrorStatement(current, sourceCode) && isProcessExitCodeNonZero(next)) {
const enclosingFn = getImmediateEnclosingFunction(current, sourceCode);
const safeToFix = enclosingFn === null || isFunctionNamedMain(enclosingFn);

@github-actions github-actions Bot left a comment

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.

The no-core-error-then-process-exitcode rule is well-structured and mirrors the sibling no-core-error-then-process-exit rule closely. The logic for identifying core.error + process.exitCode = nonzero pairs, the safe-to-fix heuristic (main() or top-level only), the fix generation, and the test coverage all look correct. No blocking issues found.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 19.8 AIC · ⌖ 4.35 AIC · ⊞ 4.9K

@github-actions

Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

✅ Test Quality Score: 100/100 — Excellent

Analyzed 1 test: 1 design, 0 implementation, 0 violations.

📊 Metrics (1 test)
Metric Value
Analyzed 1 (TypeScript/ESLint: 1)
✅ Design 1 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 14 scenarios (valid + invalid cases)
Duplicate clusters 0
Inflation 0.56:1 (excellent)
🚨 Violations 0
Test File Classification Issues
valid and invalid cases eslint-factory/src/rules/no-core-error-then-process-exitcode.test.ts:12 Design / Behavioral contract None

Test Details

valid and invalid cases — Uses ESLint RuleTester to verify the rule behavior across 14 comprehensive scenarios:

  • Valid cases (9): core.setFailed (correct pattern), exitCode=0 (clean exit), no error call, exitCode without error, non-core object, wrong method (warning vs error), variable assignments, process.exit() (covered by sibling rule), operator variations (+=)
  • Invalid cases (5): Simple pattern violation, different message text, template literals, function scope sensitivity (named helper vs main), exit code variations (1, 2)
  • Edge cases: Autofix suggestions differ by context (safe for main(), unsafe for named helpers); async functions handled; core aliases checked

Quality signals:

  • All assertions are behavioral (error messages, counts, autofix output verified)
  • Test inflation 0.56:1 (excellent — well below 2:1 threshold)
  • No mocks or implementation-leaking patterns
  • 100% of assertions verify user-facing behavior, not internal implementation details

Verdict

✅ Passed. Test Quality Score: 100/100. 0% implementation tests (threshold: 30%). This is exemplary ESLint rule test coverage — comprehensive, focused, and properly weighted.

🧪 Test quality analysis by Test Quality Sentinel · 14.1 AIC · ⌖ 16.4 AIC · ⊞ 7K · ◷
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

✅ Test Quality Sentinel: 100/100. 0% implementation tests (threshold: 30%). Exemplary ESLint rule test coverage with comprehensive valid/invalid scenarios and excellent edge-case handling.

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /tdd — requesting changes on three correctness gaps.

📋 Key Themes & Highlights

Issues Found

  1. export function main() not treated as safe-to-fix — isFunctionNamedMain checks fn.parent.type === Program but exported declarations have an intermediate ExportNamedDeclaration node, so the autofix suggestion is silently suppressed for the common export async function main() pattern (line 38).

  2. Potential false positive via Program filter — The Program visitor filters out ImportDeclaration / ExportAllDeclaration before running checkStatements. An ExportNamedDeclaration (re-export) interspersed between core.error and process.exitCode = 1 would be removed by the filter, making them appear adjacent and producing a false positive (line 152).

  3. Misleading suggestion message for module-level fixes — The single replaceWithSetFailed messageId says "return;" in the description, but module-level fixes intentionally omit return (which is invalid there). Two message variants would be clearer (line 140).

  4. SwitchCase path lacks test coverage — The SwitchCase visitor exists in the implementation but has no test case exercising it (test file line 91).

Positive Highlights

  • ✅ Clean parallel with the sibling no-core-error-then-process-exit rule — consistent patterns throughout
  • ✅ Good low-false-positive design: only integer literals flagged, zero excluded, variables excluded
  • ✅ Context-aware autofix (safe only at module level or inside main()) is well thought out
  • ✅ Comprehensive test coverage for template literals, async main, and non-main helpers

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 43.1 AIC · ⌖ 5.11 AIC · ⊞ 6.6K
Comment /matt to run again

if (
declarator == null ||
declarator.type !== AST_NODE_TYPES.VariableDeclarator ||
declarator.id.type !== AST_NODE_TYPES.Identifier ||

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.

[/tdd] isFunctionNamedMain does not handle export async function main() {...} — in that case fn.parent.type is ExportNamedDeclaration, not Program, so the autofix suggestion is silently suppressed for exported main functions.

💡 Suggested fix + test

Add a check for the exported case:

if (fn.type === AST_NODE_TYPES.FunctionDeclaration) {
  if (fn.id?.name !== "main") return false;
  if (fn.parent?.type === AST_NODE_TYPES.Program) return true;
  // export function main() {}
  return (
    fn.parent?.type === AST_NODE_TYPES.ExportNamedDeclaration &&
    fn.parent.parent?.type === AST_NODE_TYPES.Program
  );
}

Add a test case:

{
  code: `export async function main() { core.error("fatal"); process.exitCode = 1; }`,
  errors: [{ messageId: "noCoreErrorThenProcessExitCode",
             suggestions: [{ messageId: "replaceWithSetFailed" }] }],
}

@copilot please address this.


return {
BlockStatement(node: TSESTree.BlockStatement) {
checkStatements(node.body);

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.

[/tdd] The Program visitor filters out ImportDeclaration and ExportAllDeclaration but not ExportNamedDeclaration. If someone writes export const x = 1; core.error("msg"); process.exitCode = 1; at the top level, the index after filtering may not align with the original body indices — the filter creates a new array, so stmts[i] and stmts[i+1] are now the correct adjacent statements within the filtered list. However, process.exitCode = 1 wrapped in ExportNamedDeclaration (e.g. export { process }) is a separate concern. More critically: if there are ExportNamedDeclarations (re-exports) interspersed, the filter removes them and two formerly non-adjacent statements may appear adjacent. This could produce false positives.

💡 Example false positive
core.error("msg");
export { helper }; // this is filtered out
process.exitCode = 1;

After filtering, core.error("msg") and process.exitCode = 1 appear adjacent and get flagged, even though they are not literally adjacent in source.

Consider using a different strategy: traverse the unfiltered body but skip non-statement nodes, or look backward from the process.exitCode statement for the nearest real statement.

@copilot please address this.

const callee = errorCall.callee as TSESTree.MemberExpression;
const objectName = sourceCode.getText(callee.object);
const replacement = enclosingFn !== null ? `${objectName}.setFailed(${args}); return;` : `${objectName}.setFailed(${args});`;
return [fixer.replaceText(current, replacement + "\n"), fixer.remove(next)];

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.

[/tdd] The autofix for the module-level (no enclosing function) case omits return;. In mcp_cli_bridge.cjs the target locations are at module top-level, and after replacing with core.setFailed(msg) only, execution continues. The PR description says the fix is core.setFailed(msg); return;, but the fixer code branches: enclosingFn !== null ? ... return; : ...setFailed(msg) — the return; is intentionally dropped for module-level. However at module top-level return is not valid JS, so this is correct, but the test outputs for the top-level case (lines 36-47 in test file) show no return; either. The inconsistency between the description ("Replace with core.setFailed(msg); return;") and the actual fix (no return at module level) may confuse users. The suggestion message should be split into two variants.

💡 Suggestion

Add a second messageId:

messages: {
  replaceWithSetFailed: "Replace with `core.setFailed(msg); return;`.",
  replaceWithSetFailedNoReturn: "Replace with `core.setFailed(msg)` (module-level — `return` is not valid here).",
}

And use the appropriate one in the fix based on enclosingFn.

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

[/tdd] Missing test coverage for the SwitchCase visitor. The rule handles switch-case bodies via checkStatements(node.consequent), but there is no test case exercising this path. A regression could silently disable detection in switch blocks.

💡 Suggested test case
{
  code: `switch (x) { case 1: core.error("fatal"); process.exitCode = 1; break; }`,
  errors: [{ messageId: "noCoreErrorThenProcessExitCode", suggestions: [] }],
}

(No autofix because the enclosing function is neither null nor main.)

@copilot please address this.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

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

Copilot AI commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

@copilot run pr-finisher skill

Addressed the rule feedback in 8ed7a04. Local validation passed (make fmt, make lint, make test-unit, make test, and make agent-report-progress). CI on this head is still unverified until a maintainer re-triggers it.

Copilot AI requested a review from pelikhan July 21, 2026 12:40
@pelikhan
pelikhan merged commit 608bf31 into main Jul 21, 2026
@pelikhan
pelikhan deleted the eslint-miner/no-core-error-then-process-exitcode-4e52a7822d92bdee branch July 21, 2026 12:43
@github-actions

Copy link
Copy Markdown
Contributor Author

🎉 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

automation cookie Issue Monster Loves Cookies! eslint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants