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
9 changes: 9 additions & 0 deletions .changeset/no-bare-date-now.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@boring-stack-pkg/eslint-plugin-code-flow": minor
---

Add `no-bare-date-now` rule.

Disallows direct calls to non-deterministic time/random sources (`Date.now()`, `new Date()` with no args, `Date()` with no args, `Math.random()`) outside an allowlisted set of utility paths. Determinism is required for snapshot tests, workflow replays, and time-travel debugging — every consumer should route through a typed util that can be faked in tests.

Configurable via the `allowedPaths` option (substring match against the source file path). Enabled in the `recommended` config.
9 changes: 9 additions & 0 deletions .changeset/no-historical-comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@boring-stack-pkg/eslint-plugin-comment-hygiene": minor
---

Add `no-historical-comments` rule.

Flags source comments that frame code relative to what it used to do or to a past incident — `Codex flagged X`, `before the fix`, `after the refactor`, `we used to`, `no longer`, `kept for backwards compat`, `historically`, `Alpine-era workaround`. Source comments must describe the current invariant; history belongs in the commit message or PR description where it stays pinned to the diff it describes.

Enabled in the `recommended` config.
77 changes: 77 additions & 0 deletions eslint-plugin-code-flow/docs/rules/no-bare-date-now.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# no-bare-date-now

Disallow direct calls to non-deterministic time/random sources outside an allowlisted set of utility paths: `Date.now()`, `new Date()` (zero-arg), `Date()` (zero-arg), `Math.random()`.

## Why

Direct clock/random reads in business logic make code untestable and undeployable:

- **Snapshot tests** become flaky because `Date.now()` drifts between runs.
- **Workflow replays** (resumable tasks, event sourcing) diverge from the original run.
- **Time-travel debugging** is impossible — there's no seam to swap in a fake clock.
- **Tests** end up wrapping every call site in stubs instead of swapping one util.

Every consumer should route through a typed util (e.g. `now()` from `src/lib/time/now.ts`, a seeded random helper, etc.). That single util is exempted via the `allowedPaths` option.

## Incorrect

```ts
function timestamp(): number {
return Date.now();
}

function makeId(): string {
return `id-${Math.random().toString(36).slice(2)}`;
}

function expiresAt(): Date {
return new Date();
}
```

## Correct

```ts
import { now } from "@/lib/time/now";
import { randomId } from "@/lib/random/id";

function timestamp(): number {
return now();
}

function makeId(): string {
return randomId();
}

function expiresAt(): Date {
return new Date(now()); // explicit argument — pure parser, not a clock read
}
```

Parsing an explicit argument is fine:

```ts
new Date("2026-01-01T00:00:00Z");
new Date(1700000000000);
Date.parse("2026-01-01");
```

## Options

```jsonc
{
"code-flow/no-bare-date-now": [
"error",
{
// Substring match against the source file's path. Files containing
// any of these segments are exempted — meant to whitelist the util
// wrappers themselves.
"allowedPaths": ["src/lib/time/", "src/lib/random/"]
}
]
}
```

## When not to use

If your codebase deliberately doesn't have determinism requirements (one-off scripts, scratch repos), disable this rule. For production source code: leave it on.
3 changes: 2 additions & 1 deletion eslint-plugin-code-flow/src/configs/recommended.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const recommendedRules = {
"code-flow/prefer-early-return": "error",
"code-flow/no-template-trim-empty-ternary": "error"
"code-flow/no-template-trim-empty-ternary": "error",
"code-flow/no-bare-date-now": "error"
} as const;
2 changes: 2 additions & 0 deletions eslint-plugin-code-flow/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { TSESLint } from "@typescript-eslint/utils";

import { recommendedRules } from "./configs/recommended";
import { rules } from "./rules";
import { noBareDateNowRule } from "./rules/noBareDateNow";
import { noTemplateTrimEmptyTernaryRule } from "./rules/noTemplateTrimEmptyTernary";
import { preferEarlyReturnRule } from "./rules/preferEarlyReturn";

Expand All @@ -25,6 +26,7 @@ plugin.configs.recommended = {
rules: recommendedRules
};

export { noBareDateNowRule };
export { noTemplateTrimEmptyTernaryRule };
export { preferEarlyReturnRule };
export { rules };
Expand Down
4 changes: 3 additions & 1 deletion eslint-plugin-code-flow/src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { noBareDateNowRule } from "./noBareDateNow";
import { noTemplateTrimEmptyTernaryRule } from "./noTemplateTrimEmptyTernary";
import { preferEarlyReturnRule } from "./preferEarlyReturn";

export const rules = {
"prefer-early-return": preferEarlyReturnRule,
"no-template-trim-empty-ternary": noTemplateTrimEmptyTernaryRule
"no-template-trim-empty-ternary": noTemplateTrimEmptyTernaryRule,
"no-bare-date-now": noBareDateNowRule
};
133 changes: 133 additions & 0 deletions eslint-plugin-code-flow/src/rules/noBareDateNow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { TSESTree } from "@typescript-eslint/utils";

import { createRule } from "../utils/createRule";

export const RULE_NAME = "no-bare-date-now";

type MessageIds =
| "bareDateNow"
| "bareNewDate"
| "bareMathRandom"
| "bareDateConstructor";

export interface INoBareDateNowOptions {
readonly allowedPaths?: readonly string[];
}

const DEFAULTS: Required<INoBareDateNowOptions> = {
allowedPaths: []
};

function fileMatchesAllowlist(
filename: string,
allowed: readonly string[]
): boolean {
if (allowed.length === 0) {
return false;
}
const normalized = filename.replace(/\\/g, "/");
return allowed.some((segment) => normalized.includes(segment));
}

function isDateNow(node: TSESTree.CallExpression): boolean {
const callee = node.callee;
return (
callee.type === "MemberExpression" &&
callee.object.type === "Identifier" &&
callee.object.name === "Date" &&
callee.property.type === "Identifier" &&
callee.property.name === "now" &&
!callee.computed
);
}

function isMathRandom(node: TSESTree.CallExpression): boolean {
const callee = node.callee;
return (
callee.type === "MemberExpression" &&
callee.object.type === "Identifier" &&
callee.object.name === "Math" &&
callee.property.type === "Identifier" &&
callee.property.name === "random" &&
!callee.computed
);
}

function isBareNewDate(node: TSESTree.NewExpression): boolean {
return (
node.callee.type === "Identifier" &&
node.callee.name === "Date" &&
node.arguments.length === 0
);
}

function isBareDateCall(node: TSESTree.CallExpression): boolean {
return (
node.callee.type === "Identifier" &&
node.callee.name === "Date" &&
node.arguments.length === 0
);
}

export const noBareDateNowRule = createRule<[INoBareDateNowOptions], MessageIds>({
name: RULE_NAME,
meta: {
type: "problem",
docs: {
description:
"Disallow direct calls to non-deterministic time/random sources (`Date.now()`, `new Date()`, `Date()`, `Math.random()`) outside an allowlisted set of utility paths. Determinism is required for snapshot tests, workflow replays, and time-travel debugging — every consumer should route through a typed util that can be faked in tests.",
recommended: true
},
schema: [
{
type: "object",
properties: {
allowedPaths: {
type: "array",
items: { type: "string" }
}
},
additionalProperties: false
}
],
messages: {
bareDateNow:
"Direct `Date.now()` is non-deterministic. Import the project's `now()` util instead (or add the file to this rule's `allowedPaths` if it IS the util).",
bareNewDate:
"Direct `new Date()` (no args) is non-deterministic. Import the project's `now()` util and pass the millisecond timestamp explicitly, or add the file to `allowedPaths`.",
bareDateConstructor:
"Direct `Date()` (no args) is non-deterministic. Import the project's `now()` util and pass the millisecond timestamp explicitly, or add the file to `allowedPaths`.",
bareMathRandom:
"Direct `Math.random()` is non-deterministic. Import the project's random util (which can be seeded in tests) instead, or add the file to `allowedPaths`."
}
},
defaultOptions: [DEFAULTS],
create(context, optionsArg) {
const options = optionsArg[0] ?? DEFAULTS;
const allowed = options.allowedPaths ?? DEFAULTS.allowedPaths;
if (fileMatchesAllowlist(context.filename, allowed)) {
return {};
}

return {
CallExpression(node: TSESTree.CallExpression) {
if (isDateNow(node)) {
context.report({ node, messageId: "bareDateNow" });
return;
}
if (isMathRandom(node)) {
context.report({ node, messageId: "bareMathRandom" });
return;
}
if (isBareDateCall(node)) {
context.report({ node, messageId: "bareDateConstructor" });
}
},
NewExpression(node: TSESTree.NewExpression) {
if (isBareNewDate(node)) {
context.report({ node, messageId: "bareNewDate" });
}
}
};
}
});
1 change: 1 addition & 0 deletions eslint-plugin-code-flow/tests/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe("plugin shape", () => {

it("exposes every rule under kebab-case keys", () => {
expect(Object.keys(plugin.rules ?? {}).sort()).toEqual([
"no-bare-date-now",
"no-template-trim-empty-ternary",
"prefer-early-return"
]);
Expand Down
91 changes: 91 additions & 0 deletions eslint-plugin-code-flow/tests/rules/noBareDateNow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { RULE_NAME, noBareDateNowRule } from "../../src/rules/noBareDateNow";
import { ruleTester } from "../test-utils/ruleTester";

ruleTester.run(RULE_NAME, noBareDateNowRule, {
valid: [
// Routed through a project util — the rule never sees the underlying
// Date.now() because the source file imports a named helper.
{
code: `import { now } from "./time"; const t = now();`
},
// `new Date(timestamp)` with an explicit argument is fine — it's a
// pure parser, not a clock read.
{
code: `const t = new Date(1700000000000);`
},
{
code: `const t = new Date("2026-01-01T00:00:00Z");`
},
{
code: `const t = Date.parse("2026-01-01");`
},
// Math.* calls that are NOT random are unaffected.
{
code: `const x = Math.floor(1.5);`
},
// File-allowlist exempts the wrapper itself from the rule.
{
code: `export const now = () => Date.now();`,
filename: "src/lib/time/now.ts",
options: [{ allowedPaths: ["src/lib/time/"] }]
},
{
code: `export const random = () => Math.random();`,
filename: "src/lib/random/index.ts",
options: [{ allowedPaths: ["src/lib/random/"] }]
},
// Empty allowedPaths (the default) means the rule applies everywhere
// — the valid cases above demonstrate non-violating call shapes.
{
code: `const t = Date.parse(input);`,
options: [{}]
}
],
invalid: [
{
code: `const t = Date.now();`,
errors: [{ messageId: "bareDateNow" }]
},
{
code: `const t = new Date();`,
errors: [{ messageId: "bareNewDate" }]
},
{
code: `const t = Date();`,
errors: [{ messageId: "bareDateConstructor" }]
},
{
code: `const r = Math.random();`,
errors: [{ messageId: "bareMathRandom" }]
},
{
code: `function stamp() { return Date.now(); }`,
errors: [{ messageId: "bareDateNow" }]
},
{
code: `const id = "id-" + Math.random().toString(36).slice(2);`,
errors: [{ messageId: "bareMathRandom" }]
},
// File path is in scope but NOT covered by allowedPaths.
{
code: `const t = Date.now();`,
filename: "src/api/billing/billing.service.ts",
options: [{ allowedPaths: ["src/lib/time/"] }],
errors: [{ messageId: "bareDateNow" }]
},
// Multiple offenders in one source.
{
code: `
function pick() {
const id = Math.random();
const at = Date.now();
return { id, at };
}
`,
errors: [
{ messageId: "bareMathRandom" },
{ messageId: "bareDateNow" }
]
}
]
});
Loading
Loading