Open History to see the last commit
1import { defineRule, type ESTree } from "@oxlint/plugins";
2import * as Option from "effect/Option";
3
4import { getPropertyName, isIdentifier, unwrapExpression } from "../utils.ts";
5
6const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
7const WALL_CLOCK_CALLS = new Set([
8 "Date.now",
9 "performance.now",
10 "process.hrtime",
11 "process.hrtime.bigint",
12]);
13const VI_MOCKING_FUNCTIONS = new Set(["fn", "mock", "setSystemTime", "spyOn"]);
14const MOCK_METHODS = new Set([
15 "mockImplementation",
16 "mockImplementationOnce",
17 "mockResolvedValue",
18 "mockResolvedValueOnce",
19 "mockReturnValue",
20 "mockReturnValueOnce",
21]);
22
23const MESSAGE =
24 "Wall-clock reading in a test; elapsed-time assertions pass or fail with runner load. Use TestClock/`Clock` under `it.effect`, `vi.setSystemTime`, or a fixed epoch. Benchmarks live behind an env gate.";
25
26const isStringLiteral = (node: unknown) =>
27 Option.exists(
28 unwrapExpression(node),
29 (expression) => expression.type === "Literal" && typeof expression.value === "string",
30 );
31
32const memberPath = (node: unknown): Option.Option<ReadonlyArray<string>> => {
33 const expression = unwrapExpression(node);
34 if (Option.isNone(expression)) return Option.none();
35 if (expression.value.type === "Identifier") return Option.some([expression.value.name]);
36 if (expression.value.type !== "MemberExpression") return Option.none();
37 // `Date[now]` with a variable key is unknowable; only a literal key names a member.
38 if (expression.value.computed && !isStringLiteral(expression.value.property))
39 return Option.none();
40 const property = getPropertyName(expression.value.property);
41 if (Option.isNone(property)) return Option.none();
42 return Option.map(memberPath(expression.value.object), (path) => [...path, property.value]);
43};
44
45const isWallClockCall = (callee: unknown): boolean =>
46 Option.match(memberPath(callee), {
47 onNone: () => false,
48 onSome: (path) => WALL_CLOCK_CALLS.has(path.join(".")),
49 });
50
51const isMockingCall = (callee: unknown): boolean => {
52 const expression = unwrapExpression(callee);
53 if (Option.isNone(expression) || expression.value.type !== "MemberExpression") return false;
54 const property = getPropertyName(expression.value.property);
55 if (Option.isNone(property)) return false;
56 if (MOCK_METHODS.has(property.value)) return true;
57 return (
58 VI_MOCKING_FUNCTIONS.has(property.value) &&
59 isIdentifier(unwrapExpression(expression.value.object), "vi")
60 );
61};
62
63// Readings that feed a mock (or live in a vi.mock factory) seed fake time
64// rather than measure the runner, so they stay allowed.
65const isMockArgument = (node: ESTree.Node): boolean => {
66 let child: ESTree.Node = node;
67 let current = node.parent;
68 while (current) {
69 if (
70 current.type === "CallExpression" &&
71 current.callee !== child &&
72 isMockingCall(current.callee)
73 ) {
74 return true;
75 }
76 child = current;
77 current = current.parent;
78 }
79 return false;
80};
81
82export default defineRule({
83 meta: {
84 type: "problem",
85 docs: {
86 description:
87 "Disallow wall-clock readings (Date.now, new Date(), performance.now, process.hrtime) in tests.",
88 },
89 },
90 create(context) {
91 if (!TEST_FILE_PATTERN.test(context.filename)) return {};
92
93 return {
94 CallExpression(node) {
95 if (!isWallClockCall(node.callee)) return;
96 if (isMockArgument(node)) return;
97 context.report({ node, message: MESSAGE });
98 },
99 NewExpression(node) {
100 if (node.arguments.length > 0) return;
101 if (!isIdentifier(unwrapExpression(node.callee), "Date")) return;
102 if (isMockArgument(node)) return;
103 context.report({ node, message: MESSAGE });
104 },
105 };
106 },
107});