Skip to content

eslint-factory: consolidate fs-sync try/catch rules and unify ESM/CJS fs binding detection#45964

Merged
pelikhan merged 6 commits into
mainfrom
copilot/consolidate-try-catch-rules
Jul 16, 2026
Merged

eslint-factory: consolidate fs-sync try/catch rules and unify ESM/CJS fs binding detection#45964
pelikhan merged 6 commits into
mainfrom
copilot/consolidate-try-catch-rules

Conversation

Copilot AI commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

require-fs-sync-try-catch and require-mkdirsync-try-catch had near-duplicate implementations and had drifted: mkdirSync handled ESM import bindings while other fs-sync methods did not. This change centralizes the shared logic so both rules enforce the same binding-resolution behavior across require(...) and import ... from "fs"/"node:fs".

  • Shared fs-sync rule machinery

    • Moved common try-boundary detection, enclosing-statement detection, and fs-sync call/binding resolution into try-catch-rule-utils.ts.
    • Added a shared resolver factory used by both rules, including:
      • module object calls (fs.readFileSync(...), computed property access),
      • destructured bindings,
      • renamed aliases,
      • member-expression aliases,
      • ESM import specifiers.
  • Rule refactor with behavior preserved

    • require-fs-sync-try-catch now delegates call matching to the shared resolver (with existing fs.* global behavior preserved).
    • require-mkdirsync-try-catch now uses the same resolver path, eliminating duplicated binding logic and keeping its rule-specific diagnostics/suggestions unchanged.
  • Coverage parity for ESM fs-sync imports

    • Extended require-fs-sync-try-catch tests to cover previously-missed ESM cases for:
      • readFileSync
      • writeFileSync
      • appendFileSync
    • This aligns fs-sync methods with existing mkdirSync ESM behavior.

Example of newly-covered detection parity:

import { readFileSync } from "node:fs";
readFileSync(path, "utf8"); // now flagged if not wrapped in try/catch

Copilot AI and others added 5 commits July 16, 2026 09:20
…rules

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Consolidate require-mkdirsync-try-catch and require-fs-sync-try-catch eslint-factory: consolidate fs-sync try/catch rules and unify ESM/CJS fs binding detection Jul 16, 2026
Copilot AI requested a review from pelikhan July 16, 2026 09:44
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for the ESLint rule consolidation work! The PR has one unchecked item remaining (final validations). Please complete the checklist — run agent-report-progress, secret scan, and parallel validation — before marking ready for review.

Generated by ✅ Contribution Check · 116.8 AIC · ⌖ 6.96 AIC · ⊞ 6.2K ·

@pelikhan
pelikhan marked this pull request as ready for review July 16, 2026 10:16
Copilot AI review requested due to automatic review settings July 16, 2026 10:16

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

Consolidates filesystem sync-call detection and try/catch analysis across two ESLint rules.

Changes:

  • Adds shared filesystem binding resolution and try/catch utilities.
  • Refactors both rules to use shared logic.
  • Adds ESM import coverage for filesystem sync methods.
Show a summary per file
File Description
try-catch-rule-utils.ts Adds shared resolution and boundary utilities.
require-mkdirsync-try-catch.ts Adopts shared rule machinery.
require-fs-sync-try-catch.ts Adopts shared resolver with existing global behavior.
require-fs-sync-try-catch.test.ts Tests ESM named and aliased imports.

Review details

Tip

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

  • Files reviewed: 4/4 changed files
  • Comments generated: 0
  • Review effort level: Medium

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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

No ADR enforcement needed: PR does not have the 'implementation' label and has ≤100 new lines of code in business logic directories (0 additions detected in src/, lib/, pkg/, internal/, app/, core/, domain/, services/, api/).

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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

@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: Clean Refactor ✅

The consolidation of isInsideTryBlock, findEnclosingStatement, and the fs binding resolution logic into try-catch-rule-utils.ts is well-structured and eliminates the prior drift between the two rules.

Key observations:

  • The shared createFsSyncMethodResolver factory correctly threads allowUnboundFsIdentifier for the original fs.* global pattern in require-fs-sync-try-catch.
  • isIdentifierBoundToFsModule in the new shared implementation gains ESM namespace import detection (via isFsImportBinding) that the old require-fs-sync-try-catch version lacked — this is a correctness improvement, not a regression.
  • isRequireFsCall evaluates node.type === AST_NODE_TYPES.CallExpression twice (once to extract firstArg, once in the return expression) — minor redundancy, no impact on correctness.
  • New ESM test cases for readFileSync, writeFileSync, and appendFileSync provide good parity coverage.

No blocking issues found.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 43.8 AIC · ⌖ 5.44 AIC · ⊞ 5K

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

⚠️ Test Quality Score: 67/100 — Acceptable

Analyzed 20 test(s): 15 design, 5 implementation, 0 violation(s).

📊 Metrics (20 tests)
Metric Value
Analyzed 20 (JS: 20 vitest cases)
✅ Design 15 (75%)
⚠️ Implementation 5 (25%)
Edge/error coverage 8 (40%)
Duplicate clusters 1 (parameter variants)
Inflation No (1.61:1 ratio, threshold 2:1)
🚨 Violations 0
📋 Test Classification
Category Tests Examples
Design 15 Try-block protection, deferred callbacks, binding patterns, ESM vs CJS
Implementation 5 Suggestion formatting, message IDs, edge positions
Edge Cases 8 try/finally, async callbacks, computed access, multiline
Regression Tests 1 action_setup_otlp.cjs false-negative case
✅ Strengths
  • Comprehensive CommonJS and ES Module context testing
  • Multiple fs binding patterns: plain fs., destructured, aliased, computed
  • Deferred callback detection: setTimeout, process.nextTick, EventEmitter .on(), Promise constructors
  • Both positive (valid, should pass) and negative (invalid, should error) cases
  • Regression coverage: real bug from actions/setup/js/action_setup_otlp.cjs
  • Boundary conditions: unsupported positions, multiline expressions
  • Error messages and suggestion fixes verified

Verdict

Passed. 25% implementation tests (threshold: 30%). No coding guideline violations.

Why this score:

  • 75% design tests verify behavioral contracts (when fs calls need protection)
  • Implementation tests are essential for ESLint RuleTester (message IDs, suggestion format)
  • Parameter variants test different method safety boundaries
  • Test:production ratio 1.61:1 is reasonable for ESLint rules
  • 40% of tests cover control flow edge cases
  • All 20 tests catch real regressions if deleted

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 · 29.2 AIC · ⌖ 13.2 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: 67/100. 25% implementation tests (threshold: 30%). All 20 tests verify genuine behavioral contracts with comprehensive edge case coverage.

@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 /codebase-design and /tdd — leaving comments, no blocking issues.

📋 Key Themes & Highlights

Key Themes

  • Small redundancy in isRequireFsCall: intermediate firstArg/firstArgValue variables are computed but the conjunction also accesses node.arguments[0] and node.arguments.length directly — worth a micro-cleanup.
  • Boolean option flag: allowUnboundFsIdentifier is the one rough edge in the factory interface; a comment or rename would make the intent clearer at call sites.
  • Coverage gap: import * as fs (namespace import) is tested for mkdirSync but not yet for readFileSync/writeFileSync/appendFileSync now that the resolver is shared.

Positive Highlights

  • ✅ Excellent reduction: ~320 lines deleted, duplicated logic eliminated cleanly.
  • createFsSyncMethodResolver factory is a genuinely deep module — simple interface, rich binding-resolution behaviour.
  • ✅ Good parity work: ESM named-import and aliased-import tests for all three fs-sync methods are new and correct.
  • require-mkdirsync-try-catch is now a thin wrapper — easy to audit and maintain.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 69.9 AIC · ⌖ 4.74 AIC · ⊞ 6.7K
Comment /matt to run again

node.arguments.length >= 1 &&
node.arguments[0].type === AST_NODE_TYPES.Literal &&
typeof firstArgValue === "string" &&
FS_MODULE_SPECIFIERS.has(firstArgValue)

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] isRequireFsCall pre-extracts firstArg/firstArgValue but then re-accesses node.arguments[0] directly in the conjunction — the intermediate variables are redundant and create a subtle inconsistency.

💡 Suggestion

Simplify to a single extraction:

function isRequireFsCall(node: TSESTree.Node | null | undefined): boolean {
  if (node?.type !== AST_NODE_TYPES.CallExpression) return false;
  const arg = node.arguments[0];
  return (
    node.callee.type === AST_NODE_TYPES.Identifier &&
    node.callee.name === 'require' &&
    arg?.type === AST_NODE_TYPES.Literal &&
    typeof arg.value === 'string' &&
    FS_MODULE_SPECIFIERS.has(arg.value)
  );
}

@copilot please address this.


if (callee.type === AST_NODE_TYPES.MemberExpression) {
if (callee.object.type !== AST_NODE_TYPES.Identifier) return null;
const canUseUnboundFsIdentifier = options.allowUnboundFsIdentifier === true && callee.object.name === "fs";

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] allowUnboundFsIdentifier is a boolean flag that selectively changes the resolution strategy — this is a classic boolean trap that makes createFsSyncMethodResolver harder to understand at call sites ({ allowUnboundFsIdentifier: true } is a leaky detail).

💡 Suggestion

Consider expressing the two resolution modes as distinct factory signatures or named presets:

// Explicit: callers declare the strategy by name
export function createFsSyncMethodResolver(sourceCode, fsSyncMethods, options?: { allowUnboundFsGlobal?: boolean })

Or split into createBoundFsSyncMethodResolver and createUnboundFsSyncMethodResolver. The current name and flag don't communicate why the rule for fs.* global calls is different (legacy CJS global pattern), so a comment at minimum would help.

@copilot please address this.

valid: [
`try { const x = fs.readFileSync(path, "utf8"); } catch (e) {}`,
`import { readFileSync } from "node:fs"; try { readFileSync(path, "utf8"); } catch (e) {}`,
`import { writeFileSync as write } from "fs"; try { write(path, data); } catch (e) {}`,

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 new ESM valid/invalid tests cover named imports (import { readFileSync }) and renamed aliases (import { writeFileSync as write }), but import * as fs from "fs"; fs.readFileSync(...) (namespace import) is not tested here — it is tested for mkdirSync (line 187 in the mkdirSync test file) but not for the fs-sync methods, creating a coverage gap now that both rules share the same resolver.

💡 Suggested test to add
// valid
`import * as fs from "node:fs"; try { fs.readFileSync(path, "utf8"); } catch (e) {}`
// invalid
{ code: `import * as fs from "fs"; fs.readFileSync(p, "utf8");`, errors: [...] }

This would confirm namespace-import detection is covered by the shared resolver for all fs-sync methods, not just mkdirSync.

@copilot please address this.

@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.

REQUEST_CHANGES — 2 correctness issues + 3 maintainability concerns

The consolidation is a solid approach and the shared-utility extraction is clean overall. However there are two correctness issues that can cause rules to silently miss violations, plus gaps in test coverage that leave the refactored CJS path unvalidated.

Blocking:

  1. Raw string "TryStatement" — the centralized isInsideTryBlock uses a string literal instead of AST_NODE_TYPES.TryStatement. Now that both rules funnel through this one path, a typo here silently disables both rules.
  2. Optional-chaining bypassfs?.readFileSync(path) wraps its callee in ChainExpression, which falls through resolveFsSyncMethod unreported. This is a trivially exploitable escape hatch from both rules.

Non-blocking but should fix:
3. allowUnboundFsIdentifier asymmetry — undocumented behavioral divergence between the two rules.
4. Missing CJS invalid test cases — refactor added only ESM invalid tests; CJS path regression is undetectable.
5. Dead precomputation in isRequireFsCall — confusing indirection with no benefit.

🔎 Code quality review by PR Code Quality Reviewer · 81 AIC · ⌖ 4.93 AIC · ⊞ 5.6K
Comment /review to run again

}

return false;
}

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.

Raw string literal "TryStatement" instead of AST_NODE_TYPES.TryStatement: if mistyped or the upstream value changes, both rules silently stop recognizing try-blocks, defeating the lint entirely. Every other node-type check in this file uses the enum. Replace with AST_NODE_TYPES.TryStatement.

if (!canUseUnboundFsIdentifier && !isIdentifierBoundToFsModule(callee.object.name, callee.object)) return null;
return getFsSyncMethodFromProperty(callee);
}

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.

Optional-chaining calls bypass the rule: fs?.readFileSync(path) produces a ChainExpression wrapping a MemberExpression, so callee.type is never MemberExpression, and the call falls through to return null unreported.

💡 Suggested fix

Unwrap ChainExpression before the type-switch:

return function resolveFsSyncMethod(node: TSESTree.CallExpression): string | null {
  let callee: TSESTree.Node = node.callee;
  // Unwrap optional chaining: fs?.readFileSync() → callee is ChainExpression
  if (callee.type === AST_NODE_TYPES.ChainExpression) {
    callee = callee.expression;
  }

  if (callee.type === AST_NODE_TYPES.MemberExpression) { ... }
  if (callee.type === AST_NODE_TYPES.Identifier) { ... }
  return null;
};

Without this, fs?.readFileSync(path) is a trivial escape hatch from both rules.

}

function isRequireFsCall(node: TSESTree.Node | null | undefined): boolean {
if (!node) return false;

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.

Dead precomputation in isRequireFsCall: firstArg and firstArgValue are computed on lines 106–107 but the return expression (lines 109–116) independently re-checks every condition from scratch. The variables add no short-circuit benefit and make the function harder to reason about.

💡 Suggested fix

Remove the early extraction and simplify to a single expression (matching the original pattern):

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 &&
    typeof node.arguments[0].value === "string" &&
    FS_MODULE_SPECIFIERS.has(node.arguments[0].value)
  );
}


return false;
}
const resolveFsSyncMethod = createFsSyncMethodResolver(sourceCode, FS_SYNC_METHODS);

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.

Silent behavioral asymmetry with require-fs-sync-try-catch: require-fs-sync-try-catch passes allowUnboundFsIdentifier: true so bare fs.readFileSync() (no import/require) is still flagged. Here, require-mkdirsync-try-catch omits the option entirely, so fs.mkdirSync() with no binding is silently skipped. This divergence is not documented and will surprise users.

💡 Suggested fix

Either add { allowUnboundFsIdentifier: true } to match the sibling rule's behavior, or add an explicit comment explaining why the asymmetry is intentional:

const resolveFsSyncMethod = createFsSyncMethodResolver(sourceCode, FS_SYNC_METHODS, {
  allowUnboundFsIdentifier: true, // flag bare fs.mkdirSync() the same as the fs-sync rule
});

@@ -351,6 +355,51 @@ describe("require-fs-sync-try-catch", () => {
},

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.

No new invalid CJS test cases after refactor: the three new invalid entries only exercise ESM import patterns. CJS destructuring (const { readFileSync } = require('fs')) and aliased CJS (const { appendFileSync: append } = require('fs')) are not covered in the new tests. If createFsSyncMethodResolver's CJS path regressed in this refactor, the test suite would not catch it.

💡 Suggested fix

Add at minimum one new invalid CJS case per newly-added method to provide parity coverage:

{
  code: `const { readFileSync } = require('fs'); readFileSync(p, 'utf8');`,
  errors: [{ messageId: 'requireTryCatch', data: { method: 'readFileSync', arg: 'p' } }],
},
{
  code: `const { writeFileSync: write } = require('node:fs'); write(outputPath, data);`,
  errors: [{ messageId: 'requireTryCatch', data: { method: 'writeFileSync', arg: 'outputPath' } }],
},

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@pelikhan
pelikhan merged commit 794144e into main Jul 16, 2026
35 of 36 checks passed
@pelikhan
pelikhan deleted the copilot/consolidate-try-catch-rules branch July 16, 2026 11:36
Copilot stopped work on behalf of gh-aw-bot due to an error July 16, 2026 11:36
Copilot AI requested a review from gh-aw-bot July 16, 2026 11:36
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.82.11

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

Labels

None yet

Projects

None yet

4 participants