Skip to content

fix(eslint-factory): require-fs-sync-try-catch catches destructured and aliased fs bindings - #44240

Merged
pelikhan merged 4 commits into
mainfrom
copilot/eslint-factory-require-fs-sync-try-catch
Jul 8, 2026
Merged

fix(eslint-factory): require-fs-sync-try-catch catches destructured and aliased fs bindings#44240
pelikhan merged 4 commits into
mainfrom
copilot/eslint-factory-require-fs-sync-try-catch

Conversation

Copilot AI commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

The require-fs-sync-try-catch rule only matched fs.method() member-expression calls, silently missing bindings introduced by destructuring or aliasing — including a live unprotected appendFileSync call in action_setup_otlp.cjs:43.

Rule changes (require-fs-sync-try-catch.ts)

  • isRequireFsCall() — recognises require("fs") and require("node:fs")
  • resolveFsSyncMethodFromIdentifier() — scope-walks the callee name and resolves two new binding shapes:
    • Shape 1 (destructured): const { appendFileSync } = require("fs") and const { appendFileSync: alias } = require("fs")
    • Shape 2 (member alias): const alias = fs.appendFileSync, gated on isIdentifierBoundToFsModule() to avoid false positives from unrelated objects named fs
  • getFsSyncMethodFromProperty() — extracted from the existing CallExpression handler and reused for both the direct fs.method() and alias paths

The CallExpression handler now dispatches to resolveFsSyncMethodFromIdentifier() when the callee is an Identifier.

// Previously missed — now flagged:
const { appendFileSync } = require("fs");
appendFileSync(filePath, `${key}=${value}\n`);  // action_setup_otlp.cjs:43

const { readFileSync } = require("node:fs");
readFileSync(path, "utf8");

const { appendFileSync: append } = require("fs");
append(filePath, data);

const fs = require("fs");
const write = fs.writeFileSync;
write(path, data);

Test changes (require-fs-sync-try-catch.test.ts)

  • Replaced the old "valid: destructured fs bindings stay out of scope" placeholder with proper valid cases (in-try) and invalid cases (bare calls) covering all four new binding forms.

…nd aliased fs bindings

Resolves the false negative where `const { appendFileSync } = require("fs")`
followed by `appendFileSync(...)` was not flagged. Adds scope analysis to
detect two new binding shapes:

1. Destructured: `const { appendFileSync } = require("fs"/"node:fs")`
   and renamed form: `const { appendFileSync: alias } = require("fs")`
2. Member-expression alias: `const alias = fs.appendFileSync`
   where `fs` is verified via scope analysis to be require("fs")

Also adds:
- `isRequireFsCall()` helper to check require("fs") / require("node:fs")
- `getFsSyncMethodFromProperty()` helper extracted from the CallExpression handler
- `isIdentifierBoundToFsModule()` helper to prevent false positives in Shape 2
- Valid tests: destructured/aliased bindings inside try blocks
- Invalid tests: bare destructured/aliased calls (including the live FN pattern)

Closes #44218

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix require-fs-sync-try-catch for destructured fs bindings fix(eslint-factory): require-fs-sync-try-catch catches destructured and aliased fs bindings Jul 8, 2026
Copilot AI requested a review from pelikhan July 8, 2026 07:57
@pelikhan
pelikhan marked this pull request as ready for review July 8, 2026 08:46
Copilot AI review requested due to automatic review settings July 8, 2026 08:46

Copilot AI left a comment

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.

Pull request overview

This PR strengthens the require-fs-sync-try-catch ESLint rule so it also flags unguarded synchronous fs I/O calls when the fs methods are invoked via destructured bindings (including aliased destructuring) or via member-expression aliases (e.g., const write = fs.writeFileSync; write(...)), and updates tests to cover these new binding forms.

Changes:

  • Extend fs-module detection to handle both require("fs") and require("node:fs").
  • Add scope-based resolution for Identifier callees to detect destructured/aliased fs sync method bindings and member-alias bindings.
  • Expand the test suite with valid/invalid cases covering the new binding shapes.
Show a summary per file
File Description
eslint-factory/src/rules/require-fs-sync-try-catch.ts Adds fs-module specifier handling and scope-based identifier resolution for destructured and aliased fs sync method calls.
eslint-factory/src/rules/require-fs-sync-try-catch.test.ts Updates and expands tests to validate the new detection paths for destructured and aliased bindings.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Low

// 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, node)) {
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR #44240 does not have the 'implementation' label and has 0 new lines of code in business logic directories (threshold: 100).

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

⚠️ PR Code Quality Reviewer failed during code quality review.

@github-actions github-actions Bot mentioned this pull request Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 6 new/modified test cases: 6 design, 0 implementation, 0 violation(s).

📊 Metrics (6 tests)
Metric Value
Analyzed 6 (JS/TypeScript: vitest)
✅ Design 6 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 6 (100%)
Duplicate clusters 0
Inflation No (ratio 0.86:1)
🚨 Violations 0
Test File Classification Issues
destructured fs bindings inside try block (modified) require-fs-sync-try-catch.test.ts:56-65 design_test None
member-expression alias inside try block (new) require-fs-sync-try-catch.test.ts:67-72 design_test None
destructured fs binding (shorthand) not in try require-fs-sync-try-catch.test.ts:330-350 design_test None
destructured fs binding via node:fs not in try require-fs-sync-try-catch.test.ts:353-373 design_test None
renamed destructured fs binding not in try require-fs-sync-try-catch.test.ts:376-396 design_test None
member-expression alias not in try require-fs-sync-try-catch.test.ts:399-419 design_test None

Coverage Highlights

  • Scope resolution: Destructured shorthand (const { readFileSync } = require("fs")), renamed (const { append } = require("fs")), and member-expression aliases (const append = fs.appendFileSync)
  • Module specifiers: Both require("fs") and require("node:fs") covered
  • Try-block protection: Valid cases test wrapped calls; invalid cases test detection and auto-fix suggestions
  • Error messages: messageId, data fields, and suggestion output validated for each case
  • No false comfort: All tests verify user-visible behavior (detection, error messaging, fix suggestions)

Verdict

Passed. 0% implementation tests (threshold: 30%). All 6 tests verify design invariants: scope-based detection of destructured and aliased fs sync methods, with and without try-block protection. No mocking, no duplicates, appropriate test inflation (0.86:1).

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • awmgmcpg

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "awmgmcpg"

See Network Configuration for more information.

🧪 Test quality analysis by Test Quality Sentinel · 20.6 AIC · ⌖ 9.13 AIC · ⊞ 6.8K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

✅ Test Quality Sentinel: 100/100. 0% implementation tests (threshold: 30%).

@github-actions github-actions Bot left a comment

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.

Review: require-fs-sync-try-catch — destructured and aliased fs bindings

The scope-walking approach and all four test cases are well-structured. One correctness bug found.

Blocking issueisIdentifierBoundToFsModule scope anchor

In resolveFsSyncMethodFromIdentifier (Shape 2), isIdentifierBoundToFsModule(init.object.name, node) passes the call site as the scope anchor. If fs is shadowed in a nested scope that encloses only the call (not the alias declaration), the lookup will find the shadow and return false, producing a false negative.

The anchor should be init.object (the fs identifier node in the alias initializer), so scope resolution is pinned to the declaration site.

Example false negative
const fs = require('fs');
const append = fs.appendFileSync;   // correctly bound to module
function inner() {
  const fs = {};                      // shadows outer fs
  append(path, data);                 // call site — scope walk from here finds {} → rule misses it
}

See inline comment on line 128 for the one-line fix.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 82.1 AIC · ⌖ 7.75 AIC · ⊞ 4.8K

// 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, node)) {

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.

Bug (false negative): scope anchor should be the declaration site, not the call site.

isIdentifierBoundToFsModule is called with node (the CallExpression at the call site), but it should be anchored at the declaration — either def.node (the VariableDeclarator) or init.object (the fs identifier node in the initializer).

As written, if fs is shadowed between the alias declaration and the call site, the scope walk finds the shadow (not the real require("fs")) and silently skips the flag:

const fs = require("fs");
const append = fs.appendFileSync;   // fs === module ✓
function inner() {
  const fs = {};                      // shadows outer fs
  append(path, data);                 // call site — scope finds {} → isIdentifierBoundToFsModule returns false → false negative
}

Suggested fix — use init.object as the scope anchor so lookup is resolved at the point of the declaration:

if (init.object.type === AST_NODE_TYPES.Identifier && isIdentifierBoundToFsModule(init.object.name, init.object)) {

@copilot please address this.

@pelikhan

pelikhan commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — requesting changes on two correctness gaps and three test-quality improvements.

📋 Key Themes & Highlights

Key Issues

  • Incomplete fix (correctness): The MemberExpression arm (line 187) still hardcodes callee.object.name !== "fs", leaving const fileSystem = require("fs"); fileSystem.readFileSync(path) as a live false negative — inconsistent with the new Identifier arm's scope-aware approach. This is the highest-priority item.
  • Missing false-positive test: No test covers a same-named method bound to a non-fs library (e.g. const { appendFileSync } = require("custom-logger")). A future regression here would be silent.
  • Scope-walk edge case: When a variable exists with no Variable defs (e.g. it's a parameter or function name), the outer scope is searched — this is intentional but undocumented and untested.

Positive Highlights

  • ✅ Root cause correctly identified and well-described in the PR body
  • ✅ New isRequireFsCall(), resolveFsSyncMethodFromIdentifier(), isIdentifierBoundToFsModule() helpers are well-decomposed and clearly documented
  • ✅ Live false-negative in action_setup_otlp.cjs:43 is directly addressed with a regression test
  • ✅ Four binding shapes covered with both valid (in-try) and invalid (bare call) cases
  • getFsSyncMethodFromProperty() extraction is a clean refactor that reduces duplication

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 126.1 AIC · ⌖ 5.89 AIC · ⊞ 6.6K
Comment /matt to run again

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.

if (callee.object.type !== AST_NODE_TYPES.Identifier || callee.object.name !== "fs") return;
if (callee.type === AST_NODE_TYPES.MemberExpression) {
// Object must be the `fs` identifier (the standard import alias in actions/setup/js).
if (callee.object.type !== AST_NODE_TYPES.Identifier || callee.object.name !== "fs") return;

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] Inconsistency: the MemberExpression arm still hardcodes callee.object.name !== "fs" instead of using the new isIdentifierBoundToFsModule() helper.

This means const fileSystem = require("fs"); fileSystem.readFileSync(path) is still a false negative — a gap that's inconsistent with the new Identifier arm's scope-aware approach.

💡 Suggested fix

Replace:

if (callee.object.type !== AST_NODE_TYPES.Identifier || callee.object.name !== "fs") return;

With:

if (callee.object.type !== AST_NODE_TYPES.Identifier) return;
if (!isIdentifierBoundToFsModule(callee.object.name, node)) return;

And add a test:

it("invalid: aliased require('fs') direct method call is flagged", () => {
  cjsRuleTester.run("require-fs-sync-try-catch", requireFsSyncTryCatchRule, {
    valid: [],
    invalid: [{
      code: `const fileSystem = require("fs"); fileSystem.readFileSync(path, "utf8");`,
      errors: [{ messageId: "requireTryCatch", data: { method: "readFileSync", arg: "path" } }],
    }],
  });
});

@copilot please address this.

}
}
}
// 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.

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.

});
});

it("invalid: destructured fs binding (shorthand) not in try is flagged — live FN in action_setup_otlp.cjs", () => {

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] Test names that encode implementation details ("live FN in action_setup_otlp.cjs") are brittle — if the filename moves the test name becomes stale. The regression motivation is valuable; consider capturing it in a comment inside the test body instead.

💡 Suggested rename
it("invalid: destructured fs binding (shorthand) not in try is flagged", () => {
  // Regression: was a live false-negative in action_setup_otlp.cjs:43
  cjsRuleTester.run(...);
});

This keeps test names stable as specifications while preserving the historical context.

@copilot please address this.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>

Copilot AI commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run pr-finisher skill

Completed a pr-finisher pass and pushed commit 30d9524 addressing the in-scope review items (scope-anchor false negative, member-alias detection, and regression coverage). Running final validation now and will hand off with merge-readiness status.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
@pelikhan
pelikhan merged commit 3943a52 into main Jul 8, 2026
@pelikhan
pelikhan deleted the copilot/eslint-factory-require-fs-sync-try-catch branch July 8, 2026 09:36
Copilot stopped work on behalf of pelikhan due to an error July 8, 2026 09:36
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.82.5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

eslint-factory: require-fs-sync-try-catch misses destructured/named fs bindings (live FN in action_setup_otlp.cjs)

3 participants