@t09tanaka/test-gates 0.0.0-stage → 0.2.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.
@@ -0,0 +1,26 @@
1
+ interface VitestGatesOptions {
2
+ /** Directory of test-gates.json: `__dirname`, or `import.meta.url` of the config file. */
3
+ rootDir?: string;
4
+ /** `test` options of Vitest. `include` and `coverage.include/thresholds/enabled` are owned by the helper. */
5
+ test?: Record<string, unknown> & {
6
+ coverage?: Record<string, unknown>;
7
+ };
8
+ /**
9
+ * Coverage provider. Default `v8`. `istanbul` (needs `@vitest/coverage-istanbul`) also counts
10
+ * the skipped side of an `if` without `else` on Vitest 3, and an unused default parameter on
11
+ * every version; see the README for the measurements. Same as `test.coverage.provider`.
12
+ */
13
+ coverageProvider?: 'v8' | 'istanbul';
14
+ /** Any other Vite option: resolve.alias, esbuild, plugins, … */
15
+ [option: string]: unknown;
16
+ }
17
+ /**
18
+ * Vitest config for the gate run: only the specs of the gates, only the gates measured, every
19
+ * gate at 100% on all four metrics (`thresholds.perFile`).
20
+ *
21
+ * Glob or path keys are deliberately not used in `thresholds`: Vitest passes when such a key
22
+ * matches no file. Pass the result to `defineConfig`.
23
+ */
24
+ declare function createVitestGatesConfig(options?: VitestGatesOptions): Record<string, unknown>;
25
+
26
+ export { type VitestGatesOptions, createVitestGatesConfig };
@@ -0,0 +1,26 @@
1
+ interface VitestGatesOptions {
2
+ /** Directory of test-gates.json: `__dirname`, or `import.meta.url` of the config file. */
3
+ rootDir?: string;
4
+ /** `test` options of Vitest. `include` and `coverage.include/thresholds/enabled` are owned by the helper. */
5
+ test?: Record<string, unknown> & {
6
+ coverage?: Record<string, unknown>;
7
+ };
8
+ /**
9
+ * Coverage provider. Default `v8`. `istanbul` (needs `@vitest/coverage-istanbul`) also counts
10
+ * the skipped side of an `if` without `else` on Vitest 3, and an unused default parameter on
11
+ * every version; see the README for the measurements. Same as `test.coverage.provider`.
12
+ */
13
+ coverageProvider?: 'v8' | 'istanbul';
14
+ /** Any other Vite option: resolve.alias, esbuild, plugins, … */
15
+ [option: string]: unknown;
16
+ }
17
+ /**
18
+ * Vitest config for the gate run: only the specs of the gates, only the gates measured, every
19
+ * gate at 100% on all four metrics (`thresholds.perFile`).
20
+ *
21
+ * Glob or path keys are deliberately not used in `thresholds`: Vitest passes when such a key
22
+ * matches no file. Pass the result to `defineConfig`.
23
+ */
24
+ declare function createVitestGatesConfig(options?: VitestGatesOptions): Record<string, unknown>;
25
+
26
+ export { type VitestGatesOptions, createVitestGatesConfig };
package/dist/vitest.js ADDED
@@ -0,0 +1,43 @@
1
+ import {
2
+ FULL_COVERAGE,
3
+ resolveRootDir
4
+ } from "./chunk-4HAJIKDZ.js";
5
+ import {
6
+ NO_SPEC_PATTERN,
7
+ gateSpecs,
8
+ loadGates
9
+ } from "./chunk-XEKQKLKY.js";
10
+
11
+ // src/vitest.ts
12
+ function createVitestGatesConfig(options = {}) {
13
+ const { rootDir: rootDirOption, coverageProvider, test = {}, ...overrides } = options;
14
+ const rootDir = resolveRootDir(rootDirOption);
15
+ const manifest = loadGates(rootDir);
16
+ const { coverage = {}, ...testOverrides } = test;
17
+ const specs = gateSpecs(manifest);
18
+ return {
19
+ ...overrides,
20
+ root: rootDir,
21
+ test: {
22
+ environment: "node",
23
+ ...testOverrides,
24
+ include: specs.length > 0 ? specs : [NO_SPEC_PATTERN],
25
+ // `test-gates selfcheck` leaves out the only spec of a single-gate project; the run must
26
+ // then fail on the coverage threshold, not on "no test files found".
27
+ passWithNoTests: true,
28
+ coverage: {
29
+ provider: "v8",
30
+ reporter: ["text"],
31
+ reportsDirectory: "coverage/gates",
32
+ ...coverage,
33
+ ...coverageProvider === void 0 ? {} : { provider: coverageProvider },
34
+ enabled: true,
35
+ include: manifest.gates.map((gate) => gate.path),
36
+ thresholds: { perFile: true, ...FULL_COVERAGE }
37
+ }
38
+ }
39
+ };
40
+ }
41
+ export {
42
+ createVitestGatesConfig
43
+ };
package/package.json CHANGED
@@ -1,6 +1,140 @@
1
1
  {
2
2
  "name": "@t09tanaka/test-gates",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Test gates for the code that must not be wrong: per-file 100% coverage as the floor, mutation testing with a per-mutant allow list as the real check.",
5
+ "author": "t09tanaka",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/t09tanaka/test-gates.git"
10
+ },
11
+ "homepage": "https://github.com/t09tanaka/test-gates#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/t09tanaka/test-gates/issues"
14
+ },
15
+ "keywords": [
16
+ "test",
17
+ "coverage",
18
+ "mutation-testing",
19
+ "stryker",
20
+ "jest",
21
+ "vitest",
22
+ "lcov",
23
+ "quality-gate",
24
+ "cli"
25
+ ],
26
+ "type": "module",
27
+ "bin": {
28
+ "test-gates": "dist/cli.js"
29
+ },
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "import": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "require": {
40
+ "types": "./dist/index.d.cts",
41
+ "default": "./dist/index.cjs"
42
+ }
43
+ },
44
+ "./jest": {
45
+ "import": {
46
+ "types": "./dist/jest.d.ts",
47
+ "default": "./dist/jest.js"
48
+ },
49
+ "require": {
50
+ "types": "./dist/jest.d.cts",
51
+ "default": "./dist/jest.cjs"
52
+ }
53
+ },
54
+ "./vitest": {
55
+ "import": {
56
+ "types": "./dist/vitest.d.ts",
57
+ "default": "./dist/vitest.js"
58
+ },
59
+ "require": {
60
+ "types": "./dist/vitest.d.cts",
61
+ "default": "./dist/vitest.cjs"
62
+ }
63
+ },
64
+ "./stryker": {
65
+ "import": {
66
+ "types": "./dist/stryker.d.ts",
67
+ "default": "./dist/stryker.js"
68
+ },
69
+ "require": {
70
+ "types": "./dist/stryker.d.cts",
71
+ "default": "./dist/stryker.cjs"
72
+ }
73
+ },
74
+ "./schema.json": "./schema/test-gates.schema.json",
75
+ "./package.json": "./package.json"
76
+ },
77
+ "files": [
78
+ "dist",
79
+ "schema",
80
+ "CHANGELOG.md"
81
+ ],
82
+ "engines": {
83
+ "node": ">=20"
84
+ },
85
+ "publishConfig": {
86
+ "access": "public"
87
+ },
88
+ "scripts": {
89
+ "build": "tsup",
90
+ "typecheck": "tsc --noEmit",
91
+ "lint": "eslint src test",
92
+ "lint:fix": "eslint src test --fix",
93
+ "format": "prettier --write .",
94
+ "format:check": "prettier --check .",
95
+ "pretest": "npm run build",
96
+ "test": "vitest run",
97
+ "test:watch": "vitest",
98
+ "test:coverage": "vitest run --coverage",
99
+ "test:gates": "node dist/cli.js check && vitest run --config vitest.gates.config.ts",
100
+ "test:gates:selfcheck": "node dist/cli.js selfcheck",
101
+ "test:gates:mutation": "node dist/cli.js mutation",
102
+ "prepublishOnly": "npm run lint && npm run format:check && npm run typecheck && npm test && npm run test:gates && npm run test:gates:selfcheck"
103
+ },
104
+ "peerDependencies": {
105
+ "@stryker-mutator/core": ">=9",
106
+ "jest": ">=29",
107
+ "vitest": ">=3",
108
+ "@vitest/coverage-istanbul": ">=3"
109
+ },
110
+ "peerDependenciesMeta": {
111
+ "@stryker-mutator/core": {
112
+ "optional": true
113
+ },
114
+ "jest": {
115
+ "optional": true
116
+ },
117
+ "vitest": {
118
+ "optional": true
119
+ },
120
+ "@vitest/coverage-istanbul": {
121
+ "optional": true
122
+ }
123
+ },
124
+ "devDependencies": {
125
+ "@eslint/js": "^9.39.5",
126
+ "@stryker-mutator/core": "^10.0.0",
127
+ "@stryker-mutator/vitest-runner": "^10.0.0",
128
+ "@types/node": "^22.20.5",
129
+ "@vitest/coverage-istanbul": "^3.2.7",
130
+ "@vitest/coverage-v8": "^3.2.7",
131
+ "eslint": "^9.39.5",
132
+ "eslint-config-prettier": "^10.1.8",
133
+ "eslint-plugin-prettier": "^5.5.6",
134
+ "prettier": "^3.9.9",
135
+ "tsup": "^8.5.1",
136
+ "typescript": "^5.9.3",
137
+ "typescript-eslint": "^8.71.1",
138
+ "vitest": "^3.2.7"
139
+ }
140
+ }
@@ -0,0 +1,409 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/t09tanaka/test-gates/blob/main/schema/test-gates.schema.json",
4
+ "title": "test-gates.json",
5
+ "description": "The single source of truth for the test gates of one subproject (@t09tanaka/test-gates).",
6
+ "type": "object",
7
+ "required": ["gates"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "$schema": {
11
+ "type": "string"
12
+ },
13
+ "gates": {
14
+ "description": "Pure modules that hold an important decision. Each must reach 100% on all four coverage metrics and leave no surviving mutant outside its allow list.",
15
+ "type": "array",
16
+ "minItems": 1,
17
+ "items": {
18
+ "$ref": "#/definitions/gate"
19
+ }
20
+ },
21
+ "candidates": {
22
+ "description": "Important logic that cannot be a gate yet. Nothing is enforced for these beyond their existence.",
23
+ "type": "array",
24
+ "items": {
25
+ "$ref": "#/definitions/candidate"
26
+ }
27
+ },
28
+ "settings": {
29
+ "$ref": "#/definitions/settings"
30
+ }
31
+ },
32
+ "definitions": {
33
+ "nonEmptyString": {
34
+ "type": "string",
35
+ "minLength": 1,
36
+ "pattern": "\\S"
37
+ },
38
+ "gate": {
39
+ "type": "object",
40
+ "required": ["path", "decides", "impact"],
41
+ "additionalProperties": true,
42
+ "properties": {
43
+ "path": {
44
+ "description": "Path of the module, relative to the subproject, with forward slashes.",
45
+ "$ref": "#/definitions/nonEmptyString"
46
+ },
47
+ "decides": {
48
+ "description": "What this module decides.",
49
+ "$ref": "#/definitions/nonEmptyString"
50
+ },
51
+ "impact": {
52
+ "description": "What goes wrong for users or the business when the decision is wrong.",
53
+ "$ref": "#/definitions/nonEmptyString"
54
+ },
55
+ "equivalentMutants": {
56
+ "description": "Surviving mutants that are allowed because they cannot be observed from outside. One entry allows exactly one mutant.",
57
+ "type": "array",
58
+ "items": {
59
+ "$ref": "#/definitions/equivalentMutant"
60
+ }
61
+ },
62
+ "expectedTimeouts": {
63
+ "description": "Since 0.2.0. Mutants that can never finish (a loop that no longer ends) and therefore always time out. Same key as equivalentMutants. They are not counted against settings.mutation.maxTimeouts. An entry whose mutant survives, or does not exist, fails the run; one that is killed is accepted.",
64
+ "type": "array",
65
+ "items": {
66
+ "$ref": "#/definitions/equivalentMutant"
67
+ }
68
+ }
69
+ }
70
+ },
71
+ "equivalentMutant": {
72
+ "type": "object",
73
+ "required": ["mutator", "original", "replacement", "reason"],
74
+ "additionalProperties": false,
75
+ "properties": {
76
+ "mutator": {
77
+ "description": "Stryker mutator name, e.g. ConditionalExpression.",
78
+ "$ref": "#/definitions/nonEmptyString"
79
+ },
80
+ "original": {
81
+ "description": "The source text the mutant replaces. Whitespace differences are ignored.",
82
+ "$ref": "#/definitions/nonEmptyString"
83
+ },
84
+ "replacement": {
85
+ "description": "The text Stryker puts in its place. May be empty.",
86
+ "type": "string"
87
+ },
88
+ "reason": {
89
+ "description": "Why no input can make the mutant observable.",
90
+ "$ref": "#/definitions/nonEmptyString"
91
+ },
92
+ "occurrence": {
93
+ "description": "1-based index among the mutants of the file with the same mutator, original and replacement. Required when there is more than one.",
94
+ "type": "integer",
95
+ "minimum": 1
96
+ }
97
+ }
98
+ },
99
+ "candidate": {
100
+ "type": "object",
101
+ "required": ["path", "decides", "blocker"],
102
+ "additionalProperties": true,
103
+ "properties": {
104
+ "path": {
105
+ "description": "File or directory, relative to the subproject.",
106
+ "$ref": "#/definitions/nonEmptyString"
107
+ },
108
+ "decides": {
109
+ "$ref": "#/definitions/nonEmptyString"
110
+ },
111
+ "blocker": {
112
+ "description": "What has to happen before this can become a gate.",
113
+ "$ref": "#/definitions/nonEmptyString"
114
+ }
115
+ }
116
+ },
117
+ "patternRule": {
118
+ "description": "A regular expression, either as a string or with flags and a reason.",
119
+ "oneOf": [
120
+ {
121
+ "$ref": "#/definitions/nonEmptyString"
122
+ },
123
+ {
124
+ "type": "object",
125
+ "required": ["pattern"],
126
+ "additionalProperties": false,
127
+ "properties": {
128
+ "pattern": {
129
+ "$ref": "#/definitions/nonEmptyString"
130
+ },
131
+ "flags": {
132
+ "type": "string"
133
+ },
134
+ "reason": {
135
+ "type": "string"
136
+ }
137
+ }
138
+ }
139
+ ]
140
+ },
141
+ "ruleList": {
142
+ "type": "object",
143
+ "additionalProperties": false,
144
+ "properties": {
145
+ "defaults": {
146
+ "description": "Set to false to drop the built-in rules.",
147
+ "type": "boolean",
148
+ "default": true
149
+ },
150
+ "add": {
151
+ "type": "array",
152
+ "items": {
153
+ "$ref": "#/definitions/patternRule"
154
+ }
155
+ }
156
+ }
157
+ },
158
+ "settings": {
159
+ "description": "Per-project conventions. Everything is optional.",
160
+ "type": "object",
161
+ "additionalProperties": false,
162
+ "properties": {
163
+ "gateExtensions": {
164
+ "description": "File extensions a gate may have.",
165
+ "type": "array",
166
+ "minItems": 1,
167
+ "items": {
168
+ "type": "string",
169
+ "pattern": "^\\."
170
+ },
171
+ "default": [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"]
172
+ },
173
+ "spec": {
174
+ "type": "object",
175
+ "additionalProperties": false,
176
+ "properties": {
177
+ "suffixes": {
178
+ "description": "Replaces the gate's extension to form the spec path. Default: \".spec<ext>\" and \".test<ext>\". An empty array means the project has no spec convention.",
179
+ "type": "array",
180
+ "items": {
181
+ "$ref": "#/definitions/nonEmptyString"
182
+ }
183
+ },
184
+ "rewrite": {
185
+ "description": "Regular expression replacements applied to the gate path before the suffix, for specs that live in another directory.",
186
+ "type": "array",
187
+ "items": {
188
+ "type": "object",
189
+ "required": ["from", "to"],
190
+ "additionalProperties": false,
191
+ "properties": {
192
+ "from": {
193
+ "$ref": "#/definitions/nonEmptyString"
194
+ },
195
+ "to": {
196
+ "type": "string"
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+ },
203
+ "impureImports": {
204
+ "description": "Modules a gate must not import at runtime (regular expressions tested against the module specifier). `import type` is never checked.",
205
+ "type": "object",
206
+ "additionalProperties": false,
207
+ "properties": {
208
+ "defaults": {
209
+ "type": "boolean",
210
+ "default": true
211
+ },
212
+ "add": {
213
+ "type": "array",
214
+ "items": {
215
+ "$ref": "#/definitions/patternRule"
216
+ }
217
+ },
218
+ "allow": {
219
+ "description": "Module specifiers exempt from every rule (regular expressions).",
220
+ "type": "array",
221
+ "items": {
222
+ "$ref": "#/definitions/nonEmptyString"
223
+ }
224
+ }
225
+ }
226
+ },
227
+ "impureNamedImports": {
228
+ "description": "Names a gate must not import from a module that is otherwise fine. Default: PrismaClient from @prisma/client.",
229
+ "type": "object",
230
+ "additionalProperties": false,
231
+ "properties": {
232
+ "defaults": {
233
+ "type": "boolean",
234
+ "default": true
235
+ },
236
+ "add": {
237
+ "type": "array",
238
+ "items": {
239
+ "type": "object",
240
+ "required": ["module", "names"],
241
+ "additionalProperties": false,
242
+ "properties": {
243
+ "module": {
244
+ "$ref": "#/definitions/nonEmptyString"
245
+ },
246
+ "names": {
247
+ "type": "array",
248
+ "minItems": 1,
249
+ "items": {
250
+ "$ref": "#/definitions/nonEmptyString"
251
+ }
252
+ },
253
+ "reason": {
254
+ "type": "string"
255
+ }
256
+ }
257
+ }
258
+ }
259
+ }
260
+ },
261
+ "impurePaths": {
262
+ "description": "Gate paths that are rejected (regular expressions). Default: components and declaration files.",
263
+ "$ref": "#/definitions/ruleList"
264
+ },
265
+ "forbiddenSource": {
266
+ "description": "Lines that must not appear in a gate (regular expressions, tested one line at a time). Default: 'use client' / 'use server'.",
267
+ "$ref": "#/definitions/ruleList"
268
+ },
269
+ "importAliases": {
270
+ "description": "Import aliases, used to recognise a spec that mocks the module under test.",
271
+ "type": "array",
272
+ "items": {
273
+ "type": "object",
274
+ "required": ["prefix", "target"],
275
+ "additionalProperties": false,
276
+ "properties": {
277
+ "prefix": {
278
+ "$ref": "#/definitions/nonEmptyString"
279
+ },
280
+ "target": {
281
+ "type": "string"
282
+ }
283
+ }
284
+ }
285
+ },
286
+ "gateCommand": {
287
+ "description": "The command `test-gates selfcheck` runs. Default: `jest --config jest.gates.config.*` or `vitest run --config vitest.gates.config.*`, whichever config exists.",
288
+ "type": "array",
289
+ "minItems": 1,
290
+ "items": {
291
+ "$ref": "#/definitions/nonEmptyString"
292
+ }
293
+ },
294
+ "selfcheck": {
295
+ "type": "object",
296
+ "additionalProperties": false,
297
+ "properties": {
298
+ "mode": {
299
+ "description": "Run the negative control for every gate, or only for the first.",
300
+ "enum": ["all", "first"],
301
+ "default": "all"
302
+ },
303
+ "failurePattern": {
304
+ "description": "Regular expression the output of the failing gate run must match. `{gate}` stands for the gate path. Default: a coverage threshold message on a line that names the gate.",
305
+ "$ref": "#/definitions/nonEmptyString"
306
+ }
307
+ }
308
+ },
309
+ "stryker": {
310
+ "type": "object",
311
+ "additionalProperties": false,
312
+ "properties": {
313
+ "configFile": {
314
+ "description": "Default: stryker.gates.config.mjs (.js / .cjs / .json).",
315
+ "$ref": "#/definitions/nonEmptyString"
316
+ },
317
+ "reportFile": {
318
+ "description": "Where the Stryker JSON report is written and read.",
319
+ "$ref": "#/definitions/nonEmptyString",
320
+ "default": "reports/mutation/mutation.json"
321
+ }
322
+ }
323
+ },
324
+ "lcov": {
325
+ "type": "object",
326
+ "additionalProperties": false,
327
+ "properties": {
328
+ "file": {
329
+ "description": "Tracefile for `test-gates lcov` when --file is not given.",
330
+ "$ref": "#/definitions/nonEmptyString"
331
+ },
332
+ "summaryExclude": {
333
+ "description": "Paths left out of the reference total (regular expressions).",
334
+ "type": "array",
335
+ "items": {
336
+ "$ref": "#/definitions/nonEmptyString"
337
+ }
338
+ }
339
+ }
340
+ },
341
+ "imports": {
342
+ "description": "Since 0.2.0. How runtime imports of a gate are judged.",
343
+ "type": "object",
344
+ "additionalProperties": false,
345
+ "properties": {
346
+ "mode": {
347
+ "description": "blocklist (default): only the impureImports rules apply. allowlist: in addition, a runtime import must be relative, go through an import alias, or be listed in allow; a project import must not point at a file matched by impurePaths; a computed require() / import() is rejected.",
348
+ "enum": ["blocklist", "allowlist"],
349
+ "default": "blocklist"
350
+ },
351
+ "allow": {
352
+ "description": "Packages a gate may import in allowlist mode. A string is a package name (its subpaths included). { pattern } is a regular expression tested against the specifier. { module, names } allows only those named imports.",
353
+ "type": "array",
354
+ "items": {
355
+ "oneOf": [
356
+ {
357
+ "$ref": "#/definitions/nonEmptyString"
358
+ },
359
+ {
360
+ "type": "object",
361
+ "required": ["pattern"],
362
+ "additionalProperties": false,
363
+ "properties": {
364
+ "pattern": {
365
+ "$ref": "#/definitions/nonEmptyString"
366
+ },
367
+ "flags": {
368
+ "type": "string"
369
+ }
370
+ }
371
+ },
372
+ {
373
+ "type": "object",
374
+ "required": ["module", "names"],
375
+ "additionalProperties": false,
376
+ "properties": {
377
+ "module": {
378
+ "$ref": "#/definitions/nonEmptyString"
379
+ },
380
+ "names": {
381
+ "type": "array",
382
+ "minItems": 1,
383
+ "items": {
384
+ "$ref": "#/definitions/nonEmptyString"
385
+ }
386
+ }
387
+ }
388
+ }
389
+ ]
390
+ }
391
+ }
392
+ }
393
+ },
394
+ "mutation": {
395
+ "description": "Since 0.2.0.",
396
+ "type": "object",
397
+ "additionalProperties": false,
398
+ "properties": {
399
+ "maxTimeouts": {
400
+ "description": "How many timed-out mutants outside expectedTimeouts are tolerated. Above it the run fails. Not set: no limit.",
401
+ "type": "integer",
402
+ "minimum": 0
403
+ }
404
+ }
405
+ }
406
+ }
407
+ }
408
+ }
409
+ }