Skip to content

eslint-factory: add gh-aw-custom/prefer-number-isnan rule for unsafe global isNaN usage#43365

Merged
pelikhan merged 6 commits into
mainfrom
copilot/eslint-miner-add-prefer-number-isnan-rule
Jul 4, 2026
Merged

eslint-factory: add gh-aw-custom/prefer-number-isnan rule for unsafe global isNaN usage#43365
pelikhan merged 6 commits into
mainfrom
copilot/eslint-miner-add-prefer-number-isnan-rule

Conversation

Copilot AI commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Global isNaN() coercion can silently accept invalid raw inputs (e.g. env/API strings), which makes numeric validation brittle in actions/setup/js. This PR adds a custom lint rule to standardize on strict Number.isNaN() semantics and surface risky callsites as warnings.

  • Rule addition: prefer-number-isnan

    • Adds eslint-factory/src/rules/prefer-number-isnan.ts.
    • Flags global/coercive forms:
      • isNaN(x)
      • globalThis.isNaN(x) / globalThis["isNaN"](x)
      • window.isNaN(x) / window["isNaN"](x)
      • global.isNaN(x) / global["isNaN"](x)
    • Intentionally ignores locally shadowed bindings (isNaN, globalThis, window, global).
  • Developer ergonomics

    • hasSuggestions: true with callee-only replacement to Number.isNaN.
    • No autofix, so callers can make context-aware argument decisions (e.g., whether to wrap raw inputs in Number(...)).
  • Factory wiring

    • Exports the new rule in eslint-factory/src/index.ts.
    • Enables it at warn severity in eslint-factory/eslint.config.cjs as:
      • gh-aw-custom/prefer-number-isnan.
  • Focused coverage

    • Adds eslint-factory/src/rules/prefer-number-isnan.test.ts covering:
      • valid Number.isNaN(...) usage
      • direct/computed global access patterns
      • shadowed-binding exclusions
      • suggestion output correctness
// flagged
if (isNaN(issueNumber) || issueNumber <= 0) { ... }

// suggested callee replacement
if (Number.isNaN(issueNumber) || issueNumber <= 0) { ... }

Generated by 👨‍🍳 PR Sous Chef · 11.4 AIC · ⌖ 4.86 AIC · ⊞ 3.4K ·
Comment /souschef to run again

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Add custom ESLint rule to prefer Number.isNaN over global isNaN eslint-factory: add gh-aw-custom/prefer-number-isnan rule for unsafe global isNaN usage Jul 4, 2026
Copilot AI requested a review from pelikhan July 4, 2026 10:38
@pelikhan pelikhan marked this pull request as ready for review July 4, 2026 10:48
Copilot AI review requested due to automatic review settings July 4, 2026 10:48

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 extends the eslint-factory custom ESLint plugin with a new rule that discourages coercive global isNaN(...) usage in favor of Number.isNaN(...), helping standardize safer numeric validation patterns in gh-aw JavaScript runtime scripts.

Changes:

  • Added a new custom rule gh-aw-custom/prefer-number-isnan with a suggestion to replace global isNaN callees with Number.isNaN.
  • Added rule tests covering valid patterns, global access patterns, shadowed-binding exclusions, and suggestion output.
  • Exported and enabled the rule at warn severity in the factory plugin and config.
Show a summary per file
File Description
eslint-factory/src/rules/prefer-number-isnan.ts Implements the new lint rule and suggestion behavior for global/coercive isNaN callsites.
eslint-factory/src/rules/prefer-number-isnan.test.ts Adds RuleTester coverage for valid/invalid patterns and suggestion output.
eslint-factory/src/index.ts Exports the new rule via the plugin rule map.
eslint-factory/eslint.config.cjs Enables gh-aw-custom/prefer-number-isnan at warning level.

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: 1
  • Review effort level: Low

Comment on lines +52 to +58
function isIsNaNProperty(node: TSESTree.MemberExpression): boolean {
const property = node.property;
const isDirectAccess = property.type === "Identifier" && property.name === "isNaN";
const isComputedAccess = property.type === "Literal" && property.value === "isNaN";

return isDirectAccess || isComputedAccess;
}
@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 4, 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.

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 86/100 — Excellent

Analyzed 5 test(s): 4 design, 1 implementation, 0 violation(s).

📊 Metrics (5 tests)
Metric Value
Analyzed 5 (Go: 0, JS/TS: 5)
✅ Design 4 (80%)
⚠️ Implementation 1 (20%)
Edge/error coverage 4 (80%)
Duplicate clusters 0
Inflation No (0.99:1 — 87 test lines vs 88 prod lines)
🚨 Violations 0
Test File Classification Issues
uses the correct docs URL prefer-number-isnan.test.ts:22 implementation_test Metadata-only assertion; low behavioral value
valid: Number.isNaN and non-global forms are accepted prefer-number-isnan.test.ts:26 design_test
valid: locally shadowed bindings are intentionally excluded prefer-number-isnan.test.ts:33 design_test
invalid: global isNaN() is flagged with a replacement suggestion prefer-number-isnan.test.ts:47 design_test
invalid: global object isNaN() access is flagged for direct and computed forms prefer-number-isnan.test.ts:60 design_test

Verdict

Passed. 20% implementation tests (threshold: 30%). No violations. Tests use ESLint RuleTester correctly, covering: happy-path valid code (no false positives), scope-shadowing edge cases, bare-global detection, and all three global object forms (globalThis, window, global) in both direct and computed property access patterns.

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 · 30.1 AIC · ⌖ 10.5 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: 86/100. 20% implementation tests (threshold: 30%). No violations.

@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: gh-aw-custom/prefer-number-isnan

High-quality, well-tested implementation. The new rule closely follows the established pattern in require-parseInt-radix — same hasLocalBinding helper, same computed-vs-direct MemberExpression check, same shadow-exclusion logic, and consistent test structure.

Design decisions look correct:

  • Number is intentionally absent from GLOBAL_IS_NAN_OBJECTS (unlike require-parseInt-radix which includes it), because Number.isNaN is the target, not a flagged form.
  • Suggestion-only (no autofix) is the right call: replacing the callee without reviewing the argument can silently change semantics (isNaN("42")true; Number.isNaN("42")false).
  • Shadow-binding exclusion correctly prevents false positives for locally declared isNaN/globalThis/window/global.

Minor pre-existing gap (not blocking): The docs.url for all rules points to https://github.com/github/gh-aw/tree/main/eslint-factory#<rule-name>, but README.md has no per-rule anchors. This is pre-existing across every rule; a follow-up to add rule docs to the README would help all rules at once.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · 53.4 AIC · ⌖ 5.87 AIC · ⊞ 4.9K

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

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


describe("prefer-number-isnan", () => {
it("uses the correct docs URL", () => {
expect(preferNumberIsNanRule.meta.docs.url).toBe("https://github.com/github/gh-aw/tree/main/eslint-factory#prefer-number-isnan");

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.

[/grill-with-docs] The docs URL points to a README anchor (#prefer-number-isnan) that doesn't exist yet — clicking this link in an IDE will land on a 404 fragment.

💡 Add the anchor to README.md

Add a rules table or section to eslint-factory/README.md so the URL resolves. For example:

## Rules

### `prefer-number-isnan`

Prefer `Number.isNaN()` over global `isNaN()` to avoid silent coercion of non-numeric inputs.

This makes every rule's docs URL a meaningful destination for developers who click through from their IDE lint warning.

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

[/tdd] Missing edge case: the suggestion replaces only the callee (isNaNNumber.isNaN) but preserves the original argument unchanged. If the argument is a raw string (e.g., isNaN(process.env.PORT)) the replacement is still wrong — Number.isNaN(process.env.PORT) will always return false. There's no test covering this unsafe-argument scenario.

💡 Consider a suggestion message that surfaces the risk

The current suggestion message says "review whether the argument should be wrapped with Number(...)" which is good, but a test that applies the suggestion to an obviously unsafe argument (e.g., isNaN(envVar)) would confirm the suggestion text actually guides the author toward the right fix.

// add to the invalid cases section
{
  code: `isNaN(process.env.PORT);`,
  errors: [{ messageId: "preferNumberIsNaN",
    suggestions: [{ messageId: "replaceWithNumberIsNaN",
      output: `Number.isNaN(process.env.PORT);` }] }],
},

This makes the "review the argument" note testable and visible in the test suite as documentation.

@copilot please address this.

function hasLocalBinding(node: TSESTree.Node, name: string): boolean {
let scope: SourceCodeScope | null = sourceCode.getScope(node);

while (scope) {

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] The hasLocalBinding function walks the entire scope chain up to the module/program scope, which means it also returns true for module-level import bindings (which have defs.length > 0). This could suppress the rule when isNaN is imported from a utility module — an import is not a local shadow that makes the call safe.

💡 Scope the "local binding" check to user-defined variables only

ESLint's scope defs have a type property. Consider restricting to Variable and Parameter def types so that import bindings don't trigger the exclusion:

if (variable?.defs.some(d => d.type === "Variable" || d.type === "Parameter")) {
  return true;
}

Or verify this can't happen in practice by adding a test case like:

// This should still be flagged — isNaN is imported, not a real local shadow
import { isNaN } from "./compat";
isNaN(value);

@copilot please address this.

cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, {
valid: [`Number.isNaN(value);`, `Number["isNaN"](value);`, `foo.isNaN(value);`],
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] The valid-cases test for Number.isNaN and non-global forms runs only under the CJS RuleTester — there's no ESM counterpart for these valid paths. Given that the rule is intended for actions/setup/js (CJS) this is acceptable, but worth an explicit comment or a matching ESM valid-case block to make the intent clear.

💡 Either add an ESM counterpart or add an intent comment
// valid cases under ESM — mirrors CJS coverage above
esmRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, {
  valid: [`Number.isNaN(value);`, `foo.isNaN(value);`],
  invalid: [],
});

Or add a brief comment: // CJS-only: actions/setup/js targets CommonJS so future readers know the asymmetry is deliberate.

@copilot please address this.

if (node.callee.type === "Identifier" && node.callee.name === "isNaN" && !hasLocalBinding(node, "isNaN")) {
report(node);
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.

[/tdd] The CallExpression visitor handles isNaN(...) but not isNaN used as a value (e.g., passed as a callback: values.some(isNaN)). There's no test for this pattern, so the intent (flag or ignore?) is undocumented.

💡 Add a test to document the intended behaviour
// Should this be flagged?  Document the decision with a test.
it("valid/invalid: isNaN used as a callback reference", () => {
  cjsRuleTester.run("prefer-number-isnan", preferNumberIsNanRule, {
    valid: [`values.some(isNaN);`], // intentionally not a CallExpression
    invalid: [],
  });
});

If the intent is to also catch reference-style usage, a separate Identifier visitor would be needed.

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

Non-blocking: one real bug, one missing test

The overall design is solid — @typescript-eslint/utils typed helpers, proper scope-chain walk via context.sourceCode.getScope(), and separate isIsNaNProperty for computed/direct dispatch. One correctness bug exists in that helper and is already flagged by the existing review comment; one inline comment here tracks the matching missing test.

Key findings

Bug: isIsNaNProperty false positive on globalThis[isNaN](x) (existing comment covers the fix in prefer-number-isnan.ts)
The isDirectAccess branch checks property.type === "Identifier" && property.name === "isNaN" without guarding !node.computed. A computed MemberExpression like globalThis[isNaN](x) — where the property is a variable reference, not the string literal "isNaN" — incorrectly satisfies this check and gets flagged. Fix: add !node.computed && before the identifier test.

Missing regression test (inline comment on test file)
The valid test suite has no case for globalThis[isNaN](value), so the false positive has no test harness to catch it before or after the fix.

Everything else — hasLocalBinding scope walk, suggestion fixer, index/config wiring, the foo.isNaN() exclusion — is correct.

🔎 Code quality review by PR Code Quality Reviewer · 172.8 AIC · ⌖ 6.35 AIC · ⊞ 5.4K
Comment /review to run again

],
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.

Missing valid test for dynamic computed property access: globalThis[isNaN](x) (where isNaN is a variable reference, not a string literal) should be a valid case, but there is no test covering it — this is the regression guard for the isIsNaNProperty false-positive bug noted in the existing review comment.

💡 Suggested addition

Add to the valid tests:

esmRuleTester.run('prefer-number-isnan', preferNumberIsNanRule, {
  valid: [
    // Dynamic computed access — identifier property, not string literal 'isNaN'
    `globalThis[isNaN](value);`,
  ],
  invalid: [],
});

The isDirectAccess branch of isIsNaNProperty checks property.type === "Identifier" && property.name === "isNaN" but does not guard against node.computed === true. That means globalThis[isNaN](x) — where the property is an identifier reference, not the literal string "isNaN" — satisfies isDirectAccess and produces a false positive. The fix is !node.computed && property.type === "Identifier" && property.name === "isNaN"; this test case is the proof it holds.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the pr-finisher skill, address outstanding review feedback, refresh the branch if needed, and rerun checks once updated.

Unresolved review feedback to address:

Generated by 👨‍🍳 PR Sous Chef · 11.4 AIC · ⌖ 4.86 AIC · ⊞ 3.4K ·
Comment /souschef to run again

Copilot AI and others added 3 commits July 4, 2026 11:52
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the pr-finisher skill, address outstanding review feedback, refresh the branch if needed, and rerun checks once updated.

Unresolved review feedback to address:...

Addressed all unresolved feedback:

  • isIsNaNProperty false positive (r3523047218, r3523017631): Added !node.computed guard to isDirectAccess so globalThis[isNaN](x) (identifier reference, not string literal) is no longer flagged. Added the regression-guard valid test case.
  • hasLocalBinding import bindings (r3523035185): Changed the check to d.type !== "ImportBinding" so imported isNaN bindings no longer suppress the rule, while local variable declarations, parameters, and function declarations still do.
  • Unsafe-argument test (r3523035184): Added isNaN(process.env.PORT) as an invalid test case to document that the suggestion preserves the raw argument and the caller must decide whether to wrap it with Number(...).
  • Callback reference test (r3523035188): Added values.some(isNaN) as a valid test case documenting that non-CallExpression reference usage is intentionally not flagged.
  • CJS-only asymmetry (r3523035186): Added a comment on the valid-cases test block clarifying the asymmetry is intentional (// CJS-only: actions/setup/js targets CommonJS).
  • Docs URL anchor (r3523035183): Added a ## Rules section with a ### prefer-number-isnan anchor to eslint-factory/README.md so the IDE link resolves. Also fixed a factual error in the description example.

Copilot AI requested a review from gh-aw-bot July 4, 2026 11:58
@pelikhan pelikhan merged commit 0f1f3a2 into main Jul 4, 2026
6 checks passed
@pelikhan pelikhan deleted the copilot/eslint-miner-add-prefer-number-isnan-rule branch July 4, 2026 12:24
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-miner] eslint: add prefer-number-isnan rule

4 participants