@crescware/eslint-plugin-crescware-single-behavior-per-test 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +683 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# @crescware/eslint-plugin-crescware-single-behavior-per-test
|
|
2
|
+
|
|
3
|
+
An ESLint-compatible rule, run through [oxlint](https://oxc.rs/docs/guide/usage/linter)'s `jsPlugins`, that forbids writing more than one top-level `expect()` in a single `test()` / `it()` — and, instead of merely banning it, tells you exactly how to fix it.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
A test should verify a single behavior, but agents and humans alike pile several `expect`s into one test. Plain prose (CLAUDE.md, review comments) does not stop this because prose has no binding force — it can be ignored. A lint rule does: it fails the edit loop and the build.
|
|
8
|
+
|
|
9
|
+
But a bare ban (e.g. `max-expects: 1`) is weak. If it only says "no" without showing the way out, the reader optimizes for the cheapest way to clear the error — commenting out one `expect`. A ban with no exit is not a norm. This rule carries the exit in the error message itself. The message is written as a prompt for the reader (today, usually a coding agent): it names the concrete next edit, and for the one genuinely undecidable case it asks rather than commands.
|
|
10
|
+
|
|
11
|
+
The ban is the floor; the guidance rides on top of it. The severity is always `error`.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm add -D @crescware/eslint-plugin-crescware-single-behavior-per-test
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Register the plugin in your `.oxlintrc.json` and enable the rule:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"jsPlugins": ["@crescware/eslint-plugin-crescware-single-behavior-per-test"],
|
|
26
|
+
"rules": {
|
|
27
|
+
"crescware-single-behavior-per-test/single-behavior-per-test": "error"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## What it reports
|
|
33
|
+
|
|
34
|
+
The rule counts the **direct** `expect()` assertions in a `test` / `it` callback — the ones written as statements directly in the callback body. When there are two or more, it classifies the test by _what varies between the assertions_ and emits one of these verdicts. Each message is either an assertion (the fix is determined by the syntax) or a question (the syntax cannot decide).
|
|
35
|
+
|
|
36
|
+
### consolidate (assertion)
|
|
37
|
+
|
|
38
|
+
Several fields of the **same object** are asserted separately. Compare the object once.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// reported
|
|
42
|
+
test("compute result", () => {
|
|
43
|
+
const r = compute();
|
|
44
|
+
expect(r.status).toBe("ok");
|
|
45
|
+
expect(r.code).toBe(200);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// fix: one exhaustive toEqual (list every field; no partial-match matcher)
|
|
49
|
+
test("compute result", () => {
|
|
50
|
+
expect(compute()).toEqual({ status: "ok", code: 200 });
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### split-by-act (assertion)
|
|
55
|
+
|
|
56
|
+
A state change (assignment, mutation, `await`, or a call on the value under test) sits **between** assertions. The before- and after-states are two behaviors.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// reported
|
|
60
|
+
test("counter", () => {
|
|
61
|
+
const c = new Counter();
|
|
62
|
+
expect(c.value).toBe(0);
|
|
63
|
+
c.increment();
|
|
64
|
+
expect(c.value).toBe(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// fix: split at the Act into two tests, each with its own Arrange/Act
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### split-by-heterogeneity (assertion)
|
|
71
|
+
|
|
72
|
+
The matchers or the asserted shapes differ — the test checks more than one contract.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// reported (a value contract and an error contract)
|
|
76
|
+
test("parse", () => {
|
|
77
|
+
expect(parse("x")).toEqual({ ok: true });
|
|
78
|
+
expect(parseThrows).toThrow();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// fix: one test() per contract
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### each-or-split (question)
|
|
85
|
+
|
|
86
|
+
The **same operation** is called with only the inputs changing. Syntax cannot tell whether these are the same claim over different data (→ `test.each`) or different contracts (→ split), so the rule asks and hands you the criterion: restate each case as one sentence — same sentence with different values means `test.each`, a different claim means split.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// reported — you decide test.each vs split
|
|
90
|
+
test("add", () => {
|
|
91
|
+
expect(add(1, 2)).toBe(3);
|
|
92
|
+
expect(add(3, 4)).toBe(7);
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### loop-each (assertion)
|
|
97
|
+
|
|
98
|
+
Assertions run inside a loop, an iteration callback (`forEach` / `map` / …), or a repeated call to a local assertion helper — a hand-rolled parametrized test. A loop applies an identical body per item, so this is unambiguously `test.each` (and `test.each` reports _which_ case failed, where a loop stops at the first).
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// reported
|
|
102
|
+
test("all positive", () => {
|
|
103
|
+
for (const x of items) {
|
|
104
|
+
expect(x).toBeGreaterThan(0);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// fix
|
|
109
|
+
test.each(items)("%s is positive", (x) => {
|
|
110
|
+
expect(x).toBeGreaterThan(0);
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### generic (question)
|
|
115
|
+
|
|
116
|
+
When the exit cannot be named — a computed matcher, an unusual receiver, different objects, an exact duplicate, mismatched call arguments — the ban still holds and the message hands you the four-branch self-diagnosis checklist so you can route the fix yourself.
|
|
117
|
+
|
|
118
|
+
## Scope
|
|
119
|
+
|
|
120
|
+
- Only **direct** assertions are classified. Assertions in a loop / iteration callback / repeated local helper are routed to `loop-each`; assertions behind a cross-file or imported helper are a static-analysis blind spot and are not counted.
|
|
121
|
+
- Modifier and async chains are understood: `expect(x).not.toBe(...)`, `expect(x).resolves.toBe(...)`, `await expect(x).resolves.toBe(...)`, `expect.soft(x).toBe(...)`.
|
|
122
|
+
- `test`, `it`, `it.only`, and `test.each(table)(...)` callbacks are recognized.
|
|
123
|
+
- The rule never autofixes — a lazy set of partial assertions cannot be mechanically rebuilt into an exhaustive `toEqual` — it reports only.
|
|
124
|
+
|
|
125
|
+
## Companion: `no-restricted-matchers`
|
|
126
|
+
|
|
127
|
+
The `consolidate` fix asks for an exhaustive `toEqual` and forbids partial-match matchers (`toMatchObject` etc.), because a field you omit goes unchecked. This rule states that in prose but does not enforce it; pair it with your test framework's `no-restricted-matchers` to give that prohibition real teeth. The two cooperate loosely and toggle independently.
|
|
128
|
+
|
|
129
|
+
## Stack
|
|
130
|
+
|
|
131
|
+
- **Runtime**: Node.js 24 (via [mise](https://mise.jdx.dev/))
|
|
132
|
+
- **Package manager**: pnpm (via corepack)
|
|
133
|
+
- **Language**: TypeScript ([native preview](https://github.com/microsoft/typescript-go))
|
|
134
|
+
- **Test**: [Vitest](https://vitest.dev/) (fixture integration tests that run oxlint over `fixtures/cases`)
|
|
135
|
+
- **Lint**: [oxlint](https://oxc.rs/docs/guide/usage/linter) (the repo dogfoods this very rule)
|
|
136
|
+
- **Format**: [oxfmt](https://github.com/oxc-project/oxc)
|
|
137
|
+
- **Unused code**: [Knip](https://knip.dev/)
|
|
138
|
+
|
|
139
|
+
## Setup
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
mise install
|
|
143
|
+
corepack enable
|
|
144
|
+
pnpm install
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Scripts
|
|
148
|
+
|
|
149
|
+
| Command | Description |
|
|
150
|
+
| ------------------ | ---------------------------------------- |
|
|
151
|
+
| `pnpm build` | Compile `src` to `dist` |
|
|
152
|
+
| `pnpm check` | Run all checks (types, lint, knip, test) |
|
|
153
|
+
| `pnpm check:types` | Type check |
|
|
154
|
+
| `pnpm check:lint` | Lint and format check |
|
|
155
|
+
| `pnpm check:knip` | Unused files/exports check |
|
|
156
|
+
| `pnpm test` | Run fixture integration tests |
|
|
157
|
+
| `pnpm format` | Fix lint and format |
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
type ReportDescriptor = {
|
|
2
|
+
message: string;
|
|
3
|
+
node: unknown;
|
|
4
|
+
};
|
|
5
|
+
type RuleContext = {
|
|
6
|
+
report: (descriptor: ReportDescriptor) => void;
|
|
7
|
+
};
|
|
8
|
+
type Visitor = Record<string, (node: never) => void>;
|
|
9
|
+
declare const plugin: {
|
|
10
|
+
meta: {
|
|
11
|
+
name: string;
|
|
12
|
+
};
|
|
13
|
+
rules: {
|
|
14
|
+
"single-behavior-per-test": {
|
|
15
|
+
meta: {
|
|
16
|
+
type: string;
|
|
17
|
+
docs: {
|
|
18
|
+
description: string;
|
|
19
|
+
};
|
|
20
|
+
schema: never[];
|
|
21
|
+
};
|
|
22
|
+
create(context: RuleContext): Visitor;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export default plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared AST helpers
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The callee identifiers whose callback bodies this rule treats as a test.
|
|
5
|
+
const TEST_CALLEES = new Set(["test", "it"]);
|
|
6
|
+
// Method names that conventionally mutate their receiver. A call to one of these
|
|
7
|
+
// between assertions is treated as an Act regardless of the receiver.
|
|
8
|
+
const MUTATING_METHODS = new Set([
|
|
9
|
+
"push",
|
|
10
|
+
"pop",
|
|
11
|
+
"shift",
|
|
12
|
+
"unshift",
|
|
13
|
+
"splice",
|
|
14
|
+
"sort",
|
|
15
|
+
"reverse",
|
|
16
|
+
"fill",
|
|
17
|
+
"copyWithin",
|
|
18
|
+
"set",
|
|
19
|
+
"delete",
|
|
20
|
+
"add",
|
|
21
|
+
"clear",
|
|
22
|
+
"dispatch",
|
|
23
|
+
]);
|
|
24
|
+
// Loop statements whose body, if it asserts, is a hand-rolled parametrized test.
|
|
25
|
+
const LOOP_TYPES = new Set([
|
|
26
|
+
"ForStatement",
|
|
27
|
+
"ForOfStatement",
|
|
28
|
+
"ForInStatement",
|
|
29
|
+
"WhileStatement",
|
|
30
|
+
"DoWhileStatement",
|
|
31
|
+
]);
|
|
32
|
+
// Array iteration methods whose callback, if it asserts, is the same anti-pattern
|
|
33
|
+
// as a loop (iterating assertions over data instead of using test.each).
|
|
34
|
+
const ITER_METHODS = new Set([
|
|
35
|
+
"forEach",
|
|
36
|
+
"map",
|
|
37
|
+
"flatMap",
|
|
38
|
+
"filter",
|
|
39
|
+
"reduce",
|
|
40
|
+
"reduceRight",
|
|
41
|
+
"some",
|
|
42
|
+
"every",
|
|
43
|
+
"find",
|
|
44
|
+
"findIndex",
|
|
45
|
+
]);
|
|
46
|
+
// `await x` -> `x`; anything else is returned unchanged.
|
|
47
|
+
const unwrapAwait = (node) => {
|
|
48
|
+
return node.type === "AwaitExpression"
|
|
49
|
+
? node.argument
|
|
50
|
+
: node;
|
|
51
|
+
};
|
|
52
|
+
// `expect(actual)` and its dotted forms (`expect.soft(actual)`): a call whose
|
|
53
|
+
// callee is the identifier `expect`, or a member access on it. This anchor is
|
|
54
|
+
// what separates a real assertion from an unrelated `foo.toBe(...)`.
|
|
55
|
+
const isExpectCall = (node) => {
|
|
56
|
+
if (node.type !== "CallExpression") {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const callee = node.callee;
|
|
60
|
+
if (callee.type === "Identifier") {
|
|
61
|
+
return callee.name === "expect";
|
|
62
|
+
}
|
|
63
|
+
if (callee.type === "MemberExpression") {
|
|
64
|
+
const object = callee.object;
|
|
65
|
+
return (object.type === "Identifier" && object.name === "expect");
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
};
|
|
69
|
+
// The identifier at the root of a call's callee, seen through member and call
|
|
70
|
+
// layers: `test(...)` -> "test", `it.only(...)` -> "it", and
|
|
71
|
+
// `test.each(table)(...)` -> "test". Returns null when the callee bottoms out in
|
|
72
|
+
// something other than an identifier.
|
|
73
|
+
const rootCalleeName = (call) => {
|
|
74
|
+
let current = call.callee;
|
|
75
|
+
while (true) {
|
|
76
|
+
if (current.type === "Identifier") {
|
|
77
|
+
return current.name;
|
|
78
|
+
}
|
|
79
|
+
if (current.type === "MemberExpression") {
|
|
80
|
+
current = current.object;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (current.type === "CallExpression") {
|
|
84
|
+
current = current.callee;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
// The first argument that is an arrow / function expression: the test body.
|
|
91
|
+
// `test.each(table)(name, cb)` keeps the callback on the outer call, so this
|
|
92
|
+
// finds it there too.
|
|
93
|
+
const testCallback = (call) => {
|
|
94
|
+
for (const arg of call.arguments) {
|
|
95
|
+
if (arg.type === "ArrowFunctionExpression" ||
|
|
96
|
+
arg.type === "FunctionExpression") {
|
|
97
|
+
return arg;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
// The deepest object identifier of a member chain: `a.b.c` -> "a", `a` -> "a".
|
|
103
|
+
// Returns null when the chain bottoms out in something else (a call, `this`).
|
|
104
|
+
const memberRoot = (node) => {
|
|
105
|
+
let current = node;
|
|
106
|
+
while (current.type === "MemberExpression") {
|
|
107
|
+
current = current.object;
|
|
108
|
+
}
|
|
109
|
+
return current.type === "Identifier" ? current.name : null;
|
|
110
|
+
};
|
|
111
|
+
const readableProperty = (node) => {
|
|
112
|
+
if (node.type === "Identifier") {
|
|
113
|
+
return node.name;
|
|
114
|
+
}
|
|
115
|
+
if (node.type === "Literal") {
|
|
116
|
+
const literal = node;
|
|
117
|
+
return literal.raw ?? String(literal.value);
|
|
118
|
+
}
|
|
119
|
+
return "#prop";
|
|
120
|
+
};
|
|
121
|
+
// A readable name for a callee / target: `foo` -> "foo", `obj.method` ->
|
|
122
|
+
// "obj.method", `arr[0]` -> "arr[0]". Exotic forms collapse to "…".
|
|
123
|
+
const readable = (node) => {
|
|
124
|
+
if (node.type === "Identifier") {
|
|
125
|
+
return node.name;
|
|
126
|
+
}
|
|
127
|
+
if (node.type === "MemberExpression") {
|
|
128
|
+
const member = node;
|
|
129
|
+
if (member.computed) {
|
|
130
|
+
return `${readable(member.object)}[${readableProperty(member.property)}]`;
|
|
131
|
+
}
|
|
132
|
+
return `${readable(member.object)}.${readableProperty(member.property)}`;
|
|
133
|
+
}
|
|
134
|
+
return "…";
|
|
135
|
+
};
|
|
136
|
+
// A structural fingerprint of a node. Literal *values* are dropped but
|
|
137
|
+
// identifier and property *names* are kept, so `add(1, 2)` and `add(3, 4)` share
|
|
138
|
+
// a key (same shape, different data) while `r.a` and `r.b` do not. With
|
|
139
|
+
// `keepValues`, literal values are kept too, which distinguishes "same shape,
|
|
140
|
+
// different values" from an exact duplicate.
|
|
141
|
+
const nodeKey = (node, keepValues) => {
|
|
142
|
+
switch (node.type) {
|
|
143
|
+
case "Identifier": {
|
|
144
|
+
return `Id:${node.name}`;
|
|
145
|
+
}
|
|
146
|
+
case "Literal": {
|
|
147
|
+
const literal = node;
|
|
148
|
+
return keepValues ? `Lit:${literal.raw ?? String(literal.value)}` : "Lit";
|
|
149
|
+
}
|
|
150
|
+
case "TemplateLiteral": {
|
|
151
|
+
const template = node;
|
|
152
|
+
const parts = template.expressions
|
|
153
|
+
.map((expression) => nodeKey(expression, keepValues))
|
|
154
|
+
.join(",");
|
|
155
|
+
return `Tpl(${parts})`;
|
|
156
|
+
}
|
|
157
|
+
case "MemberExpression": {
|
|
158
|
+
const member = node;
|
|
159
|
+
const property = member.computed
|
|
160
|
+
? `[${nodeKey(member.property, keepValues)}]`
|
|
161
|
+
: `.${readableProperty(member.property)}`;
|
|
162
|
+
return `${nodeKey(member.object, keepValues)}${property}`;
|
|
163
|
+
}
|
|
164
|
+
case "CallExpression": {
|
|
165
|
+
const call = node;
|
|
166
|
+
return `${nodeKey(call.callee, keepValues)}(${tupleKey(call.arguments, keepValues)})`;
|
|
167
|
+
}
|
|
168
|
+
case "ArrayExpression": {
|
|
169
|
+
const array = node;
|
|
170
|
+
const elements = array.elements
|
|
171
|
+
.map((element) => element === null ? "Hole" : nodeKey(element, keepValues))
|
|
172
|
+
.join(",");
|
|
173
|
+
return `[${elements}]`;
|
|
174
|
+
}
|
|
175
|
+
case "ObjectExpression": {
|
|
176
|
+
const object = node;
|
|
177
|
+
const properties = object.properties
|
|
178
|
+
.map((property) => nodeKey(property, keepValues))
|
|
179
|
+
.join(",");
|
|
180
|
+
return `{${properties}}`;
|
|
181
|
+
}
|
|
182
|
+
case "Property": {
|
|
183
|
+
const property = node;
|
|
184
|
+
const key = property.computed
|
|
185
|
+
? `[${nodeKey(property.key, keepValues)}]`
|
|
186
|
+
: readableProperty(property.key);
|
|
187
|
+
return `${key}:${nodeKey(property.value, keepValues)}`;
|
|
188
|
+
}
|
|
189
|
+
case "SpreadElement": {
|
|
190
|
+
return `...${nodeKey(node.argument, keepValues)}`;
|
|
191
|
+
}
|
|
192
|
+
case "UnaryExpression": {
|
|
193
|
+
const unary = node;
|
|
194
|
+
return `${unary.operator}${nodeKey(unary.argument, keepValues)}`;
|
|
195
|
+
}
|
|
196
|
+
default: {
|
|
197
|
+
return node.type;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const tupleKey = (nodes, keepValues) => {
|
|
202
|
+
return nodes.map((node) => nodeKey(node, keepValues)).join(",");
|
|
203
|
+
};
|
|
204
|
+
const hasSpread = (nodes) => {
|
|
205
|
+
return nodes.some((node) => node.type === "SpreadElement");
|
|
206
|
+
};
|
|
207
|
+
// `items.forEach(...)`, `items.map(...)`, etc.: a call whose callee is one of the
|
|
208
|
+
// iteration methods.
|
|
209
|
+
const isIterationCall = (node) => {
|
|
210
|
+
if (node.type !== "CallExpression") {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const callee = node.callee;
|
|
214
|
+
if (callee.type !== "MemberExpression") {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
const member = callee;
|
|
218
|
+
return (!member.computed &&
|
|
219
|
+
member.property.type === "Identifier" &&
|
|
220
|
+
ITER_METHODS.has(member.property.name));
|
|
221
|
+
};
|
|
222
|
+
// Whether an arbitrary AST subtree contains an `expect(...)` anchor. Walks the
|
|
223
|
+
// real node objects generically (skipping the `parent` back-reference to avoid
|
|
224
|
+
// cycles) so it works on nodes whose shape this file does not model.
|
|
225
|
+
const containsExpectCall = (value) => {
|
|
226
|
+
if (value === null || typeof value !== "object") {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(value)) {
|
|
230
|
+
return value.some((item) => containsExpectCall(item));
|
|
231
|
+
}
|
|
232
|
+
const record = value;
|
|
233
|
+
if (typeof record["type"] === "string" && isExpectCall(value)) {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
for (const key of Object.keys(record)) {
|
|
237
|
+
if (key === "parent") {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (containsExpectCall(record[key])) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
};
|
|
246
|
+
// Whether the test body iterates assertions: a loop, or an iteration-method
|
|
247
|
+
// callback, whose subtree contains an `expect(...)`. That is a hand-rolled
|
|
248
|
+
// parametrized test and should become test.each.
|
|
249
|
+
const hasLoopAssertion = (value) => {
|
|
250
|
+
if (value === null || typeof value !== "object") {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
if (Array.isArray(value)) {
|
|
254
|
+
return value.some((item) => hasLoopAssertion(item));
|
|
255
|
+
}
|
|
256
|
+
const record = value;
|
|
257
|
+
const type = record["type"];
|
|
258
|
+
if (typeof type === "string") {
|
|
259
|
+
if (LOOP_TYPES.has(type) && containsExpectCall(value)) {
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (isIterationCall(value) && containsExpectCall(value)) {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
for (const key of Object.keys(record)) {
|
|
267
|
+
if (key === "parent") {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (hasLoopAssertion(record[key])) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
};
|
|
276
|
+
// The names of locally-declared functions whose body asserts (`const check =
|
|
277
|
+
// (v) => expect(v)...`, `function check() { expect... }`). Calling such a helper
|
|
278
|
+
// repeatedly is the same hand-rolled parametrization as a loop.
|
|
279
|
+
const assertingHelperNames = (statements) => {
|
|
280
|
+
const names = new Set();
|
|
281
|
+
for (const statement of statements) {
|
|
282
|
+
if (statement.type === "FunctionDeclaration") {
|
|
283
|
+
const fn = statement;
|
|
284
|
+
if (fn.id !== null &&
|
|
285
|
+
fn.id.type === "Identifier" &&
|
|
286
|
+
containsExpectCall(statement)) {
|
|
287
|
+
names.add(fn.id.name);
|
|
288
|
+
}
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (statement.type === "VariableDeclaration") {
|
|
292
|
+
for (const declarator of statement
|
|
293
|
+
.declarations) {
|
|
294
|
+
if (declarator.type !== "VariableDeclarator") {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const declared = declarator;
|
|
298
|
+
const init = declared.init;
|
|
299
|
+
if (init !== null &&
|
|
300
|
+
(init.type === "ArrowFunctionExpression" ||
|
|
301
|
+
init.type === "FunctionExpression") &&
|
|
302
|
+
declared.id.type === "Identifier" &&
|
|
303
|
+
containsExpectCall(init)) {
|
|
304
|
+
names.add(declared.id.name);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return names;
|
|
310
|
+
};
|
|
311
|
+
// Whether an asserting local helper is invoked two or more times as direct
|
|
312
|
+
// statements: the helper form of a parametrized test.
|
|
313
|
+
const hasRepeatedHelperAssertion = (body) => {
|
|
314
|
+
const helpers = assertingHelperNames(body.body);
|
|
315
|
+
if (helpers.size === 0) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const counts = new Map();
|
|
319
|
+
for (const statement of body.body) {
|
|
320
|
+
if (statement.type !== "ExpressionStatement") {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const expression = unwrapAwait(statement.expression);
|
|
324
|
+
if (expression.type !== "CallExpression") {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const callee = expression.callee;
|
|
328
|
+
if (callee.type !== "Identifier") {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const name = callee.name;
|
|
332
|
+
if (!helpers.has(name)) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
336
|
+
}
|
|
337
|
+
for (const count of counts.values()) {
|
|
338
|
+
if (count >= 2) {
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
};
|
|
344
|
+
const baseShapeOf = (base) => {
|
|
345
|
+
if (base === undefined) {
|
|
346
|
+
return { kind: "other" };
|
|
347
|
+
}
|
|
348
|
+
// `r?.a` parses as a ChainExpression wrapping the (optional) member access.
|
|
349
|
+
const node = base.type === "ChainExpression"
|
|
350
|
+
? base.expression
|
|
351
|
+
: base;
|
|
352
|
+
if (node.type === "Identifier") {
|
|
353
|
+
return { kind: "identifier", root: node.name };
|
|
354
|
+
}
|
|
355
|
+
if (node.type === "CallExpression") {
|
|
356
|
+
const call = node;
|
|
357
|
+
return { kind: "call", calleeNode: call.callee, argsNode: call.arguments };
|
|
358
|
+
}
|
|
359
|
+
if (node.type === "MemberExpression") {
|
|
360
|
+
const accessPath = [];
|
|
361
|
+
let computed = false;
|
|
362
|
+
let current = node;
|
|
363
|
+
while (current.type === "MemberExpression") {
|
|
364
|
+
const member = current;
|
|
365
|
+
if (member.computed) {
|
|
366
|
+
// A dynamic field (`r[i]`) has no statically known name, so we cannot
|
|
367
|
+
// safely assert a consolidate; treat the whole base as unclassifiable.
|
|
368
|
+
computed = true;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
accessPath.unshift(readableProperty(member.property));
|
|
372
|
+
}
|
|
373
|
+
current = member.object;
|
|
374
|
+
}
|
|
375
|
+
if (current.type !== "Identifier" || computed) {
|
|
376
|
+
return { kind: "other" };
|
|
377
|
+
}
|
|
378
|
+
return { kind: "member", root: current.name, accessPath };
|
|
379
|
+
}
|
|
380
|
+
return { kind: "other" };
|
|
381
|
+
};
|
|
382
|
+
// Parse `expect(base).<not?>.<resolves|rejects?>.matcher(args)` into a record.
|
|
383
|
+
// Returns null when the expression is not a matcher call anchored at expect
|
|
384
|
+
// (e.g. a bare `expect(x)` with no matcher), so such a statement neither counts
|
|
385
|
+
// as a direct assertion nor pollutes routing.
|
|
386
|
+
const parseExpectChain = (expression) => {
|
|
387
|
+
const root = unwrapAwait(expression);
|
|
388
|
+
if (root.type !== "CallExpression") {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
const matcherCall = root;
|
|
392
|
+
const callee = matcherCall.callee;
|
|
393
|
+
if (callee.type !== "MemberExpression") {
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const matcherMember = callee;
|
|
397
|
+
let matcherName;
|
|
398
|
+
if (matcherMember.computed) {
|
|
399
|
+
matcherName = null;
|
|
400
|
+
}
|
|
401
|
+
else if (matcherMember.property.type === "Identifier") {
|
|
402
|
+
matcherName = matcherMember.property.name;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
matcherName = null;
|
|
406
|
+
}
|
|
407
|
+
let negation = false;
|
|
408
|
+
let modifier = null;
|
|
409
|
+
let current = matcherMember.object;
|
|
410
|
+
while (current.type === "MemberExpression") {
|
|
411
|
+
const member = current;
|
|
412
|
+
if (!member.computed && member.property.type === "Identifier") {
|
|
413
|
+
const name = member.property.name;
|
|
414
|
+
if (name === "not") {
|
|
415
|
+
negation = true;
|
|
416
|
+
}
|
|
417
|
+
else if (name === "resolves" || name === "rejects") {
|
|
418
|
+
modifier = name;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
current = member.object;
|
|
422
|
+
}
|
|
423
|
+
if (!isExpectCall(current)) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
const base = current.arguments[0];
|
|
427
|
+
return {
|
|
428
|
+
matcherName,
|
|
429
|
+
negation,
|
|
430
|
+
modifier,
|
|
431
|
+
matcherCall,
|
|
432
|
+
baseShape: baseShapeOf(base),
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Act detection (Step 0)
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// A statement that changes observable state, used to detect "assert, act,
|
|
439
|
+
// assert" inside one test. Conservative on calls: a bare function call is NOT an
|
|
440
|
+
// Act (it may be pure logging), but a method call IS an Act when it either names
|
|
441
|
+
// a known mutating method or is invoked on the value under test (e.g.
|
|
442
|
+
// `counter.increment()` while the test asserts `counter.value`).
|
|
443
|
+
const isActStatement = (statement, underTestRoots) => {
|
|
444
|
+
if (statement.type !== "ExpressionStatement") {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
const expression = statement.expression;
|
|
448
|
+
if (expression.type === "AwaitExpression" ||
|
|
449
|
+
expression.type === "AssignmentExpression" ||
|
|
450
|
+
expression.type === "UpdateExpression") {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
if (expression.type !== "CallExpression") {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
const callee = expression.callee;
|
|
457
|
+
if (callee.type !== "MemberExpression") {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
const member = callee;
|
|
461
|
+
if (!member.computed &&
|
|
462
|
+
member.property.type === "Identifier" &&
|
|
463
|
+
MUTATING_METHODS.has(member.property.name)) {
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
const root = memberRoot(member.object);
|
|
467
|
+
return root !== null && underTestRoots.has(root);
|
|
468
|
+
};
|
|
469
|
+
const actDescription = (statement) => {
|
|
470
|
+
const expression = statement.expression;
|
|
471
|
+
if (expression.type === "CallExpression") {
|
|
472
|
+
return `${readable(expression.callee)}()`;
|
|
473
|
+
}
|
|
474
|
+
if (expression.type === "AwaitExpression") {
|
|
475
|
+
const argument = expression.argument;
|
|
476
|
+
if (argument.type === "CallExpression") {
|
|
477
|
+
return `await ${readable(argument.callee)}()`;
|
|
478
|
+
}
|
|
479
|
+
return "an await";
|
|
480
|
+
}
|
|
481
|
+
if (expression.type === "AssignmentExpression") {
|
|
482
|
+
return `${readable(expression.left)} = …`;
|
|
483
|
+
}
|
|
484
|
+
if (expression.type === "UpdateExpression") {
|
|
485
|
+
const update = expression;
|
|
486
|
+
return `${readable(update.argument)}${update.operator}`;
|
|
487
|
+
}
|
|
488
|
+
return "a state change";
|
|
489
|
+
};
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Messages: each carries the fix as a prompt for the reader (a coding agent),
|
|
492
|
+
// an evaluable criterion, and a guard against the cheap dodge of deleting an
|
|
493
|
+
// assertion to silence the rule.
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
const consolidateMessage = (root, fields) => {
|
|
496
|
+
return `This test asserts ${fields.length} fields of the same object '${root}' (${fields.join(", ")}) with separate expect() calls. Replace them with ONE exhaustive 'expect(${root}).toEqual({ ... })' that lists every field, and delete the individual expect() statements. A dotted field denotes nesting ('meta.id' becomes { meta: { id: ... } }). Do not use a partial-match matcher (toMatchObject etc.): a field you omit would go unchecked. This is not a split and not test.each — it is one object compared once.`;
|
|
497
|
+
};
|
|
498
|
+
const splitByActMessage = (description) => {
|
|
499
|
+
return `This test asserts state both before and after a change (${description}) in the same test, so it verifies two behaviors. Criterion: name what each side guarantees — the first the state before ${description}, the second its effect; if both guarantees matter, they are two tests. Split into separate test() calls at that boundary, giving the second its own Arrange/Act; never leave an empty test, and do not just delete the before-state assertion to silence this. The problem is not the number of expects but the Act between them. This is not a toEqual consolidation.`;
|
|
500
|
+
};
|
|
501
|
+
const splitByHeterogeneityMessage = (summary) => {
|
|
502
|
+
return `This test mixes assertions of different shapes (${summary}), so it verifies more than one contract. Criterion: each distinct shape is its own contract; give each its own test() with its own setup. Do not delete the odd assertion to make the shapes match — that silences the check instead of restoring single behavior. This is not a toEqual consolidation and not test.each.`;
|
|
503
|
+
};
|
|
504
|
+
const eachOrSplitMessage = (operation, caseCount) => {
|
|
505
|
+
return `This test calls the same operation '${operation}' ${caseCount} times with only the inputs changing. Syntax cannot decide the fix, so this is a question, not a verdict. Option 1: if these are the same logic with different data, rewrite as test.each. Option 2: if they assert different contracts (e.g. normal vs boundary/error), split into separate test() calls. Criterion: restate each case as one sentence — same sentence with different values means test.each, a different claim means split. Either way keep every case; do not clear this by deleting cases down to one. You, who know the intent, decide.`;
|
|
506
|
+
};
|
|
507
|
+
const genericMessage = (count) => {
|
|
508
|
+
return `This test makes ${count} top-level expect() assertions, but a test must verify a single behavior. The exit could not be named automatically, so keep the ban and pick the fix with this checklist: (1) Is there a state change (assignment, mutation, await, or a call on the value under test) between assertions? Split into separate tests at that boundary. (2) Do the matchers or asserted shapes differ? Split into one test per contract. (3) Are these different fields of the same object? Replace them with one exhaustive toEqual (no partial match). (4) Is it the same operation with only the inputs changing? Syntax cannot decide this one — restate each case as a sentence: the same sentence with different values suggests test.each, a different claim suggests split; ask, do not guess. Do not silence this by deleting an assertion.`;
|
|
509
|
+
};
|
|
510
|
+
const loopEachMessage = () => {
|
|
511
|
+
return `This test runs the same assertion logic over several inputs by hand — via a loop, an iteration callback, or a repeated call to a local assertion helper. That is a hand-rolled parametrized test. Rewrite it as test.each(cases)(name, (case) => { ... }) so every case becomes a named, independently-reported test — a loop or repeated call stops at the first failure and hides which input failed. This is parametrization, not a consolidation or a split.`;
|
|
512
|
+
};
|
|
513
|
+
// A human description of each occurrence's shape, used to explain a
|
|
514
|
+
// heterogeneity split: "toEqual on parse() vs toThrow on errs".
|
|
515
|
+
const shapeSummary = (occurrences) => {
|
|
516
|
+
const describe = (occurrence) => {
|
|
517
|
+
const matcher = `${occurrence.negation ? "not." : ""}${occurrence.modifier !== null ? `${occurrence.modifier}.` : ""}${occurrence.matcherName ?? "?"}`;
|
|
518
|
+
const shape = occurrence.baseShape;
|
|
519
|
+
let receiver;
|
|
520
|
+
if (shape.kind === "call") {
|
|
521
|
+
receiver = `${readable(shape.calleeNode)}()`;
|
|
522
|
+
}
|
|
523
|
+
else if (shape.kind === "member") {
|
|
524
|
+
receiver = [shape.root, ...shape.accessPath].join(".");
|
|
525
|
+
}
|
|
526
|
+
else if (shape.kind === "identifier") {
|
|
527
|
+
receiver = shape.root;
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
receiver = "an expression";
|
|
531
|
+
}
|
|
532
|
+
return `${matcher} on ${receiver}`;
|
|
533
|
+
};
|
|
534
|
+
const seen = new Set();
|
|
535
|
+
const labels = [];
|
|
536
|
+
for (const occurrence of occurrences) {
|
|
537
|
+
const label = describe(occurrence);
|
|
538
|
+
if (!seen.has(label)) {
|
|
539
|
+
seen.add(label);
|
|
540
|
+
labels.push(label);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return labels.join(" vs ");
|
|
544
|
+
};
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// routeTest: the heuristic router. Returns the message to report, or null when
|
|
547
|
+
// the test is not a violation (0 or 1 direct expect).
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
const routeTest = (body) => {
|
|
550
|
+
// A loop / iteration callback that asserts, or an asserting local helper
|
|
551
|
+
// called repeatedly, is a hand-rolled parametrized test; route it to test.each
|
|
552
|
+
// before counting direct expects.
|
|
553
|
+
if (hasLoopAssertion(body) || hasRepeatedHelperAssertion(body)) {
|
|
554
|
+
return loopEachMessage();
|
|
555
|
+
}
|
|
556
|
+
const statements = body.body;
|
|
557
|
+
const entries = [];
|
|
558
|
+
statements.forEach((statement, index) => {
|
|
559
|
+
if (statement.type !== "ExpressionStatement") {
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const occurrence = parseExpectChain(statement.expression);
|
|
563
|
+
if (occurrence !== null) {
|
|
564
|
+
entries.push({ index, occurrence });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
const count = entries.length;
|
|
568
|
+
if (count <= 1) {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
const occurrences = entries.map((entry) => entry.occurrence);
|
|
572
|
+
// The objects under direct test, used to recognize a mutation of the subject
|
|
573
|
+
// (e.g. `counter.increment()`) as an Act.
|
|
574
|
+
const underTestRoots = new Set();
|
|
575
|
+
for (const occurrence of occurrences) {
|
|
576
|
+
const shape = occurrence.baseShape;
|
|
577
|
+
if (shape.kind === "member" || shape.kind === "identifier") {
|
|
578
|
+
underTestRoots.add(shape.root);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// Step 0: an Act between the first and last assertion means the test observes
|
|
582
|
+
// two states; split at the boundary.
|
|
583
|
+
const expectIndices = new Set(entries.map((entry) => entry.index));
|
|
584
|
+
const first = entries[0]?.index ?? 0;
|
|
585
|
+
const last = entries[count - 1]?.index ?? 0;
|
|
586
|
+
for (let index = first + 1; index < last; index++) {
|
|
587
|
+
if (expectIndices.has(index)) {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const statement = statements[index];
|
|
591
|
+
if (statement === undefined) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
if (isActStatement(statement, underTestRoots)) {
|
|
595
|
+
return splitByActMessage(actDescription(statement));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
// Guard: anything we cannot classify safely keeps the ban but falls back to
|
|
599
|
+
// the generic self-diagnosis message.
|
|
600
|
+
if (occurrences.some((occurrence) => occurrence.matcherName === null ||
|
|
601
|
+
occurrence.baseShape.kind === "other")) {
|
|
602
|
+
return genericMessage(count);
|
|
603
|
+
}
|
|
604
|
+
// Step 1: homogeneity of the assertion shape.
|
|
605
|
+
const signature = (occurrence) => {
|
|
606
|
+
return `${occurrence.matcherName}|${occurrence.negation}|${occurrence.modifier}|${occurrence.baseShape.kind}`;
|
|
607
|
+
};
|
|
608
|
+
if (new Set(occurrences.map(signature)).size > 1) {
|
|
609
|
+
return splitByHeterogeneityMessage(shapeSummary(occurrences));
|
|
610
|
+
}
|
|
611
|
+
// Step 2: where the variation lives.
|
|
612
|
+
const kind = occurrences[0]?.baseShape.kind;
|
|
613
|
+
if (kind === "member") {
|
|
614
|
+
const shapes = occurrences.map((occurrence) => occurrence.baseShape);
|
|
615
|
+
if (new Set(shapes.map((shape) => shape.root)).size > 1) {
|
|
616
|
+
return genericMessage(count);
|
|
617
|
+
}
|
|
618
|
+
const paths = shapes.map((shape) => shape.accessPath.join("."));
|
|
619
|
+
const distinct = [...new Set(paths)];
|
|
620
|
+
if (distinct.length >= 2) {
|
|
621
|
+
return consolidateMessage(shapes[0]?.root ?? "", distinct);
|
|
622
|
+
}
|
|
623
|
+
return genericMessage(count);
|
|
624
|
+
}
|
|
625
|
+
if (kind === "call") {
|
|
626
|
+
const shapes = occurrences.map((occurrence) => occurrence.baseShape);
|
|
627
|
+
if (new Set(shapes.map((shape) => nodeKey(shape.calleeNode, false))).size > 1) {
|
|
628
|
+
return splitByHeterogeneityMessage(shapeSummary(occurrences));
|
|
629
|
+
}
|
|
630
|
+
if (shapes.some((shape) => hasSpread(shape.argsNode))) {
|
|
631
|
+
return genericMessage(count);
|
|
632
|
+
}
|
|
633
|
+
if (new Set(shapes.map((shape) => tupleKey(shape.argsNode, false))).size > 1) {
|
|
634
|
+
return genericMessage(count);
|
|
635
|
+
}
|
|
636
|
+
// Same callee, same argument shape: an each candidate only if the input
|
|
637
|
+
// values actually differ. Identical inputs (only the expected value varies,
|
|
638
|
+
// or an exact duplicate) are not parameterizable.
|
|
639
|
+
if (new Set(shapes.map((shape) => tupleKey(shape.argsNode, true))).size <= 1) {
|
|
640
|
+
return genericMessage(count);
|
|
641
|
+
}
|
|
642
|
+
return eachOrSplitMessage(readable(shapes[0]?.calleeNode ?? { type: "" }), count);
|
|
643
|
+
}
|
|
644
|
+
return genericMessage(count);
|
|
645
|
+
};
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// Rule / Plugin
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
const rule = {
|
|
650
|
+
meta: {
|
|
651
|
+
type: "problem",
|
|
652
|
+
docs: {
|
|
653
|
+
description: "Disallow multiple top-level expect() assertions in a single test, and tell the author how to fix it (consolidate, split, or test.each).",
|
|
654
|
+
},
|
|
655
|
+
schema: [],
|
|
656
|
+
},
|
|
657
|
+
create(context) {
|
|
658
|
+
const checkCall = (node) => {
|
|
659
|
+
const root = rootCalleeName(node);
|
|
660
|
+
if (root === null || !TEST_CALLEES.has(root)) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const callback = testCallback(node);
|
|
664
|
+
if (callback === null || callback.body.type !== "BlockStatement") {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const message = routeTest(callback.body);
|
|
668
|
+
if (message !== null) {
|
|
669
|
+
context.report({ message, node });
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
return {
|
|
673
|
+
CallExpression: checkCall,
|
|
674
|
+
};
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
const plugin = {
|
|
678
|
+
meta: { name: "crescware-single-behavior-per-test" },
|
|
679
|
+
rules: {
|
|
680
|
+
"single-behavior-per-test": rule,
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
export default plugin;
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crescware/eslint-plugin-crescware-single-behavior-per-test",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist"
|
|
6
|
+
],
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "rimraf dist && tsgo -p tsconfig.build.json",
|
|
17
|
+
"check": "pnpm run check:types && pnpm run check:lint && pnpm run check:knip && pnpm run test",
|
|
18
|
+
"check:knip": "knip",
|
|
19
|
+
"check:lint": "oxlint && oxfmt --check",
|
|
20
|
+
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
21
|
+
"exec:fixtures": "oxlint -c fixtures/oxlintrc.default.json --no-ignore -f json fixtures/cases/",
|
|
22
|
+
"format": "oxlint --fix && oxfmt",
|
|
23
|
+
"prepublishOnly": "pnpm run build",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "24.12.2",
|
|
28
|
+
"@typescript/native-preview": "7.0.0-dev.20260422.1",
|
|
29
|
+
"knip": "6.7.0",
|
|
30
|
+
"oxfmt": "0.47.0",
|
|
31
|
+
"oxlint": "1.62.0",
|
|
32
|
+
"rimraf": "6.1.3",
|
|
33
|
+
"vitest": "4.1.5"
|
|
34
|
+
},
|
|
35
|
+
"packageManager": "pnpm@10.33.2"
|
|
36
|
+
}
|