@shivaedev/quality 0.30.0 → 0.31.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +7 -3
  3. package/dist/imports/fences/reach.d.ts +9 -0
  4. package/dist/imports/fences/reach.d.ts.map +1 -1
  5. package/dist/imports/fences/reach.js +5 -2
  6. package/dist/imports/fences/reach.js.map +1 -1
  7. package/dist/naming/testName.d.ts +1 -1
  8. package/dist/naming/testName.d.ts.map +1 -1
  9. package/dist/naming/testName.js +1 -1
  10. package/dist/naming/testName.js.map +1 -1
  11. package/dist/rules/stories/engineOf.d.ts +3 -0
  12. package/dist/rules/stories/engineOf.d.ts.map +1 -0
  13. package/dist/rules/stories/engineOf.js +62 -0
  14. package/dist/rules/stories/engineOf.js.map +1 -0
  15. package/dist/rules/stories/specs.d.ts.map +1 -1
  16. package/dist/rules/stories/specs.js +7 -2
  17. package/dist/rules/stories/specs.js.map +1 -1
  18. package/dist/rules/stories/unitSyntax.d.ts +8 -0
  19. package/dist/rules/stories/unitSyntax.d.ts.map +1 -0
  20. package/dist/rules/stories/unitSyntax.js +77 -0
  21. package/dist/rules/stories/unitSyntax.js.map +1 -0
  22. package/dist/rules/stories/unitTests.d.ts +4 -0
  23. package/dist/rules/stories/unitTests.d.ts.map +1 -0
  24. package/dist/rules/stories/unitTests.js +76 -0
  25. package/dist/rules/stories/unitTests.js.map +1 -0
  26. package/dist/rules/test-names/colocated.d.ts.map +1 -1
  27. package/dist/rules/test-names/colocated.js +2 -1
  28. package/dist/rules/test-names/colocated.js.map +1 -1
  29. package/dist/rules/test-names/follow.d.ts.map +1 -1
  30. package/dist/rules/test-names/follow.js +16 -6
  31. package/dist/rules/test-names/follow.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/imports/fences/reach.ts +12 -3
  34. package/src/naming/testName.ts +1 -1
  35. package/src/rules/stories/engineOf.ts +65 -0
  36. package/src/rules/stories/specs.ts +7 -3
  37. package/src/rules/stories/unitSyntax.ts +95 -0
  38. package/src/rules/stories/unitTests.ts +86 -0
  39. package/src/rules/test-names/colocated.ts +2 -1
  40. package/src/rules/test-names/follow.ts +17 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.31.0 - 2026-10-19
4
+
5
+ ### Added
6
+
7
+ - A pure function with no engine behind it gets a plain test in a unit test file, declared with the `unit` modifier: `parse.unit.test.ts` beside `parse.ts`, or `<behaviour>.unit.spec.ts`. `tests/story-specs` accepts plain Vitest tests there and still rejects them in every other test file, so the default answer stays a story.
8
+ - `tests/story-specs` rejects a `.unit` file that reaches an engine, directly or through what it imports: Node's file, network, process and worker modules, database clients, HTTP, socket and DOM libraries, and the story runners. It follows runtime imports through Quality's import graph, ignores type-only imports, and names the reached engine and the import chain. When the module under test reaches the engine, the finding asks you to split that module so its pure code can be tested alone.
9
+ - `tests/story-specs` also rejects mocking (`vi.mock`, `vi.spyOn`, `vi.fn`, env and global stubs, fake timers), providing services (`Effect.provide*`, `Layer`, `ManagedRuntime`, `it.layer`) and DOM use in a `.unit` file and in the test-support fixtures it imports. Running an Effect that needs nothing, with `Effect.runSync` or `it.effect`, stays allowed.
10
+ - `tests/follow` and `tests/colocated` accept `unit` as a test modifier beside `dom`, `slow` and `typecheck`, keep it in the names they suggest, and reject `.unit.dom` because a unit test runs without the DOM. `testProjects()` runs `.unit` files in the Node project. `ENVIRONMENTS` from `naming/testName.ts` now includes `"unit"`.
11
+ - Every `tests/story-specs` finding names both repairs: drive the engine through the package's story kit, or, when the code under test is pure, move the test to a `.unit` file.
12
+ - To adopt it, rename each baselined plain test of pure code to `<name>.unit.test.ts` (or `.unit.spec.ts`), run `quality lint` to confirm it reaches no engine and mocks nothing, then run `quality baseline tighten` so its `tests/story-specs` entry leaves the baseline and other entries move with the file. Replace a `tests/story-specs` exception kept for pure tests with the same rename.
13
+
3
14
  ## 0.30.0 - 2026-10-19
4
15
 
5
16
  ### Added
package/README.md CHANGED
@@ -128,7 +128,11 @@ export default defineConfig({
128
128
 
129
129
  Write setup in the domain kit under `test-support/` and keep each test specific to its story. [`@shivaedev/test-story`](https://github.com/ShivaeDev/platform/tree/main/packages/test-story#readme) explains how to build the kit, traits and verbs over a real engine.
130
130
 
131
- Quality's `tests/story-specs` rule reports a test file that declares tests without a kit's `it`, including plain Vitest `it` and `test` declarations mixed with stories. Typecheck tests are exempt, as are test-story itself and its transitive workspace dependencies, including development, optional and peer dependencies: requiring those packages to depend on test-story would create a cycle. Pure-function tests are included: the baseline records existing files, and that debt should only go down.
131
+ Quality's `tests/story-specs` rule reports a test file that declares tests without a kit's `it`, including plain Vitest `it` and `test` declarations mixed with stories. Typecheck tests are exempt, as are test-story itself and its transitive workspace dependencies, including development, optional and peer dependencies: requiring those packages to depend on test-story would create a cycle.
132
+
133
+ There is one other honest form. A pure function with no engine behind it gets a plain test in a **unit test** file, declared by the `unit` modifier: `parse.unit.test.ts` beside `parse.ts`, or `<behaviour>.unit.spec.ts`. A unit test exercises one unit and reaches nothing bigger, so `tests/story-specs` rejects a `.unit` file that reaches an engine, directly or through what it imports, following runtime imports through the shared import graph; type-only imports do not count. The engines are Node's file, network, process and worker modules, database clients, HTTP and socket libraries, DOM libraries and the story runners. A test-support fixture the unit file imports must pass the same check. The rule also rejects mocking (`vi.mock`, `vi.spyOn`, `vi.fn`, env and global stubs, fake timers), providing services (`Effect.provide*`, `Layer`, `ManagedRuntime`, `it.layer`) and DOM use in the unit file and its test-support fixtures. Running an Effect that needs nothing, such as with `Effect.runSync` or `it.effect`, is fine. When the module under test reaches an engine, the finding names the import chain and asks you to split the module: move the pure code into a module that reaches no engine and test that one. The check reads imports and syntax; it cannot see I/O hidden inside an external package or a global such as `fetch`.
134
+
135
+ Every finding names both repairs: drive the engine through the package's story kit, or, when the code under test is pure, move the test to a `.unit` file. Never wrap a pure function in a story that ignores its kit.
132
136
 
133
137
  Every package tests its real engine through its own story kit, so setup and actions share the package's domain vocabulary. To repair a finding, build the kit with `storyKit` from `@shivaedev/test-story/server/storyKit.ts` in `src/test-support`, import it in the spec and replace plain registrations with `kit.it(name, traits, body)`. The rule follows imports, aliases and conditional registrars back to the kit; it checks declarations, while review and runtime tests establish whether the kit exercises the real engine.
134
138
 
@@ -189,7 +193,7 @@ Keep the default error level. The command replaces that rule's entries and leave
189
193
  - **Suppressions:** the `suppressions/*` rules reject inline lint and compiler directives, casts through `unknown`, `any` or `never`, Biome settings that weaken a check, recommended Biome rules the preset leaves below `error`, and `ignoreDeprecations`. None of them takes exceptions. A type test (`*.typecheck.test.ts`) may use `@ts-expect-error`; a test that must pass a rejected value at run time calls the API through `Reflect.apply`.
190
194
  - **Imports:** cycles, unresolvable imports, workspace imports missing from the manifest, relative imports that leave their folder, production imports of test code, runtime folders and fences, as described above.
191
195
  - **Names and files:** the Biome preset's naming rules and plugins, and the `files/*` rules, which name a file after its main export with its folders as the prefix. No naming rule renames anything; the author picks the name. A name an outside system owns, such as a query parameter or an environment variable, is written as a quoted key, which the naming rules skip. A `Schema.Struct` field stays camelCase even when quoted and maps to the outside name with `Schema.encodeKeys`. Biome's naming messages print only a pattern: spell acronyms as words (`HttpClient`, `userId`, `parseUrl`) rather than turning off `strictCase` as its hint suggests, give a type no `I` prefix, and name a type parameter `T`, `T` plus a PascalCase name (`TItem`), or Effect's `A`, `E` and `R`. No rule checks these, so review does: a boolean reads as a question (`isOpen`, `canRetry`) unless the DOM or React names it (`open`, `disabled`); a schema shares its name with its type (`type Item = typeof Item.Type`); an unused parameter is removed rather than renamed with the `_` prefix TypeScript accepts. The naming rules take repository facts: `toolOwned` lists `.gitignore` patterns whose names a tool fixes, and a configured list extends the rule's defaults, so list only repository-specific additions; `content` lists snake_case content folders and files; `tests/follow` and `tests/colocated` take `suites` for test folders with their own layout, such as a Playwright suite.
192
- - **Tests:** `tests/follow` and `tests/colocated` keep a test beside the code it covers, named `<file>[.<environment>].test.ts` or `<behaviour>[.<environment>].spec.ts`, and the story rules above keep setup in the kit.
196
+ - **Tests:** `tests/follow` and `tests/colocated` keep a test beside the code it covers, named `<file>[.<modifier>].test.ts` or `<behaviour>[.<modifier>].spec.ts` with at most one modifier of `dom`, `slow`, `typecheck` and `unit`; a unit test runs without the DOM, so `.unit.dom` is rejected. The story rules above keep setup in the kit.
193
197
  - **Manifests:** `manifests/sorted` keeps every `package.json` in sort-package-json order, and `manifests/source-first` puts the `source` condition first in `exports` and `imports`. `manifests/exports-shape` checks the package root and workspace members with exports. They use one file-preserving TypeScript wildcard: for example, `./*.ts` maps to `./src/*.ts`, `./dist/*.d.ts` and `./dist/*.js` under `source`, `types` and `default`. A subtree pattern such as `./server/*.ts` keeps that same subtree in its targets. An optional root entry, exact-path non-code assets such as JSON presets, `./package.json` and `null` blocks are allowed. Import each module at its path under `src/`; named aliases, flattened paths, per-file module lists and additional non-null wildcards fail.
194
198
  - **Biome:** the `biome` rule reports Biome's error findings as `biome/<category>`. A Biome run that fails without an error finding, such as one whose config leaves no file to check, fails lint and fix with Biome's message. The preset leaves out the `.worktrees` folder at the root, so a checkout does not lint its linked worktrees, while a worktree inside it lints all of its own files. The preset's plugins ban ambient time, randomness, `console` and `process.env` in favour of Effect's services.
195
199
 
@@ -292,7 +296,7 @@ import { defineConfig } from "vitest/config";
292
296
  export default defineConfig({ test: testProjects() });
293
297
  ```
294
298
 
295
- `testProjects` runs unit tests and specs in Node and `.dom` tests and specs in test-story's DOM environment by default. The DOM environment keeps Node's host constructors separate from the happy-dom window, so DOM specs need no project remap. Each test gets 30 seconds instead of Vitest's 5, because tests that start servers, browsers or CLIs take seconds on a busy CI runner. `.slow` tests run only when `--project slow` asks for them. Type tests are excluded from Vitest and belong in the compiler's input. Every project excludes `**/.worktrees/**` so a checkout does not collect specs from its worktrees.
299
+ `testProjects` runs tests, `.unit` tests and specs in Node and `.dom` tests and specs in test-story's DOM environment by default. The DOM environment keeps Node's host constructors separate from the happy-dom window, so DOM specs need no project remap. Each test gets 30 seconds instead of Vitest's 5, because tests that start servers, browsers or CLIs take seconds on a busy CI runner. `.slow` tests run only when `--project slow` asks for them. Type tests are excluded from Vitest and belong in the compiler's input. Every project excludes `**/.worktrees/**` so a checkout does not collect specs from its worktrees.
296
300
 
297
301
  Worker concurrency comes from a configured PostgreSQL connection budget, independent of CPU count: `max(1, floor(connectionBudget / connectionsPerWorker))`. The defaults are 80 usable connections and 14 connections per worker, giving five workers. The budget leaves room on a typical 100-connection server for administration and other clients; the estimate allows for full-app tests with several pools. Set the budget from your server's `SHOW max_connections`, subtracting connections reserved for other clients and concurrent test processes. Quality does not query the server. If even one worker exceeds the budget, it still runs one worker; reduce that test's pool sizes or increase the server capacity.
298
302
 
@@ -6,7 +6,16 @@ export interface Reached {
6
6
  readonly path: readonly string[];
7
7
  readonly target: Endpoint;
8
8
  }
9
+ export interface Trail {
10
+ readonly line: number;
11
+ readonly path: readonly string[];
12
+ }
9
13
  export declare function labelOf(endpoint: Endpoint): string;
10
14
  export declare function outgoing(edges: readonly ImportEdge[]): ReadonlyMap<string, readonly ImportEdge[]>;
15
+ export interface Walked {
16
+ readonly reached: readonly Reached[];
17
+ readonly visited: ReadonlyMap<string, Trail>;
18
+ }
19
+ export declare function walkFrom(start: string, edgesFrom: ReadonlyMap<string, readonly ImportEdge[]>, forbidden: Matcher): Walked;
11
20
  export declare function reachedFrom(start: string, edgesFrom: ReadonlyMap<string, readonly ImportEdge[]>, forbidden: Matcher): readonly Reached[];
12
21
  //# sourceMappingURL=reach.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reach.d.ts","sourceRoot":"","sources":["../../../src/imports/fences/reach.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,MAAM,WAAW,OAAO;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;CAC1B;AAOD,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAMlD;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,WAAW,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC,CAQjG;AAqBD,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC,EAAE,SAAS,EAAE,OAAO,GAAG,SAAS,OAAO,EAAE,CASxI"}
1
+ {"version":3,"file":"reach.d.ts","sourceRoot":"","sources":["../../../src/imports/fences/reach.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAE1C,MAAM,WAAW,OAAO;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;CAC1B;AAED,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;CACjC;AAED,wBAAgB,OAAO,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAMlD;AAED,wBAAgB,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,GAAG,WAAW,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC,CAQjG;AAoBD,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,OAAO,EAAE,SAAS,OAAO,EAAE,CAAC;IACrC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;CAC7C;AAGD,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CASzH;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAC,EAAE,SAAS,EAAE,OAAO,GAAG,SAAS,OAAO,EAAE,CAExI"}
@@ -26,7 +26,7 @@ function follow(walk, before, edge) {
26
26
  }
27
27
  }
28
28
  // A breadth-first walk that stops at each forbidden endpoint, so a finding names the shortest path to the first forbidden step.
29
- export function reachedFrom(start, edgesFrom, forbidden) {
29
+ export function walkFrom(start, edgesFrom, forbidden) {
30
30
  const walk = { forbidden, queue: [start], reached: new Map(), trails: new Map([[start, { line: 0, path: [start] }]]) };
31
31
  for (const node of walk.queue) {
32
32
  const before = walk.trails.get(node) ?? { line: 0, path: [node] };
@@ -34,6 +34,9 @@ export function reachedFrom(start, edgesFrom, forbidden) {
34
34
  follow(walk, before, edge);
35
35
  }
36
36
  }
37
- return [...walk.reached.values()];
37
+ return { reached: [...walk.reached.values()], visited: walk.trails };
38
+ }
39
+ export function reachedFrom(start, edgesFrom, forbidden) {
40
+ return walkFrom(start, edgesFrom, forbidden).reached;
38
41
  }
39
42
  //# sourceMappingURL=reach.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"reach.js","sourceRoot":"","sources":["../../../src/imports/fences/reach.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"reach.js","sourceRoot":"","sources":["../../../src/imports/fences/reach.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- export declare const ENVIRONMENTS: readonly ["dom", "slow", "typecheck"];
1
+ export declare const ENVIRONMENTS: readonly ["dom", "slow", "typecheck", "unit"];
2
2
  export type Environment = (typeof ENVIRONMENTS)[number];
3
3
  export interface TestName {
4
4
  readonly extension: string;
@@ -1 +1 @@
1
- {"version":3,"file":"testName.d.ts","sourceRoot":"","sources":["../../src/naming/testName.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,YAAI,KAAK,EAAE,MAAM,EAAE,WAAW,CAAU,CAAC;AAElE,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;AAExD,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAID,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAQ3D;AAID,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD"}
1
+ {"version":3,"file":"testName.d.ts","sourceRoot":"","sources":["../../src/naming/testName.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,YAAI,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAU,CAAC;AAE1E,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,CAAC,CAAC;AAExD,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAID,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAQ3D;AAID,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhD"}
@@ -1,5 +1,5 @@
1
1
  import { posix } from "node:path";
2
- export const ENVIRONMENTS = ["dom", "slow", "typecheck"];
2
+ export const ENVIRONMENTS = ["dom", "slow", "typecheck", "unit"];
3
3
  const TEST_FILE = /^(?<base>.+)\.(?<kind>test|spec)(?<extension>\.[cm]?[jt]sx?)$/u;
4
4
  export function testName(path) {
5
5
  const groups = TEST_FILE.exec(posix.basename(path))?.groups;
@@ -1 +1 @@
1
- {"version":3,"file":"testName.js","sourceRoot":"","sources":["../../src/naming/testName.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAElC,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,CAAU,CAAC;AAYlE,MAAM,SAAS,GAAG,gEAAgE,CAAC;AAEnF,MAAM,UAAU,QAAQ,CAAC,IAAY;IACpC,MAAM,MAAM,GAAG,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC;IAC5D,MAAM,IAAI,GAAG,MAAM,EAAE,IAAI,CAAC;IAC1B,IAAI,MAAM,EAAE,IAAI,KAAK,SAAS,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,KAAK,MAAM,CAAC,EAAE,CAAC;QAC1G,OAAO,SAAS,CAAC;IAClB,CAAC;IACD,MAAM,CAAC,IAAI,GAAG,EAAE,EAAE,GAAG,SAAS,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACzD,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;AAC5F,CAAC;AAED,MAAM,YAAY,GAAG,cAAc,CAAC;AAEpC,MAAM,UAAU,aAAa,CAAC,IAAY;IACzC,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,IAAY;IACtC,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,SAAS,IAAI,aAAa,CAAC,IAAI,CAAC,CAAC;AAC5D,CAAC"}
1
+ {"version":3,"file":"testName.js","sourceRoot":"","sources":["../../src/naming/testName.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAElC,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAU,CAAC;AAY1E,MAAM,SAAS,GAAG,gEAAgE,CAAC;AAEnF,MAAM,UAAU,QAAQ,CAAC,IAAY;IACpC,MAAM,MAAM,GAAG,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,CAAC;IAC5D,MAAM,IAAI,GAAG,MAAM,EAAE,IAAI,CAAC;IAC1B,IAAI,MAAM,EAAE,IAAI,KAAK,SAAS,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC,IAAI,KAAK,MAAM,IAAI,IAAI,KAAK,MAAM,CAAC,EAAE,CAAC;QAC1G,OAAO,SAAS,CAAC;IAClB,CAAC;IACD,MAAM,CAAC,IAAI,GAAG,EAAE,EAAE,GAAG,SAAS,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACzD,OAAO,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;AAC5F,CAAC;AAED,MAAM,YAAY,GAAG,cAAc,CAAC;AAEpC,MAAM,UAAU,aAAa,CAAC,IAAY;IACzC,OAAO,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;AAC9D,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,IAAY;IACtC,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,SAAS,IAAI,aAAa,CAAC,IAAI,CAAC,CAAC;AAC5D,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { Endpoint } from "#imports/resolve.ts";
2
+ export declare function engineOf(endpoint: Endpoint): string | undefined;
3
+ //# sourceMappingURL=engineOf.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engineOf.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/engineOf.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AA2DpD,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAAG,SAAS,CAK/D"}
@@ -0,0 +1,62 @@
1
+ const ENGINES = [
2
+ ["the file system", ["node:fs"]],
3
+ [
4
+ "the network",
5
+ [
6
+ "node:dgram",
7
+ "node:dns",
8
+ "node:http",
9
+ "node:http2",
10
+ "node:https",
11
+ "node:net",
12
+ "node:tls",
13
+ "effect/http",
14
+ "effect/socket",
15
+ "@effect/platform-browser",
16
+ "@effect/platform-bun",
17
+ "@effect/platform-node",
18
+ "@effect/platform-node-shared",
19
+ "@trpc/client",
20
+ "@trpc/server",
21
+ "express",
22
+ "fastify",
23
+ "hono",
24
+ "undici",
25
+ "ws",
26
+ ],
27
+ ],
28
+ ["a process", ["node:child_process", "node:cluster", "node:process", "node:readline", "effect/process", "claude-code"]],
29
+ ["a worker", ["node:worker_threads", "effect/workers"]],
30
+ [
31
+ "a database",
32
+ [
33
+ "node:sqlite",
34
+ "effect/persistence",
35
+ "effect/sql",
36
+ "@effect/sql",
37
+ "@effect/sql-pg",
38
+ "@effect/sql-sqlite-node",
39
+ "@electric-sql/pglite",
40
+ "@prisma/client",
41
+ "@prisma-next/",
42
+ "better-auth",
43
+ "better-sqlite3",
44
+ "mysql2",
45
+ "pg",
46
+ "pg-boss",
47
+ "postgres",
48
+ ],
49
+ ],
50
+ ["the DOM", ["@happy-dom/", "@playwright/test", "@testing-library/", "happy-dom", "jsdom", "playwright", "react-dom"]],
51
+ ["a story engine", ["@shivaedev/effect-test-sql", "@shivaedev/local-postgres", "@shivaedev/test-story"]],
52
+ ];
53
+ function names(prefix, specifier) {
54
+ return prefix.endsWith("/") ? specifier.startsWith(prefix) : specifier === prefix || specifier.startsWith(`${prefix}/`);
55
+ }
56
+ export function engineOf(endpoint) {
57
+ if (endpoint.kind === "file") {
58
+ return undefined;
59
+ }
60
+ return ENGINES.find(([, prefixes]) => prefixes.some((prefix) => names(prefix, endpoint.specifier) || names(prefix, endpoint.package)))?.[0];
61
+ }
62
+ //# sourceMappingURL=engineOf.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"engineOf.js","sourceRoot":"","sources":["../../../src/rules/stories/engineOf.ts"],"names":[],"mappings":"AAEA,MAAM,OAAO,GAAwD;IACpE,CAAC,iBAAiB,EAAE,CAAC,SAAS,CAAC,CAAC;IAChC;QACC,aAAa;QACb;YACC,YAAY;YACZ,UAAU;YACV,WAAW;YACX,YAAY;YACZ,YAAY;YACZ,UAAU;YACV,UAAU;YACV,aAAa;YACb,eAAe;YACf,0BAA0B;YAC1B,sBAAsB;YACtB,uBAAuB;YACvB,8BAA8B;YAC9B,cAAc;YACd,cAAc;YACd,SAAS;YACT,SAAS;YACT,MAAM;YACN,QAAQ;YACR,IAAI;SACJ;KACD;IACD,CAAC,WAAW,EAAE,CAAC,oBAAoB,EAAE,cAAc,EAAE,cAAc,EAAE,eAAe,EAAE,gBAAgB,EAAE,aAAa,CAAC,CAAC;IACvH,CAAC,UAAU,EAAE,CAAC,qBAAqB,EAAE,gBAAgB,CAAC,CAAC;IACvD;QACC,YAAY;QACZ;YACC,aAAa;YACb,oBAAoB;YACpB,YAAY;YACZ,aAAa;YACb,gBAAgB;YAChB,yBAAyB;YACzB,sBAAsB;YACtB,gBAAgB;YAChB,eAAe;YACf,aAAa;YACb,gBAAgB;YAChB,QAAQ;YACR,IAAI;YACJ,SAAS;YACT,UAAU;SACV;KACD;IACD,CAAC,SAAS,EAAE,CAAC,aAAa,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,EAAE,WAAW,CAAC,CAAC;IACtH,CAAC,gBAAgB,EAAE,CAAC,4BAA4B,EAAE,2BAA2B,EAAE,uBAAuB,CAAC,CAAC;CACxG,CAAC;AAEF,SAAS,KAAK,CAAC,MAAc,EAAE,SAAiB;IAC/C,OAAO,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,KAAK,MAAM,IAAI,SAAS,CAAC,UAAU,CAAC,GAAG,MAAM,GAAG,CAAC,CAAC;AACzH,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,QAAkB;IAC1C,IAAI,QAAQ,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;QAC9B,OAAO,SAAS,CAAC;IAClB,CAAC;IACD,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,EAAE,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC,SAAS,CAAC,IAAI,KAAK,CAAC,MAAM,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;AAC7I,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"specs.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/specs.ts"],"names":[],"mappings":"AAsDA,eAAO,MAAM,UAAU,yDAsCrB,CAAC"}
1
+ {"version":3,"file":"specs.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/specs.ts"],"names":[],"mappings":"AAuDA,eAAO,MAAM,UAAU,yDAyCrB,CAAC"}
@@ -5,6 +5,7 @@ import { testName } from "#naming/testName.ts";
5
5
  import { defineRule } from "#rule.ts";
6
6
  import { storyOrigins } from "./origins.js";
7
7
  import { storyPrerequisites } from "./prerequisites.js";
8
+ import { unitTests } from "./unitTests.js";
8
9
  function namesTest(node) {
9
10
  if (ts.isIdentifier(node)) {
10
11
  return node.text === "it" || node.text === "test";
@@ -45,8 +46,12 @@ export const storySpecs = defineRule({
45
46
  const graph = await importGraph(inputs);
46
47
  const exempt = await storyPrerequisites(inputs, graph.packages);
47
48
  const origins = storyOrigins(inputs, graph);
49
+ const unit = unitTests(graph, inputs.sources);
48
50
  return inputs.sources.flatMap((file) => {
49
51
  const name = testName(file.path);
52
+ if (name?.modifiers.includes("unit") === true) {
53
+ return unit(file.path);
54
+ }
50
55
  const owner = packageOf(graph.packages, file.path);
51
56
  if (name === undefined || name.modifiers.includes("typecheck") || exempt.has(owner?.directory ?? "")) {
52
57
  return [];
@@ -69,12 +74,12 @@ export const storySpecs = defineRule({
69
74
  {
70
75
  file: file.path,
71
76
  line: source.getLineAndCharacterOfPosition(plain.getStart(source)).line + 1,
72
- message: "Declares tests without a test-story story kit. Build the package's kit with storyKit from @shivaedev/test-story/server/storyKit.ts in src/test-support, and declare every test through the kit's it. See https://github.com/ShivaeDev/platform/tree/main/packages/quality#story-tests.",
77
+ message: `Declares tests without a test-story story kit. Drive the engine through the package's story kit: build it with storyKit from @shivaedev/test-story/server/storyKit.ts in src/test-support, and declare every test through the kit's it. If the code under test is pure, move the test to ${name.stem}.unit.${name.kind}${name.extension}, which reaches no engine and mocks nothing. See https://github.com/ShivaeDev/platform/tree/main/packages/quality#story-tests.`,
73
78
  },
74
79
  ];
75
80
  });
76
81
  },
77
- description: "Every package tests its real engine through its own test-story kit. Typecheck tests and test-story's workspace prerequisites are exempt.",
82
+ description: "Every package tests its real engine through its own test-story kit, and a plain test lives only in a .unit file that reaches no engine and mocks nothing. Typecheck tests and test-story's workspace prerequisites need no kit.",
78
83
  id: "tests/story-specs",
79
84
  });
80
85
  //# sourceMappingURL=specs.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"specs.js","sourceRoot":"","sources":["../../../src/rules/stories/specs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAC5B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAExD,SAAS,SAAS,CAAC,IAAmB;IACrC,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3B,OAAO,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC;IACnD,CAAC;IACD,IAAI,EAAE,CAAC,0BAA0B,CAAC,IAAI,CAAC,EAAE,CAAC;QACzC,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IAC9D,CAAC;IACD,OAAO,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AAChE,CAAC;AAID,SAAS,QAAQ,CAAC,IAAa,EAAE,MAAgB;IAChD,OAAO,CACN,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC;WACrB,EAAE,CAAC,oBAAoB,CAAC,IAAI,CAAC;WAC7B,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,0BAA0B,CAAC,IAAI,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,UAAU,CAAC,CAClG,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,IAAuB,EAAE,MAAgB;IAC5D,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;IAChC,OAAO,CACN,KAAK,KAAK,SAAS;WAChB,CAAC,EAAE,CAAC,mBAAmB,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC;WACjE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CACzE,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAa,EAAE,MAAgB;IACxD,KAAK,IAAI,OAAO,GAAG,IAAI,CAAC,MAAM,EAAE,OAAO,KAAK,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;QACjF,IAAI,EAAE,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,gBAAgB,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,KAAK,OAAO,CAAC,EAAE,CAAC;YAC3H,OAAO,KAAK,CAAC;QACd,CAAC;IACF,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC;AAED,SAAS,qBAAqB,CAAC,IAAuB,EAAE,QAAkB;IACzE,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACzC,IAAI,MAAM,KAAK,OAAO,IAAI,MAAM,KAAK,OAAO,EAAE,CAAC;QAC9C,OAAO,KAAK,CAAC;IACd,CAAC;IACD,OAAO,MAAM,KAAK,OAAO,IAAI,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,IAAI,gBAAgB,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC;AAC/H,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAG,UAAU,CAAC;IACpC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC;QACxC,MAAM,MAAM,GAAG,MAAM,kBAAkB,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC;QAChE,MAAM,OAAO,GAAG,YAAY,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QAC5C,OAAO,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE;YACtC,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACjC,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;YACnD,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,IAAI,EAAE,CAAC,EAAE,CAAC;gBACtG,OAAO,EAAE,CAAC;YACX,CAAC;YACD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACzC,IAAI,KAAoC,CAAC;YACzC,SAAS,KAAK,CAAC,IAAa;gBAC3B,IAAI,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,qBAAqB,CAAC,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;oBAC9E,KAAK,KAAK,IAAI,CAAC;gBAChB,CAAC;gBACD,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YAC9B,CAAC;YACD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;gBAC1B,KAAK,CAAC,MAAM,CAAC,CAAC;YACf,CAAC;YACD,IAAI,MAAM,KAAK,SAAS,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACjD,OAAO,EAAE,CAAC;YACX,CAAC;YACD,OAAO;gBACN;oBACC,IAAI,EAAE,IAAI,CAAC,IAAI;oBACf,IAAI,EAAE,MAAM,CAAC,6BAA6B,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC;oBAC3E,OAAO,EACN,wRAAwR;iBACzR;aACD,CAAC;QACH,CAAC,CAAC,CAAC;IACJ,CAAC;IACD,WAAW,EACV,0IAA0I;IAC3I,EAAE,EAAE,mBAAmB;CACvB,CAAC,CAAC"}
1
+ {"version":3,"file":"specs.js","sourceRoot":"","sources":["../../../src/rules/stories/specs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAC5B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE3C,SAAS,SAAS,CAAC,IAAmB;IACrC,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3B,OAAO,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC;IACnD,CAAC;IACD,IAAI,EAAE,CAAC,0BAA0B,CAAC,IAAI,CAAC,EAAE,CAAC;QACzC,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,IAAI,IAAI,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IAC9D,CAAC;IACD,OAAO,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;AAChE,CAAC;AAID,SAAS,QAAQ,CAAC,IAAa,EAAE,MAAgB;IAChD,OAAO,CACN,EAAE,CAAC,eAAe,CAAC,IAAI,CAAC;WACrB,EAAE,CAAC,oBAAoB,CAAC,IAAI,CAAC;WAC7B,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,0BAA0B,CAAC,IAAI,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,UAAU,CAAC,CAClG,CAAC;AACH,CAAC;AAED,SAAS,UAAU,CAAC,IAAuB,EAAE,MAAgB;IAC5D,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;IAChC,OAAO,CACN,KAAK,KAAK,SAAS;WAChB,CAAC,EAAE,CAAC,mBAAmB,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,oBAAoB,CAAC,KAAK,CAAC,CAAC;WACjE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CACzE,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAa,EAAE,MAAgB;IACxD,KAAK,IAAI,OAAO,GAAG,IAAI,CAAC,MAAM,EAAE,OAAO,KAAK,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;QACjF,IAAI,EAAE,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC,gBAAgB,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,KAAK,OAAO,CAAC,EAAE,CAAC;YAC3H,OAAO,KAAK,CAAC;QACd,CAAC;IACF,CAAC;IACD,OAAO,IAAI,CAAC;AACb,CAAC;AAED,SAAS,qBAAqB,CAAC,IAAuB,EAAE,QAAkB;IACzE,MAAM,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;IACzC,IAAI,MAAM,KAAK,OAAO,IAAI,MAAM,KAAK,OAAO,EAAE,CAAC;QAC9C,OAAO,KAAK,CAAC;IACd,CAAC;IACD,OAAO,MAAM,KAAK,OAAO,IAAI,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,IAAI,gBAAgB,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,CAAC;AAC/H,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAG,UAAU,CAAC;IACpC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE;QACvB,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC;QACxC,MAAM,MAAM,GAAG,MAAM,kBAAkB,CAAC,MAAM,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC;QAChE,MAAM,OAAO,GAAG,YAAY,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QAC5C,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;QAC9C,OAAO,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE;YACtC,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACjC,IAAI,IAAI,EAAE,SAAS,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;gBAC/C,OAAO,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACxB,CAAC;YACD,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,QAAQ,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC;YACnD,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,IAAI,EAAE,CAAC,EAAE,CAAC;gBACtG,OAAO,EAAE,CAAC;YACX,CAAC;YACD,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACzC,IAAI,KAAoC,CAAC;YACzC,SAAS,KAAK,CAAC,IAAa;gBAC3B,IAAI,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,IAAI,qBAAqB,CAAC,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;oBAC9E,KAAK,KAAK,IAAI,CAAC;gBAChB,CAAC;gBACD,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;YAC9B,CAAC;YACD,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;gBAC1B,KAAK,CAAC,MAAM,CAAC,CAAC;YACf,CAAC;YACD,IAAI,MAAM,KAAK,SAAS,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;gBACjD,OAAO,EAAE,CAAC;YACX,CAAC;YACD,OAAO;gBACN;oBACC,IAAI,EAAE,IAAI,CAAC,IAAI;oBACf,IAAI,EAAE,MAAM,CAAC,6BAA6B,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC;oBAC3E,OAAO,EAAE,4RAA4R,IAAI,CAAC,IAAI,SAAS,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,gIAAgI;iBACjd;aACD,CAAC;QACH,CAAC,CAAC,CAAC;IACJ,CAAC;IACD,WAAW,EACV,iOAAiO;IAClO,EAAE,EAAE,mBAAmB;CACvB,CAAC,CAAC"}
@@ -0,0 +1,8 @@
1
+ import ts from "typescript";
2
+ export interface Stand {
3
+ readonly line: number;
4
+ readonly what: string;
5
+ readonly why: "mocks" | "provides";
6
+ }
7
+ export declare function standIns(source: ts.SourceFile): readonly Stand[];
8
+ //# sourceMappingURL=unitSyntax.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unitSyntax.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/unitSyntax.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,MAAM,WAAW,KAAK;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,CAAC;CACnC;AAyED,wBAAgB,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,GAAG,SAAS,KAAK,EAAE,CAehE"}
@@ -0,0 +1,77 @@
1
+ import ts from "typescript";
2
+ const MOCKS = new Set([
3
+ "doMock",
4
+ "fn",
5
+ "hoisted",
6
+ "importActual",
7
+ "importMock",
8
+ "mock",
9
+ "mocked",
10
+ "mockObject",
11
+ "setSystemTime",
12
+ "spyOn",
13
+ "stubEnv",
14
+ "stubGlobal",
15
+ "useFakeTimers",
16
+ ]);
17
+ const VITEST = new Set(["vitest", "@effect/vitest"]);
18
+ const EFFECT_MODULE = /^effect\/(?<name>[A-Z]\w*)$/u;
19
+ function bindingOf(module, imported) {
20
+ const name = EFFECT_MODULE.exec(module)?.groups?.name;
21
+ return name === undefined ? { module, path: imported } : { module: "effect", path: [name, ...imported] };
22
+ }
23
+ function importsOf(source) {
24
+ const bound = new Map();
25
+ for (const statement of source.statements) {
26
+ if (!(ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier))) {
27
+ continue;
28
+ }
29
+ const module = statement.moduleSpecifier.text;
30
+ const bindings = statement.importClause?.namedBindings;
31
+ if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
32
+ bound.set(bindings.name.text, bindingOf(module, []));
33
+ }
34
+ for (const element of bindings !== undefined && ts.isNamedImports(bindings) ? bindings.elements : []) {
35
+ bound.set(element.name.text, bindingOf(module, [(element.propertyName ?? element.name).text]));
36
+ }
37
+ }
38
+ return bound;
39
+ }
40
+ function qualified(node, bound) {
41
+ if (ts.isIdentifier(node)) {
42
+ return bound.get(node.text) ?? (node.text === "vi" ? { module: "vitest", path: ["vi"] } : undefined);
43
+ }
44
+ const receiver = ts.isPropertyAccessExpression(node) ? qualified(node.expression, bound) : undefined;
45
+ return receiver === undefined || !ts.isPropertyAccessExpression(node) ? undefined : { ...receiver, path: [...receiver.path, node.name.text] };
46
+ }
47
+ function standOf({ module, path }) {
48
+ const [head = "", member = "", ...rest] = path;
49
+ if (rest.length > 0) {
50
+ return undefined;
51
+ }
52
+ if (VITEST.has(module) && head === "vi" && MOCKS.has(member)) {
53
+ return "mocks";
54
+ }
55
+ if (module === "@effect/vitest" && ((head === "layer" && member === "") || (head === "it" && member === "layer"))) {
56
+ return "provides";
57
+ }
58
+ const provides = (head === "Effect" && member.startsWith("provide")) || ((head === "Layer" || head === "ManagedRuntime") && member !== "");
59
+ return module === "effect" && provides ? "provides" : undefined;
60
+ }
61
+ export function standIns(source) {
62
+ const bound = importsOf(source);
63
+ const found = new Map();
64
+ function visit(node) {
65
+ const called = ts.isIdentifier(node) && ts.isCallExpression(node.parent) && node.parent.expression === node;
66
+ const name = ts.isPropertyAccessExpression(node) || (called && ts.isIdentifier(node)) ? qualified(node, bound) : undefined;
67
+ const why = name === undefined ? undefined : standOf(name);
68
+ const key = name === undefined ? "" : `${name.module}#${name.path.join(".")}`;
69
+ if (why !== undefined && !found.has(key)) {
70
+ found.set(key, { line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, what: node.getText(source), why });
71
+ }
72
+ ts.forEachChild(node, visit);
73
+ }
74
+ visit(source);
75
+ return [...found.values()];
76
+ }
77
+ //# sourceMappingURL=unitSyntax.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unitSyntax.js","sourceRoot":"","sources":["../../../src/rules/stories/unitSyntax.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import type { ImportGraph } from "#imports/graph.ts";
2
+ import type { Finding, SourceFile } from "#rule.ts";
3
+ export declare function unitTests(graph: ImportGraph, sources: readonly SourceFile[]): (file: string) => readonly Finding[];
4
+ //# sourceMappingURL=unitTests.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unitTests.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/unitTests.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAc,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGjE,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAsEpD,wBAAgB,SAAS,CAAC,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,SAAS,UAAU,EAAE,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,SAAS,OAAO,EAAE,CAWlH"}
@@ -0,0 +1,76 @@
1
+ import { labelOf, outgoing, walkFrom } from "#imports/fences/reach.ts";
2
+ import { packageNameOf } from "#imports/resolve.ts";
3
+ import { isTestSupport } from "#naming/testName.ts";
4
+ import { parse } from "#rules/syntax.ts";
5
+ import { usesDom } from "#rules/test-names/usesDom.ts";
6
+ import { engineOf } from "./engineOf.js";
7
+ import { standIns } from "./unitSyntax.js";
8
+ const RELATIVE = /^[./#]/u;
9
+ const KIT = "Drive the engine through the package's story kit in a story, or, if the code under test is pure,";
10
+ function runtimeEdges(graph) {
11
+ const packages = graph.unresolved
12
+ .filter((request) => request.missing === undefined && !RELATIVE.test(request.specifier))
13
+ .map((request) => ({ ...request, to: { kind: "external", package: packageNameOf(request.specifier), specifier: request.specifier } }));
14
+ return outgoing([...graph.edges, ...packages].filter((edge) => edge.kind === "import" && !edge.type));
15
+ }
16
+ function repair(path) {
17
+ const [, first, ...rest] = path;
18
+ if (first === undefined || rest.length === 0) {
19
+ return "remove that import from the .unit file.";
20
+ }
21
+ return isTestSupport(first)
22
+ ? `give the .unit file fixtures from a test-support module that reaches no engine instead of ${first}.`
23
+ : `split ${first}: move the pure code under test into its own module that reaches no engine, and test that module in the .unit file.`;
24
+ }
25
+ function reachFinding(file, reached) {
26
+ return {
27
+ file,
28
+ line: reached.line,
29
+ message: `Is a unit test but reaches ${engineOf(reached.target) ?? "an engine"} (${labelOf(reached.target)}) through ${reached.path.join(" -> ")}. A unit test reaches no engine, directly or through what it imports. ${KIT} ${repair(reached.path)}`,
30
+ };
31
+ }
32
+ function standMessage(stand) {
33
+ return stand.why === "mocks"
34
+ ? `mocks with ${stand.what}. A unit test calls real code with real values and stands nothing in for a collaborator.`
35
+ : `provides services with ${stand.what}. A unit test runs only requirement-free code, such as Effect.runSync on an Effect that needs nothing.`;
36
+ }
37
+ function where(file, module, trail) {
38
+ return module === file ? "" : ` in ${module}, which it imports through ${trail.path.join(" -> ")},`;
39
+ }
40
+ function syntaxFindings(file, module, trail, source) {
41
+ const syntax = source === undefined ? undefined : parse(source);
42
+ if (source === undefined || syntax === undefined) {
43
+ return [];
44
+ }
45
+ function line(own) {
46
+ return module === file ? own : trail.line;
47
+ }
48
+ const stands = standIns(syntax).map((stand) => ({
49
+ file,
50
+ line: line(stand.line),
51
+ message: `Is a unit test but${where(file, module, trail)} ${standMessage(stand)} ${KIT} test it in the .unit file without stand-ins.`,
52
+ }));
53
+ const dom = usesDom(source)
54
+ ? [
55
+ {
56
+ file,
57
+ line: line(1),
58
+ message: `Is a unit test but${where(file, module, trail)} uses the DOM. A unit test runs without one. ${KIT} test it in the .unit file without the DOM.`,
59
+ },
60
+ ]
61
+ : [];
62
+ return [...stands, ...dom];
63
+ }
64
+ export function unitTests(graph, sources) {
65
+ const edgesFrom = runtimeEdges(graph);
66
+ const byPath = new Map(sources.map((source) => [source.path, source]));
67
+ return (file) => {
68
+ const walked = walkFrom(file, edgesFrom, (endpoint) => engineOf(endpoint) !== undefined);
69
+ const checked = [...walked.visited].filter(([module]) => module === file || isTestSupport(module));
70
+ return [
71
+ ...[...walked.reached].sort((a, b) => a.line - b.line).map((reached) => reachFinding(file, reached)),
72
+ ...checked.flatMap(([module, trail]) => syntaxFindings(file, module, trail, byPath.get(module))),
73
+ ];
74
+ };
75
+ }
76
+ //# sourceMappingURL=unitTests.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unitTests.js","sourceRoot":"","sources":["../../../src/rules/stories/unitTests.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"colocated.d.ts","sourceRoot":"","sources":["../../../src/rules/test-names/colocated.ts"],"names":[],"mappings":"AAyBA,eAAO,MAAM,cAAc;;EAQzB,CAAC"}
1
+ {"version":3,"file":"colocated.d.ts","sourceRoot":"","sources":["../../../src/rules/test-names/colocated.ts"],"names":[],"mappings":"AA0BA,eAAO,MAAM,cAAc;;EAQzB,CAAC"}
@@ -12,10 +12,11 @@ function findingOf(path) {
12
12
  if (name === undefined || mirror === undefined) {
13
13
  return [];
14
14
  }
15
+ const modifiers = name.modifiers.map((modifier) => `.${modifier}`).join("");
15
16
  return [
16
17
  {
17
18
  file: path,
18
- message: `Sits in the test folder "${mirror}/". A test sits beside the file it covers, as ${name.stem}.test${name.extension} beside ${name.stem}.ts, or as a <behaviour>.spec${name.extension} in the folder it covers. A folder of tests that cover several packages is a suite in the "suites" option.`,
19
+ message: `Sits in the test folder "${mirror}/". A test sits beside the file it covers, as ${name.stem}${modifiers}.test${name.extension} beside ${name.stem}.ts, or as a <behaviour>${modifiers}.spec${name.extension} in the folder it covers. A folder of tests that cover several packages is a suite in the "suites" option.`,
19
20
  },
20
21
  ];
21
22
  }
@@ -1 +1 @@
1
- {"version":3,"file":"colocated.js","sourceRoot":"","sources":["../../../src/rules/test-names/colocated.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAgB,MAAM,UAAU,CAAC;AAEpD,MAAM,cAAc,GAAwB,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAE5F,MAAM,qBAAqB,GAAG,MAAM,CAAC,MAAM,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;CAC3F,CAAC,CAAC;AAEH,SAAS,SAAS,CAAC,IAAY;IAC9B,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC5B,MAAM,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;IACpF,IAAI,IAAI,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QAChD,OAAO,EAAE,CAAC;IACX,CAAC;IACD,OAAO;QACN;YACC,IAAI,EAAE,IAAI;YACV,OAAO,EAAE,4BAA4B,MAAM,iDAAiD,IAAI,CAAC,IAAI,QAAQ,IAAI,CAAC,SAAS,WAAW,IAAI,CAAC,IAAI,gCAAgC,IAAI,CAAC,SAAS,4GAA4G;SACzS;KACD,CAAC;AACH,CAAC;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,UAAU,CAAC;IACxC,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;QACjD,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IACzE,CAAC;IACD,WAAW,EAAE,gIAAgI;IAC7I,EAAE,EAAE,iBAAiB;IACrB,OAAO,EAAE,MAAM,CAAC,kBAAkB,CAAC,qBAAqB,EAAE,EAAE,YAAY,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,EAAE,CAAC;CACzH,CAAC,CAAC"}
1
+ {"version":3,"file":"colocated.js","sourceRoot":"","sources":["../../../src/rules/test-names/colocated.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,UAAU,EAAgB,MAAM,UAAU,CAAC;AAEpD,MAAM,cAAc,GAAwB,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAE5F,MAAM,qBAAqB,GAAG,MAAM,CAAC,MAAM,CAAC;IAC3C,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;CAC3F,CAAC,CAAC;AAEH,SAAS,SAAS,CAAC,IAAY;IAC9B,MAAM,IAAI,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC;IAC5B,MAAM,MAAM,GAAG,IAAI,EAAE,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;IACpF,IAAI,IAAI,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QAChD,OAAO,EAAE,CAAC;IACX,CAAC;IACD,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,QAAQ,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC5E,OAAO;QACN;YACC,IAAI,EAAE,IAAI;YACV,OAAO,EAAE,4BAA4B,MAAM,iDAAiD,IAAI,CAAC,IAAI,GAAG,SAAS,QAAQ,IAAI,CAAC,SAAS,WAAW,IAAI,CAAC,IAAI,2BAA2B,SAAS,QAAQ,IAAI,CAAC,SAAS,4GAA4G;SACjU;KACD,CAAC;AACH,CAAC;AAED,MAAM,CAAC,MAAM,cAAc,GAAG,UAAU,CAAC;IACxC,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;QACjD,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IACzE,CAAC;IACD,WAAW,EAAE,gIAAgI;IAC7I,EAAE,EAAE,iBAAiB;IACrB,OAAO,EAAE,MAAM,CAAC,kBAAkB,CAAC,qBAAqB,EAAE,EAAE,YAAY,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,EAAE,CAAC;CACzH,CAAC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../../../src/rules/test-names/follow.ts"],"names":[],"mappings":"AAmEA,eAAO,MAAM,WAAW;;EAUtB,CAAC"}
1
+ {"version":3,"file":"follow.d.ts","sourceRoot":"","sources":["../../../src/rules/test-names/follow.ts"],"names":[],"mappings":"AA8EA,eAAO,MAAM,WAAW;;EAUtB,CAAC"}
@@ -8,7 +8,7 @@ import { usesDom } from "#rules/test-names/usesDom.ts";
8
8
  const ENVIRONMENT_NAMES = new Set(ENVIRONMENTS);
9
9
  const FIXTURE_SUFFIXES = new Set(["integration", "postgres"]);
10
10
  const CODE = /\.[cm]?[jt]sx?$/u;
11
- const GRAMMAR = "A test file is <file>[.<environment>].test.ts beside its file or <behaviour>[.<environment>].spec.ts in the folder it covers, with at most one environment of dom, slow and typecheck.";
11
+ const GRAMMAR = "A test file is <file>[.<modifier>].test.ts beside its file or <behaviour>[.<modifier>].spec.ts in the folder it covers, with at most one modifier of dom, slow, typecheck and unit.";
12
12
  const TestsFollowOptions = Schema.Struct({
13
13
  suites: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
14
14
  });
@@ -20,29 +20,39 @@ function suffixMessage(file, name) {
20
20
  if (first === undefined || (rest.length === 0 && ENVIRONMENT_NAMES.has(first))) {
21
21
  return undefined;
22
22
  }
23
+ if (name.modifiers.includes("unit") && name.modifiers.includes("dom")) {
24
+ return `"${file}" is both a unit and a DOM test, but a unit test runs without the DOM. Drive the DOM through the package's story kit in ${name.stem}.dom.${name.kind}${name.extension}, or keep only pure code in ${name.stem}.unit.${name.kind}${name.extension}.`;
25
+ }
23
26
  const fixture = name.modifiers.find((modifier) => FIXTURE_SUFFIXES.has(modifier));
24
27
  if (fixture !== undefined) {
25
28
  return `".${fixture}" is not a test environment. A test that needs a database or a running app takes it from the app's test fixture and is named like any other test. ${GRAMMAR}`;
26
29
  }
27
30
  return `"${file}" has the suffixes ".${name.modifiers.join(".")}". ${GRAMMAR} An aspect of a module gets its own file in the module's folder, or a .spec.`;
28
31
  }
32
+ function suffixOf(name) {
33
+ const [only, ...rest] = name.modifiers;
34
+ return only !== undefined && rest.length === 0 && ENVIRONMENT_NAMES.has(only) ? `.${only}` : "";
35
+ }
29
36
  function placeMessage(file, name, siblings) {
30
37
  const covered = siblings.has(`${name.folder}/${name.stem}`);
38
+ const test = `${suffixOf(name)}.test${name.extension}`;
39
+ const spec = `checkoutFlow${suffixOf(name)}.spec${name.extension}`;
31
40
  if (name.kind === "test") {
32
41
  return covered
33
42
  ? undefined
34
- : `"${file}" follows no file: ${name.stem}.test${name.extension} sits beside ${name.stem}.ts in the same folder. A test that covers the folder as a whole is a <behaviour>.spec${name.extension}, such as checkoutFlow.spec${name.extension}.`;
43
+ : `"${file}" follows no file: ${name.stem}${test} sits beside ${name.stem}.ts in the same folder. A test that covers the folder as a whole is a <behaviour>.spec${name.extension}, such as ${spec}.`;
35
44
  }
36
45
  if (covered) {
37
- return `"${file}" shares its name with ${name.stem}.ts. A .spec names a behaviour of its folder, such as checkoutFlow.spec${name.extension}; a test of ${name.stem}.ts is ${name.stem}.test${name.extension}.`;
46
+ return `"${file}" shares its name with ${name.stem}.ts. A .spec names a behaviour of its folder, such as ${spec}; a test of ${name.stem}.ts is ${name.stem}${test}.`;
38
47
  }
39
- return CAMEL.test(name.stem) ? undefined : `"${file}" names its behaviour in camelCase, such as checkoutFlow.spec${name.extension}.`;
48
+ return CAMEL.test(name.stem) ? undefined : `"${file}" names its behaviour in camelCase, such as ${spec}.`;
40
49
  }
41
50
  function environmentMessage(file, name, source) {
42
51
  if (name.modifiers.includes("dom") || name.modifiers.includes("typecheck") || !usesDom(source)) {
43
52
  return undefined;
44
53
  }
45
- return `"${file}" uses the DOM, so it is ${name.stem}.dom.${name.kind}${name.extension} and runs in the dom project.`;
54
+ const unit = name.modifiers.includes("unit") ? ", not a unit test, and drives the DOM through the package's story kit" : "";
55
+ return `"${file}" uses the DOM, so it is ${name.stem}.dom.${name.kind}${name.extension}${unit} and runs in the dom project.`;
46
56
  }
47
57
  function findingsOf(source, siblings) {
48
58
  const name = testName(source.path);
@@ -59,7 +69,7 @@ export const testsFollow = defineRule({
59
69
  const siblings = siblingsIn(files);
60
70
  return sources.filter((source) => !suites.ignores(source.path)).flatMap((source) => findingsOf(source, siblings));
61
71
  },
62
- description: "A .test file follows the file beside it, a .spec names a behaviour of its folder, the only suffix is one environment of dom, slow and typecheck, and a test that uses the DOM is a .dom test.",
72
+ description: "A .test file follows the file beside it, a .spec names a behaviour of its folder, the only suffix is one modifier of dom, slow, typecheck and unit, and a test that uses the DOM is a .dom test.",
63
73
  id: "tests/follow",
64
74
  options: Schema.toStandardSchemaV1(TestsFollowOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
65
75
  });
@@ -1 +1 @@
1
- {"version":3,"file":"follow.js","sourceRoot":"","sources":["../../../src/rules/test-names/follow.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"follow.js","sourceRoot":"","sources":["../../../src/rules/test-names/follow.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/quality",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Repository quality gate with typed rules, a baseline of existing debt and a registry of reasoned exceptions",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/quality#readme",
6
6
  "bugs": {
@@ -8,7 +8,7 @@ export interface Reached {
8
8
  readonly target: Endpoint;
9
9
  }
10
10
 
11
- interface Trail {
11
+ export interface Trail {
12
12
  readonly line: number;
13
13
  readonly path: readonly string[];
14
14
  }
@@ -49,8 +49,13 @@ function follow(walk: Walk, before: Trail, edge: ImportEdge): void {
49
49
  }
50
50
  }
51
51
 
52
+ export interface Walked {
53
+ readonly reached: readonly Reached[];
54
+ readonly visited: ReadonlyMap<string, Trail>;
55
+ }
56
+
52
57
  // A breadth-first walk that stops at each forbidden endpoint, so a finding names the shortest path to the first forbidden step.
53
- export function reachedFrom(start: string, edgesFrom: ReadonlyMap<string, readonly ImportEdge[]>, forbidden: Matcher): readonly Reached[] {
58
+ export function walkFrom(start: string, edgesFrom: ReadonlyMap<string, readonly ImportEdge[]>, forbidden: Matcher): Walked {
54
59
  const walk: Walk = { forbidden, queue: [start], reached: new Map(), trails: new Map([[start, { line: 0, path: [start] }]]) };
55
60
  for (const node of walk.queue) {
56
61
  const before = walk.trails.get(node) ?? { line: 0, path: [node] };
@@ -58,5 +63,9 @@ export function reachedFrom(start: string, edgesFrom: ReadonlyMap<string, readon
58
63
  follow(walk, before, edge);
59
64
  }
60
65
  }
61
- return [...walk.reached.values()];
66
+ return { reached: [...walk.reached.values()], visited: walk.trails };
67
+ }
68
+
69
+ export function reachedFrom(start: string, edgesFrom: ReadonlyMap<string, readonly ImportEdge[]>, forbidden: Matcher): readonly Reached[] {
70
+ return walkFrom(start, edgesFrom, forbidden).reached;
62
71
  }
@@ -1,6 +1,6 @@
1
1
  import { posix } from "node:path";
2
2
 
3
- export const ENVIRONMENTS = ["dom", "slow", "typecheck"] as const;
3
+ export const ENVIRONMENTS = ["dom", "slow", "typecheck", "unit"] as const;
4
4
 
5
5
  export type Environment = (typeof ENVIRONMENTS)[number];
6
6
 
@@ -0,0 +1,65 @@
1
+ import type { Endpoint } from "#imports/resolve.ts";
2
+
3
+ const ENGINES: ReadonlyArray<readonly [string, readonly string[]]> = [
4
+ ["the file system", ["node:fs"]],
5
+ [
6
+ "the network",
7
+ [
8
+ "node:dgram",
9
+ "node:dns",
10
+ "node:http",
11
+ "node:http2",
12
+ "node:https",
13
+ "node:net",
14
+ "node:tls",
15
+ "effect/http",
16
+ "effect/socket",
17
+ "@effect/platform-browser",
18
+ "@effect/platform-bun",
19
+ "@effect/platform-node",
20
+ "@effect/platform-node-shared",
21
+ "@trpc/client",
22
+ "@trpc/server",
23
+ "express",
24
+ "fastify",
25
+ "hono",
26
+ "undici",
27
+ "ws",
28
+ ],
29
+ ],
30
+ ["a process", ["node:child_process", "node:cluster", "node:process", "node:readline", "effect/process", "claude-code"]],
31
+ ["a worker", ["node:worker_threads", "effect/workers"]],
32
+ [
33
+ "a database",
34
+ [
35
+ "node:sqlite",
36
+ "effect/persistence",
37
+ "effect/sql",
38
+ "@effect/sql",
39
+ "@effect/sql-pg",
40
+ "@effect/sql-sqlite-node",
41
+ "@electric-sql/pglite",
42
+ "@prisma/client",
43
+ "@prisma-next/",
44
+ "better-auth",
45
+ "better-sqlite3",
46
+ "mysql2",
47
+ "pg",
48
+ "pg-boss",
49
+ "postgres",
50
+ ],
51
+ ],
52
+ ["the DOM", ["@happy-dom/", "@playwright/test", "@testing-library/", "happy-dom", "jsdom", "playwright", "react-dom"]],
53
+ ["a story engine", ["@shivaedev/effect-test-sql", "@shivaedev/local-postgres", "@shivaedev/test-story"]],
54
+ ];
55
+
56
+ function names(prefix: string, specifier: string): boolean {
57
+ return prefix.endsWith("/") ? specifier.startsWith(prefix) : specifier === prefix || specifier.startsWith(`${prefix}/`);
58
+ }
59
+
60
+ export function engineOf(endpoint: Endpoint): string | undefined {
61
+ if (endpoint.kind === "file") {
62
+ return undefined;
63
+ }
64
+ return ENGINES.find(([, prefixes]) => prefixes.some((prefix) => names(prefix, endpoint.specifier) || names(prefix, endpoint.package)))?.[0];
65
+ }
@@ -5,6 +5,7 @@ import { testName } from "#naming/testName.ts";
5
5
  import { defineRule } from "#rule.ts";
6
6
  import { storyOrigins } from "./origins.ts";
7
7
  import { storyPrerequisites } from "./prerequisites.ts";
8
+ import { unitTests } from "./unitTests.ts";
8
9
 
9
10
  function namesTest(node: ts.Expression): boolean {
10
11
  if (ts.isIdentifier(node)) {
@@ -57,8 +58,12 @@ export const storySpecs = defineRule({
57
58
  const graph = await importGraph(inputs);
58
59
  const exempt = await storyPrerequisites(inputs, graph.packages);
59
60
  const origins = storyOrigins(inputs, graph);
61
+ const unit = unitTests(graph, inputs.sources);
60
62
  return inputs.sources.flatMap((file) => {
61
63
  const name = testName(file.path);
64
+ if (name?.modifiers.includes("unit") === true) {
65
+ return unit(file.path);
66
+ }
62
67
  const owner = packageOf(graph.packages, file.path);
63
68
  if (name === undefined || name.modifiers.includes("typecheck") || exempt.has(owner?.directory ?? "")) {
64
69
  return [];
@@ -81,13 +86,12 @@ export const storySpecs = defineRule({
81
86
  {
82
87
  file: file.path,
83
88
  line: source.getLineAndCharacterOfPosition(plain.getStart(source)).line + 1,
84
- message:
85
- "Declares tests without a test-story story kit. Build the package's kit with storyKit from @shivaedev/test-story/server/storyKit.ts in src/test-support, and declare every test through the kit's it. See https://github.com/ShivaeDev/platform/tree/main/packages/quality#story-tests.",
89
+ message: `Declares tests without a test-story story kit. Drive the engine through the package's story kit: build it with storyKit from @shivaedev/test-story/server/storyKit.ts in src/test-support, and declare every test through the kit's it. If the code under test is pure, move the test to ${name.stem}.unit.${name.kind}${name.extension}, which reaches no engine and mocks nothing. See https://github.com/ShivaeDev/platform/tree/main/packages/quality#story-tests.`,
86
90
  },
87
91
  ];
88
92
  });
89
93
  },
90
94
  description:
91
- "Every package tests its real engine through its own test-story kit. Typecheck tests and test-story's workspace prerequisites are exempt.",
95
+ "Every package tests its real engine through its own test-story kit, and a plain test lives only in a .unit file that reaches no engine and mocks nothing. Typecheck tests and test-story's workspace prerequisites need no kit.",
92
96
  id: "tests/story-specs",
93
97
  });
@@ -0,0 +1,95 @@
1
+ import ts from "typescript";
2
+
3
+ export interface Stand {
4
+ readonly line: number;
5
+ readonly what: string;
6
+ readonly why: "mocks" | "provides";
7
+ }
8
+
9
+ const MOCKS: ReadonlySet<string> = new Set([
10
+ "doMock",
11
+ "fn",
12
+ "hoisted",
13
+ "importActual",
14
+ "importMock",
15
+ "mock",
16
+ "mocked",
17
+ "mockObject",
18
+ "setSystemTime",
19
+ "spyOn",
20
+ "stubEnv",
21
+ "stubGlobal",
22
+ "useFakeTimers",
23
+ ]);
24
+
25
+ const VITEST: ReadonlySet<string> = new Set(["vitest", "@effect/vitest"]);
26
+
27
+ const EFFECT_MODULE = /^effect\/(?<name>[A-Z]\w*)$/u;
28
+
29
+ interface Name {
30
+ readonly module: string;
31
+ readonly path: readonly string[];
32
+ }
33
+
34
+ function bindingOf(module: string, imported: readonly string[]): Name {
35
+ const name = EFFECT_MODULE.exec(module)?.groups?.name;
36
+ return name === undefined ? { module, path: imported } : { module: "effect", path: [name, ...imported] };
37
+ }
38
+
39
+ function importsOf(source: ts.SourceFile): ReadonlyMap<string, Name> {
40
+ const bound = new Map<string, Name>();
41
+ for (const statement of source.statements) {
42
+ if (!(ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier))) {
43
+ continue;
44
+ }
45
+ const module = statement.moduleSpecifier.text;
46
+ const bindings = statement.importClause?.namedBindings;
47
+ if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
48
+ bound.set(bindings.name.text, bindingOf(module, []));
49
+ }
50
+ for (const element of bindings !== undefined && ts.isNamedImports(bindings) ? bindings.elements : []) {
51
+ bound.set(element.name.text, bindingOf(module, [(element.propertyName ?? element.name).text]));
52
+ }
53
+ }
54
+ return bound;
55
+ }
56
+
57
+ function qualified(node: ts.Expression, bound: ReadonlyMap<string, Name>): Name | undefined {
58
+ if (ts.isIdentifier(node)) {
59
+ return bound.get(node.text) ?? (node.text === "vi" ? { module: "vitest", path: ["vi"] } : undefined);
60
+ }
61
+ const receiver = ts.isPropertyAccessExpression(node) ? qualified(node.expression, bound) : undefined;
62
+ return receiver === undefined || !ts.isPropertyAccessExpression(node) ? undefined : { ...receiver, path: [...receiver.path, node.name.text] };
63
+ }
64
+
65
+ function standOf({ module, path }: Name): Stand["why"] | undefined {
66
+ const [head = "", member = "", ...rest] = path;
67
+ if (rest.length > 0) {
68
+ return undefined;
69
+ }
70
+ if (VITEST.has(module) && head === "vi" && MOCKS.has(member)) {
71
+ return "mocks";
72
+ }
73
+ if (module === "@effect/vitest" && ((head === "layer" && member === "") || (head === "it" && member === "layer"))) {
74
+ return "provides";
75
+ }
76
+ const provides = (head === "Effect" && member.startsWith("provide")) || ((head === "Layer" || head === "ManagedRuntime") && member !== "");
77
+ return module === "effect" && provides ? "provides" : undefined;
78
+ }
79
+
80
+ export function standIns(source: ts.SourceFile): readonly Stand[] {
81
+ const bound = importsOf(source);
82
+ const found = new Map<string, Stand>();
83
+ function visit(node: ts.Node): void {
84
+ const called = ts.isIdentifier(node) && ts.isCallExpression(node.parent) && node.parent.expression === node;
85
+ const name = ts.isPropertyAccessExpression(node) || (called && ts.isIdentifier(node)) ? qualified(node, bound) : undefined;
86
+ const why = name === undefined ? undefined : standOf(name);
87
+ const key = name === undefined ? "" : `${name.module}#${name.path.join(".")}`;
88
+ if (why !== undefined && !found.has(key)) {
89
+ found.set(key, { line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, what: node.getText(source), why });
90
+ }
91
+ ts.forEachChild(node, visit);
92
+ }
93
+ visit(source);
94
+ return [...found.values()];
95
+ }
@@ -0,0 +1,86 @@
1
+ import { labelOf, outgoing, type Reached, type Trail, walkFrom } from "#imports/fences/reach.ts";
2
+ import type { ImportEdge, ImportGraph } from "#imports/graph.ts";
3
+ import { packageNameOf } from "#imports/resolve.ts";
4
+ import { isTestSupport } from "#naming/testName.ts";
5
+ import type { Finding, SourceFile } from "#rule.ts";
6
+ import { parse } from "#rules/syntax.ts";
7
+ import { usesDom } from "#rules/test-names/usesDom.ts";
8
+ import { engineOf } from "./engineOf.ts";
9
+ import { type Stand, standIns } from "./unitSyntax.ts";
10
+
11
+ const RELATIVE = /^[./#]/u;
12
+
13
+ const KIT = "Drive the engine through the package's story kit in a story, or, if the code under test is pure,";
14
+
15
+ function runtimeEdges(graph: ImportGraph): ReadonlyMap<string, readonly ImportEdge[]> {
16
+ const packages = graph.unresolved
17
+ .filter((request) => request.missing === undefined && !RELATIVE.test(request.specifier))
18
+ .map((request) => ({ ...request, to: { kind: "external" as const, package: packageNameOf(request.specifier), specifier: request.specifier } }));
19
+ return outgoing([...graph.edges, ...packages].filter((edge) => edge.kind === "import" && !edge.type));
20
+ }
21
+
22
+ function repair(path: readonly string[]): string {
23
+ const [, first, ...rest] = path;
24
+ if (first === undefined || rest.length === 0) {
25
+ return "remove that import from the .unit file.";
26
+ }
27
+ return isTestSupport(first)
28
+ ? `give the .unit file fixtures from a test-support module that reaches no engine instead of ${first}.`
29
+ : `split ${first}: move the pure code under test into its own module that reaches no engine, and test that module in the .unit file.`;
30
+ }
31
+
32
+ function reachFinding(file: string, reached: Reached): Finding {
33
+ return {
34
+ file,
35
+ line: reached.line,
36
+ message: `Is a unit test but reaches ${engineOf(reached.target) ?? "an engine"} (${labelOf(reached.target)}) through ${reached.path.join(" -> ")}. A unit test reaches no engine, directly or through what it imports. ${KIT} ${repair(reached.path)}`,
37
+ };
38
+ }
39
+
40
+ function standMessage(stand: Stand): string {
41
+ return stand.why === "mocks"
42
+ ? `mocks with ${stand.what}. A unit test calls real code with real values and stands nothing in for a collaborator.`
43
+ : `provides services with ${stand.what}. A unit test runs only requirement-free code, such as Effect.runSync on an Effect that needs nothing.`;
44
+ }
45
+
46
+ function where(file: string, module: string, trail: Trail): string {
47
+ return module === file ? "" : ` in ${module}, which it imports through ${trail.path.join(" -> ")},`;
48
+ }
49
+
50
+ function syntaxFindings(file: string, module: string, trail: Trail, source: SourceFile | undefined): readonly Finding[] {
51
+ const syntax = source === undefined ? undefined : parse(source);
52
+ if (source === undefined || syntax === undefined) {
53
+ return [];
54
+ }
55
+ function line(own: number): number {
56
+ return module === file ? own : trail.line;
57
+ }
58
+ const stands = standIns(syntax).map((stand) => ({
59
+ file,
60
+ line: line(stand.line),
61
+ message: `Is a unit test but${where(file, module, trail)} ${standMessage(stand)} ${KIT} test it in the .unit file without stand-ins.`,
62
+ }));
63
+ const dom = usesDom(source)
64
+ ? [
65
+ {
66
+ file,
67
+ line: line(1),
68
+ message: `Is a unit test but${where(file, module, trail)} uses the DOM. A unit test runs without one. ${KIT} test it in the .unit file without the DOM.`,
69
+ },
70
+ ]
71
+ : [];
72
+ return [...stands, ...dom];
73
+ }
74
+
75
+ export function unitTests(graph: ImportGraph, sources: readonly SourceFile[]): (file: string) => readonly Finding[] {
76
+ const edgesFrom = runtimeEdges(graph);
77
+ const byPath = new Map(sources.map((source) => [source.path, source]));
78
+ return (file) => {
79
+ const walked = walkFrom(file, edgesFrom, (endpoint) => engineOf(endpoint) !== undefined);
80
+ const checked = [...walked.visited].filter(([module]) => module === file || isTestSupport(module));
81
+ return [
82
+ ...[...walked.reached].sort((a, b) => a.line - b.line).map((reached) => reachFinding(file, reached)),
83
+ ...checked.flatMap(([module, trail]) => syntaxFindings(file, module, trail, byPath.get(module))),
84
+ ];
85
+ };
86
+ }
@@ -15,10 +15,11 @@ function findingOf(path: string): readonly Finding[] {
15
15
  if (name === undefined || mirror === undefined) {
16
16
  return [];
17
17
  }
18
+ const modifiers = name.modifiers.map((modifier) => `.${modifier}`).join("");
18
19
  return [
19
20
  {
20
21
  file: path,
21
- message: `Sits in the test folder "${mirror}/". A test sits beside the file it covers, as ${name.stem}.test${name.extension} beside ${name.stem}.ts, or as a <behaviour>.spec${name.extension} in the folder it covers. A folder of tests that cover several packages is a suite in the "suites" option.`,
22
+ message: `Sits in the test folder "${mirror}/". A test sits beside the file it covers, as ${name.stem}${modifiers}.test${name.extension} beside ${name.stem}.ts, or as a <behaviour>${modifiers}.spec${name.extension} in the folder it covers. A folder of tests that cover several packages is a suite in the "suites" option.`,
22
23
  },
23
24
  ];
24
25
  }
@@ -13,7 +13,7 @@ const FIXTURE_SUFFIXES: ReadonlySet<string> = new Set(["integration", "postgres"
13
13
  const CODE = /\.[cm]?[jt]sx?$/u;
14
14
 
15
15
  const GRAMMAR =
16
- "A test file is <file>[.<environment>].test.ts beside its file or <behaviour>[.<environment>].spec.ts in the folder it covers, with at most one environment of dom, slow and typecheck.";
16
+ "A test file is <file>[.<modifier>].test.ts beside its file or <behaviour>[.<modifier>].spec.ts in the folder it covers, with at most one modifier of dom, slow, typecheck and unit.";
17
17
 
18
18
  const TestsFollowOptions = Schema.Struct({
19
19
  suites: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
@@ -28,6 +28,9 @@ function suffixMessage(file: string, name: TestName): string | undefined {
28
28
  if (first === undefined || (rest.length === 0 && ENVIRONMENT_NAMES.has(first))) {
29
29
  return undefined;
30
30
  }
31
+ if (name.modifiers.includes("unit") && name.modifiers.includes("dom")) {
32
+ return `"${file}" is both a unit and a DOM test, but a unit test runs without the DOM. Drive the DOM through the package's story kit in ${name.stem}.dom.${name.kind}${name.extension}, or keep only pure code in ${name.stem}.unit.${name.kind}${name.extension}.`;
33
+ }
31
34
  const fixture = name.modifiers.find((modifier) => FIXTURE_SUFFIXES.has(modifier));
32
35
  if (fixture !== undefined) {
33
36
  return `".${fixture}" is not a test environment. A test that needs a database or a running app takes it from the app's test fixture and is named like any other test. ${GRAMMAR}`;
@@ -35,24 +38,32 @@ function suffixMessage(file: string, name: TestName): string | undefined {
35
38
  return `"${file}" has the suffixes ".${name.modifiers.join(".")}". ${GRAMMAR} An aspect of a module gets its own file in the module's folder, or a .spec.`;
36
39
  }
37
40
 
41
+ function suffixOf(name: TestName): string {
42
+ const [only, ...rest] = name.modifiers;
43
+ return only !== undefined && rest.length === 0 && ENVIRONMENT_NAMES.has(only) ? `.${only}` : "";
44
+ }
45
+
38
46
  function placeMessage(file: string, name: TestName, siblings: ReadonlySet<string>): string | undefined {
39
47
  const covered = siblings.has(`${name.folder}/${name.stem}`);
48
+ const test = `${suffixOf(name)}.test${name.extension}`;
49
+ const spec = `checkoutFlow${suffixOf(name)}.spec${name.extension}`;
40
50
  if (name.kind === "test") {
41
51
  return covered
42
52
  ? undefined
43
- : `"${file}" follows no file: ${name.stem}.test${name.extension} sits beside ${name.stem}.ts in the same folder. A test that covers the folder as a whole is a <behaviour>.spec${name.extension}, such as checkoutFlow.spec${name.extension}.`;
53
+ : `"${file}" follows no file: ${name.stem}${test} sits beside ${name.stem}.ts in the same folder. A test that covers the folder as a whole is a <behaviour>.spec${name.extension}, such as ${spec}.`;
44
54
  }
45
55
  if (covered) {
46
- return `"${file}" shares its name with ${name.stem}.ts. A .spec names a behaviour of its folder, such as checkoutFlow.spec${name.extension}; a test of ${name.stem}.ts is ${name.stem}.test${name.extension}.`;
56
+ return `"${file}" shares its name with ${name.stem}.ts. A .spec names a behaviour of its folder, such as ${spec}; a test of ${name.stem}.ts is ${name.stem}${test}.`;
47
57
  }
48
- return CAMEL.test(name.stem) ? undefined : `"${file}" names its behaviour in camelCase, such as checkoutFlow.spec${name.extension}.`;
58
+ return CAMEL.test(name.stem) ? undefined : `"${file}" names its behaviour in camelCase, such as ${spec}.`;
49
59
  }
50
60
 
51
61
  function environmentMessage(file: string, name: TestName, source: SourceFile): string | undefined {
52
62
  if (name.modifiers.includes("dom") || name.modifiers.includes("typecheck") || !usesDom(source)) {
53
63
  return undefined;
54
64
  }
55
- return `"${file}" uses the DOM, so it is ${name.stem}.dom.${name.kind}${name.extension} and runs in the dom project.`;
65
+ const unit = name.modifiers.includes("unit") ? ", not a unit test, and drives the DOM through the package's story kit" : "";
66
+ return `"${file}" uses the DOM, so it is ${name.stem}.dom.${name.kind}${name.extension}${unit} and runs in the dom project.`;
56
67
  }
57
68
 
58
69
  function findingsOf(source: SourceFile, siblings: ReadonlySet<string>): readonly Finding[] {
@@ -72,7 +83,7 @@ export const testsFollow = defineRule({
72
83
  return sources.filter((source) => !suites.ignores(source.path)).flatMap((source) => findingsOf(source, siblings));
73
84
  },
74
85
  description:
75
- "A .test file follows the file beside it, a .spec names a behaviour of its folder, the only suffix is one environment of dom, slow and typecheck, and a test that uses the DOM is a .dom test.",
86
+ "A .test file follows the file beside it, a .spec names a behaviour of its folder, the only suffix is one modifier of dom, slow, typecheck and unit, and a test that uses the DOM is a .dom test.",
76
87
  id: "tests/follow",
77
88
  options: Schema.toStandardSchemaV1(TestsFollowOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
78
89
  });