{"_id":"@crescware/eslint-plugin-crescware-single-behavior-per-test","name":"@crescware/eslint-plugin-crescware-single-behavior-per-test","dist-tags":{"latest":"0.0.1"},"versions":{"0.0.1":{"name":"@crescware/eslint-plugin-crescware-single-behavior-per-test","version":"0.0.1","type":"module","main":"dist/index.js","exports":{".":"./dist/index.js"},"publishConfig":{"access":"public"},"scripts":{"build":"rimraf dist && tsgo -p tsconfig.build.json","check":"pnpm run check:types && pnpm run check:lint && pnpm run check:knip && pnpm run test","check:knip":"knip","check:lint":"oxlint && oxfmt --check","check:types":"tsgo -p tsconfig.json --noEmit","exec:fixtures":"oxlint -c fixtures/oxlintrc.default.json --no-ignore -f json fixtures/cases/","format":"oxlint --fix && oxfmt","prepublishOnly":"pnpm run build","test":"vitest run"},"devDependencies":{"@types/node":"24.12.2","@typescript/native-preview":"7.0.0-dev.20260422.1","knip":"6.7.0","oxfmt":"0.47.0","oxlint":"1.62.0","rimraf":"6.1.3","vitest":"4.1.5"},"packageManager":"pnpm@10.33.2","gitHead":"eb62668d2c145ed24d32ee2049609a3fd30e2ae0","types":"./dist/index.d.ts","_id":"@crescware/eslint-plugin-crescware-single-behavior-per-test@0.0.1","description":"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 ","_nodeVersion":"24.15.0","_npmVersion":"11.12.1","dist":{"integrity":"sha512-wE8iEySELRPJ6rqunUV4MGqBu35n/tYxIDv97G4IpYW8jO9uZW98NKkhXEKx1nO3w6kzjg7AkM8JtytJoUeaVQ==","shasum":"68a91919366e6bd4fee9a95383c042e547946b95","tarball":"https://registry.npmjs.org/@crescware/eslint-plugin-crescware-single-behavior-per-test/-/eslint-plugin-crescware-single-behavior-per-test-0.0.1.tgz","fileCount":4,"unpackedSize":36120,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCp/Q73SlwkgLUk+WI5K5mG3YXt15lnWYzMb9d2ce0sCwIhAJ/p/wJoEV3dpKvY38LPOqlSBrASc6KDJIY40kYJbc3O"}]},"_npmUser":{"name":"crescware","email":"okunokentaro+npm@crescware.com"},"directories":{},"maintainers":[{"name":"crescware","email":"okunokentaro+npm@crescware.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/eslint-plugin-crescware-single-behavior-per-test_0.0.1_1782261289959_0.8080797858665869"},"_hasShrinkwrap":false}},"time":{"created":"2026-06-24T00:34:49.794Z","0.0.1":"2026-06-24T00:34:50.107Z","modified":"2026-06-24T00:34:50.324Z"},"maintainers":[{"name":"crescware","email":"okunokentaro+npm@crescware.com"}],"description":"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 ","readme":"# @crescware/eslint-plugin-crescware-single-behavior-per-test\n\nAn 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.\n\n## Why\n\nA 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.\n\nBut 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.\n\nThe ban is the floor; the guidance rides on top of it. The severity is always `error`.\n\n## Install\n\n```sh\npnpm add -D @crescware/eslint-plugin-crescware-single-behavior-per-test\n```\n\n## Usage\n\nRegister the plugin in your `.oxlintrc.json` and enable the rule:\n\n```json\n{\n  \"jsPlugins\": [\"@crescware/eslint-plugin-crescware-single-behavior-per-test\"],\n  \"rules\": {\n    \"crescware-single-behavior-per-test/single-behavior-per-test\": \"error\"\n  }\n}\n```\n\n## What it reports\n\nThe 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).\n\n### consolidate (assertion)\n\nSeveral fields of the **same object** are asserted separately. Compare the object once.\n\n```ts\n// reported\ntest(\"compute result\", () => {\n  const r = compute();\n  expect(r.status).toBe(\"ok\");\n  expect(r.code).toBe(200);\n});\n\n// fix: one exhaustive toEqual (list every field; no partial-match matcher)\ntest(\"compute result\", () => {\n  expect(compute()).toEqual({ status: \"ok\", code: 200 });\n});\n```\n\n### split-by-act (assertion)\n\nA state change (assignment, mutation, `await`, or a call on the value under test) sits **between** assertions. The before- and after-states are two behaviors.\n\n```ts\n// reported\ntest(\"counter\", () => {\n  const c = new Counter();\n  expect(c.value).toBe(0);\n  c.increment();\n  expect(c.value).toBe(1);\n});\n\n// fix: split at the Act into two tests, each with its own Arrange/Act\n```\n\n### split-by-heterogeneity (assertion)\n\nThe matchers or the asserted shapes differ — the test checks more than one contract.\n\n```ts\n// reported (a value contract and an error contract)\ntest(\"parse\", () => {\n  expect(parse(\"x\")).toEqual({ ok: true });\n  expect(parseThrows).toThrow();\n});\n\n// fix: one test() per contract\n```\n\n### each-or-split (question)\n\nThe **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.\n\n```ts\n// reported — you decide test.each vs split\ntest(\"add\", () => {\n  expect(add(1, 2)).toBe(3);\n  expect(add(3, 4)).toBe(7);\n});\n```\n\n### loop-each (assertion)\n\nAssertions 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).\n\n```ts\n// reported\ntest(\"all positive\", () => {\n  for (const x of items) {\n    expect(x).toBeGreaterThan(0);\n  }\n});\n\n// fix\ntest.each(items)(\"%s is positive\", (x) => {\n  expect(x).toBeGreaterThan(0);\n});\n```\n\n### generic (question)\n\nWhen 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.\n\n## Scope\n\n- 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.\n- Modifier and async chains are understood: `expect(x).not.toBe(...)`, `expect(x).resolves.toBe(...)`, `await expect(x).resolves.toBe(...)`, `expect.soft(x).toBe(...)`.\n- `test`, `it`, `it.only`, and `test.each(table)(...)` callbacks are recognized.\n- The rule never autofixes — a lazy set of partial assertions cannot be mechanically rebuilt into an exhaustive `toEqual` — it reports only.\n\n## Companion: `no-restricted-matchers`\n\nThe `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.\n\n## Stack\n\n- **Runtime**: Node.js 24 (via [mise](https://mise.jdx.dev/))\n- **Package manager**: pnpm (via corepack)\n- **Language**: TypeScript ([native preview](https://github.com/microsoft/typescript-go))\n- **Test**: [Vitest](https://vitest.dev/) (fixture integration tests that run oxlint over `fixtures/cases`)\n- **Lint**: [oxlint](https://oxc.rs/docs/guide/usage/linter) (the repo dogfoods this very rule)\n- **Format**: [oxfmt](https://github.com/oxc-project/oxc)\n- **Unused code**: [Knip](https://knip.dev/)\n\n## Setup\n\n```sh\nmise install\ncorepack enable\npnpm install\n```\n\n## Scripts\n\n| Command            | Description                              |\n| ------------------ | ---------------------------------------- |\n| `pnpm build`       | Compile `src` to `dist`                  |\n| `pnpm check`       | Run all checks (types, lint, knip, test) |\n| `pnpm check:types` | Type check                               |\n| `pnpm check:lint`  | Lint and format check                    |\n| `pnpm check:knip`  | Unused files/exports check               |\n| `pnpm test`        | Run fixture integration tests            |\n| `pnpm format`      | Fix lint and format                      |\n","readmeFilename":"README.md","_rev":"1-faac0bd7b69e74cbcba6b445af57759d"}