From 37e642e611aadf0a98258bfe9620fdd7ba7c2bc9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:09:58 +0000 Subject: [PATCH 1/5] Initial plan From 597d9b4633c6b9c6a81154864808a2d47bd89a86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:20:46 +0000 Subject: [PATCH 2/5] fix(eslint-factory): suppress no-core-error-then-process-exit autofix for non-main helper functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suggestion fix replacing `core.error(msg); process.exit(nonzero)` with `core.setFailed(msg); return;` was unsafe inside helper functions: `return` only exits the helper, not the process, silently converting a hard abort into a fall-through that returns `undefined` to the caller. Changes: - Add `getImmediateEnclosingFunction` to find the innermost enclosing function - Add `isFunctionNamedMain` to identify `main()` entrypoints - Suppress the autofix suggestion when the pair is inside any function that is NOT named `main` (i.e., report-only with `suggest: []` for helpers) - Soften the diagnostic message to acknowledge standalone-node contexts - Update test: `function run()` now expects no suggestions - Add tests: value-returning helper → no autofix; `async function main()` and `const main = async () => {}` entrypoints → autofix retained Closes #46540 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../no-core-error-then-process-exit.test.ts | 20 +++- .../rules/no-core-error-then-process-exit.ts | 105 ++++++++++++------ 2 files changed, 92 insertions(+), 33 deletions(-) diff --git a/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts b/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts index e570a56e19f..782bc8ad4f5 100644 --- a/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts +++ b/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts @@ -53,8 +53,26 @@ describe("no-core-error-then-process-exit", () => { errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [{ messageId: "replaceWithSetFailed", output: "core.setFailed(`ERROR: ${message}`);\n " }] }], }, { + // pair inside a non-main function: no autofix because `return` only exits the helper, + // not the process. The `run` name is not the entrypoint. code: `function run() { core.error("oops"); process.exit(1); }`, - errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [{ messageId: "replaceWithSetFailed", output: 'function run() { core.setFailed("oops"); return;\n }' }] }], + errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [] }], + }, + { + // pair inside a value-returning helper: no autofix — `return` would make the helper + // return `undefined` instead of aborting the process (acceptance criterion a). + code: `function requireEnvVar(name) { const value = process.env[name]; if (!value) { core.error(\`ERROR: \${name} required\`); process.exit(1); } return value; }`, + errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [] }], + }, + { + // pair inside async function main() entrypoint: autofix retained (acceptance criterion c). + code: `async function main() { core.error("fatal"); process.exit(1); }`, + errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [{ messageId: "replaceWithSetFailed", output: 'async function main() { core.setFailed("fatal"); return;\n }' }] }], + }, + { + // pair inside const main = async () => {} entrypoint: autofix retained. + code: `const main = async () => { core.error("fatal"); process.exit(1); }`, + errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [{ messageId: "replaceWithSetFailed", output: 'const main = async () => { core.setFailed("fatal"); return;\n }' }] }], }, { // Computed property: core["error"] diff --git a/eslint-factory/src/rules/no-core-error-then-process-exit.ts b/eslint-factory/src/rules/no-core-error-then-process-exit.ts index c0ea3aa593d..eb3bbc7ce8b 100644 --- a/eslint-factory/src/rules/no-core-error-then-process-exit.ts +++ b/eslint-factory/src/rules/no-core-error-then-process-exit.ts @@ -10,6 +10,46 @@ function isCoreLikeIdentifier(name: string): boolean { return CORE_ALIASES.has(name); } +type FunctionNode = TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression; + +/** + * Returns the innermost enclosing function node for `node`, or null when + * `node` is at module top level (not inside any function). + */ +function getImmediateEnclosingFunction(node: TSESTree.Node, sourceCode: SourceCode): FunctionNode | null { + const ancestors = sourceCode.getAncestors(node); + for (let i = ancestors.length - 1; i >= 0; i--) { + const ancestor = ancestors[i]; + if ( + ancestor.type === AST_NODE_TYPES.FunctionDeclaration || + ancestor.type === AST_NODE_TYPES.FunctionExpression || + ancestor.type === AST_NODE_TYPES.ArrowFunctionExpression + ) { + return ancestor as FunctionNode; + } + } + return null; +} + +/** + * Returns true when `fn` is a conventional module entrypoint named `main`: + * - `function main() {}` / `async function main() {}` + * - `const main = function() {}` / `const main = async () => {}` + */ +function isFunctionNamedMain(fn: FunctionNode): boolean { + if (fn.type === AST_NODE_TYPES.FunctionDeclaration) { + return fn.id?.name === "main"; + } + // FunctionExpression or ArrowFunctionExpression assigned to a variable named `main` + const parent = fn.parent; + return ( + parent != null && + parent.type === AST_NODE_TYPES.VariableDeclarator && + parent.id.type === AST_NODE_TYPES.Identifier && + parent.id.name === "main" + ); +} + /** * Returns true when `node` is an expression statement containing a call to * `core.error(...)` (direct, computed, or aliased). @@ -70,12 +110,16 @@ export const noCoreErrorThenProcessExitRule = createRule({ description: "Disallow the pattern `core.error(msg); process.exit(nonzero)` in GitHub Actions scripts. " + "`core.error()` annotates the log but does not mark the action as failed. " + - "Using `process.exit(nonzero)` after it bypasses the proper GitHub Actions failure lifecycle. " + - "Use `core.setFailed(msg); return;` instead so the action is correctly marked as failed and all cleanup hooks run.", + "Prefer `core.setFailed(msg)` which correctly marks the action as failed and allows post-action " + + "cleanup hooks to run. Note: in a standalone `node` script, `process.exit(nonzero)` does fail the " + + "step, but `core.setFailed` is more portable and is still recommended.", }, schema: [], messages: { - noCoreErrorThenProcessExit: "Avoid `core.error()` followed by `process.exit(nonzero)`. Use `core.setFailed(msg); return;` instead to correctly signal action failure without bypassing cleanup hooks.", + noCoreErrorThenProcessExit: + "Avoid `core.error()` followed by `process.exit(nonzero)`. Prefer `core.setFailed(msg)` to signal " + + "action failure; it marks the action failed and allows post-action cleanup hooks to run. " + + "In standalone `node` scripts, `process.exit(nonzero)` does fail the step, but `core.setFailed` is more portable.", replaceWithSetFailed: "Replace `core.error(msg); process.exit(...)` with `core.setFailed(msg); return;`.", }, }, @@ -88,38 +132,35 @@ export const noCoreErrorThenProcessExitRule = createRule({ const current = stmts[i]; const next = stmts[i + 1]; if (isCoreErrorStatement(current, sourceCode) && isProcessExitNonZero(next)) { - // Report on the pair (the core.error call) + // The autofix suggestion is only safe when the pair is at module top level or directly + // inside a `main()` entrypoint. Inside helper functions, `return;` only exits the helper + // and lets the caller continue — it does NOT abort the process like `process.exit` does. + const enclosingFn = getImmediateEnclosingFunction(current, sourceCode); + const safeToFix = enclosingFn === null || isFunctionNamedMain(enclosingFn); + context.report({ node: current, messageId: "noCoreErrorThenProcessExit", - suggest: [ - { - messageId: "replaceWithSetFailed", - fix(fixer: TSESLint.RuleFixer) { - const errorCall = (current as TSESTree.ExpressionStatement).expression as TSESTree.CallExpression; - const args = errorCall.arguments.map(a => sourceCode.getText(a)).join(", "); - - // Detect the core object name (e.g. "core") - const callee = errorCall.callee as TSESTree.MemberExpression; - const objectName = sourceCode.getText(callee.object); - - const isInsideFunction = (() => { - const ancestors = sourceCode.getAncestors(current); - for (let j = ancestors.length - 1; j >= 0; j--) { - const a = ancestors[j]; - if (a.type === AST_NODE_TYPES.FunctionDeclaration || a.type === AST_NODE_TYPES.FunctionExpression || a.type === AST_NODE_TYPES.ArrowFunctionExpression) { - return true; - } - } - return false; - })(); - - const replacement = isInsideFunction ? `${objectName}.setFailed(${args}); return;` : `${objectName}.setFailed(${args});`; - - return [fixer.replaceText(current, replacement + "\n"), fixer.remove(next)]; - }, - }, - ], + suggest: safeToFix + ? [ + { + messageId: "replaceWithSetFailed", + fix(fixer: TSESLint.RuleFixer) { + const errorCall = (current as TSESTree.ExpressionStatement).expression as TSESTree.CallExpression; + const args = errorCall.arguments.map(a => sourceCode.getText(a)).join(", "); + + // Detect the core object name (e.g. "core") + const callee = errorCall.callee as TSESTree.MemberExpression; + const objectName = sourceCode.getText(callee.object); + + const isInsideFunction = enclosingFn !== null; + const replacement = isInsideFunction ? `${objectName}.setFailed(${args}); return;` : `${objectName}.setFailed(${args});`; + + return [fixer.replaceText(current, replacement + "\n"), fixer.remove(next)]; + }, + }, + ] + : [], }); } } From dbd04a043bbb9b762a31ddf80f9ea15adf8e6695 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:06:46 +0000 Subject: [PATCH 3/5] fix(eslint-factory): restrict isFunctionNamedMain to module-scope declarations only Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../no-core-error-then-process-exit.test.ts | 11 +++++++ .../rules/no-core-error-then-process-exit.ts | 30 +++++++++---------- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts b/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts index 782bc8ad4f5..46da8bb16ab 100644 --- a/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts +++ b/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts @@ -64,6 +64,17 @@ describe("no-core-error-then-process-exit", () => { code: `function requireEnvVar(name) { const value = process.env[name]; if (!value) { core.error(\`ERROR: \${name} required\`); process.exit(1); } return value; }`, errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [] }], }, + { + // nested function main() inside another function: must NOT get autofix — `return` only + // exits the inner `main`, so the outer helper continues (module-scope restriction). + code: `function setup() { function main() { core.error("fatal"); process.exit(1); } main(); }`, + errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [] }], + }, + { + // nested const main = () => {} inside another function: must NOT get autofix. + code: `function setup() { const main = async () => { core.error("fatal"); process.exit(1); }; main(); }`, + errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [] }], + }, { // pair inside async function main() entrypoint: autofix retained (acceptance criterion c). code: `async function main() { core.error("fatal"); process.exit(1); }`, diff --git a/eslint-factory/src/rules/no-core-error-then-process-exit.ts b/eslint-factory/src/rules/no-core-error-then-process-exit.ts index eb3bbc7ce8b..ac54f880109 100644 --- a/eslint-factory/src/rules/no-core-error-then-process-exit.ts +++ b/eslint-factory/src/rules/no-core-error-then-process-exit.ts @@ -20,11 +20,7 @@ function getImmediateEnclosingFunction(node: TSESTree.Node, sourceCode: SourceCo const ancestors = sourceCode.getAncestors(node); for (let i = ancestors.length - 1; i >= 0; i--) { const ancestor = ancestors[i]; - if ( - ancestor.type === AST_NODE_TYPES.FunctionDeclaration || - ancestor.type === AST_NODE_TYPES.FunctionExpression || - ancestor.type === AST_NODE_TYPES.ArrowFunctionExpression - ) { + if (ancestor.type === AST_NODE_TYPES.FunctionDeclaration || ancestor.type === AST_NODE_TYPES.FunctionExpression || ancestor.type === AST_NODE_TYPES.ArrowFunctionExpression) { return ancestor as FunctionNode; } } @@ -32,22 +28,24 @@ function getImmediateEnclosingFunction(node: TSESTree.Node, sourceCode: SourceCo } /** - * Returns true when `fn` is a conventional module entrypoint named `main`: - * - `function main() {}` / `async function main() {}` - * - `const main = function() {}` / `const main = async () => {}` + * Returns true when `fn` is a conventional module entrypoint named `main` + * declared at module top level (not nested inside another function): + * - `function main() {}` / `async function main() {}` at Program scope + * - `const main = function() {}` / `const main = async () => {}` at Program scope */ function isFunctionNamedMain(fn: FunctionNode): boolean { if (fn.type === AST_NODE_TYPES.FunctionDeclaration) { - return fn.id?.name === "main"; + // Must be named `main` and declared directly inside the Program (module top level) + return fn.id?.name === "main" && fn.parent?.type === AST_NODE_TYPES.Program; } // FunctionExpression or ArrowFunctionExpression assigned to a variable named `main` - const parent = fn.parent; - return ( - parent != null && - parent.type === AST_NODE_TYPES.VariableDeclarator && - parent.id.type === AST_NODE_TYPES.Identifier && - parent.id.name === "main" - ); + const declarator = fn.parent; + if (declarator == null || declarator.type !== AST_NODE_TYPES.VariableDeclarator || declarator.id.type !== AST_NODE_TYPES.Identifier || declarator.id.name !== "main") { + return false; + } + // The VariableDeclaration containing this declarator must be at module top level + const varDecl = declarator.parent; + return varDecl?.type === AST_NODE_TYPES.VariableDeclaration && varDecl.parent?.type === AST_NODE_TYPES.Program; } /** From 85d90f9ceae9a0147e5b65d68eb332d31f7fe15b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:08:27 +0000 Subject: [PATCH 4/5] fix(eslint-factory): improve readability and fix em dash in comments Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../rules/no-core-error-then-process-exit.test.ts | 2 +- .../src/rules/no-core-error-then-process-exit.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts b/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts index 46da8bb16ab..23b65a5d688 100644 --- a/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts +++ b/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts @@ -65,7 +65,7 @@ describe("no-core-error-then-process-exit", () => { errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [] }], }, { - // nested function main() inside another function: must NOT get autofix — `return` only + // nested function main() inside another function: must NOT get autofix -- `return` only // exits the inner `main`, so the outer helper continues (module-scope restriction). code: `function setup() { function main() { core.error("fatal"); process.exit(1); } main(); }`, errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [] }], diff --git a/eslint-factory/src/rules/no-core-error-then-process-exit.ts b/eslint-factory/src/rules/no-core-error-then-process-exit.ts index ac54f880109..b3728fdb456 100644 --- a/eslint-factory/src/rules/no-core-error-then-process-exit.ts +++ b/eslint-factory/src/rules/no-core-error-then-process-exit.ts @@ -20,7 +20,12 @@ function getImmediateEnclosingFunction(node: TSESTree.Node, sourceCode: SourceCo const ancestors = sourceCode.getAncestors(node); for (let i = ancestors.length - 1; i >= 0; i--) { const ancestor = ancestors[i]; - if (ancestor.type === AST_NODE_TYPES.FunctionDeclaration || ancestor.type === AST_NODE_TYPES.FunctionExpression || ancestor.type === AST_NODE_TYPES.ArrowFunctionExpression) { + // prettier-ignore + if ( + ancestor.type === AST_NODE_TYPES.FunctionDeclaration || + ancestor.type === AST_NODE_TYPES.FunctionExpression || + ancestor.type === AST_NODE_TYPES.ArrowFunctionExpression + ) { return ancestor as FunctionNode; } } @@ -40,7 +45,13 @@ function isFunctionNamedMain(fn: FunctionNode): boolean { } // FunctionExpression or ArrowFunctionExpression assigned to a variable named `main` const declarator = fn.parent; - if (declarator == null || declarator.type !== AST_NODE_TYPES.VariableDeclarator || declarator.id.type !== AST_NODE_TYPES.Identifier || declarator.id.name !== "main") { + // prettier-ignore + if ( + declarator == null || + declarator.type !== AST_NODE_TYPES.VariableDeclarator || + declarator.id.type !== AST_NODE_TYPES.Identifier || + declarator.id.name !== "main" + ) { return false; } // The VariableDeclaration containing this declarator must be at module top level From 7149980facfbeb02dae0fcd03bf7a056a793122a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:19:01 +0000 Subject: [PATCH 5/5] fix(eslint-factory): add explicit module top-level test and clarify fixer logic comment Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../src/rules/no-core-error-then-process-exit.test.ts | 7 +++++++ .../src/rules/no-core-error-then-process-exit.ts | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts b/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts index 23b65a5d688..b0e6051eab9 100644 --- a/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts +++ b/eslint-factory/src/rules/no-core-error-then-process-exit.test.ts @@ -36,6 +36,13 @@ describe("no-core-error-then-process-exit", () => { `core.error("msg"); process.exit("1");`, ], invalid: [ + { + // module top-level: no enclosing function (enclosingFn === null), autofix is safe because + // there is no caller that could continue after the replacement statement. + // The trailing space in the output is inter-statement whitespace left by the fixer. + code: `core.error("fatal"); process.exit(1);`, + errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [{ messageId: "replaceWithSetFailed", output: 'core.setFailed("fatal");\n ' }] }], + }, { // The trailing space in each output is the whitespace between the two original // statements that is not part of either ExpressionStatement node's range. The diff --git a/eslint-factory/src/rules/no-core-error-then-process-exit.ts b/eslint-factory/src/rules/no-core-error-then-process-exit.ts index b3728fdb456..0178b084eca 100644 --- a/eslint-factory/src/rules/no-core-error-then-process-exit.ts +++ b/eslint-factory/src/rules/no-core-error-then-process-exit.ts @@ -162,8 +162,10 @@ export const noCoreErrorThenProcessExitRule = createRule({ const callee = errorCall.callee as TSESTree.MemberExpression; const objectName = sourceCode.getText(callee.object); - const isInsideFunction = enclosingFn !== null; - const replacement = isInsideFunction ? `${objectName}.setFailed(${args}); return;` : `${objectName}.setFailed(${args});`; + // At module top-level (enclosingFn === null) there is nothing to `return` from, + // so we just replace with setFailed. Inside main() we append `return;` to exit + // the entrypoint in the same way process.exit would. + const replacement = enclosingFn !== null ? `${objectName}.setFailed(${args}); return;` : `${objectName}.setFailed(${args});`; return [fixer.replaceText(current, replacement + "\n"), fixer.remove(next)]; },