@shivaedev/quality 0.16.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,15 @@
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
+
3
13
  ## 0.16.0 - 2026-10-06
4
14
 
5
15
  ### Added
package/README.md CHANGED
@@ -524,23 +524,21 @@ Test code is a test file, as `tests/follow` reads the name (`*.test.ts` or `*.sp
524
524
 
525
525
  ## Story tests
526
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/`, so a test says what it needs in domain words and never builds state by hand.
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
528
 
529
529
  ```ts
530
- it("ships a paid order from stock", () => {
531
- const { customer, warehouse } = newShop(hasInStock(3, "lamp"), hasInCart(1, "lamp"));
532
-
530
+ shop.it("ships a paid order from stock", [hasInStock(3, "lamp"), hasInCart(1, "lamp")], function* ({ customer, warehouse }) {
533
531
  customer.checksOut();
534
- warehouse.settles();
532
+ yield* warehouse.shipsEverything();
535
533
 
536
534
  expect(warehouse.shipped()).toEqual(["lamp"]);
537
535
  expect(warehouse.inStock("lamp")).toBe(2);
538
536
  });
539
537
  ```
540
538
 
541
- - **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 entry point, such as `newShop(...traits)`, 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.
542
- - **Actions go through real entry points.** The test acts the way a user or caller does: through the command, service, route or UI path they reach, never by writing internal state.
543
- - **Failures print the story.** Every trait and action adds a line to the story log, and a failing test prints it, so the failure says what happened, not only which values differ.
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.
544
542
 
545
543
  `tests/story-setup` holds test files to this. In a test file, it reports:
546
544
 
@@ -551,10 +549,9 @@ Each finding names its line and has the helper or the function it calls as its s
551
549
 
552
550
  ## Vitest projects
553
551
 
554
- `@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:
555
553
 
556
554
  ```ts
557
- // vitest.config.ts
558
555
  import { testProjects } from "@shivaedev/quality/vitest.ts";
559
556
  import { defineConfig } from "vitest/config";
560
557
 
@@ -571,6 +568,21 @@ Type tests (`*.typecheck.test.ts`, `*.typecheck.spec.ts` and `typecheck.test.ts`
571
568
 
572
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`.
573
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
+
574
586
  ## tsconfig presets
575
587
 
576
588
  Two presets hold one strict, current TypeScript setup for every repository. They target TypeScript 7.
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.16.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": {
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
+ }