Skip to content

[eslint-miner] Add ESLint rule: require-invalid-date-check-before-compare - #51354

Merged
pelikhan merged 3 commits into
mainfrom
eslint-miner/require-invalid-date-check-before-compare-3dc100e8f34dffb1
Aug 8, 2026
Merged

[eslint-miner] Add ESLint rule: require-invalid-date-check-before-compare#51354
pelikhan merged 3 commits into
mainfrom
eslint-miner/require-invalid-date-check-before-compare-3dc100e8f34dffb1

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

ESLint Miner — new rule: require-invalid-date-check-before-compare

Mission

Add one high-signal, net-new custom ESLint rule for actions/setup/js/** (per the daily ESLint Miner mandate).

What was mined

Scanned actions/setup/js/*.cjs for recurring new Date(...) construction patterns feeding relational comparisons (<, >, <=, >=). Cross-checked existing rules in eslint-factory/src/rules (including require-nan-check-after-env-numeric-parse, which validates numeric parses from process.env but does not cover Date parsing) — confirmed no overlapping rule exists for Date validation.

The bug pattern

new Date(x) where x is unparseable produces an "Invalid Date" whose .getTime() is NaN. Every relational comparison (<, >, <=, >=) involving NaN evaluates to false — silently, with no thrown error. In time-window/threshold logic (rate-limit cutoffs, freshness checks, "pick the latest" comparisons) this means malformed timestamps are quietly treated as "does not exceed/precede" instead of surfacing a parse failure.

Two real, currently-live instances found:

  • actions/setup/js/check_rate_limit.cjs:161const runCreatedAt = new Date(run.created_at); if (runCreatedAt < thresholdTime) { ... } — no NaN guard before the pagination-window cutoff check.
  • actions/setup/js/check_runs_helpers.cjs:43if (!existing || new Date(run.started_at ?? 0) > new Date(existing.started_at ?? 0)) — two constructed dates compared directly with no validation.

For contrast, several files in the same directory already do this correctly (check_stop_time.cjs, ephemerals.cjs, expired_entity_cleanup_helpers.cjs, create_project_status_update.cjs, unified_timeline.cjs) — these all call Number.isNaN(d.getTime()) right after construction. The new rule recognizes and accepts that pattern.

Rule design (low false-positive risk)

  • Only flags new Date(arg) where arg is not trivially always-valid: bare new Date() and anything derived from Date.now() (including Date.now() + n arithmetic) are excluded, since those can never produce an Invalid Date.
  • Only flags usage in a relational comparison (<, >, <=, >=) — not arbitrary use (e.g. formatting via .toISOString() is out of scope; that's a different failure mode already partially covered elsewhere).
  • Recognizes Number.isNaN(d.getTime()) and isNaN(d.getTime()) as valid guards, matching the codebase's established idiom.
  • Registered at "warn" in eslint.config.cjs, consistent with the rest of the ruleset.

Validation

  • cd eslint-factory && npm install && npm run build — clean.
  • New rule's own test suite (6 cases: 2 invalid grounded in real code, 4 valid covering guarded/trivial cases) — all pass.
  • npm run lint:setup-js — full run shows the two real findings above and zero regressions (pre-existing warning count for all other rules unchanged).
  • Note: require-fs-io-try-catch.test.ts has 5 pre-existing failures unrelated to this change (verified via git stash — failures reproduce on main before this PR).

Files changed

  • eslint-factory/src/rules/require-invalid-date-check-before-compare.ts (new rule)
  • eslint-factory/src/rules/require-invalid-date-check-before-compare.test.ts (new tests)
  • eslint-factory/src/index.ts (registration)
  • eslint-factory/eslint.config.cjs (enable at warn)

Generated by ESLint Miner · auto · 158.5 AIC · ⌖ 9.1 AIC · ⊞ 6.3K ·

  • expires on Aug 15, 2026, 1:09 AM UTC-08:00

New custom rule in eslint-factory/src/rules that flags new Date(x)
constructed from a non-literal argument and used in a relational
comparison (<, >, <=, >=) without first validating it via
Number.isNaN(x.getTime()) (or isNaN(x.getTime())).

An Invalid Date compares as neither greater than nor less than any
other date — all relational comparisons involving NaN evaluate to
false — which silently defeats time-window/threshold checks instead
of surfacing a parse error.

Found two real, currently-live instances of this pattern while
scanning actions/setup/js:
- check_rate_limit.cjs:161 — runCreatedAt < thresholdTime with no
  NaN guard on the pagination cutoff check.
- check_runs_helpers.cjs:43 — new Date(a) > new Date(b) comparing two
  unvalidated constructed dates when picking the latest check run.

The rule intentionally excludes new Date() and new Date(Date.now()...)
(and arithmetic derived from Date.now()) since those can never be
Invalid Date, keeping false-positive risk low. Existing correctly
validated call sites (check_stop_time.cjs, ephemerals.cjs,
expired_entity_cleanup_helpers.cjs, create_project_status_update.cjs,
unified_timeline.cjs) are unaffected — confirmed via full lint run,
which shows zero regressions.

Registered at "warn" in eslint.config.cjs, consistent with the rest
of the ruleset.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Great work! 🎯 This ESLint rule addition looks solid and ready for review.

The PR demonstrates excellent craft:

  • Focused scope — one new rule, clearly addressing a real bug pattern in date comparisons
  • Strong validation — test suite covers both real-world cases and edge cases; lint run confirms zero regressions
  • Low false-positive risk — design deliberately excludes trivially-valid cases (new Date(), Date.now() arithmetic)
  • Clear documentation — the PR body thoroughly explains the bug pattern, real instances, and rule design rationale
  • Agentic alignment — generated by ESLint Miner workflow, following the project's core-team development model

The rule is registered at "warn" level, consistent with the existing ruleset, and identifies two live instances that should be addressed. This is exactly the kind of targeted, high-signal contribution the ESLint Miner mandate aims to produce.

Ready for maintainer review! 👍

Generated by ✅ Contribution Check · auto · 70.5 AIC · ⌖ 3.07 AIC · ⊞ 8.7K ·

@pelikhan
pelikhan marked this pull request as ready for review August 8, 2026 15:01
Copilot AI balanced review requested due to automatic review settings August 8, 2026 15:01
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

PR Code Quality Reviewer completed the code quality review.

Warning

Threat Detection Engine Failure — The analysis engine could not complete. This is a tooling failure, not a security finding.

What happened

The threat detection engine failed to produce results.

Review the workflow run logs for details.

Warning

Firewall blocked 1 domain

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

  • api.individual.githubcopilot.com

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

network:
  allowed:
    - defaults
    - "api.individual.githubcopilot.com"

See Network Configuration for more information.

🔎 Code quality review by PR Code Quality Reviewer

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

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

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

🏗️ ADR gate enforced by Design Decision Gate 🏗️

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

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

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Test Quality Sentinel completed test quality analysis.

No test files were added or modified in this PR. Test Quality Sentinel skipped.

🧪 Test quality analysis by Test Quality Sentinel

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

Adds a custom ESLint rule to detect relational comparisons involving potentially invalid Date values.

Changes:

  • Implements invalid-date comparison detection.
  • Adds rule tests.
  • Registers and enables the rule at warning level.
Show a summary per file
File Description
eslint-factory/src/rules/require-invalid-date-check-before-compare.ts Implements date validation analysis.
eslint-factory/src/rules/require-invalid-date-check-before-compare.test.ts Tests reported and accepted patterns.
eslint-factory/src/index.ts Registers the rule.
eslint-factory/eslint.config.cjs Enables the rule as a warning.

Review details

Tip

Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Suppressed comments (1)

eslint-factory/src/rules/require-invalid-date-check-before-compare.ts:110

  • A program-wide set keyed only by identifier spelling does not establish that this comparison is safe. For example, d < cutoff; Number.isNaN(d.getTime()) is accepted even though the check is too late; an ignored check or a check of a shadowed d also suppresses the report. Track resolved variable bindings and verify that this comparison executes only on a path where that specific value has already been proven non-NaN (including the supported short-circuit forms).
          if (!validated.has(name)) {
  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment on lines +16 to +33
// `new Date(Date.now())` and `new Date(Date.now() + n)` are always valid — Date.now() cannot
// produce NaN, and arithmetic on a finite number stays finite.
if (isDateNowDerived(arg)) return false;

return true;
}

function isDateNowDerived(node: TSESTree.Node): boolean {
if (node.type === AST_NODE_TYPES.CallExpression && node.callee.type === AST_NODE_TYPES.MemberExpression) {
const { object, property } = node.callee;
if (object.type === AST_NODE_TYPES.Identifier && object.name === "Date" && property.type === AST_NODE_TYPES.Identifier && property.name === "now") {
return true;
}
}
if (node.type === AST_NODE_TYPES.BinaryExpression) {
return isDateNowDerived(node.left) || isDateNowDerived(node.right);
}
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.

Fixed in cd5eaf8: isPotentiallyInvalidDateConstruction now only exempts the exact Date.now() call (no arithmetic). new Date(Date.now() - windowMs) is now flagged since windowMs isn't guaranteed numeric — added a test for this and updated check_rate_limit.cjs's finding accordingly.

},
schema: [],
messages: {
requireInvalidDateCheck: "{{subject}} is constructed with new Date(...) from a non-literal value and compared with a relational operator ({{operator}}) without ever being checked via Number.isNaN({{getTimeTarget}}.getTime()). An unparseable date silently fails every comparison instead of surfacing an error.",

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.

Fixed in cd5eaf8: reworded the message to "{{subject}} may be an Invalid Date and is compared with..." which no longer claims the input is non-literal.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: require-invalid-date-check-before-compare ESLint rule

The rule targets a real correctness issue (silently failed comparisons against Invalid Dates) and the overall approach is sound. Two blocking issues found:

1. Cross-scope false negatives (blocking)dateVars, validated, and comparisons are module-level and never reset between function scopes. If two functions use the same variable name d, a getTime() NaN check in the first function will suppress errors in the second function. This makes the rule unreliable in real codebases with multiple functions.

2. Duplicate errors for inline new Date() on both sides of a comparison (blocking)new Date(a) > new Date(b) reports two separate errors on the same BinaryExpression node. The test encodes this as expected behavior, but it is surprising and noisy. A single diagnostic per comparison node would be clearer.

Non-blocking suggestions
  • Reporting on the BinaryExpression node while the message says "is constructed with new Date(...)" can be confusing — consider pointing at the declarator too.
  • The test file uses a fixed URL for the docs check which will drift if the rule is renamed.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 27 AIC · ⌖ 6.78 AIC · ⊞ 5.5K

const arg = node.arguments[0] as TSESTree.CallExpression;
const obj = (arg.callee as TSESTree.MemberExpression).object;
if (obj.type === AST_NODE_TYPES.Identifier) {
validated.add(obj.name);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Cross-scope false negatives due to module-level tracking

The dateVars, validated, and comparisons maps/sets span the entire program and are never reset between function scopes. This causes false negatives when the same variable name is used in multiple functions:

function a() {
  const d = new Date(input);
  if (Number.isNaN(d.getTime())) return;
  if (d < threshold) { } // correctly passes
}
function b() {
  const d = new Date(other); // different scope, same name
  if (d < threshold) { } // SHOULD be flagged, but WON'T because 'd' is in `validated`
}

Consider resetting or scoping these collections per function body using enter/exit visitors (FunctionDeclaration, ArrowFunctionExpression, FunctionExpression).

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

Fixed in cd5eaf8: dateVars/validated are now keyed by the scope-resolved Variable object (via sourceCode.getScope(...).set.get(name) walking .upper, matching the convention in core-method-resolve.ts) instead of by name string, so same-named locals in different functions no longer collide. Added a test for this exact repro.

for (const side of [node.left, node.right]) {
// Direct relational use of an inline `new Date(...)` expression.
if (side.type === AST_NODE_TYPES.NewExpression && isPotentiallyInvalidDateConstruction(side)) {
comparisons.push({ name: "<inline>", operator: node.operator, node });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Inline new Date() errors reported twice when both sides of a comparison are inline Date constructions

When both node.left and node.right are inline new Date(...) expressions (e.g. new Date(a) > new Date(b)), the same BinaryExpression node is pushed to comparisons twice, and reported twice in Program:exit. The test actually asserts two errors for this case, effectively enshrining the duplicate. However downstream tooling and users will see a confusing double error on the same AST node.

Consider deduplicating by the node itself (e.g. track reported nodes in a Set), or emit a single error that names both operands.

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

Fixed in cd5eaf8: when both sides of a comparison are unvalidated new Date(...), the rule now emits a single combined diagnostic ("Both operands of this comparison...") instead of two identical reports on the same node. Updated the test to expect one error.

return {
VariableDeclarator(node) {
if (node.id.type === AST_NODE_TYPES.Identifier && node.init?.type === AST_NODE_TYPES.NewExpression && isPotentiallyInvalidDateConstruction(node.init)) {
dateVars.set(node.id.name, node);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness: Deferred reporting in Program:exit misses the comparison node location

All errors are reported on the BinaryExpression node (the comparison site), regardless of whether the unvalidated new Date() construction is far away. This is useful, but the message text '{{subject}}' is constructed with new Date(...)... could be confusing since the highlighted code is the comparison, not the construction.

Consider also reporting the VariableDeclarator node location (or using suggest), so the user can quickly navigate to where validation should be inserted.

Non-blocking suggestion — just worth considering for developer experience.

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

Kept reporting on the comparison node — it's the site where the missing guard actually matters (a new Date(...) construction site alone isn't wrong, it's only a problem once it reaches an unguarded relational comparison), and it matches how ESLint typically flags the point of misuse. Since this was flagged as non-blocking, not adding a second report location to keep the diagnostic focused.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /tdd — commenting with targeted issues; no blocking correctness errors, but one important logic gap worth addressing before merge.

📋 Key Themes & Highlights

Key Issues

  • Scope blindness (highest impact): dateVars and validated are keyed by variable name across the entire module. Two functions with a local named d share the same validated/unvalidated state — a false-negative that defeats the rule's core promise. See inline comment on line 88.
  • Deduplication gap: inline new Date(...) diagnostics are not deduplicated; nested expressions could produce duplicate reports. See line 100.
  • Deferred flush: the comparisons array includes entries that will be suppressed at flush time — a minor design smell. See line 83.
  • Test gap: no test exercises isNaN (global) in a same-condition guard (versus early-return form). See test line 52.

Positive Highlights

  • ✅ Excellent PR description — two real production instances identified, false-positive exclusions well-reasoned, validation steps documented.
  • isDateNowDerived correctly handles arithmetic on Date.now() (e.g., Date.now() - windowMs) — a subtle correctness detail handled well.
  • ✅ Rule type is "problem" (not "suggestion") — appropriate for a silent-failure correctness bug.
  • ✅ Registration at "warn" consistent with existing ruleset; non-breaking rollout.
  • Program:exit flush pattern correctly avoids false positives when validation appears after construction but before comparison — the right design for file-level analysis.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 35.1 AIC · ⌖ 7.86 AIC · ⊞ 7.1K
Comment /matt to run again

}
}
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The dateVars and validated sets are shared across the entire program scope. If two functions declare locals with the same name (e.g. d), and one validates it while the other does not, the validated set will suppress the warning on the un-guarded usage — a false negative that undermines the rule's core mission.

💡 Suggested fix

Use ESLint's scope analysis or key by node identity rather than variable name:

// Key by declarator node (identity) instead of string name
const dateVarNodes = new Set<TSESTree.Node>();

Minimal repro that currently silently passes (should warn on b):

function a() { const d = new Date(x); if (Number.isNaN(d.getTime())) return; if (d > t) {} }
function b() { const d = new Date(y); if (d > t) {} } // no 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.

Same fix as the sibling thread above — resolved in cd5eaf8 by keying on the scope-resolved Variable identity instead of the name string.

}
if (side.type === AST_NODE_TYPES.Identifier && dateVars.has(side.name)) {
comparisons.push({ name: side.name, operator: node.operator, node });
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Inline new Date(...) expressions are reported but never de-duplicated across sides. When the same binary expression triggers two <inline> entries (as shown in the test for check_runs_helpers.cjs), the first comparisons.push for the left side and the second for the right side each emit a report against the same node. That produces two errors on one BinaryExpression node — which is the intended behaviour — but the logic has no guard against reporting the same inline node multiple times if it appears in a nested expression. Consider adding a reportedNodes WeakSet to avoid duplicate diagnostics on complex expressions.

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

Same fix as the sibling thread above — resolved in cd5eaf8 by collecting both sides per BinaryExpression and emitting one combined report instead of one per side.

if (isGetTimeNaNCheck(node)) {
const arg = node.arguments[0] as TSESTree.CallExpression;
const obj = (arg.callee as TSESTree.MemberExpression).object;
if (obj.type === AST_NODE_TYPES.Identifier) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The comparisons array accumulates entries and is only flushed in Program:exit. For a long file with many date variables this is fine, but the array grows proportionally with the number of relational comparisons — including valid ones that will be suppressed. Consider only pushing to comparisons when the variable is actually in dateVars, and skipping the final iteration over entries that are already in validated by checking at push-time (early exit pattern) rather than at flush-time.

💡 Why this matters

The current design means Program:exit iterates all collected comparisons even for variables that were validated far earlier in the file. For the codebase's actual files this is immaterial, but it's a minor design smell that could cause confusion during maintenance — the flush loop and the push loop are structurally separate with no obvious link.

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

Kept the deferred flush in Program:exit: validation (Number.isNaN(d.getTime())) can appear after the comparison in valid code paths, so validated membership can't be checked reliably until traversal completes. The comparisons list only ever contains sides already confirmed to reference a tracked dateVars entry (checked at push time), so it doesn't grow with unrelated comparisons — just with the ones that could still turn out to be guarded later in the file.

`const d = new Date(input); if (isNaN(d.getTime())) { return; } if (d >= threshold) { doIt(); }`,
],
invalid: [],
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The valid-pattern tests omit a guard idiom that the codebase uses: !Number.isNaN(d.getTime()) && d > threshold as the sole branch (without a preceding throw/return). The test at line 52 covers this form, but there's no test for isNaN (the global) in the same guard-then-compare position, only isNaN with an early return (line 56). Adding a test for isNaN(d.getTime()) || d > threshold — which is also a valid guarded comparison pattern — would strengthen confidence that the isNaNGlobal branch in isGetTimeNaNCheck is exercised through a real comparison path.

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

Added in cd5eaf8: a valid test case for isNaN(d.getTime()) || d > threshold.

@pelikhan

pelikhan commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

…rule

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI requested a review from pelikhan August 8, 2026 15:25
@pelikhan
pelikhan merged commit da8dc15 into main Aug 8, 2026
@pelikhan
pelikhan deleted the eslint-miner/require-invalid-date-check-before-compare-3dc100e8f34dffb1 branch August 8, 2026 15:34
@github-actions

Copy link
Copy Markdown
Contributor Author

🎉 This pull request is included in a new release.

Release: v0.86.2

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants