Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion eslint-factory/src/rules/no-core-error-then-process-exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,8 +60,37 @@ 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,

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] No test covers the module top-level case (pair outside any function, enclosingFn === null), which is the other path that enables the autofix.

💡 Suggested test
{
  // module top-level: no enclosing function, autofix is safe
  code: `core.error("fatal"); process.exit(1);`,
  errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [{ messageId: "replaceWithSetFailed", output: `core.setFailed("fatal");\n` }] }],
},

Without this, a future regression that accidentally suppresses top-level autofixes would go undetected.

@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 7149980: an explicit test case labeled // module top-level: no enclosing function (enclosingFn === null), autofix is safe is now the first invalid case in the suite, directly documenting the enclosingFn === null path. The isInsideFunction variable was also inlined with a clarifying comment.

// 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: [] }],
},
{
// 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); }`,
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"]
Expand Down
116 changes: 84 additions & 32 deletions eslint-factory/src/rules/no-core-error-then-process-exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,55 @@ 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];
// 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;
}
}
return null;
}

/**
* 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
*/

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.

[/diagnosing-bugs] isFunctionNamedMain does not verify that main is at module top-level — a nested function named main inside another function will incorrectly receive the autofix, where return only exits the inner main, not the outer function.

💡 Reproducer & suggested fix

Reproducer (currently gets unsafe autofix):

function outer() {
  async function main() {
    core.error("x");
    process.exit(1);  // ← autofix replaces with `return;`, but outer() continues
  }
  main();
}

Fix: after confirming the function is named main, also check that its own parent is a Program node (or a VariableDeclaration at Program level):

function isFunctionNamedMain(fn: FunctionNode): boolean {
  if (fn.type === AST_NODE_TYPES.FunctionDeclaration) {
    if (fn.id?.name !== "main") return false;
    return fn.parent?.type === AST_NODE_TYPES.Program;
  }
  const parent = fn.parent;
  if (
    parent?.type === AST_NODE_TYPES.VariableDeclarator &&
    parent.id.type === AST_NODE_TYPES.Identifier &&
    parent.id.name === "main"
  ) {
    // Walk up: VariableDeclarator → VariableDeclaration → must be Program
    return parent.parent?.parent?.type === AST_NODE_TYPES.Program;
  }
  return false;
}

Add a corresponding test case:

{
  // nested main() is NOT a safe entrypoint — no autofix
  code: `function outer() { async function main() { core.error("x"); process.exit(1); } }`,
  errors: [{ messageId: "noCoreErrorThenProcessExit", suggestions: [] }],
},

@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 dbd04a0. isFunctionNamedMain now checks fn.parent?.type === AST_NODE_TYPES.Program for FunctionDeclaration, and for variable-assigned forms walks VariableDeclarator → VariableDeclaration and confirms varDecl.parent?.type === AST_NODE_TYPES.Program. Both nested forms now produce suggestions: [].

function isFunctionNamedMain(fn: FunctionNode): boolean {
if (fn.type === AST_NODE_TYPES.FunctionDeclaration) {
// 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 declarator = fn.parent;
// 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
const varDecl = declarator.parent;
return varDecl?.type === AST_NODE_TYPES.VariableDeclaration && varDecl.parent?.type === AST_NODE_TYPES.Program;
}

/**
* Returns true when `node` is an expression statement containing a call to
* `core.error(...)` (direct, computed, or aliased).
Expand Down Expand Up @@ -70,12 +119,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;`.",
},
},
Expand All @@ -88,38 +141,37 @@ 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);

// 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)];
},
},
]
: [],
});
}
}
Expand Down