Skip to content
38 changes: 38 additions & 0 deletions eslint-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ This project hosts custom ESLint linters for `/actions/setup/js`.
| [`require-new-url-try-catch`](#require-new-url-try-catch) | Require try/catch around `new URL(variable)` calls |
| [`require-parseInt-radix`](#require-parseInt-radix) | Require an explicit radix argument to `parseInt()` |
| [`require-return-after-core-setfailed`](#require-return-after-core-setfailed) | Require a control-transfer statement after `core.setFailed()` |
| [`require-execsync-try-catch`](#require-execsync-try-catch) | Require try/catch around `execSync(...)` calls from `child_process` |
| [`require-execfilesync-try-catch`](#require-execfilesync-try-catch) | Require try/catch around `execFileSync(...)` calls from `child_process` |
| [`require-spawnsync-error-check`](#require-spawnsync-error-check) | Require checking `result.error` after `spawnSync` calls |

### `no-github-request-interpolated-route`
Expand Down Expand Up @@ -429,3 +431,39 @@ Prefer `@actions/core` logging methods (`core.info`, `core.debug`) over `console

`console.error` and `console.warn` write to **`process.stderr`**, while `core.error` and `core.warning` emit GitHub Actions workflow commands to **`process.stdout`**. For processes that own stdout as a data/protocol channel — such as stdio MCP servers and transports — replacing stderr logging with stdout logging would corrupt the JSON-RPC stream. Because the stream change is not behavior-preserving, the rule never reports `console.error` or `console.warn` and offers no suggestion to replace them.


### `require-execsync-try-catch`

Require `execSync` calls sourced from `child_process` to be wrapped in `try/catch`.

Why: `execSync` throws an `Error` containing child-process result fields (for example `status`, `signal`, `stdout`, `stderr`) when the child process exits with a non-zero status code or is killed by a signal. An unhandled throw crashes the action without surfacing a useful diagnostic.

**Detected forms:**
- `const { execSync } = require("child_process"); execSync(...)` — destructured.
- `const cp = require("child_process"); cp.execSync(...)` — namespace access.
- `const run = cp.execSync; run(...)` — aliased via member expression.
- `import { execSync } from "child_process"; execSync(...)` — ESM named import.
- Both `"child_process"` and `"node:child_process"` specifiers are recognized.

**Not flagged:**
- `execSync` from any module other than `child_process` / `node:child_process`.
- Calls already inside an enclosing `try { ... } catch { ... }` block.

### `require-execfilesync-try-catch`

Require `execFileSync` calls sourced from `child_process` to be wrapped in `try/catch`.

Why: `execFileSync` has identical throw-on-failure semantics to `execSync` — it throws an `Error` containing child-process result fields (for example `status`, `signal`, `stdout`, `stderr`) when the child process exits with a non-zero status code or is killed by a signal. An unhandled throw crashes the action without surfacing a useful diagnostic.

**Detected forms:**
- `const { execFileSync } = require("child_process"); execFileSync(...)` — destructured.
- `const cp = require("child_process"); cp.execFileSync(...)` — namespace access.
- `const run = cp.execFileSync; run(...)` — aliased via member expression.
- `import { execFileSync } from "child_process"; execFileSync(...)` — ESM named import.
- Both `"child_process"` and `"node:child_process"` specifiers are recognized.

**Not flagged:**
- `execFileSync` from any module other than `child_process` / `node:child_process`.
- Calls already inside an enclosing `try { ... } catch { ... }` block.

**Out of scope:** `execFile` (the async, callback-based sibling) is intentionally excluded. The async form accepts a callback and does not throw synchronously; errors are delivered through the callback or the returned `ChildProcess` event emitter, so a synchronous try/catch provides no protection.
1 change: 1 addition & 0 deletions eslint-factory/eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ module.exports = [
"gh-aw-custom/no-core-error-then-process-exitcode": "warn",
"gh-aw-custom/no-exec-interpolated-command": "warn",
"gh-aw-custom/require-execsync-try-catch": "warn",
"gh-aw-custom/require-execfilesync-try-catch": "warn",
"gh-aw-custom/require-fs-io-try-catch": "warn",
"gh-aw-custom/no-setfailed-then-exit-zero": "warn",
},
Expand Down
2 changes: 2 additions & 0 deletions eslint-factory/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { noCoreErrorThenProcessExitRule } from "./rules/no-core-error-then-proce
import { noCoreErrorThenProcessExitCodeRule } from "./rules/no-core-error-then-process-exitcode";
import { noExecInterpolatedCommandRule } from "./rules/no-exec-interpolated-command";
import { requireExecSyncTryCatchRule } from "./rules/require-execsync-try-catch";
import { requireExecFileSyncTryCatchRule } from "./rules/require-execfilesync-try-catch";
import { requireFsIoTryCatchRule } from "./rules/require-fs-io-try-catch";
import { noSetFailedThenExitZeroRule } from "./rules/no-setfailed-then-exit-zero";

Expand Down Expand Up @@ -55,6 +56,7 @@ const plugin = {
"no-core-error-then-process-exitcode": noCoreErrorThenProcessExitCodeRule,
"no-exec-interpolated-command": noExecInterpolatedCommandRule,
"require-execsync-try-catch": requireExecSyncTryCatchRule,
"require-execfilesync-try-catch": requireExecFileSyncTryCatchRule,
"require-fs-io-try-catch": requireFsIoTryCatchRule,
"no-setfailed-then-exit-zero": noSetFailedThenExitZeroRule,
},
Expand Down
182 changes: 182 additions & 0 deletions eslint-factory/src/rules/require-execfilesync-try-catch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { RuleTester } from "eslint";
import { describe, it } from "vitest";
import { requireExecFileSyncTryCatchRule } from "./require-execfilesync-try-catch";

const cjsRuleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: "commonjs",
},
});

const esmRuleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
});

describe("require-execfilesync-try-catch", () => {
it("valid: execFileSync inside try block passes (CommonJS, destructured)", () => {
cjsRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [
`const { execFileSync } = require("child_process"); try { execFileSync("git", ["status"]); } catch (e) {}`,
`const { execFileSync } = require("node:child_process"); try { execFileSync("git", ["status"]); } catch (e) {}`,
`const { execFileSync } = require("child_process"); function f() { try { execFileSync("git", ["status"]); } catch (e) {} }`,
],
invalid: [],
});
});

it("valid: execFileSync inside try block passes (CommonJS, namespace)", () => {
cjsRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [`const cp = require("child_process"); try { cp.execFileSync("git", ["status"]); } catch (e) {}`],
invalid: [],
});
});

it("valid: execFileSync inside try block passes (ES module)", () => {
esmRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [`import { execFileSync } from "child_process"; try { execFileSync("git", ["status"]); } catch (e) {}`, `import { execFileSync } from "node:child_process"; try { execFileSync("git", ["status"]); } catch (e) {}`],
invalid: [],
});
});

it("valid: execFileSync from non-child_process module is ignored", () => {
cjsRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [
// execFileSync from an unrelated module — should not be flagged
`const { execFileSync } = require("some-other-lib"); execFileSync("git", ["status"]);`,
// bare execFileSync without any require — should not be flagged
`execFileSync("git", ["status"]);`,
// member call on unrelated object
`mockChild.execFileSync("git", ["status"]);`,
],
invalid: [],
});
});

it("invalid: execFileSync without try/catch (CommonJS, destructured)", () => {
cjsRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [],
invalid: [
{
code: `const { execFileSync } = require("child_process"); execFileSync("git", ["status"]);`,
errors: [
{
messageId: "requireTryCatch",
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const { execFileSync } = require("child_process"); try {\n execFileSync("git", ["status"]);\n} catch (err) {\n // TODO: handle execFileSync failure (non-zero exit / signal termination).\n throw new Error(\n "execFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
],
});
});

it("invalid: execFileSync without try/catch (CommonJS, namespace)", () => {
cjsRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [],
invalid: [
{
code: `const cp = require("child_process"); cp.execFileSync("git", ["status"]);`,
errors: [
{
messageId: "requireTryCatch",
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const cp = require("child_process"); try {\n cp.execFileSync("git", ["status"]);\n} catch (err) {\n // TODO: handle execFileSync failure (non-zero exit / signal termination).\n throw new Error(\n "execFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
{
code: `const cp = require("node:child_process"); cp.execFileSync("git", ["status"]);`,
errors: [
{
messageId: "requireTryCatch",
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const cp = require("node:child_process"); try {\n cp.execFileSync("git", ["status"]);\n} catch (err) {\n // TODO: handle execFileSync failure (non-zero exit / signal termination).\n throw new Error(\n "execFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
],
});
});

it("invalid: execFileSync without try/catch (ES module)", () => {
esmRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [],
invalid: [
{
code: `import { execFileSync } from "child_process"; execFileSync("git", ["status"]);`,
errors: [
{
messageId: "requireTryCatch",
suggestions: [
{
messageId: "wrapInTryCatch",
output: `import { execFileSync } from "child_process"; try {\n execFileSync("git", ["status"]);\n} catch (err) {\n // TODO: handle execFileSync failure (non-zero exit / signal termination).\n throw new Error(\n "execFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},

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 test title says "aliased execFileSync without try/catch is flagged" but only covers destructuring-alias (const { execFileSync: run } = require(...)). The implementation also handles the member-expression alias path (const run = cp.execFileSync) documented in both the README and source — that path has no invalid test.

💡 Suggested test to add
it('invalid: member-expression alias without try/catch is flagged', () => {
  cjsRuleTester.run('require-execfilesync-try-catch', requireExecFileSyncTryCatchRule, {
    valid: [],
    invalid: [
      {
        code: `const cp = require('child_process'); const run = cp.execFileSync; run('git', ['status']);`,
        errors: [{ messageId: 'requireTryCatch' }],
      },
    ],
  });
});

Without this, a regression in isChildProcessObjectBinding for the alias path would go undetected.

@copilot please address this.

],
},

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.

Missing invalid-case test for node:child_process namespace form — a regression in the node: prefix path for namespace requires would go undetected.

💡 Details

The valid-case tests include node:child_process (lines 113, 129), but the invalid cases only test child_process. There is no test asserting that:

const cp = require('node:child_process');
cp.execFileSync('git', ['status']); // should be flagged

Add at least one invalid case using the node: specifier with namespace access to ensure CHILD_PROCESS_SPECIFIERS is applied symmetrically for the flagging path, not just the pass-through path.

],
});
});

it("invalid: destructured alias of execFileSync without try/catch is flagged", () => {
cjsRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [],
invalid: [
{
code: `const { execFileSync: run } = require("child_process"); run("git", ["status"]);`,
Comment on lines +143 to +144
errors: [
{
messageId: "requireTryCatch",
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const { execFileSync: run } = require("child_process"); try {\n run("git", ["status"]);\n} catch (err) {\n // TODO: handle execFileSync failure (non-zero exit / signal termination).\n throw new Error(\n "execFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
],
});
});

it("invalid: member-expression alias of execFileSync without try/catch is flagged", () => {
cjsRuleTester.run("require-execfilesync-try-catch", requireExecFileSyncTryCatchRule, {
valid: [],
invalid: [
{
code: `const cp = require("child_process"); const run = cp.execFileSync; run("git", ["status"]);`,
errors: [
{
messageId: "requireTryCatch",
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const cp = require("child_process"); const run = cp.execFileSync; try {\n run("git", ["status"]);\n} catch (err) {\n // TODO: handle execFileSync failure (non-zero exit / signal termination).\n throw new Error(\n "execFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
],
});
});
});
135 changes: 135 additions & 0 deletions eslint-factory/src/rules/require-execfilesync-try-catch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { AST_NODE_TYPES, ESLintUtils, TSESLint, TSESTree } from "@typescript-eslint/utils";

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] This file is nearly identical to require-execsync-try-catch.ts — only the function name (execFileSync vs execSync) differs across ~180 lines. The binding-resolution helpers (isRequireChildProcess, isChildProcessImportBinding, isChildProcessObjectBinding) are copy-pasted verbatim and will silently diverge.

💡 Suggested approach

Extract a generic factory into try-catch-rule-utils.ts:

export function createChildProcessSyncRule(fnName: string, ruleName: string): Rule.RuleModule { ... }

Then both rules become one-liners:

export const requireExecFileSyncTryCatchRule = createChildProcessSyncRule('execFileSync', 'require-execfilesync-try-catch');

This eliminates two maintenance surfaces where fixes/improvements to binding resolution must be applied twice.

@copilot please address this.

import { buildTryCatchSuggestion, findEnclosingStatement, isChildProcessImportBinding, isChildProcessObjectBinding, isInsideTryBlock, isRequireChildProcess } from "./try-catch-rule-utils";

const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`);

type SourceCodeScope = ReturnType<TSESLint.SourceCode["getScope"]>;

/**
* Walks the scope chain to decide whether `identifierName` resolves to
* `execFileSync` from `child_process`.
*/
function isExecFileSyncBinding(identifierName: string, scopeNode: TSESTree.Node, sourceCode: TSESLint.SourceCode): boolean {
let scope: SourceCodeScope | null = sourceCode.getScope(scopeNode);
while (scope) {
const variable = scope.set.get(identifierName);
if (variable && variable.defs.length > 0) {
for (const def of variable.defs) {
// ESM: import { execFileSync } from "child_process"
if (isChildProcessImportBinding(def) && def.node.type === AST_NODE_TYPES.ImportSpecifier) {
const specifier = def.node as TSESTree.ImportSpecifier;
const importedName = specifier.imported.type === AST_NODE_TYPES.Identifier ? specifier.imported.name : null;
if (importedName === "execFileSync") return true;
}
// CJS: const { execFileSync } = require("child_process")
if (def.type === "Variable") {
const declarator = def.node as TSESTree.VariableDeclarator;
if (declarator.id.type === AST_NODE_TYPES.ObjectPattern && isRequireChildProcess(declarator.init)) {
for (const prop of declarator.id.properties) {
if (prop.type !== AST_NODE_TYPES.Property) continue;
if (prop.key.type !== AST_NODE_TYPES.Identifier || prop.key.name !== "execFileSync") continue;
const boundName = prop.value.type === AST_NODE_TYPES.Identifier ? prop.value.name : null;
if (boundName === identifierName) return true;
}
}
// const execFileSync = childProcess.execFileSync
if (declarator.id.type === AST_NODE_TYPES.Identifier && declarator.init?.type === AST_NODE_TYPES.MemberExpression) {
const init = declarator.init;
if (
!init.computed &&
init.object.type === AST_NODE_TYPES.Identifier &&
isChildProcessObjectBinding(init.object.name, init.object, sourceCode) &&
init.property.type === AST_NODE_TYPES.Identifier &&
init.property.name === "execFileSync"
) {
return true;
}
}
}
}
return false;
}
scope = scope.upper;
}
return false;
}

/**
* Returns true if the CallExpression is an `execFileSync(...)` call sourced from
* the `child_process` module.
*/
function isExecFileSyncCall(node: TSESTree.CallExpression, sourceCode: TSESLint.SourceCode): boolean {
const callee = node.callee;

// execFileSync(...) — destructured or aliased
if (callee.type === AST_NODE_TYPES.Identifier) {
return isExecFileSyncBinding(callee.name, callee, sourceCode);
}

// childProcess.execFileSync(...) or cp.execFileSync(...)
if (callee.type === AST_NODE_TYPES.MemberExpression && !callee.computed && callee.object.type === AST_NODE_TYPES.Identifier && callee.property.type === AST_NODE_TYPES.Identifier && callee.property.name === "execFileSync") {
return isChildProcessObjectBinding(callee.object.name, callee.object, sourceCode);
}

return false;
}

export const requireExecFileSyncTryCatchRule = createRule({
name: "require-execfilesync-try-catch",
meta: {
type: "problem",
hasSuggestions: true,
docs: {
description:
"Require execFileSync calls in actions/setup/js scripts to be wrapped in try/catch. " +
"execFileSync throws an Error containing child-process result fields when the child process exits with a non-zero status code or is killed by a signal; " +
"an unhandled throw crashes the action without surfacing a useful diagnostic.",
},
schema: [],
messages: {
requireTryCatch: "Wrap execFileSync({{arg}}) in try/catch — execFileSync throws when the process exits non-zero or is killed by a signal, " + "and will crash the action if the error is unhandled.",
wrapInTryCatch: "Wrap in try { ... } catch { ... } and re-throw with { cause: err } to preserve context.",
},
},
defaultOptions: [],
create(context) {
const sourceCode = context.sourceCode;

return {
CallExpression(node) {
if (!isExecFileSyncCall(node, sourceCode)) return;
if (isInsideTryBlock(sourceCode, node)) return;

const argText = node.arguments.length > 0 ? sourceCode.getText(node.arguments[0]) : "";
const stmt = findEnclosingStatement(sourceCode, node);

context.report({
node,
messageId: "requireTryCatch",
data: { arg: argText },
suggest: stmt
? [
{
messageId: "wrapInTryCatch",
fix(fixer) {
const stmtText = sourceCode.getText(stmt);
const startLine = stmt.loc?.start.line;
const stmtLine = startLine !== undefined ? (sourceCode.lines[startLine - 1] ?? "") : "";
const indent = stmtLine.match(/^(\s*)/)?.[1] ?? "";
return fixer.replaceText(
stmt,
buildTryCatchSuggestion(stmtText, {
indent,
todoComment: "TODO: handle execFileSync failure (non-zero exit / signal termination).",
errorPrefix: "execFileSync failed: ",
})
);
},
},
]
: [],
});
},
};
},
});
Loading