@shivaedev/quality 0.15.0 → 0.17.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 - 2026-10-06
4
+
5
+ ### Added
6
+
7
+ - `inheritTags(projects, root)` in `@shivaedev/quality/vitest.ts` gives each inline Vitest project the tags declared by the config file it `extends`, which Vitest drops. A root config that gathers the projects of several packages, or whose projects extend a shared base config, passes them through it, so a tagged test runs from the root and `--tags-filter` works across the workspace. A project's own tag wins over an inherited one of the same name; projects that extend `true`, globs and file paths come back unchanged.
8
+
9
+ ### Changed
10
+
11
+ - The README's story-test section shows a kit built with `@shivaedev/test-story`: a test hands its traits to the kit's `it`, acts through the kit's verbs and lets the engine run with `story.runUntil`.
12
+
13
+ ## 0.16.0 - 2026-10-06
14
+
15
+ ### Added
16
+
17
+ - The README states the story-test convention: a test reads as a story over a domain kit the repository keeps in `test-support/`, names its setup as traits, acts through real entry points and prints the story when it fails.
18
+ - `tests/story-setup` reports, in a test file, a top-level function named `seed…`, `make…`, `build…` or `setup…`, and each call that writes fixture files through Node's `fs` or `fs/promises`, such as `writeFileSync`, `mkdirSync` or `mkdtemp`. Its message asks for the setup as traits of the story kit and points at the convention. Files under `test-support/` are not tests, so the kit itself is not reported. The rule has no fix. A repository that upgrades records its existing findings with `quality baseline write --rule tests/story-setup`.
19
+
3
20
  ## 0.15.0 - 2026-10-05
4
21
 
5
22
  ### Added
package/README.md CHANGED
@@ -520,14 +520,38 @@ Both rules take a `suites` option, `.gitignore` patterns of folders that hold te
520
520
 
521
521
  ### Test code
522
522
 
523
- Test code is a test file, as `tests/follow` reads the name (`*.test.ts` or `*.spec.ts`, with any environment), or any file under a folder named `test-support/`. `test-support/` is the one folder name for the fixtures, harnesses and generated clients that tests share. Test code ships nowhere, so the rules that check shipped code skip it: `imports/fences` never holds it, and `structure/max-lines` gives it the test limit. The test rules still read only test files: a file under `test-support/` is not a test, so `tests/follow` and `tests/colocated` leave it alone.
523
+ Test code is a test file, as `tests/follow` reads the name (`*.test.ts` or `*.spec.ts`, with any environment), or any file under a folder named `test-support/`. `test-support/` is the one folder name for the fixtures, harnesses and generated clients that tests share. Test code ships nowhere, so the rules that check shipped code skip it: `imports/fences` never holds it, and `structure/max-lines` gives it the test limit. The test rules still read only test files: a file under `test-support/` is not a test, so `tests/follow`, `tests/colocated` and `tests/story-setup` leave it alone.
524
+
525
+ ## Story tests
526
+
527
+ A test reads as a short story over the domain: what exists, what someone does, and what is true afterwards. The words come from a story kit that the repository keeps in `test-support/`, built with [`@shivaedev/test-story`](https://github.com/ShivaeDev/platform/tree/main/packages/test-story#readme), so a test says what it needs in domain words and never builds state by hand.
528
+
529
+ ```ts
530
+ shop.it("ships a paid order from stock", [hasInStock(3, "lamp"), hasInCart(1, "lamp")], function* ({ customer, warehouse }) {
531
+ customer.checksOut();
532
+ yield* warehouse.shipsEverything();
533
+
534
+ expect(warehouse.shipped()).toEqual(["lamp"]);
535
+ expect(warehouse.inStock("lamp")).toBe(2);
536
+ });
537
+ ```
538
+
539
+ - **Setup is traits.** A trait is one sentence of setup in domain words and the change it makes, such as `hasInStock(3, "lamp")`. A test hands the traits it needs to the kit's `it`, which starts a fresh engine and seeds them, and a setup that recurs becomes one named trait made of others. A trait that asks for an impossible state refuses and fails the test, instead of seeding it quietly.
540
+ - **Actions go through real entry points.** The test acts through the kit's verbs, which reach the command, service, route or UI path a user or caller reaches, never by writing internal state. A verb that lets the engine run, such as `warehouse.shipsEverything()`, steps it with `story.runUntil` until what it waits for holds.
541
+ - **Failures print the story.** Every trait, verb and engine step tells a line of the story, and a failing test prints the story with where it stopped and the engine's state, so the failure says what happened, not only which values differ.
542
+
543
+ `tests/story-setup` holds test files to this. In a test file, it reports:
544
+
545
+ - a top-level function whose name starts with the word `seed`, `make`, `build` or `setup`, declared as a function or as a variable that holds an arrow function or function expression: `function seedCart()`, `const makeRun = () => …`. The prefix is a whole word, so `builder` and `settings` pass;
546
+ - a call that writes fixture files through Node's `fs` or `fs/promises`: `writeFile`, `appendFile`, `mkdir`, `mkdtemp`, `copyFile`, `cp` and `symlink`, each in its sync form too, whether imported by name, through a namespace or default import, or through `promises`.
547
+
548
+ Each finding names its line and has the helper or the function it calls as its subject. Its message asks for the setup as traits of the story kit in `test-support/` and points here. A file under `test-support/` is not a test, so the kit itself builds state and writes files freely. The rule only reports; nothing rewrites a test. A repository adopts it through the baseline: `quality baseline write --rule tests/story-setup`.
524
549
 
525
550
  ## Vitest projects
526
551
 
527
- `@shivaedev/quality/vitest.ts` sets up the tests of a package by file name, so no test file sets its environment with a pragma. It needs `vitest`, and `happy-dom` for DOM tests.
552
+ `@shivaedev/quality/vitest.ts` sets up the tests of a package by file name, so no test file sets its environment with a pragma. It needs `vitest`, and `happy-dom` for DOM tests. A package's `vitest.config.ts` reads:
528
553
 
529
554
  ```ts
530
- // vitest.config.ts
531
555
  import { testProjects } from "@shivaedev/quality/vitest.ts";
532
556
  import { defineConfig } from "vitest/config";
533
557
 
@@ -544,6 +568,21 @@ Type tests (`*.typecheck.test.ts`, `*.typecheck.spec.ts` and `typecheck.test.ts`
544
568
 
545
569
  A folder that another runner owns, such as a Playwright suite of `.spec.ts` files, is left out with `testProjects({ exclude: ["e2e/**"] })`; the globs are added to every project's `exclude`.
546
570
 
571
+ ### Inherited tags
572
+
573
+ Vitest gives an inline project only the [tags](https://vitest.dev/guide/test-tags) it declares itself, never those of a config file it `extends`. A test tagged in that file then fails with `cannot apply "<tag>" tag for this test` as soon as a root config runs it. A root config that gathers the projects of several packages, or whose projects extend a shared base config, passes them through `inheritTags`:
574
+
575
+ ```ts
576
+ import { inheritTags } from "@shivaedev/quality/vitest.ts";
577
+ import { defineConfig } from "vitest/config";
578
+
579
+ const bakery = { extends: "./packages/bakery/vitest.config.ts", root: "./packages/bakery", test: { name: "bakery" } };
580
+
581
+ export default defineConfig(async () => ({ test: { projects: await inheritTags([bakery], import.meta.dirname) } }));
582
+ ```
583
+
584
+ `inheritTags(projects, root)` loads the config file of each inline project whose `extends` is a path, resolved against `root`, and adds the tags that file declares to the project's own, so `vitest run --tags-filter=<tag>` works across the workspace. A tag the project declares itself wins over an inherited one of the same name. The config may export an object, a promise or a function. A project that extends `true`, and a glob or file path, comes back unchanged, because Vitest already gives those the root's tags. The extended files load with Node's own `import`, as `vitest --configLoader native` loads configs, so each must be a module Node runs without a bundler.
585
+
547
586
  ## tsconfig presets
548
587
 
549
588
  Two presets hold one strict, current TypeScript setup for every repository. They target TypeScript 7.
@@ -587,4 +626,4 @@ A package that type-checks its tests with one config and builds `src` with anoth
587
626
 
588
627
  ## Validation
589
628
 
590
- `pnpm ready` checks formatting, TypeScript 7, the rules, the import graph against seeded repositories and the fence policy against its examples, config, discovery, registry, baseline and report behavior, the command line against seeded repositories and git histories, the Biome preset against every rule Biome recommends and against its declarations, its naming rules and plugins against seeded files that break and keep each one, the file, folder, other-file and test naming rules against seeded trees, the `biome` and `manifests/sorted` rules and `quality fix` against seeded repositories, the Vitest projects against a seeded repository that Vitest runs, an installed tarball consumer that type-checks a config and runs the `quality` bin through a baseline that takes in the preset's lint and plugin findings, and installed consumers that extend each tsconfig preset, type-check a fixture with an expected error for each check the base turns on, and run the package preset's build output.
629
+ `pnpm ready` checks formatting, TypeScript 7, the rules, the import graph against seeded repositories and the fence policy against its examples, config, discovery, registry, baseline and report behavior, the command line against seeded repositories and git histories, the Biome preset against every rule Biome recommends and against its declarations, its naming rules and plugins against seeded files that break and keep each one, the file, folder, other-file and test naming rules against seeded trees, `tests/story-setup` against seeded test, source and `test-support/` files, the `biome` and `manifests/sorted` rules and `quality fix` against seeded repositories, the Vitest projects against a seeded repository that Vitest runs, an installed tarball consumer that type-checks a config and runs the `quality` bin through a baseline that takes in the preset's lint and plugin findings, and installed consumers that extend each tsconfig preset, type-check a fixture with an expected error for each check the base turns on, and run the package preset's build output.
@@ -8,5 +8,6 @@ export interface TestName {
8
8
  readonly stem: string;
9
9
  }
10
10
  export declare function testName(path: string): TestName | undefined;
11
+ export declare function isTestSupport(path: string): boolean;
11
12
  export declare function isTestCode(path: string): boolean;
12
13
  //# sourceMappingURL=testName.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"testName.d.ts","sourceRoot":"","sources":["../../src/naming/testName.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,YAAY,uCAAwC,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,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,uCAAwC,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"}
@@ -11,7 +11,10 @@ export function testName(path) {
11
11
  return { extension: groups.extension, folder: posix.dirname(path), kind, modifiers, stem };
12
12
  }
13
13
  const TEST_SUPPORT = "test-support";
14
+ export function isTestSupport(path) {
15
+ return posix.dirname(path).split("/").includes(TEST_SUPPORT);
16
+ }
14
17
  export function isTestCode(path) {
15
- return testName(path) !== undefined || posix.dirname(path).split("/").includes(TEST_SUPPORT);
18
+ return testName(path) !== undefined || isTestSupport(path);
16
19
  }
17
20
  //# sourceMappingURL=testName.js.map
@@ -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,UAAU,CAAC,IAAY;IACtC,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,SAAS,IAAI,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;AAC9F,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,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"}
@@ -29,5 +29,5 @@ export declare const builtInRules: readonly [import("../rule.ts").Rule<"structur
29
29
  readonly suites?: readonly string[];
30
30
  }>, import("../rule.ts").Rule<"tests/colocated", {
31
31
  readonly suites?: readonly string[];
32
- }>];
32
+ }>, import("../rule.ts").Rule<"tests/story-setup", undefined>];
33
33
  //# sourceMappingURL=built-in.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"built-in.d.ts","sourceRoot":"","sources":["../../src/rules/built-in.ts"],"names":[],"mappings":"AAyBA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyBf,CAAC"}
1
+ {"version":3,"file":"built-in.d.ts","sourceRoot":"","sources":["../../src/rules/built-in.ts"],"names":[],"mappings":"AA0BA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8DA0Bf,CAAC"}
@@ -12,6 +12,7 @@ import { importsAliased } from "#rules/imports/aliased.ts";
12
12
  import { importCycles } from "#rules/imports/cycles.ts";
13
13
  import { importFences } from "#rules/imports/fences.ts";
14
14
  import { importsResolvable } from "#rules/imports/resolvable.ts";
15
+ import { storySetup } from "#rules/stories/setup.ts";
15
16
  import { biomeOverrides } from "#rules/suppressions/biome-overrides.ts";
16
17
  import { biomeRecommended } from "#rules/suppressions/biomeRecommended.ts";
17
18
  import { noDoubleCast } from "#rules/suppressions/no-double-cast.ts";
@@ -47,5 +48,6 @@ export const builtInRules = [
47
48
  otherNames,
48
49
  testsFollow,
49
50
  testsColocated,
51
+ storySetup,
50
52
  ];
51
53
  //# sourceMappingURL=built-in.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"built-in.js","sourceRoot":"","sources":["../../src/rules/built-in.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAC7D,OAAO,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0CAA0C,CAAC;AAC/E,OAAO,EAAE,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACtD,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,4BAA4B,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACpE,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,EAAE,cAAc,EAAE,MAAM,wCAAwC,CAAC;AACxE,OAAO,EAAE,gBAAgB,EAAE,MAAM,yCAAyC,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAC;AACrE,OAAO,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,6CAA6C,CAAC;AACnF,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAC1D,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C,MAAM,CAAC,MAAM,YAAY,GAAG;IAC3B,QAAQ;IACR,OAAO;IACP,eAAe;IACf,aAAa;IACb,QAAQ;IACR,MAAM;IACN,mBAAmB;IACnB,UAAU;IACV,QAAQ;IACR,YAAY;IACZ,oBAAoB;IACpB,cAAc;IACd,gBAAgB;IAChB,KAAK;IACL,YAAY;IACZ,YAAY;IACZ,iBAAiB;IACjB,cAAc;IACd,eAAe;IACf,gBAAgB;IAChB,WAAW;IACX,UAAU;IACV,WAAW;IACX,cAAc;CACL,CAAC"}
1
+ {"version":3,"file":"built-in.js","sourceRoot":"","sources":["../../src/rules/built-in.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAC7D,OAAO,EAAE,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACxD,OAAO,EAAE,mBAAmB,EAAE,MAAM,0CAA0C,CAAC;AAC/E,OAAO,EAAE,OAAO,EAAE,MAAM,6BAA6B,CAAC;AACtD,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,aAAa,EAAE,MAAM,oCAAoC,CAAC;AACnE,OAAO,EAAE,MAAM,EAAE,MAAM,4BAA4B,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAC;AACpE,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAC3D,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AACxD,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,EAAE,cAAc,EAAE,MAAM,wCAAwC,CAAC;AACxE,OAAO,EAAE,gBAAgB,EAAE,MAAM,yCAAyC,CAAC;AAC3E,OAAO,EAAE,YAAY,EAAE,MAAM,uCAAuC,CAAC;AACrE,OAAO,EAAE,QAAQ,EAAE,MAAM,kCAAkC,CAAC;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,6CAA6C,CAAC;AACnF,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAC1D,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AACnC,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C,MAAM,CAAC,MAAM,YAAY,GAAG;IAC3B,QAAQ;IACR,OAAO;IACP,eAAe;IACf,aAAa;IACb,QAAQ;IACR,MAAM;IACN,mBAAmB;IACnB,UAAU;IACV,QAAQ;IACR,YAAY;IACZ,oBAAoB;IACpB,cAAc;IACd,gBAAgB;IAChB,KAAK;IACL,YAAY;IACZ,YAAY;IACZ,iBAAiB;IACjB,cAAc;IACd,eAAe;IACf,gBAAgB;IAChB,WAAW;IACX,UAAU;IACV,WAAW;IACX,cAAc;IACd,UAAU;CACD,CAAC"}
@@ -0,0 +1,7 @@
1
+ import ts from "typescript";
2
+ export interface FixtureWrite {
3
+ readonly call: ts.CallExpression;
4
+ readonly name: string;
5
+ }
6
+ export declare function fixtureWrites(source: ts.SourceFile): readonly FixtureWrite[];
7
+ //# sourceMappingURL=fixtureWrites.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fixtureWrites.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/fixtureWrites.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAyB5B,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC,cAAc,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAwDD,wBAAgB,aAAa,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,GAAG,SAAS,YAAY,EAAE,CAiB5E"}
@@ -0,0 +1,83 @@
1
+ import ts from "typescript";
2
+ const FS_MODULES = new Set(["fs", "fs/promises", "node:fs", "node:fs/promises"]);
3
+ const FIXTURE_WRITES = new Set([
4
+ "appendFile",
5
+ "appendFileSync",
6
+ "copyFile",
7
+ "copyFileSync",
8
+ "cp",
9
+ "cpSync",
10
+ "mkdir",
11
+ "mkdirSync",
12
+ "mkdtemp",
13
+ "mkdtempSync",
14
+ "symlink",
15
+ "symlinkSync",
16
+ "writeFile",
17
+ "writeFileSync",
18
+ ]);
19
+ function fsImportsOf(source) {
20
+ return source.statements.flatMap((statement) => ts.isImportDeclaration(statement)
21
+ && ts.isStringLiteral(statement.moduleSpecifier)
22
+ && FS_MODULES.has(statement.moduleSpecifier.text)
23
+ && statement.importClause !== undefined
24
+ ? [statement.importClause]
25
+ : []);
26
+ }
27
+ function addBindings(clause, functions, namespaces) {
28
+ const bindings = clause.namedBindings;
29
+ for (const local of [clause.name, bindings !== undefined && ts.isNamespaceImport(bindings) ? bindings.name : undefined]) {
30
+ if (local !== undefined) {
31
+ namespaces.add(local.text);
32
+ }
33
+ }
34
+ for (const element of bindings !== undefined && ts.isNamedImports(bindings) ? bindings.elements : []) {
35
+ const imported = (element.propertyName ?? element.name).text;
36
+ if (imported === "promises") {
37
+ namespaces.add(element.name.text);
38
+ }
39
+ else if (FIXTURE_WRITES.has(imported)) {
40
+ functions.set(element.name.text, imported);
41
+ }
42
+ }
43
+ }
44
+ function fsBindingsOf(source) {
45
+ const functions = new Map();
46
+ const namespaces = new Set();
47
+ for (const clause of fsImportsOf(source)) {
48
+ addBindings(clause, functions, namespaces);
49
+ }
50
+ return { functions, namespaces };
51
+ }
52
+ function isFsNamespace(node, bindings) {
53
+ return ((ts.isIdentifier(node) && bindings.namespaces.has(node.text))
54
+ || (ts.isPropertyAccessExpression(node) && node.name.text === "promises" && isFsNamespace(node.expression, bindings)));
55
+ }
56
+ function fixtureWriteOf(callee, bindings) {
57
+ if (ts.isIdentifier(callee)) {
58
+ return bindings.functions.get(callee.text);
59
+ }
60
+ if (ts.isPropertyAccessExpression(callee) && FIXTURE_WRITES.has(callee.name.text) && isFsNamespace(callee.expression, bindings)) {
61
+ return callee.name.text;
62
+ }
63
+ return undefined;
64
+ }
65
+ export function fixtureWrites(source) {
66
+ const bindings = fsBindingsOf(source);
67
+ if (bindings.functions.size === 0 && bindings.namespaces.size === 0) {
68
+ return [];
69
+ }
70
+ const writes = [];
71
+ function visit(node) {
72
+ if (ts.isCallExpression(node)) {
73
+ const name = fixtureWriteOf(node.expression, bindings);
74
+ if (name !== undefined) {
75
+ writes.push({ call: node, name });
76
+ }
77
+ }
78
+ ts.forEachChild(node, visit);
79
+ }
80
+ visit(source);
81
+ return writes;
82
+ }
83
+ //# sourceMappingURL=fixtureWrites.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fixtureWrites.js","sourceRoot":"","sources":["../../../src/rules/stories/fixtureWrites.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,MAAM,UAAU,GAAwB,IAAI,GAAG,CAAC,CAAC,IAAI,EAAE,aAAa,EAAE,SAAS,EAAE,kBAAkB,CAAC,CAAC,CAAC;AACtG,MAAM,cAAc,GAAwB,IAAI,GAAG,CAAC;IACnD,YAAY;IACZ,gBAAgB;IAChB,UAAU;IACV,cAAc;IACd,IAAI;IACJ,QAAQ;IACR,OAAO;IACP,WAAW;IACX,SAAS;IACT,aAAa;IACb,SAAS;IACT,aAAa;IACb,WAAW;IACX,eAAe;CACf,CAAC,CAAC;AAYH,SAAS,WAAW,CAAC,MAAqB;IACzC,OAAO,MAAM,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,SAAS,EAAE,EAAE,CAC9C,EAAE,CAAC,mBAAmB,CAAC,SAAS,CAAC;WAC9B,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,eAAe,CAAC;WAC7C,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,eAAe,CAAC,IAAI,CAAC;WAC9C,SAAS,CAAC,YAAY,KAAK,SAAS;QACtC,CAAC,CAAC,CAAC,SAAS,CAAC,YAAY,CAAC;QAC1B,CAAC,CAAC,EAAE,CACL,CAAC;AACH,CAAC;AAED,SAAS,WAAW,CAAC,MAAuB,EAAE,SAA8B,EAAE,UAAuB;IACpG,MAAM,QAAQ,GAAG,MAAM,CAAC,aAAa,CAAC;IACtC,KAAK,MAAM,KAAK,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,QAAQ,KAAK,SAAS,IAAI,EAAE,CAAC,iBAAiB,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,CAAC;QACzH,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACzB,UAAU,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QAC5B,CAAC;IACF,CAAC;IACD,KAAK,MAAM,OAAO,IAAI,QAAQ,KAAK,SAAS,IAAI,EAAE,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;QACtG,MAAM,QAAQ,GAAG,CAAC,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC;QAC7D,IAAI,QAAQ,KAAK,UAAU,EAAE,CAAC;YAC7B,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnC,CAAC;aAAM,IAAI,cAAc,CAAC,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;YACzC,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QAC5C,CAAC;IACF,CAAC;AACF,CAAC;AAED,SAAS,YAAY,CAAC,MAAqB;IAC1C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC5C,MAAM,UAAU,GAAG,IAAI,GAAG,EAAU,CAAC;IACrC,KAAK,MAAM,MAAM,IAAI,WAAW,CAAC,MAAM,CAAC,EAAE,CAAC;QAC1C,WAAW,CAAC,MAAM,EAAE,SAAS,EAAE,UAAU,CAAC,CAAC;IAC5C,CAAC;IACD,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC;AAClC,CAAC;AAED,SAAS,aAAa,CAAC,IAAmB,EAAE,QAAoB;IAC/D,OAAO,CACN,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,QAAQ,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;WAC1D,CAAC,EAAE,CAAC,0BAA0B,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI,CAAC,IAAI,KAAK,UAAU,IAAI,aAAa,CAAC,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC,CACrH,CAAC;AACH,CAAC;AAED,SAAS,cAAc,CAAC,MAAqB,EAAE,QAAoB;IAClE,IAAI,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,EAAE,CAAC;QAC7B,OAAO,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC5C,CAAC;IACD,IAAI,EAAE,CAAC,0BAA0B,CAAC,MAAM,CAAC,IAAI,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,aAAa,CAAC,MAAM,CAAC,UAAU,EAAE,QAAQ,CAAC,EAAE,CAAC;QACjI,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC;IACzB,CAAC;IACD,OAAO,SAAS,CAAC;AAClB,CAAC;AAED,MAAM,UAAU,aAAa,CAAC,MAAqB;IAClD,MAAM,QAAQ,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IACtC,IAAI,QAAQ,CAAC,SAAS,CAAC,IAAI,KAAK,CAAC,IAAI,QAAQ,CAAC,UAAU,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;QACrE,OAAO,EAAE,CAAC;IACX,CAAC;IACD,MAAM,MAAM,GAAmB,EAAE,CAAC;IAClC,SAAS,KAAK,CAAC,IAAa;QAC3B,IAAI,EAAE,CAAC,gBAAgB,CAAC,IAAI,CAAC,EAAE,CAAC;YAC/B,MAAM,IAAI,GAAG,cAAc,CAAC,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAC;YACvD,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;gBACxB,MAAM,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YACnC,CAAC;QACF,CAAC;QACD,EAAE,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAC9B,CAAC;IACD,KAAK,CAAC,MAAM,CAAC,CAAC;IACd,OAAO,MAAM,CAAC;AACf,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare const storySetup: import("#rule.ts").Rule<"tests/story-setup", undefined>;
2
+ //# sourceMappingURL=setup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/setup.ts"],"names":[],"mappings":"AA6DA,eAAO,MAAM,UAAU,yDAKrB,CAAC"}
@@ -0,0 +1,56 @@
1
+ import ts from "typescript";
2
+ import { isTestSupport, testName } from "#naming/testName.ts";
3
+ import { defineRule } from "#rule.ts";
4
+ import { parse } from "#rules/syntax.ts";
5
+ import { fixtureWrites } from "./fixtureWrites.js";
6
+ const SETUP_HELPER = /^(?:seed|make|build|setup)(?![a-z])/u;
7
+ const STORY_KIT = "Name the setup in domain words as traits of the repository's story kit in test-support/, so the test reads as a story: see https://github.com/ShivaeDev/platform/tree/main/packages/quality#story-tests.";
8
+ function isTestFile(path) {
9
+ return testName(path) !== undefined && !isTestSupport(path);
10
+ }
11
+ function lineOf(source, node) {
12
+ return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
13
+ }
14
+ function unwrapped(node) {
15
+ return ts.isParenthesizedExpression(node) ? unwrapped(node.expression) : node;
16
+ }
17
+ function isFunction(node) {
18
+ return node !== undefined && (ts.isArrowFunction(unwrapped(node)) || ts.isFunctionExpression(unwrapped(node)));
19
+ }
20
+ function helpersOf(statement) {
21
+ if (ts.isFunctionDeclaration(statement)) {
22
+ return statement.name === undefined || statement.body === undefined ? [] : [statement.name];
23
+ }
24
+ if (!ts.isVariableStatement(statement)) {
25
+ return [];
26
+ }
27
+ return statement.declarationList.declarations.flatMap((declaration) => ts.isIdentifier(declaration.name) && isFunction(declaration.initializer) ? [declaration.name] : []);
28
+ }
29
+ function storySetupIn(file) {
30
+ const source = isTestFile(file.path) ? parse(file) : undefined;
31
+ if (source === undefined) {
32
+ return [];
33
+ }
34
+ const helpers = source.statements
35
+ .flatMap(helpersOf)
36
+ .filter((name) => SETUP_HELPER.test(name.text))
37
+ .map((name) => ({
38
+ file: file.path,
39
+ line: lineOf(source, name),
40
+ message: `Declares the setup helper "${name.text}". ${STORY_KIT}`,
41
+ subject: name.text,
42
+ }));
43
+ const writes = fixtureWrites(source).map(({ call, name }) => ({
44
+ file: file.path,
45
+ line: lineOf(source, call),
46
+ message: `Writes a fixture with ${name}. ${STORY_KIT}`,
47
+ subject: name,
48
+ }));
49
+ return [...helpers, ...writes];
50
+ }
51
+ export const storySetup = defineRule({
52
+ check: ({ sources }) => sources.flatMap(storySetupIn),
53
+ description: "A test reads as a story over the domain words of the repository's story kit in test-support/. It names its setup as traits instead of declaring seed, make, build or setup helpers or writing fixture files.",
54
+ id: "tests/story-setup",
55
+ });
56
+ //# sourceMappingURL=setup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"setup.js","sourceRoot":"","sources":["../../../src/rules/stories/setup.ts"],"names":[],"mappings":"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"}
package/dist/vitest.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { type TestUserConfig } from "vitest/config";
1
+ import { type TestProjectConfiguration, type TestUserConfig } from "vitest/config";
2
2
  export declare function testProjects({ exclude }?: {
3
3
  readonly exclude?: readonly string[];
4
4
  }): TestUserConfig;
5
+ export declare function inheritTags(projects: readonly TestProjectConfiguration[], root: string): Promise<TestProjectConfiguration[]>;
5
6
  //# sourceMappingURL=vitest.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vitest.d.ts","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAepE,wBAAgB,YAAY,CAAC,EAAE,OAAY,EAAE,GAAE;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAO,GAAG,cAAc,CAS5G"}
1
+ {"version":3,"file":"vitest.d.ts","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAEA,OAAO,EAAkB,KAAK,wBAAwB,EAA0B,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAe3H,wBAAgB,YAAY,CAAC,EAAE,OAAY,EAAE,GAAE;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAO,GAAG,cAAc,CAS5G;AAsBD,wBAAgB,WAAW,CAAC,QAAQ,EAAE,SAAS,wBAAwB,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,wBAAwB,EAAE,CAAC,CAW5H"}
package/dist/vitest.js CHANGED
@@ -1,3 +1,13 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
1
11
  import { defaultExclude } from "vitest/config";
2
12
  const EXTENSION = "?(c|m)[jt]s?(x)";
3
13
  const TYPECHECK_ALONE = `**/typecheck.test.${EXTENSION}`;
@@ -17,4 +27,29 @@ export function testProjects({ exclude = [] } = {}) {
17
27
  ],
18
28
  };
19
29
  }
30
+ function isConfigFunction(value) {
31
+ return typeof value === "function";
32
+ }
33
+ function tagsOf(config) {
34
+ const test = typeof config === "object" && config !== null && "test" in config ? config.test : undefined;
35
+ const tags = typeof test === "object" && test !== null && "tags" in test ? test.tags : undefined;
36
+ return Array.isArray(tags)
37
+ ? tags.filter((tag) => typeof tag === "object" && tag !== null && typeof tag.name === "string")
38
+ : [];
39
+ }
40
+ async function declaredTags(configFile) {
41
+ const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(configFile).href));
42
+ return tagsOf(isConfigFunction(loaded.default) ? await loaded.default({ command: "serve", mode: "test" }) : await loaded.default);
43
+ }
44
+ // Vitest reads an inline project's tags only from the project itself, never from the config file it extends.
45
+ export function inheritTags(projects, root) {
46
+ return Promise.all(projects.map(async (project) => {
47
+ if (typeof project !== "object" || !("extends" in project) || typeof project.extends !== "string") {
48
+ return project;
49
+ }
50
+ const own = project.test?.tags ?? [];
51
+ const inherited = (await declaredTags(resolve(root, project.extends))).filter((tag) => !own.some((mine) => mine.name === tag.name));
52
+ return { ...project, test: { ...project.test, tags: [...own, ...inherited] } };
53
+ }));
54
+ }
20
55
  //# sourceMappingURL=vitest.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"vitest.js","sourceRoot":"","sources":["../src/vitest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAuB,MAAM,eAAe,CAAC;AAGpE,MAAM,SAAS,GAAG,iBAAiB,CAAC;AAEpC,MAAM,eAAe,GAAG,qBAAqB,SAAS,EAAE,CAAC;AAEzD,SAAS,KAAK,CAAC,WAAyB;IACvC,OAAO,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,qBAAqB,SAAS,EAAE,CAAC,CAAC,CAAC,QAAQ,WAAW,iBAAiB,SAAS,EAAE,CAAC;AACvH,CAAC;AAED,SAAS,SAAS,CAAC,OAA0B,EAAE,GAAG,QAA2B;IAC5E,OAAO,CAAC,GAAG,cAAc,EAAE,KAAK,CAAC,WAAW,CAAC,EAAE,eAAe,EAAE,GAAG,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAC;AAC1F,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,EAAE,OAAO,GAAG,EAAE,KAA+C,EAAE;IAC3F,OAAO;QACN,OAAO,EAAE,CAAC,MAAM,EAAE,KAAK,CAAC;QACxB,QAAQ,EAAE;YACT,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;YAC5I,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,WAAW,EAAE,OAAO,EAAE,SAAS,CAAC,OAAO,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE;YACvI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,CAAC,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE;SACrH;KACD,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"vitest.js","sourceRoot":"","sources":["../src/vitest.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.15.0",
3
+ "version": "0.17.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": {
@@ -26,6 +26,10 @@ export function testName(path: string): TestName | undefined {
26
26
 
27
27
  const TEST_SUPPORT = "test-support";
28
28
 
29
+ export function isTestSupport(path: string): boolean {
30
+ return posix.dirname(path).split("/").includes(TEST_SUPPORT);
31
+ }
32
+
29
33
  export function isTestCode(path: string): boolean {
30
- return testName(path) !== undefined || posix.dirname(path).split("/").includes(TEST_SUPPORT);
34
+ return testName(path) !== undefined || isTestSupport(path);
31
35
  }
@@ -12,6 +12,7 @@ import { importsAliased } from "#rules/imports/aliased.ts";
12
12
  import { importCycles } from "#rules/imports/cycles.ts";
13
13
  import { importFences } from "#rules/imports/fences.ts";
14
14
  import { importsResolvable } from "#rules/imports/resolvable.ts";
15
+ import { storySetup } from "#rules/stories/setup.ts";
15
16
  import { biomeOverrides } from "#rules/suppressions/biome-overrides.ts";
16
17
  import { biomeRecommended } from "#rules/suppressions/biomeRecommended.ts";
17
18
  import { noDoubleCast } from "#rules/suppressions/no-double-cast.ts";
@@ -48,4 +49,5 @@ export const builtInRules = [
48
49
  otherNames,
49
50
  testsFollow,
50
51
  testsColocated,
52
+ storySetup,
51
53
  ] as const;
@@ -0,0 +1,102 @@
1
+ import ts from "typescript";
2
+
3
+ const FS_MODULES: ReadonlySet<string> = new Set(["fs", "fs/promises", "node:fs", "node:fs/promises"]);
4
+ const FIXTURE_WRITES: ReadonlySet<string> = new Set([
5
+ "appendFile",
6
+ "appendFileSync",
7
+ "copyFile",
8
+ "copyFileSync",
9
+ "cp",
10
+ "cpSync",
11
+ "mkdir",
12
+ "mkdirSync",
13
+ "mkdtemp",
14
+ "mkdtempSync",
15
+ "symlink",
16
+ "symlinkSync",
17
+ "writeFile",
18
+ "writeFileSync",
19
+ ]);
20
+
21
+ interface FsBindings {
22
+ readonly functions: ReadonlyMap<string, string>;
23
+ readonly namespaces: ReadonlySet<string>;
24
+ }
25
+
26
+ export interface FixtureWrite {
27
+ readonly call: ts.CallExpression;
28
+ readonly name: string;
29
+ }
30
+
31
+ function fsImportsOf(source: ts.SourceFile): readonly ts.ImportClause[] {
32
+ return source.statements.flatMap((statement) =>
33
+ ts.isImportDeclaration(statement)
34
+ && ts.isStringLiteral(statement.moduleSpecifier)
35
+ && FS_MODULES.has(statement.moduleSpecifier.text)
36
+ && statement.importClause !== undefined
37
+ ? [statement.importClause]
38
+ : [],
39
+ );
40
+ }
41
+
42
+ function addBindings(clause: ts.ImportClause, functions: Map<string, string>, namespaces: Set<string>): void {
43
+ const bindings = clause.namedBindings;
44
+ for (const local of [clause.name, bindings !== undefined && ts.isNamespaceImport(bindings) ? bindings.name : undefined]) {
45
+ if (local !== undefined) {
46
+ namespaces.add(local.text);
47
+ }
48
+ }
49
+ for (const element of bindings !== undefined && ts.isNamedImports(bindings) ? bindings.elements : []) {
50
+ const imported = (element.propertyName ?? element.name).text;
51
+ if (imported === "promises") {
52
+ namespaces.add(element.name.text);
53
+ } else if (FIXTURE_WRITES.has(imported)) {
54
+ functions.set(element.name.text, imported);
55
+ }
56
+ }
57
+ }
58
+
59
+ function fsBindingsOf(source: ts.SourceFile): FsBindings {
60
+ const functions = new Map<string, string>();
61
+ const namespaces = new Set<string>();
62
+ for (const clause of fsImportsOf(source)) {
63
+ addBindings(clause, functions, namespaces);
64
+ }
65
+ return { functions, namespaces };
66
+ }
67
+
68
+ function isFsNamespace(node: ts.Expression, bindings: FsBindings): boolean {
69
+ return (
70
+ (ts.isIdentifier(node) && bindings.namespaces.has(node.text))
71
+ || (ts.isPropertyAccessExpression(node) && node.name.text === "promises" && isFsNamespace(node.expression, bindings))
72
+ );
73
+ }
74
+
75
+ function fixtureWriteOf(callee: ts.Expression, bindings: FsBindings): string | undefined {
76
+ if (ts.isIdentifier(callee)) {
77
+ return bindings.functions.get(callee.text);
78
+ }
79
+ if (ts.isPropertyAccessExpression(callee) && FIXTURE_WRITES.has(callee.name.text) && isFsNamespace(callee.expression, bindings)) {
80
+ return callee.name.text;
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ export function fixtureWrites(source: ts.SourceFile): readonly FixtureWrite[] {
86
+ const bindings = fsBindingsOf(source);
87
+ if (bindings.functions.size === 0 && bindings.namespaces.size === 0) {
88
+ return [];
89
+ }
90
+ const writes: FixtureWrite[] = [];
91
+ function visit(node: ts.Node): void {
92
+ if (ts.isCallExpression(node)) {
93
+ const name = fixtureWriteOf(node.expression, bindings);
94
+ if (name !== undefined) {
95
+ writes.push({ call: node, name });
96
+ }
97
+ }
98
+ ts.forEachChild(node, visit);
99
+ }
100
+ visit(source);
101
+ return writes;
102
+ }
@@ -0,0 +1,67 @@
1
+ import ts from "typescript";
2
+ import { isTestSupport, testName } from "#naming/testName.ts";
3
+ import { defineRule, type Finding, type SourceFile } from "#rule.ts";
4
+ import { parse } from "#rules/syntax.ts";
5
+ import { fixtureWrites } from "./fixtureWrites.ts";
6
+
7
+ const SETUP_HELPER = /^(?:seed|make|build|setup)(?![a-z])/u;
8
+ const STORY_KIT =
9
+ "Name the setup in domain words as traits of the repository's story kit in test-support/, so the test reads as a story: see https://github.com/ShivaeDev/platform/tree/main/packages/quality#story-tests.";
10
+
11
+ function isTestFile(path: string): boolean {
12
+ return testName(path) !== undefined && !isTestSupport(path);
13
+ }
14
+
15
+ function lineOf(source: ts.SourceFile, node: ts.Node): number {
16
+ return source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
17
+ }
18
+
19
+ function unwrapped(node: ts.Expression): ts.Expression {
20
+ return ts.isParenthesizedExpression(node) ? unwrapped(node.expression) : node;
21
+ }
22
+
23
+ function isFunction(node: ts.Expression | undefined): boolean {
24
+ return node !== undefined && (ts.isArrowFunction(unwrapped(node)) || ts.isFunctionExpression(unwrapped(node)));
25
+ }
26
+
27
+ function helpersOf(statement: ts.Statement): readonly ts.Identifier[] {
28
+ if (ts.isFunctionDeclaration(statement)) {
29
+ return statement.name === undefined || statement.body === undefined ? [] : [statement.name];
30
+ }
31
+ if (!ts.isVariableStatement(statement)) {
32
+ return [];
33
+ }
34
+ return statement.declarationList.declarations.flatMap((declaration) =>
35
+ ts.isIdentifier(declaration.name) && isFunction(declaration.initializer) ? [declaration.name] : [],
36
+ );
37
+ }
38
+
39
+ function storySetupIn(file: SourceFile): readonly Finding[] {
40
+ const source = isTestFile(file.path) ? parse(file) : undefined;
41
+ if (source === undefined) {
42
+ return [];
43
+ }
44
+ const helpers = source.statements
45
+ .flatMap(helpersOf)
46
+ .filter((name) => SETUP_HELPER.test(name.text))
47
+ .map((name) => ({
48
+ file: file.path,
49
+ line: lineOf(source, name),
50
+ message: `Declares the setup helper "${name.text}". ${STORY_KIT}`,
51
+ subject: name.text,
52
+ }));
53
+ const writes = fixtureWrites(source).map(({ call, name }) => ({
54
+ file: file.path,
55
+ line: lineOf(source, call),
56
+ message: `Writes a fixture with ${name}. ${STORY_KIT}`,
57
+ subject: name,
58
+ }));
59
+ return [...helpers, ...writes];
60
+ }
61
+
62
+ export const storySetup = defineRule({
63
+ check: ({ sources }) => sources.flatMap(storySetupIn),
64
+ description:
65
+ "A test reads as a story over the domain words of the repository's story kit in test-support/. It names its setup as traits instead of declaring seed, make, build or setup helpers or writing fixture files.",
66
+ id: "tests/story-setup",
67
+ });
package/src/vitest.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { defaultExclude, type TestUserConfig } from "vitest/config";
1
+ import { resolve } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { defaultExclude, type TestProjectConfiguration, type TestTagDefinition, type TestUserConfig } from "vitest/config";
2
4
  import type { Environment } from "#naming/testName.ts";
3
5
 
4
6
  const EXTENSION = "?(c|m)[jt]s?(x)";
@@ -23,3 +25,36 @@ export function testProjects({ exclude = [] }: { readonly exclude?: readonly str
23
25
  ],
24
26
  };
25
27
  }
28
+
29
+ type ConfigFunction = (env: { readonly command: "serve"; readonly mode: "test" }) => unknown;
30
+
31
+ function isConfigFunction(value: unknown): value is ConfigFunction {
32
+ return typeof value === "function";
33
+ }
34
+
35
+ function tagsOf(config: unknown): readonly TestTagDefinition[] {
36
+ const test = typeof config === "object" && config !== null && "test" in config ? config.test : undefined;
37
+ const tags = typeof test === "object" && test !== null && "tags" in test ? test.tags : undefined;
38
+ return Array.isArray(tags)
39
+ ? tags.filter((tag): tag is TestTagDefinition => typeof tag === "object" && tag !== null && typeof tag.name === "string")
40
+ : [];
41
+ }
42
+
43
+ async function declaredTags(configFile: string): Promise<readonly TestTagDefinition[]> {
44
+ const loaded: { readonly default?: unknown } = await import(pathToFileURL(configFile).href);
45
+ return tagsOf(isConfigFunction(loaded.default) ? await loaded.default({ command: "serve", mode: "test" }) : await loaded.default);
46
+ }
47
+
48
+ // Vitest reads an inline project's tags only from the project itself, never from the config file it extends.
49
+ export function inheritTags(projects: readonly TestProjectConfiguration[], root: string): Promise<TestProjectConfiguration[]> {
50
+ return Promise.all(
51
+ projects.map(async (project) => {
52
+ if (typeof project !== "object" || !("extends" in project) || typeof project.extends !== "string") {
53
+ return project;
54
+ }
55
+ const own = project.test?.tags ?? [];
56
+ const inherited = (await declaredTags(resolve(root, project.extends))).filter((tag) => !own.some((mine) => mine.name === tag.name));
57
+ return { ...project, test: { ...project.test, tags: [...own, ...inherited] } };
58
+ }),
59
+ );
60
+ }