@shivaedev/quality 0.29.1 → 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 (61) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +11 -7
  3. package/dist/domEnvironment.config.js +1 -1
  4. package/dist/domEnvironment.config.js.map +1 -1
  5. package/dist/imports/fences/reach.d.ts +9 -0
  6. package/dist/imports/fences/reach.d.ts.map +1 -1
  7. package/dist/imports/fences/reach.js +5 -2
  8. package/dist/imports/fences/reach.js.map +1 -1
  9. package/dist/naming/testName.d.ts +1 -1
  10. package/dist/naming/testName.d.ts.map +1 -1
  11. package/dist/naming/testName.js +1 -1
  12. package/dist/naming/testName.js.map +1 -1
  13. package/dist/rules/biome.d.ts +1 -1
  14. package/dist/rules/biome.d.ts.map +1 -1
  15. package/dist/rules/biome.js +1 -1
  16. package/dist/rules/biome.js.map +1 -1
  17. package/dist/rules/builtIn.d.ts +1 -1
  18. package/dist/rules/builtIn.d.ts.map +1 -1
  19. package/dist/rules/builtIn.js +2 -0
  20. package/dist/rules/builtIn.js.map +1 -1
  21. package/dist/rules/manifestsExportsShape.d.ts +2 -0
  22. package/dist/rules/manifestsExportsShape.d.ts.map +1 -0
  23. package/dist/rules/manifestsExportsShape.js +65 -0
  24. package/dist/rules/manifestsExportsShape.js.map +1 -0
  25. package/dist/rules/stories/engineOf.d.ts +3 -0
  26. package/dist/rules/stories/engineOf.d.ts.map +1 -0
  27. package/dist/rules/stories/engineOf.js +62 -0
  28. package/dist/rules/stories/engineOf.js.map +1 -0
  29. package/dist/rules/stories/origins.js +2 -2
  30. package/dist/rules/stories/origins.js.map +1 -1
  31. package/dist/rules/stories/specs.d.ts.map +1 -1
  32. package/dist/rules/stories/specs.js +7 -2
  33. package/dist/rules/stories/specs.js.map +1 -1
  34. package/dist/rules/stories/unitSyntax.d.ts +8 -0
  35. package/dist/rules/stories/unitSyntax.d.ts.map +1 -0
  36. package/dist/rules/stories/unitSyntax.js +77 -0
  37. package/dist/rules/stories/unitSyntax.js.map +1 -0
  38. package/dist/rules/stories/unitTests.d.ts +4 -0
  39. package/dist/rules/stories/unitTests.d.ts.map +1 -0
  40. package/dist/rules/stories/unitTests.js +76 -0
  41. package/dist/rules/stories/unitTests.js.map +1 -0
  42. package/dist/rules/test-names/colocated.d.ts.map +1 -1
  43. package/dist/rules/test-names/colocated.js +2 -1
  44. package/dist/rules/test-names/colocated.js.map +1 -1
  45. package/dist/rules/test-names/follow.d.ts.map +1 -1
  46. package/dist/rules/test-names/follow.js +16 -6
  47. package/dist/rules/test-names/follow.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/domEnvironment.config.ts +1 -1
  50. package/src/imports/fences/reach.ts +12 -3
  51. package/src/naming/testName.ts +1 -1
  52. package/src/rules/biome.ts +1 -1
  53. package/src/rules/builtIn.ts +2 -0
  54. package/src/rules/manifestsExportsShape.ts +79 -0
  55. package/src/rules/stories/engineOf.ts +65 -0
  56. package/src/rules/stories/origins.ts +2 -2
  57. package/src/rules/stories/specs.ts +7 -3
  58. package/src/rules/stories/unitSyntax.ts +95 -0
  59. package/src/rules/stories/unitTests.ts +86 -0
  60. package/src/rules/test-names/colocated.ts +2 -1
  61. package/src/rules/test-names/follow.ts +17 -6
@@ -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
  });