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
141 changes: 138 additions & 3 deletions eslint-factory/src/rules/require-fs-sync-try-catch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,21 @@ describe("require-fs-sync-try-catch", () => {
});
});

it("valid: destructured fs bindings stay out of scope", () => {
it("valid: destructured fs bindings inside try block pass", () => {
cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
valid: [`const { readFileSync } = require("fs"); readFileSync(path, "utf8");`],
valid: [
`const { readFileSync } = require("fs"); try { readFileSync(path, "utf8"); } catch (e) {}`,
`const { appendFileSync } = require("node:fs"); try { appendFileSync(p, data); } catch (e) {}`,
`const { appendFileSync: append } = require("fs"); try { append(p, data); } catch (e) {}`,
`const { appendFileSync } = require("custom-logger"); appendFileSync(filePath, data);`,
],
invalid: [],
});
});

it("valid: member-expression alias inside try block passes", () => {
cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
valid: [`const fs = require("fs"); const append = fs.appendFileSync; try { append(p, data); } catch (e) {}`],
invalid: [],
});

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] Missing false-positive edge case: no test verifies that a non-fs identifier with the same name as an fs method is not flagged.

If appendFileSync is destructured from a non-fs library, the scope-walk will return null — but without a test, a future refactor could silently introduce false positives.

💡 Suggested valid test case
it("valid: same-named method bound to non-fs library is not flagged", () => {
  cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
    valid: [
      `const { appendFileSync } = require("custom-logger"); appendFileSync(filePath, data);`,
    ],
    invalid: [],
  });
});

@copilot please address this.

});
Expand Down Expand Up @@ -159,7 +171,7 @@ describe("require-fs-sync-try-catch", () => {
});
});

it('invalid: computed fs["readFileSync"] access is flagged when not in try block', () => {
it('invalid: computed fs["readFileSync"] and aliased fs member access are flagged when not in try block', () => {
cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
valid: [],
invalid: [
Expand All @@ -173,6 +185,21 @@ describe("require-fs-sync-try-catch", () => {
},
],
},
{
code: `const fileSystem = require("fs"); fileSystem.readFileSync(path, "utf8");`,
errors: [
{
messageId: "requireTryCatch",
data: { method: "readFileSync", arg: "path" },
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const fileSystem = require("fs"); try {\n fileSystem.readFileSync(path, "utf8");\n} catch (err) {\n // TODO: handle I/O failure for this fs.readFileSync call.\n throw new Error(\n "fs.readFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
],
});
});
Expand Down Expand Up @@ -315,4 +342,112 @@ describe("require-fs-sync-try-catch", () => {
],
});
});

it("invalid: destructured fs binding (shorthand) not in try is flagged", () => {
cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
valid: [],
invalid: [
{
// Regression: was a live false negative in actions/setup/js/action_setup_otlp.cjs.
code: `const { appendFileSync } = require("fs"); appendFileSync(filePath, \`\${key}=\${value}\\n\`);`,
errors: [
{
messageId: "requireTryCatch",
data: { method: "appendFileSync", arg: "filePath" },
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const { appendFileSync } = require("fs"); try {\n appendFileSync(filePath, \`\${key}=\${value}\\n\`);\n} catch (err) {\n // TODO: handle I/O failure for this fs.appendFileSync call.\n throw new Error(\n "fs.appendFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
],
});
});

it("invalid: destructured fs binding via node:fs not in try is flagged", () => {
cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
valid: [],
invalid: [
{
code: `const { readFileSync } = require("node:fs"); readFileSync(path, "utf8");`,
errors: [
{
messageId: "requireTryCatch",
data: { method: "readFileSync", arg: "path" },
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const { readFileSync } = require("node:fs"); try {\n readFileSync(path, "utf8");\n} catch (err) {\n // TODO: handle I/O failure for this fs.readFileSync call.\n throw new Error(\n "fs.readFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
],
});
});

it("invalid: renamed destructured fs binding not in try is flagged", () => {
cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
valid: [],
invalid: [
{
code: `const { appendFileSync: append } = require("fs"); append(filePath, data);`,
errors: [
{
messageId: "requireTryCatch",
data: { method: "appendFileSync", arg: "filePath" },
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const { appendFileSync: append } = require("fs"); try {\n append(filePath, data);\n} catch (err) {\n // TODO: handle I/O failure for this fs.appendFileSync call.\n throw new Error(\n "fs.appendFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
],
});
});

it("invalid: member-expression alias (const alias = fs.method) not in try is flagged", () => {
cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
valid: [],
invalid: [
{
code: `const fs = require("fs"); const append = fs.appendFileSync; append(filePath, data);`,
errors: [
{
messageId: "requireTryCatch",
data: { method: "appendFileSync", arg: "filePath" },
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const fs = require("fs"); const append = fs.appendFileSync; try {\n append(filePath, data);\n} catch (err) {\n // TODO: handle I/O failure for this fs.appendFileSync call.\n throw new Error(\n "fs.appendFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n}`,
},
],
},
],
},
{
code: `const fs = require("fs"); const append = fs.appendFileSync; function inner() { const fs = {}; append(filePath, data); }`,
errors: [
{
messageId: "requireTryCatch",
data: { method: "appendFileSync", arg: "filePath" },
suggestions: [
{
messageId: "wrapInTryCatch",
output: `const fs = require("fs"); const append = fs.appendFileSync; function inner() { const fs = {}; try {\n append(filePath, data);\n} catch (err) {\n // TODO: handle I/O failure for this fs.appendFileSync call.\n throw new Error(\n "fs.appendFileSync failed: " + (err instanceof Error ? err.message : String(err)),\n { cause: err },\n );\n} }`,
},
],
},
],
},
],
});
});
});
137 changes: 124 additions & 13 deletions eslint-factory/src/rules/require-fs-sync-try-catch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const createRule = ESLintUtils.RuleCreator(name => `https://github.com/github/gh
// now to keep FP risk low on the first iteration.
const FS_SYNC_METHODS = new Set(["readFileSync", "writeFileSync", "appendFileSync"]);

// fs module specifiers recognised as the Node.js built-in file system module.
const FS_MODULE_SPECIFIERS = new Set(["fs", "node:fs"]);

export const requireFsSyncTryCatchRule = createRule({
name: "require-fs-sync-try-catch",
meta: {
Expand All @@ -29,6 +32,7 @@ export const requireFsSyncTryCatchRule = createRule({
defaultOptions: [],
create(context) {
const sourceCode = context.sourceCode;
type SourceCodeScope = ReturnType<typeof sourceCode.getScope>;

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 SourceCodeScope type alias is defined inside create() but is not exported or used in the test file. While harmless, it obscures where the type actually comes from and may confuse future readers unfamiliar with the eslint-visitor-keys scope type.

Consider using the more explicit inline type ReturnType<typeof sourceCode.getScope> at both use sites, or extract it to a module-level type alias — keeping it inside create() limits reuse.

@copilot please address this.


function isInsideTryBlock(node: TSESTree.Node): boolean {
const ancestors = sourceCode.getAncestors(node);
Expand Down Expand Up @@ -65,23 +69,130 @@ export const requireFsSyncTryCatchRule = createRule({
return null;
}

/**
* Returns true if `node` is a `require("fs")` or `require("node:fs")` call.
*/
function isRequireFsCall(node: TSESTree.Node | null | undefined): boolean {
if (!node) return false;
return (
node.type === AST_NODE_TYPES.CallExpression &&
node.callee.type === AST_NODE_TYPES.Identifier &&
node.callee.name === "require" &&
node.arguments.length >= 1 &&
node.arguments[0].type === AST_NODE_TYPES.Literal &&
FS_MODULE_SPECIFIERS.has(node.arguments[0].value as string)
);
}

/**
* Resolves the original fs sync method name for an Identifier callee via scope analysis.
* Handles two binding shapes:
* 1. Destructured: `const { appendFileSync } = require("fs")`
* or `const { appendFileSync: alias } = require("fs")`
* 2. Member-expression alias: `const alias = fs.appendFileSync`
*
* Returns the canonical method name (e.g. "appendFileSync") or null if the identifier
* does not trace back to an in-scope fs sync method.
*/
function resolveFsSyncMethodFromIdentifier(node: TSESTree.CallExpression): string | null {
const callee = node.callee;
if (callee.type !== AST_NODE_TYPES.Identifier) return null;

let scope: SourceCodeScope | null = sourceCode.getScope(node);
while (scope) {
const variable = scope.set.get(callee.name);
if (variable && variable.defs.length > 0) {
for (const def of variable.defs) {
if (def.type !== "Variable") continue;
const declarator = def.node as TSESTree.VariableDeclarator;

// Shape 1: `const { appendFileSync } = require("fs")`
// or `const { appendFileSync: alias } = require("fs")`
if (declarator.id.type === AST_NODE_TYPES.ObjectPattern && isRequireFsCall(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) continue;
if (!FS_SYNC_METHODS.has(prop.key.name)) continue;
// prop.value is the bound identifier (same as key for shorthand)
const boundName = prop.value.type === AST_NODE_TYPES.Identifier ? prop.value.name : null;
if (boundName === callee.name) {
return prop.key.name;
}
}
}

// Shape 2: `const alias = fs.appendFileSync`
// Only matches when the `fs` identifier is itself bound to require("fs") / require("node:fs").
if (declarator.id.type === AST_NODE_TYPES.Identifier && declarator.init?.type === AST_NODE_TYPES.MemberExpression) {
const init = declarator.init;
if (init.object.type === AST_NODE_TYPES.Identifier && isIdentifierBoundToFsModule(init.object.name, init.object)) {
const methodName = getFsSyncMethodFromProperty(init);
if (methodName !== null) return methodName;
}
}
}
// Variable is locally defined but not as an fs sync method binding — stop searching.

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] Scope-walk stops at the first definition block of a variable regardless of whether it contains any Variable defs. If all defs for an identifier are non-Variable type (e.g. FunctionName, Parameter, ImportBinding), the for loop produces no return and falls through to line 135 — which is correct. But if there are no defs at all (variable.defs.length === 0), the guard on line 104 prevents entry, so we walk up the scope chain, potentially matching an outer scope that has an unrelated binding with the same name.

This is an edge case unlikely in practice, but worth a comment or a test to document the intent.

💡 Low-risk clarifying comment
// If variable is found but has no defs (e.g. global built-in), skip to outer scope.
// This is intentional: we want to walk up to find a require("fs") binding.

Alternatively, a test like:

// valid: a globally-named `appendFileSync` that is not from require("fs")
`appendFileSync(filePath, data);` // should NOT be flagged (no binding found at all)

@copilot please address this.

return null;
}
scope = scope.upper;
}
return null;
}

/**
* Returns the fs sync method name from a MemberExpression property, or null if the property
* is not one of the in-scope fs sync methods. Handles both direct and computed string-literal access.
*/
function getFsSyncMethodFromProperty(memberExpr: TSESTree.MemberExpression): string | null {
const property = memberExpr.property;
if (!memberExpr.computed && property.type === AST_NODE_TYPES.Identifier && FS_SYNC_METHODS.has(property.name)) {
return property.name;
}
if (memberExpr.computed && property.type === AST_NODE_TYPES.Literal && typeof property.value === "string" && FS_SYNC_METHODS.has(property.value)) {
return property.value;
}
return null;
}

/**
* Returns true if the named identifier is bound to `require("fs")` or `require("node:fs")`
* anywhere in the scope chain visible from `scopeNode`.
*/
function isIdentifierBoundToFsModule(identifierName: string, scopeNode: TSESTree.Node): 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) {
if (def.type !== "Variable") continue;
const declarator = def.node as TSESTree.VariableDeclarator;
if (declarator.id.type === AST_NODE_TYPES.Identifier && isRequireFsCall(declarator.init)) {
return true;
}
}
return false; // Identifier found but not bound to require("fs")
}
scope = scope.upper;
}
return false;
}

return {
CallExpression(node) {
const callee = node.callee;
if (callee.type !== AST_NODE_TYPES.MemberExpression) return;

// Object must be the `fs` identifier (the standard import alias in actions/setup/js).
// Aliased references (const r = fs.readFileSync; r(path)) are intentionally out of scope.
if (callee.object.type !== AST_NODE_TYPES.Identifier || callee.object.name !== "fs") return;

// Accept both direct property access (fs.readFileSync) and computed string-literal access
// (fs["readFileSync"]). Dynamic computed access (fs[varName]) is excluded.
const property = callee.property;
let methodName: string | null = null;
if (!callee.computed && property.type === AST_NODE_TYPES.Identifier && FS_SYNC_METHODS.has(property.name)) {
methodName = property.name;
} else if (callee.computed && property.type === AST_NODE_TYPES.Literal && typeof property.value === "string" && FS_SYNC_METHODS.has(property.value)) {
methodName = property.value;

if (callee.type === AST_NODE_TYPES.MemberExpression) {
// Object may be `fs` or any identifier bound to require("fs") / require("node:fs").
if (callee.object.type !== AST_NODE_TYPES.Identifier) return;
if (callee.object.name !== "fs" && !isIdentifierBoundToFsModule(callee.object.name, callee.object)) return;

// Accept both direct property access (fs.readFileSync) and computed string-literal access
// (fs["readFileSync"]). Dynamic computed access (fs[varName]) is excluded.
methodName = getFsSyncMethodFromProperty(callee);
} else if (callee.type === AST_NODE_TYPES.Identifier) {
// Destructured or aliased fs binding — resolve via scope analysis.
methodName = resolveFsSyncMethodFromIdentifier(node);
}

if (!methodName) return;
Expand Down