@shivaedev/quality 0.36.0 → 0.39.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 (76) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +6 -4
  3. package/dist/inventory/ignoreScope.d.ts +1 -0
  4. package/dist/inventory/ignoreScope.d.ts.map +1 -1
  5. package/dist/inventory/ignoreScope.js +11 -1
  6. package/dist/inventory/ignoreScope.js.map +1 -1
  7. package/dist/inventory/trackedPaths.d.ts +1 -0
  8. package/dist/inventory/trackedPaths.d.ts.map +1 -1
  9. package/dist/inventory/trackedPaths.js +10 -6
  10. package/dist/inventory/trackedPaths.js.map +1 -1
  11. package/dist/inventory/walk.d.ts +1 -1
  12. package/dist/inventory/walk.d.ts.map +1 -1
  13. package/dist/inventory/walk.js +16 -7
  14. package/dist/inventory/walk.js.map +1 -1
  15. package/dist/rule.d.ts +1 -1
  16. package/dist/rules/builtIn.d.ts +1 -1
  17. package/dist/rules/builtIn.d.ts.map +1 -1
  18. package/dist/rules/builtIn.js +2 -0
  19. package/dist/rules/builtIn.js.map +1 -1
  20. package/dist/rules/stories/engine.d.ts +2 -0
  21. package/dist/rules/stories/engine.d.ts.map +1 -0
  22. package/dist/rules/stories/engine.js +47 -0
  23. package/dist/rules/stories/engine.js.map +1 -0
  24. package/dist/rules/stories/importOrigin.d.ts +2 -0
  25. package/dist/rules/stories/importOrigin.d.ts.map +1 -0
  26. package/dist/rules/stories/importOrigin.js +20 -0
  27. package/dist/rules/stories/importOrigin.js.map +1 -0
  28. package/dist/rules/stories/namedValues.d.ts.map +1 -1
  29. package/dist/rules/stories/namedValues.js +28 -2
  30. package/dist/rules/stories/namedValues.js.map +1 -1
  31. package/dist/rules/stories/noExpect.d.ts.map +1 -1
  32. package/dist/rules/stories/noExpect.js +11 -1
  33. package/dist/rules/stories/noExpect.js.map +1 -1
  34. package/dist/rules/stories/origins.d.ts +1 -0
  35. package/dist/rules/stories/origins.d.ts.map +1 -1
  36. package/dist/rules/stories/origins.js +15 -22
  37. package/dist/rules/stories/origins.js.map +1 -1
  38. package/dist/rules/stories/registersTests.d.ts +3 -0
  39. package/dist/rules/stories/registersTests.d.ts.map +1 -0
  40. package/dist/rules/stories/registersTests.js +69 -0
  41. package/dist/rules/stories/registersTests.js.map +1 -0
  42. package/dist/rules/stories/returnValues.d.ts +3 -0
  43. package/dist/rules/stories/returnValues.d.ts.map +1 -0
  44. package/dist/rules/stories/returnValues.js +24 -0
  45. package/dist/rules/stories/returnValues.js.map +1 -0
  46. package/dist/rules/stories/specs.d.ts.map +1 -1
  47. package/dist/rules/stories/specs.js +3 -0
  48. package/dist/rules/stories/specs.js.map +1 -1
  49. package/dist/rules/stories/usesInputs.d.ts +3 -0
  50. package/dist/rules/stories/usesInputs.d.ts.map +1 -0
  51. package/dist/rules/stories/usesInputs.js +39 -0
  52. package/dist/rules/stories/usesInputs.js.map +1 -0
  53. package/dist/testProjects.d.ts.map +1 -1
  54. package/dist/testProjects.js +15 -2
  55. package/dist/testProjects.js.map +1 -1
  56. package/dist/unitIoGuard.config.d.ts +2 -0
  57. package/dist/unitIoGuard.config.d.ts.map +1 -0
  58. package/dist/unitIoGuard.config.js +69 -0
  59. package/dist/unitIoGuard.config.js.map +1 -0
  60. package/package.json +4 -3
  61. package/src/inventory/ignoreScope.ts +12 -1
  62. package/src/inventory/trackedPaths.ts +12 -7
  63. package/src/inventory/walk.ts +19 -7
  64. package/src/rule.ts +1 -1
  65. package/src/rules/builtIn.ts +2 -0
  66. package/src/rules/stories/engine.ts +56 -0
  67. package/src/rules/stories/importOrigin.ts +20 -0
  68. package/src/rules/stories/namedValues.ts +29 -2
  69. package/src/rules/stories/noExpect.ts +13 -1
  70. package/src/rules/stories/origins.ts +16 -24
  71. package/src/rules/stories/registersTests.ts +78 -0
  72. package/src/rules/stories/returnValues.ts +24 -0
  73. package/src/rules/stories/specs.ts +3 -0
  74. package/src/rules/stories/usesInputs.ts +38 -0
  75. package/src/testProjects.ts +15 -2
  76. package/src/unitIoGuard.config.ts +72 -0
package/src/rule.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Bivariant } from "@shivaedev/types/bivariant.ts";
1
+ import type { Bivariant } from "@shivaedev/types/Bivariant.ts";
2
2
  import type { ExceptionDeclaration } from "#exceptions/declaration.ts";
3
3
  import { describeIssue, type StandardSchemaV1 } from "./standardSchema.ts";
4
4
 
@@ -17,6 +17,7 @@ import { noTestCode } from "#rules/imports/noTestCode.ts";
17
17
  import { importsResolvable } from "#rules/imports/resolvable.ts";
18
18
  import { importsRuntime } from "#rules/imports/runtime.ts";
19
19
  import { authSql } from "#rules/stories/authSql.ts";
20
+ import { storyEngine } from "#rules/stories/engine.ts";
20
21
  import { httpStatus } from "#rules/stories/httpStatus.ts";
21
22
  import { namedValues } from "#rules/stories/namedValues.ts";
22
23
  import { noExpect } from "#rules/stories/noExpect.ts";
@@ -75,6 +76,7 @@ export const builtInRules = [
75
76
  testsColocated,
76
77
  storySetup,
77
78
  storySpecs,
79
+ storyEngine,
78
80
  queueSql,
79
81
  authSql,
80
82
  noExpect,
@@ -0,0 +1,56 @@
1
+ import ts from "typescript";
2
+ import { importGraph } from "#imports/graph.ts";
3
+ import { packageOf } from "#imports/workspace.ts";
4
+ import { testName } from "#naming/testName.ts";
5
+ import { defineRule } from "#rule.ts";
6
+ import { storyOrigins } from "./origins.ts";
7
+ import { storyPrerequisites } from "./prerequisites.ts";
8
+ import { nodesWhere, unwrapped } from "./syntax.ts";
9
+ import { storyUsesInputs } from "./usesInputs.ts";
10
+
11
+ function ignoresEngine(call: ts.CallExpression, checker: ts.TypeChecker): boolean {
12
+ const traits = call.arguments[1];
13
+ const given = traits === undefined ? undefined : unwrapped(traits);
14
+ const body = call.arguments[2];
15
+ return (
16
+ given !== undefined
17
+ && ts.isArrayLiteralExpression(given)
18
+ && given.elements.length === 0
19
+ && (body === undefined || storyUsesInputs(body, checker) === false)
20
+ );
21
+ }
22
+
23
+ export const storyEngine = defineRule({
24
+ check: async (inputs) => {
25
+ const graph = await importGraph(inputs);
26
+ const exempt = await storyPrerequisites(inputs, graph.packages);
27
+ const origins = storyOrigins(inputs, graph);
28
+ return inputs.sources.flatMap((file) => {
29
+ const name = testName(file.path);
30
+ const owner = packageOf(graph.packages, file.path);
31
+ if (
32
+ name === undefined
33
+ || name.modifiers.some((modifier) => modifier === "unit" || modifier === "typecheck")
34
+ || exempt.has(owner?.directory ?? "")
35
+ ) {
36
+ return [];
37
+ }
38
+ const source = origins.source(file.path);
39
+ if (source === undefined) {
40
+ return [];
41
+ }
42
+ const stories = nodesWhere(
43
+ source,
44
+ (node) => ts.isCallExpression(node) && origins.origin(node.expression) === "story" && ignoresEngine(node, origins.checker),
45
+ );
46
+ return stories.map((story) => ({
47
+ file: file.path,
48
+ line: source.getLineAndCharacterOfPosition(story.getStart(source)).line + 1,
49
+ message: `Declares a story without traits whose body never references its verbs, its story or its test context. Drive the engine through one of the kit's verbs or traits. If the code under test is pure, move the test to ${name.stem}.unit.${name.kind}${name.extension}, which reaches no engine and mocks nothing. See https://github.com/ShivaeDev/platform/tree/main/packages/quality#story-tests.`,
50
+ }));
51
+ });
52
+ },
53
+ description:
54
+ "A story without traits references its kit's verbs, its story or its test context; pure tests belong in .unit files that reach no engine and mock nothing.",
55
+ id: "tests/story-engine",
56
+ });
@@ -0,0 +1,20 @@
1
+ function moduleOrigin(from: string | undefined) {
2
+ if (from === "vitest" || from === "@effect/vitest") {
3
+ return "vitest";
4
+ }
5
+ return from === "@shivaedev/test-story" || from === "@shivaedev/test-story/server/storyKit.ts" ? "stories" : undefined;
6
+ }
7
+
8
+ export function importOrigin(from: string | undefined, name?: string) {
9
+ const namespace = moduleOrigin(from);
10
+ if (name === undefined) {
11
+ return namespace;
12
+ }
13
+ if (name === "storyKit" && namespace === "stories") {
14
+ return "factory";
15
+ }
16
+ if ((name === "describe" || name === "suite") && namespace === "vitest") {
17
+ return "suite";
18
+ }
19
+ return (name === "it" || name === "test") && namespace === "vitest" ? "plain" : undefined;
20
+ }
@@ -1,15 +1,42 @@
1
1
  import ts from "typescript";
2
2
  import { defineRule } from "#rule.ts";
3
- import { isSpec, memberName, nodesWhere, numericValue, syntaxFindings } from "./syntax.ts";
3
+ import { isSpec, memberName, nodesWhere, numericValue, syntaxFindings, unwrapped } from "./syntax.ts";
4
4
 
5
5
  const MESSAGE =
6
6
  "Passes a numeric literal to toBe or toHaveLength. Assert the observed items, or give the expected value a domain name before asserting it.";
7
7
 
8
+ function codeSubject(expression: ts.Expression): boolean {
9
+ const value = unwrapped(expression);
10
+ if (!(ts.isPropertyAccessExpression(value) || ts.isElementAccessExpression(value))) {
11
+ return false;
12
+ }
13
+ let root = unwrapped(value.expression);
14
+ while ((ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root)) && memberName(root) === "not") {
15
+ root = unwrapped(root.expression);
16
+ }
17
+ if (!ts.isCallExpression(root)) {
18
+ return false;
19
+ }
20
+ const callee = unwrapped(root.expression);
21
+ const receiver = ts.isPropertyAccessExpression(callee) ? unwrapped(callee.expression) : callee;
22
+ const expect = ts.isIdentifier(receiver) && receiver.text === "expect" && (receiver === callee || memberName(callee) === "soft");
23
+ const argument = root.arguments[0];
24
+ if (!expect || argument === undefined) {
25
+ return false;
26
+ }
27
+ const subject = unwrapped(argument);
28
+ return ["status", "code", "exitCode"].includes(ts.isIdentifier(subject) ? subject.text : (memberName(subject) ?? ""));
29
+ }
30
+
8
31
  function numericAssertion(node: ts.Node): boolean {
9
32
  return (
10
33
  ts.isCallExpression(node)
11
34
  && ["toBe", "toHaveLength"].includes(memberName(node.expression) ?? "")
12
- && node.arguments.some((argument) => numericValue(argument) !== undefined)
35
+ && !codeSubject(node.expression)
36
+ && node.arguments.some((argument) => {
37
+ const value = numericValue(argument);
38
+ return value !== undefined && value !== 0;
39
+ })
13
40
  );
14
41
  }
15
42
 
@@ -1,6 +1,8 @@
1
1
  import ts from "typescript";
2
+ import { importGraph } from "#imports/graph.ts";
2
3
  import { isTestSupport } from "#naming/testName.ts";
3
4
  import { defineRule } from "#rule.ts";
5
+ import { registersTests } from "./registersTests.ts";
4
6
  import { memberName, nodesWhere, syntaxFindings, unwrapped } from "./syntax.ts";
5
7
 
6
8
  const MESSAGE =
@@ -33,7 +35,17 @@ function assertionsIn(source: ts.SourceFile): readonly ts.Node[] {
33
35
  }
34
36
 
35
37
  export const noExpect = defineRule({
36
- check: ({ sources }) => sources.filter((file) => isTestSupport(file.path)).flatMap((file) => syntaxFindings(file, assertionsIn, MESSAGE)),
38
+ check: async (inputs) => {
39
+ const support = inputs.sources.filter((file) => isTestSupport(file.path));
40
+ if (support.length === 0) {
41
+ return [];
42
+ }
43
+ const graph = await importGraph(inputs);
44
+ const imported = new Set(graph.edges.flatMap((edge) => (edge.kind === "import" && edge.to.kind === "file" ? [edge.to.path] : [])));
45
+ return support.flatMap((file) =>
46
+ syntaxFindings(file, (source) => (!imported.has(file.path) && registersTests(source) ? [] : assertionsIn(source)), MESSAGE),
47
+ );
48
+ },
37
49
  description: "Test-support performs actions and exposes observations; specs own expectations.",
38
50
  id: "tests/no-expect-in-support",
39
51
  });
@@ -2,7 +2,9 @@ import { join } from "node:path";
2
2
  import ts from "typescript";
3
3
  import type { ImportGraph } from "#imports/graph.ts";
4
4
  import type { RuleInputs } from "#rule.ts";
5
+ import { importOrigin } from "./importOrigin.ts";
5
6
  import { storyProgram } from "./program.ts";
7
+ import { returnValues } from "./returnValues.ts";
6
8
 
7
9
  type Origin = "factory" | "kit" | "story" | "plain" | "suite" | "vitest" | "stories" | "function" | undefined;
8
10
 
@@ -15,24 +17,6 @@ function importFrom(node: ts.Node): string | undefined {
15
17
  return undefined;
16
18
  }
17
19
 
18
- function moduleOrigin(from: string | undefined): Origin {
19
- if (from === "vitest" || from === "@effect/vitest") {
20
- return "vitest";
21
- }
22
- return from === "@shivaedev/test-story" || from === "@shivaedev/test-story/server/storyKit.ts" ? "stories" : undefined;
23
- }
24
-
25
- function imported(name: string, from: string | undefined): Origin {
26
- const namespace = moduleOrigin(from);
27
- if (name === "storyKit" && namespace === "stories") {
28
- return "factory";
29
- }
30
- if ((name === "describe" || name === "suite") && namespace === "vitest") {
31
- return "suite";
32
- }
33
- return (name === "it" || name === "test") && namespace === "vitest" ? "plain" : undefined;
34
- }
35
-
36
20
  export function storyOrigins(inputs: RuleInputs, graph: ImportGraph) {
37
21
  const program = storyProgram(inputs, graph);
38
22
  const checker = program.getTypeChecker();
@@ -50,10 +34,10 @@ export function storyOrigins(inputs: RuleInputs, graph: ImportGraph) {
50
34
 
51
35
  function declaration(node: ts.Node): Origin {
52
36
  if (ts.isImportSpecifier(node)) {
53
- return imported((node.propertyName ?? node.name).text, importFrom(node));
37
+ return importOrigin(importFrom(node), (node.propertyName ?? node.name).text);
54
38
  }
55
39
  if (ts.isNamespaceImport(node)) {
56
- return moduleOrigin(importFrom(node));
40
+ return importOrigin(importFrom(node));
57
41
  }
58
42
  if (ts.isVariableDeclaration(node) || ts.isPropertyAssignment(node)) {
59
43
  return node.initializer === undefined ? undefined : origin(node.initializer);
@@ -97,7 +81,7 @@ export function storyOrigins(inputs: RuleInputs, graph: ImportGraph) {
97
81
  return receiver;
98
82
  }
99
83
  const from = receiver === "vitest" ? "vitest" : undefined;
100
- return imported(name, receiver === "stories" ? "@shivaedev/test-story/server/storyKit.ts" : from);
84
+ return importOrigin(receiver === "stories" ? "@shivaedev/test-story/server/storyKit.ts" : from, name);
101
85
  }
102
86
 
103
87
  function identifier(node: ts.Identifier): Origin {
@@ -118,13 +102,21 @@ export function storyOrigins(inputs: RuleInputs, graph: ImportGraph) {
118
102
  return expression(node);
119
103
  }
120
104
 
105
+ function call(node: ts.CallExpression): Origin {
106
+ const callee = visit(node.expression);
107
+ if (callee === "function") {
108
+ const values = returnValues(checker.getResolvedSignature(node)?.declaration);
109
+ return values.length > 0 && values.every((value) => value !== undefined && visit(value) === "kit") ? "kit" : callee;
110
+ }
111
+ return callee === "factory" ? "kit" : callee;
112
+ }
113
+
121
114
  function expression(node: ts.Node): Origin {
122
115
  if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) {
123
116
  return visit(node.expression);
124
117
  }
125
118
  if (ts.isCallExpression(node)) {
126
- const callee = visit(node.expression);
127
- return callee === "factory" ? "kit" : callee;
119
+ return call(node);
128
120
  }
129
121
  if (ts.isPropertyAccessExpression(node)) {
130
122
  return member(node.expression, node.name.text) ?? symbol(checker.getSymbolAtLocation(node.name));
@@ -143,5 +135,5 @@ export function storyOrigins(inputs: RuleInputs, graph: ImportGraph) {
143
135
  return result;
144
136
  }
145
137
 
146
- return { origin: visit, source: (path: string) => program.getSourceFile(join(inputs.root, path)) };
138
+ return { checker, origin: visit, source: (path: string) => program.getSourceFile(join(inputs.root, path)) };
147
139
  }
@@ -0,0 +1,78 @@
1
+ import ts from "typescript";
2
+ import { memberName, unwrapped } from "./syntax.ts";
3
+
4
+ function vitestBindings(source: ts.SourceFile): ReadonlyMap<string, string> {
5
+ const names = new Map<string, string>();
6
+ for (const statement of source.statements) {
7
+ if (
8
+ !(
9
+ ts.isImportDeclaration(statement)
10
+ && ts.isStringLiteral(statement.moduleSpecifier)
11
+ && ["vitest", "@effect/vitest"].includes(statement.moduleSpecifier.text)
12
+ )
13
+ ) {
14
+ continue;
15
+ }
16
+ const bindings = statement.importClause?.namedBindings;
17
+ if (bindings !== undefined && ts.isNamedImports(bindings)) {
18
+ for (const element of bindings.elements) {
19
+ names.set(element.name.text, (element.propertyName ?? element.name).text);
20
+ }
21
+ } else if (bindings !== undefined) {
22
+ names.set(bindings.name.text, "namespace");
23
+ }
24
+ }
25
+ return names;
26
+ }
27
+
28
+ function localBindings(source: ts.SourceFile): ReadonlyMap<string, ts.Expression> {
29
+ const bindings = new Map<string, ts.Expression>();
30
+ for (const statement of source.statements) {
31
+ if (ts.isVariableStatement(statement)) {
32
+ for (const declaration of statement.declarationList.declarations) {
33
+ if (ts.isIdentifier(declaration.name) && declaration.initializer !== undefined) {
34
+ bindings.set(declaration.name.text, declaration.initializer);
35
+ }
36
+ }
37
+ }
38
+ }
39
+ return bindings;
40
+ }
41
+
42
+ export function registersTests(source: ts.SourceFile): boolean {
43
+ const bindings = localBindings(source);
44
+ const imports = vitestBindings(source);
45
+ function registrar(expression: ts.Expression, seen = new Set<string>()): boolean {
46
+ const node = unwrapped(expression);
47
+ if (ts.isIdentifier(node)) {
48
+ if (seen.has(node.text)) {
49
+ return false;
50
+ }
51
+ seen.add(node.text);
52
+ const binding = bindings.get(node.text);
53
+ return ["it", "test", "describe"].includes(imports.get(node.text) ?? node.text) || (binding !== undefined && registrar(binding, seen));
54
+ }
55
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
56
+ const name = memberName(node);
57
+ return (
58
+ name === "it"
59
+ || (ts.isIdentifier(node.expression) && imports.get(node.expression.text) === "namespace" && (name === "test" || name === "describe"))
60
+ || registrar(node.expression, seen)
61
+ );
62
+ }
63
+ return ts.isCallExpression(node) && registrar(node.expression, seen);
64
+ }
65
+ return source.statements.some((statement) => {
66
+ if (!ts.isExpressionStatement(statement)) {
67
+ return false;
68
+ }
69
+ const call = unwrapped(statement.expression);
70
+ const title = ts.isCallExpression(call) ? call.arguments[0] : undefined;
71
+ return (
72
+ ts.isCallExpression(call)
73
+ && title !== undefined
74
+ && (ts.isStringLiteralLike(title) || ts.isTemplateExpression(title))
75
+ && registrar(call.expression)
76
+ );
77
+ });
78
+ }
@@ -0,0 +1,24 @@
1
+ import ts from "typescript";
2
+
3
+ export function returnValues(declaration: ts.Signature["declaration"]): readonly (ts.Expression | undefined)[] {
4
+ const body = declaration !== undefined && "body" in declaration ? declaration.body : undefined;
5
+ if (body === undefined) {
6
+ return [];
7
+ }
8
+ if (!ts.isBlock(body)) {
9
+ return [body];
10
+ }
11
+ const values: (ts.Expression | undefined)[] = [];
12
+ function visit(node: ts.Node): void {
13
+ if (ts.isFunctionLike(node)) {
14
+ return;
15
+ }
16
+ if (ts.isReturnStatement(node)) {
17
+ values.push(node.expression);
18
+ return;
19
+ }
20
+ ts.forEachChild(node, visit);
21
+ }
22
+ visit(body);
23
+ return values;
24
+ }
@@ -38,6 +38,9 @@ function testShaped(call: ts.CallExpression, origin: OriginOf): boolean {
38
38
 
39
39
  function declarationScope(node: ts.Node, origin: OriginOf): boolean {
40
40
  for (let current = node.parent; current !== undefined; current = current.parent) {
41
+ if (ts.isCallExpression(current) && origin(current.expression) === "story") {
42
+ return false;
43
+ }
41
44
  if (ts.isFunctionLike(current) && (!ts.isCallExpression(current.parent) || origin(current.parent.expression) !== "suite")) {
42
45
  return false;
43
46
  }
@@ -0,0 +1,38 @@
1
+ import ts from "typescript";
2
+
3
+ export function storyUsesInputs(body: ts.Expression, checker: ts.TypeChecker): boolean | undefined {
4
+ const declaration = checker.getTypeAtLocation(body).getCallSignatures()[0]?.declaration;
5
+ if (declaration === undefined || !("body" in declaration) || declaration.body === undefined) {
6
+ return undefined;
7
+ }
8
+ const inputs = new Set<ts.Symbol>();
9
+ function bind(name: ts.BindingName): void {
10
+ if (ts.isIdentifier(name)) {
11
+ const symbol = checker.getSymbolAtLocation(name);
12
+ if (symbol !== undefined) {
13
+ inputs.add(symbol);
14
+ }
15
+ } else {
16
+ for (const element of name.elements) {
17
+ if (ts.isBindingElement(element)) {
18
+ bind(element.name);
19
+ }
20
+ }
21
+ }
22
+ }
23
+ for (const parameter of declaration.parameters.slice(0, 2)) {
24
+ bind(parameter.name);
25
+ }
26
+ function references(node: ts.Node): boolean {
27
+ if (ts.isIdentifier(node)) {
28
+ const symbol = ts.isShorthandPropertyAssignment(node.parent)
29
+ ? checker.getShorthandAssignmentValueSymbol(node.parent)
30
+ : checker.getSymbolAtLocation(node);
31
+ if (symbol !== undefined && inputs.has(symbol)) {
32
+ return true;
33
+ }
34
+ }
35
+ return ts.forEachChild(node, references) ?? false;
36
+ }
37
+ return references(declaration.body);
38
+ }
@@ -55,9 +55,22 @@ export function testProjects(options: TestProjectsOptions = {}): TestUserConfig
55
55
  ...(coverage === true ? {} : coverage),
56
56
  },
57
57
  }),
58
- project: ["unit", "dom"],
58
+ project: ["unit", "pure", "dom"],
59
59
  projects: [
60
- { extends: true, test: { environment: "node", exclude: excluding(named("dom"), named("slow")), include: [named()], name: "unit" } },
60
+ {
61
+ extends: true,
62
+ test: { environment: "node", exclude: excluding(named("dom"), named("slow"), named("unit")), include: [named()], name: "unit" },
63
+ },
64
+ {
65
+ extends: true,
66
+ test: {
67
+ environment: "node",
68
+ exclude: excluding(),
69
+ include: [named("unit")],
70
+ name: "pure",
71
+ setupFiles: [fileURLToPath(import.meta.resolve("#unitIoGuard.config.ts"))],
72
+ },
73
+ },
61
74
  {
62
75
  extends: true,
63
76
  test: {
@@ -0,0 +1,72 @@
1
+ import { createRequire, syncBuiltinESMExports } from "node:module";
2
+ // Materialize Node's lazy stdio exports before guarding Socket construction.
3
+ import "node:process";
4
+
5
+ const load = createRequire(import.meta.url);
6
+ const installed = Symbol.for("@shivaedev/quality/unitIoGuard");
7
+ let loading = 0;
8
+
9
+ function assertUnitIoAllowed(call: string, boundary: (...args: never[]) => unknown): void {
10
+ const worker: { readonly filepath?: string } | undefined = Reflect.get(globalThis, "__vitest_worker__");
11
+ const file = worker?.filepath;
12
+ if (loading > 0 || file === undefined || !/\.unit\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(file)) {
13
+ return;
14
+ }
15
+ const error = new Error(`Unit test ${file} cannot call ${call}; use a story through the package's kit for I/O.`);
16
+ Error.captureStackTrace(error, boundary);
17
+ const caller = error.stack
18
+ ?.split("\n")
19
+ .slice(1)
20
+ .find((line) => !/node:(?:fs|internal\/fs)/u.test(line));
21
+ // Node and Vitest still need to load the unit's modules; only their own immediate calls may pass.
22
+ if (caller !== undefined && /node:internal\/modules\/|\/node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?(?:vitest|@vitest|vite)\//u.test(caller)) {
23
+ return;
24
+ }
25
+ throw error;
26
+ }
27
+
28
+ function runAllowed<T>(run: () => T): T {
29
+ loading += 1;
30
+ try {
31
+ return run();
32
+ } finally {
33
+ loading -= 1;
34
+ }
35
+ }
36
+
37
+ function guardModule(name: string): void {
38
+ const module: Record<string, unknown> = load(name);
39
+ for (const key of Object.keys(module)) {
40
+ const original = module[key];
41
+ if (typeof original !== "function") {
42
+ continue;
43
+ }
44
+ const apply: NonNullable<ProxyHandler<typeof original>["apply"]> = (target, receiver, args) => {
45
+ assertUnitIoAllowed(`${name}.${key}`, apply);
46
+ return runAllowed(() => Reflect.apply(target, receiver, args));
47
+ };
48
+ const construct: NonNullable<ProxyHandler<typeof original>["construct"]> = (target, args, newTarget) => {
49
+ assertUnitIoAllowed(`${name}.${key}`, construct);
50
+ return runAllowed(() => Reflect.construct(target, args, newTarget));
51
+ };
52
+ Object.defineProperty(module, key, {
53
+ configurable: true,
54
+ enumerable: true,
55
+ value: new Proxy(original, { apply, construct }),
56
+ writable: true,
57
+ });
58
+ }
59
+ }
60
+
61
+ if (Reflect.get(globalThis, installed) !== true) {
62
+ Reflect.set(globalThis, installed, true);
63
+ for (const name of ["node:fs", "node:fs/promises", "node:net", "node:http", "node:https", "node:child_process", "node:worker_threads"]) {
64
+ guardModule(name);
65
+ }
66
+ const fetch = globalThis.fetch;
67
+ globalThis.fetch = function unitIoFetch(...args) {
68
+ assertUnitIoAllowed("fetch", unitIoFetch);
69
+ return fetch(...args);
70
+ };
71
+ syncBuiltinESMExports();
72
+ }