@shivaedev/quality 0.15.0 → 0.16.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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.16.0 - 2026-10-06
4
+
5
+ ### Added
6
+
7
+ - 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.
8
+ - `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`.
9
+
3
10
  ## 0.15.0 - 2026-10-05
4
11
 
5
12
  ### Added
package/README.md CHANGED
@@ -520,7 +520,34 @@ 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/`, so a test says what it needs in domain words and never builds state by hand.
528
+
529
+ ```ts
530
+ it("ships a paid order from stock", () => {
531
+ const { customer, warehouse } = newShop(hasInStock(3, "lamp"), hasInCart(1, "lamp"));
532
+
533
+ customer.checksOut();
534
+ warehouse.settles();
535
+
536
+ expect(warehouse.shipped()).toEqual(["lamp"]);
537
+ expect(warehouse.inStock("lamp")).toBe(2);
538
+ });
539
+ ```
540
+
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.
544
+
545
+ `tests/story-setup` holds test files to this. In a test file, it reports:
546
+
547
+ - 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;
548
+ - 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`.
549
+
550
+ 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
551
 
525
552
  ## Vitest projects
526
553
 
@@ -587,4 +614,4 @@ A package that type-checks its tests with one config and builds `src` with anoth
587
614
 
588
615
  ## Validation
589
616
 
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.
617
+ `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":"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"}
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/quality",
3
- "version": "0.15.0",
3
+ "version": "0.16.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
+ });