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
16 changes: 16 additions & 0 deletions eslint-factory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ This project hosts custom ESLint linters for `/actions/setup/js`.
| [`require-fetch-response-body-try-catch`](#require-fetch-response-body-try-catch) | Require try/catch around `.json()` or `.text()` on Responses from `fetch(...)` |
| [`require-fetch-timeout`](#require-fetch-timeout) | Require `fetch(...)` calls to include a non-nullish abort `signal` option |
| [`require-fetch-try-catch`](#require-fetch-try-catch) | Require try/catch around awaited `fetch(...)` calls, including chained promise forms without rejection handlers |
| [`require-fs-chmod-try-catch`](#require-fs-chmod-try-catch) | Require try/catch around `fs.chmodSync` and `fs.fchmodSync` |
| [`require-fs-close-sync`](#require-fs-close-sync) | Require `fs.openSync(...)` file descriptors to be closed with `fs.closeSync(fd)` in the same function |
| [`require-fs-io-try-catch`](#require-fs-io-try-catch) | Require try/catch around `fs.statSync`, `readdirSync`, `copyFileSync`, `unlinkSync`, and `renameSync` |
| [`require-fs-sync-try-catch`](#require-fs-sync-try-catch) | Require try/catch around `fs.readFileSync`, `writeFileSync`, and `appendFileSync` |
Expand Down Expand Up @@ -396,6 +397,21 @@ try {
}
```

### `require-fs-chmod-try-catch`

Require `fs.chmodSync` and `fs.fchmodSync` calls to be wrapped in `try/catch`.

Why: these calls can throw for missing files or descriptors, permission errors, and unsupported filesystems. A call-site catch preserves useful error context.

**Detected forms:**
- `fs.chmodSync(path, mode)` and `fs["chmodSync"](path, mode)`.
- `fs.fchmodSync(fd, mode)`.
- Bindings imported or required from `fs` / `node:fs`, including destructured bindings.

**Out of scope:**
- Objects that are not resolved to the Node `fs` / `node:fs` module.
- `try { ... } finally { ... }` without a `catch` clause is still flagged.

### `require-fs-close-sync`

Require file descriptors returned by `fs.openSync(...)` to be closed with `fs.closeSync(fd)` in the same enclosing function.
Expand Down
1 change: 1 addition & 0 deletions eslint-factory/eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ module.exports = [
"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/require-fs-chmod-try-catch": "warn",
"gh-aw-custom/no-setfailed-then-exit-zero": "warn",
"gh-aw-custom/no-err-stack-then-string-fallback": "warn",
"gh-aw-custom/no-caught-error-interpolation": "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 @@ -59,6 +59,7 @@ import { noStringFallbackForNonStringMessageRule } from "./rules/no-string-fallb
import { requireGetExecOutputExitCodeCheckRule } from "./rules/require-getexecoutput-exitcode-check";
import { preferActionsExecOverChildProcessRule } from "./rules/prefer-actions-exec-over-child-process";
import { noMisplacedErrorCodeDefinitionRule } from "./rules/no-misplaced-error-code-definition";
import { requireFsChmodTryCatchRule } from "./rules/require-fs-chmod-try-catch";

const plugin = {
meta: {
Expand Down Expand Up @@ -127,6 +128,7 @@ const plugin = {
"require-getexecoutput-exitcode-check": requireGetExecOutputExitCodeCheckRule,
"prefer-actions-exec-over-child-process": preferActionsExecOverChildProcessRule,
"no-misplaced-error-code-definition": noMisplacedErrorCodeDefinitionRule,
"require-fs-chmod-try-catch": requireFsChmodTryCatchRule,
},
};

Expand Down
75 changes: 75 additions & 0 deletions eslint-factory/src/rules/require-fs-chmod-try-catch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { RuleTester } from "eslint";
import { describe, it } from "vitest";
import { requireFsChmodTryCatchRule } from "./require-fs-chmod-try-catch";

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

function expectedWrapInTryCatchSuggestion(method: string, statement: string, prefix = "") {
return {
messageId: "wrapInTryCatch",
output: `${prefix}try {\n` + ` ${statement}\n` + `} catch (err) {\n` + ` throw new Error(\n` + ` "fs.${method} failed: " + (err instanceof Error ? err.message : String(err)),\n` + ` { cause: err },\n` + ` );\n` + `}`,
};
}

describe("require-fs-chmod-try-catch", () => {
it("valid: fs.chmodSync and fs.fchmodSync inside try block pass", () => {
cjsRuleTester.run("require-fs-chmod-try-catch", requireFsChmodTryCatchRule, {
valid: [`try { fs.chmodSync(path, 0o600); } catch (e) {}`, `try { fs.fchmodSync(fd, 0o600); } catch (e) {}`],
invalid: [],
});
});

it("valid: other fs methods and non-fs identifiers are ignored", () => {
cjsRuleTester.run("require-fs-chmod-try-catch", requireFsChmodTryCatchRule, {
valid: [`fs.existsSync(path);`, `fs.readFileSync(path, "utf8");`, `fs.statSync(path);`, `mockFs.chmodSync(path, 0o600);`, `storage.fchmodSync(fd, 0o600);`],
invalid: [],
});
});

it("invalid: fs.chmodSync outside try/catch is flagged", () => {
cjsRuleTester.run("require-fs-chmod-try-catch", requireFsChmodTryCatchRule, {
valid: [],
invalid: [
{
code: `fs.chmodSync(path, 0o600);`,
errors: [{ messageId: "requireTryCatch", data: { method: "chmodSync", arg: "path" }, suggestions: [expectedWrapInTryCatchSuggestion("chmodSync", "fs.chmodSync(path, 0o600);")] }],
},
],
});
});

it("invalid: fs.fchmodSync outside try/catch is flagged", () => {
cjsRuleTester.run("require-fs-chmod-try-catch", requireFsChmodTryCatchRule, {
valid: [],
invalid: [
{
code: `fs.fchmodSync(fd, 0o600);`,
errors: [{ messageId: "requireTryCatch", data: { method: "fchmodSync", arg: "fd" }, suggestions: [expectedWrapInTryCatchSuggestion("fchmodSync", "fs.fchmodSync(fd, 0o600);")] }],
},
],
});
});

it("invalid: fs.chmodSync after an unrelated try block is still flagged", () => {
cjsRuleTester.run("require-fs-chmod-try-catch", requireFsChmodTryCatchRule, {
valid: [],
invalid: [
{
code: `try { doSomethingElse(); } catch (e) {}\nfs.chmodSync(dir, 0o700);`,
errors: [
{
messageId: "requireTryCatch",
data: { method: "chmodSync", arg: "dir" },
suggestions: [expectedWrapInTryCatchSuggestion("chmodSync", "fs.chmodSync(dir, 0o700);", "try { doSomethingElse(); } catch (e) {}\n")],
},
],
},
],
});
});
});
67 changes: 67 additions & 0 deletions eslint-factory/src/rules/require-fs-chmod-try-catch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ESLintUtils } from "@typescript-eslint/utils";
import { buildTryCatchSuggestion, createFsSyncMethodResolver, findEnclosingStatement, isInsideTryBlock } from "./try-catch-rule-utils";

const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh-aw/tree/main/eslint-factory#${name}`);
Comment thread
github-actions[bot] marked this conversation as resolved.

Comment thread
github-actions[bot] marked this conversation as resolved.
const FS_CHMOD_METHODS = new Set(["chmodSync", "fchmodSync"]);

export const requireFsChmodTryCatchRule = createRule({
name: "require-fs-chmod-try-catch",
meta: {
type: "problem",
hasSuggestions: true,
docs: {
description: "Require fs.chmodSync and fs.fchmodSync calls in actions/setup/js scripts to be wrapped in try/catch.",
},
schema: [],
messages: {
requireTryCatch: "Wrap fs.{{method}}({{arg}}) in try/catch.",
wrapInTryCatch: "Wrap in try { ... } catch { ... } and re-throw with { cause: err } to preserve context.",
Comment thread
github-actions[bot] marked this conversation as resolved.
},
},
defaultOptions: [],
create(context) {
const sourceCode = context.sourceCode;
const resolveFsChmodMethod = createFsSyncMethodResolver(sourceCode, FS_CHMOD_METHODS, { allowUnboundFsIdentifier: true });

return {
CallExpression(node) {
const methodName = resolveFsChmodMethod(node);

if (!methodName) return;

if (isInsideTryBlock(sourceCode, node)) return;

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

context.report({
node,
messageId: "requireTryCatch",
data: { method, 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,
errorPrefix: `fs.${method} failed: `,
})
);
},
Comment thread
github-actions[bot] marked this conversation as resolved.
},
]
: [],
});
},
};
},
});
4 changes: 2 additions & 2 deletions eslint-factory/src/rules/try-catch-rule-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export function createFsSyncMethodResolver(sourceCode: TSESLint.SourceCode, fsSy

type TryCatchSuggestionOptions = {
indent: string;
todoComment: string;
todoComment?: string;
errorPrefix: string;
};

Expand All @@ -311,7 +311,7 @@ export function buildTryCatchSuggestion(stmtText: string, options: TryCatchSugge
"try {",
indentedStatement,
`${indent}} catch (err) {`,
`${indent} // ${todoComment}`,
...(todoComment ? [`${indent} // ${todoComment}`] : []),
`${indent} throw new Error(`,
`${indent} "${errorPrefix}" + (err instanceof Error ? err.message : String(err)),`,
`${indent} { cause: err },`,
Expand Down