@systemfsoftware/oxlint-plugin-test-discipline 3.8.1 → 4.0.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 +12 -0
- package/README.md +18 -14
- package/dist/index.d.ts +3 -0
- package/dist/index.mjs +579 -177
- package/dist/oxlint-plugin-test-discipline-untrimmed.d.ts +3 -0
- package/dist/oxlint-plugin-test-discipline.d.ts +3 -0
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -8,7 +8,7 @@ const EMIT_CALLEES = [
|
|
|
8
8
|
];
|
|
9
9
|
const EXPECTED$3 = "a span declared once with Span.declare({ id, name, attrs }) and started through the start(attrs) it returns";
|
|
10
10
|
const FIX$3 = "declare the span with Span.declare({ id, name, attrs }) and emit it with SomeSpan.start(attrs)";
|
|
11
|
-
const meta$
|
|
11
|
+
const meta$28 = {
|
|
12
12
|
type: "problem",
|
|
13
13
|
docs: { description: "Ban a raw span name passed to an emit API (startSpan, spanBuilder, startActiveSpan). Span names enter through Span.declare, never as a literal at the emit site." },
|
|
14
14
|
schema: [],
|
|
@@ -18,14 +18,14 @@ const meta$25 = {
|
|
|
18
18
|
//#region src/rules/ban-raw-span-name-emit.ts
|
|
19
19
|
const INTERPOLATION = "${}";
|
|
20
20
|
const isEmitCallee$1 = (name) => EMIT_CALLEES.some((emit) => emit === name);
|
|
21
|
-
const memberPropertyName = (callee) => {
|
|
21
|
+
const memberPropertyName$1 = (callee) => {
|
|
22
22
|
if (callee.computed) return null;
|
|
23
23
|
if (callee.property.type !== "Identifier") return null;
|
|
24
24
|
return callee.property.name;
|
|
25
25
|
};
|
|
26
|
-
const calleeName$
|
|
26
|
+
const calleeName$2 = (callee) => {
|
|
27
27
|
if (callee.type === "Identifier") return callee.name;
|
|
28
|
-
if (callee.type === "MemberExpression") return memberPropertyName(callee);
|
|
28
|
+
if (callee.type === "MemberExpression") return memberPropertyName$1(callee);
|
|
29
29
|
return null;
|
|
30
30
|
};
|
|
31
31
|
const templateName = (node) => node.quasis.map((quasi) => quasi.value.raw).join(INTERPOLATION);
|
|
@@ -39,7 +39,7 @@ const argName = (arg) => {
|
|
|
39
39
|
return inlineName(arg);
|
|
40
40
|
};
|
|
41
41
|
const inspect = (node) => {
|
|
42
|
-
const callee = calleeName$
|
|
42
|
+
const callee = calleeName$2(node.callee);
|
|
43
43
|
if (callee === null || !isEmitCallee$1(callee)) return null;
|
|
44
44
|
const name = argName(node.arguments[0]);
|
|
45
45
|
if (name === null) return null;
|
|
@@ -49,7 +49,7 @@ const inspect = (node) => {
|
|
|
49
49
|
};
|
|
50
50
|
};
|
|
51
51
|
const banRawSpanNameEmit = defineRule({
|
|
52
|
-
meta: meta$
|
|
52
|
+
meta: meta$28,
|
|
53
53
|
create(context) {
|
|
54
54
|
return { CallExpression(node) {
|
|
55
55
|
const emitCall = inspect(node);
|
|
@@ -71,6 +71,7 @@ const banRawSpanNameEmit = defineRule({
|
|
|
71
71
|
//#endregion
|
|
72
72
|
//#region src/rules/path.config.ts
|
|
73
73
|
const TEST_BASENAME = /\.(?:test|spec)\.[cm]?tsx?$/;
|
|
74
|
+
const TYPE_TEST_BASENAME = /\.tst\.[cm]?tsx?$/;
|
|
74
75
|
const SANCTIONED_TEST_DIRS = /* @__PURE__ */ new Set(["tests"]);
|
|
75
76
|
/**
|
|
76
77
|
* The only test location sanctioned under `src/`. A workflow property test
|
|
@@ -80,7 +81,6 @@ const SANCTIONED_TEST_DIRS = /* @__PURE__ */ new Set(["tests"]);
|
|
|
80
81
|
const NESTED_TEST_DIR = "__tests__";
|
|
81
82
|
const TEST_TREE_DIRS = /* @__PURE__ */ new Set([...SANCTIONED_TEST_DIRS, NESTED_TEST_DIR]);
|
|
82
83
|
const PROPERTY_SUFFIX = ".property.test.ts";
|
|
83
|
-
const INTEGRATION_SUFFIX = ".integration.test.ts";
|
|
84
84
|
const DIFFERENTIAL_SUFFIX = ".differential.test.ts";
|
|
85
85
|
/**
|
|
86
86
|
* Forbidden outright. A schema's laws are generated, so a hand-written
|
|
@@ -97,26 +97,42 @@ const SCHEMA_SUFFIX = ".schema.test.ts";
|
|
|
97
97
|
const WORKFLOW_TEST_BASENAME = /^[^.]+\.workflow\.property\.test\.ts$/;
|
|
98
98
|
const GHERKIN_PACKAGE = "@systemfsoftware/effect-gherkin-spec";
|
|
99
99
|
const DIFFERENTIAL_PACKAGE = "@systemfsoftware/differential-spec";
|
|
100
|
+
const CONFORMANCE_PACKAGE = "@systemfsoftware/conformance-spec";
|
|
100
101
|
const TRACE_SPEC_PACKAGE = "@systemfsoftware/trace-spec";
|
|
101
|
-
const
|
|
102
|
+
const LAWFUL_RUNNER = "@systemfsoftware/vitest";
|
|
103
|
+
const FOREIGN_VITEST_SOURCES = {
|
|
104
|
+
"vitest": true,
|
|
105
|
+
"@effect/vitest": true
|
|
106
|
+
};
|
|
107
|
+
/** Runners a harness-owned test (Gherkin, conformance, differential) must not import directly. */
|
|
108
|
+
const FOREIGN_RUNNERS = {
|
|
109
|
+
...FOREIGN_VITEST_SOURCES,
|
|
110
|
+
[LAWFUL_RUNNER]: true
|
|
111
|
+
};
|
|
112
|
+
/** Packages whose own tests drive Vitest directly: the runner and its conformance harness. */
|
|
113
|
+
const RAW_VITEST_PACKAGES = {
|
|
114
|
+
"effect-spec-runtime": true,
|
|
115
|
+
"vitest": true,
|
|
116
|
+
"vitest-conformance": true
|
|
117
|
+
};
|
|
102
118
|
const RUNNER_NAMES = /* @__PURE__ */ new Set([
|
|
103
119
|
"it",
|
|
104
120
|
"test",
|
|
105
121
|
"describe"
|
|
106
122
|
]);
|
|
107
|
-
const MESSAGE$
|
|
123
|
+
const MESSAGE$8 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
108
124
|
const ABSENCE_MESSAGE = "{{name}} is untested. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
109
125
|
//#endregion
|
|
110
126
|
//#region src/rules/behaviour-exercises-use-case.config.ts
|
|
111
127
|
const NO_SUBJECT_IMPORT_NAME = "a *.integration.test.ts that reaches no package code";
|
|
112
128
|
const NO_SUBJECT_IMPORT_EXPECTED = "an import of the package code under test";
|
|
113
|
-
const NO_SUBJECT_IMPORT_ACTUAL = "a behaviour file whose every runtime import is vitest, @effect/vitest, the gherkin spec package, effect, a Node builtin, or the file itself";
|
|
129
|
+
const NO_SUBJECT_IMPORT_ACTUAL = "a behaviour file whose every runtime import is vitest, @effect/vitest, @systemfsoftware/vitest, the gherkin spec package, effect, a Node builtin, or the file itself";
|
|
114
130
|
const NO_SUBJECT_IMPORT_FIX = "a behaviour test exercises a use case, so it has to reach the package. A file that imports nothing but its runner and effect is asserting over values it built in the same file. Ask whether the assertion tests anything at all: if it restates a literal, delete the scenario; if it states an invariant that holds over generated inputs, move it to a *.property.test.ts beside the cell that decides it. Type-only imports never count - they are erased before anything runs - while a side-effect import (import \"../src/x.js\", import {} from \"../src/x.js\") does count, because it executes that module.";
|
|
115
|
-
const meta$
|
|
131
|
+
const meta$27 = {
|
|
116
132
|
type: "problem",
|
|
117
133
|
docs: { description: "A *.integration.test.ts must import the package under test, not only its runner and effect, so the scenario exercises code that ships rather than values the test built itself." },
|
|
118
134
|
schema: [],
|
|
119
|
-
messages: { noSubjectImport: MESSAGE$
|
|
135
|
+
messages: { noSubjectImport: MESSAGE$8 }
|
|
120
136
|
};
|
|
121
137
|
//#endregion
|
|
122
138
|
//#region src/rules/path.ts
|
|
@@ -126,6 +142,7 @@ const basenameOf = (filename) => Array$1.lastNonEmpty(segmentsOf(filename));
|
|
|
126
142
|
/** Directory segments only — the basename never counts as a directory. */
|
|
127
143
|
const directoriesOf = (filename) => Array$1.initNonEmpty(segmentsOf(filename));
|
|
128
144
|
const isUnderSrc = (filename) => directoriesOf(filename).includes("src");
|
|
145
|
+
const isRawVitestPackage = (filename) => directoriesOf(filename).some((segment) => RAW_VITEST_PACKAGES[segment] === true);
|
|
129
146
|
const isInSanctionedTestDir = (filename) => directoriesOf(filename).some((segment) => SANCTIONED_TEST_DIRS.has(segment));
|
|
130
147
|
const isInTestsImportScope = (filename) => {
|
|
131
148
|
const segments = segmentsOf(filename);
|
|
@@ -135,13 +152,14 @@ const isInTestsImportScope = (filename) => {
|
|
|
135
152
|
};
|
|
136
153
|
const isInConfiguredTestDir = (filename, dirs) => directoriesOf(filename).some((segment) => dirs.includes(segment));
|
|
137
154
|
const isTestFile = (basename) => TEST_BASENAME.test(basename);
|
|
155
|
+
const isBehaviourBasename = (basename) => basename.endsWith(".integration.test.ts") || basename.endsWith(".contract.test.ts");
|
|
138
156
|
const CELL_SUFFIX = /\.([^.]+)\.[cm]?tsx?$/;
|
|
139
157
|
const cellOf = (basename) => CELL_SUFFIX.exec(basename)?.[1];
|
|
140
158
|
//#endregion
|
|
141
159
|
//#region src/rules/behaviour-exercises-use-case.ts
|
|
142
160
|
const EFFECT_PACKAGE = "effect";
|
|
143
161
|
const FOUNDATION_PACKAGES = /* @__PURE__ */ new Set([
|
|
144
|
-
...FOREIGN_RUNNERS,
|
|
162
|
+
...Object.keys(FOREIGN_RUNNERS),
|
|
145
163
|
GHERKIN_PACKAGE,
|
|
146
164
|
EFFECT_PACKAGE
|
|
147
165
|
]);
|
|
@@ -164,7 +182,6 @@ const isFoundationImport = (source, filename) => {
|
|
|
164
182
|
if (source === "@systemfsoftware/effect-gherkin-spec" && isGherkinPackageTree(filename)) return false;
|
|
165
183
|
return FOUNDATION_PACKAGES.has(source) || source.startsWith(`${EFFECT_PACKAGE}/`) || source.startsWith("node:");
|
|
166
184
|
};
|
|
167
|
-
const isBehaviourTest$3 = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
|
|
168
185
|
/** The stem of a path with its final extension stripped, for identity comparisons. */
|
|
169
186
|
const stemOf = (file) => file.replace(/\.[^/]+$/, "");
|
|
170
187
|
/**
|
|
@@ -216,16 +233,17 @@ const DIST_SEGMENT = /(?:^|\/)dist\//;
|
|
|
216
233
|
* name documents it for the reader without pretending to be evidence.
|
|
217
234
|
*
|
|
218
235
|
* What counts as reaching: a runtime import from anything that is not the
|
|
219
|
-
* scaffolding - vitest, @effect/vitest, the gherkin
|
|
220
|
-
* subpath of it, or a Node builtin - and not the test
|
|
221
|
-
* `import(...)` of such a source. Type-only
|
|
222
|
-
* count; a side-effect import (`import
|
|
223
|
-
* executes its module and counts for
|
|
224
|
-
*
|
|
225
|
-
*
|
|
236
|
+
* scaffolding - vitest, @effect/vitest, @systemfsoftware/vitest, the gherkin
|
|
237
|
+
* spec package, effect or a subpath of it, or a Node builtin - and not the test
|
|
238
|
+
* file itself, or a dynamic `import(...)` of such a source. Type-only
|
|
239
|
+
* specifiers are erased and never count; a side-effect import (`import
|
|
240
|
+
* "./x.js"`, `import {} from "./x.js"`) executes its module and counts for
|
|
241
|
+
* whatever that module is. Whether a named module actually exists is not
|
|
242
|
+
* observable from one file, so an import whose path names nothing satisfies the
|
|
243
|
+
* rule the same way a real one does.
|
|
226
244
|
*/
|
|
227
245
|
const behaviourExercisesUseCase = defineRule({
|
|
228
|
-
meta: meta$
|
|
246
|
+
meta: meta$27,
|
|
229
247
|
create(context) {
|
|
230
248
|
let reached = false;
|
|
231
249
|
return {
|
|
@@ -240,7 +258,7 @@ const behaviourExercisesUseCase = defineRule({
|
|
|
240
258
|
if (typeof node.value === "string" && DIST_SEGMENT.test(node.value)) reached = true;
|
|
241
259
|
},
|
|
242
260
|
"Program:exit"(node) {
|
|
243
|
-
if (!
|
|
261
|
+
if (!isBehaviourBasename(basenameOf(context.filename))) return;
|
|
244
262
|
if (reached) return;
|
|
245
263
|
for (const statement of node.body) {
|
|
246
264
|
if (statement.type !== "ImportDeclaration") continue;
|
|
@@ -275,13 +293,13 @@ const TOO_MANY_FEATURES_NAME = "a *.integration.test.ts accumulating multiple Fe
|
|
|
275
293
|
const TOO_MANY_FEATURES_EXPECTED = "exactly one Feature(...) — every additional one signals a junk drawer";
|
|
276
294
|
const TOO_MANY_FEATURES_ACTUAL = "a behaviour file with two or more Feature(...) calls";
|
|
277
295
|
const TOO_MANY_FEATURES_FIX = "splitting a junk drawer into several smaller junk drawers is not an improvement. When separating scenarios surfaces assertions that restate a pure function return value — change detectors against a lookup table or constant — those get deleted, not rehoused. Each surviving capability keeps its own file with exactly one Feature.";
|
|
278
|
-
const meta$
|
|
296
|
+
const meta$26 = {
|
|
279
297
|
type: "problem",
|
|
280
298
|
docs: { description: "A *.integration.test.ts must contain exactly one Feature(...) call. Zero or two-or-more is the junk-drawer signal that produced 41 scenarios of pure-function assertions in a single file." },
|
|
281
299
|
schema: [],
|
|
282
300
|
messages: {
|
|
283
|
-
tooFewFeatures: MESSAGE$
|
|
284
|
-
tooManyFeatures: MESSAGE$
|
|
301
|
+
tooFewFeatures: MESSAGE$8,
|
|
302
|
+
tooManyFeatures: MESSAGE$8
|
|
285
303
|
}
|
|
286
304
|
};
|
|
287
305
|
//#endregion
|
|
@@ -300,7 +318,6 @@ const isFeatureCallStatement = (statement) => {
|
|
|
300
318
|
if (statement.expression.type !== "CallExpression") return false;
|
|
301
319
|
return rootCalleeName(statement.expression.callee) === "Feature";
|
|
302
320
|
};
|
|
303
|
-
const isBehaviourTest$2 = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
|
|
304
321
|
const findSecondFeatureCall = (program) => {
|
|
305
322
|
let seen = 0;
|
|
306
323
|
for (const statement of program.body) {
|
|
@@ -320,10 +337,10 @@ const hasAnyFeatureCall = (program) => {
|
|
|
320
337
|
return false;
|
|
321
338
|
};
|
|
322
339
|
const behaviourOneFeaturePerFile = defineRule({
|
|
323
|
-
meta: meta$
|
|
340
|
+
meta: meta$26,
|
|
324
341
|
create(context) {
|
|
325
342
|
return { "Program:exit"(node) {
|
|
326
|
-
if (!
|
|
343
|
+
if (!isBehaviourBasename(basenameOf(context.filename))) return;
|
|
327
344
|
const excess = findSecondFeatureCall(node);
|
|
328
345
|
if (excess !== null) {
|
|
329
346
|
const total = countFeatureCalls(node);
|
|
@@ -355,19 +372,19 @@ const behaviourOneFeaturePerFile = defineRule({
|
|
|
355
372
|
//#endregion
|
|
356
373
|
//#region src/rules/behaviour-test-requires-gherkin.config.ts
|
|
357
374
|
const FOREIGN_RUNNER_EXPECTED = "it and layer imported from @systemfsoftware/effect-gherkin-spec";
|
|
358
|
-
const FOREIGN_RUNNER_ACTUAL = "a test runner imported directly from vitest or @
|
|
359
|
-
const FOREIGN_RUNNER_FIX = "import { it
|
|
375
|
+
const FOREIGN_RUNNER_ACTUAL = "a test runner imported directly from vitest, @effect/vitest or @systemfsoftware/vitest in a behaviour file";
|
|
376
|
+
const FOREIGN_RUNNER_FIX = "import { it } from @systemfsoftware/effect-gherkin-spec and build the suite with makeFeature({ it })";
|
|
360
377
|
const MISSING_MAKE_FEATURE_NAME = "a *.integration.test.ts without makeFeature";
|
|
361
378
|
const MISSING_MAKE_FEATURE_EXPECTED = "makeFeature imported from @systemfsoftware/effect-gherkin-spec";
|
|
362
379
|
const MISSING_MAKE_FEATURE_ACTUAL = "a behaviour file that never constructs a Gherkin feature";
|
|
363
380
|
const MISSING_MAKE_FEATURE_FIX = "import { makeFeature } from @systemfsoftware/effect-gherkin-spec and declare `const Feature = makeFeature({ it, layer })`";
|
|
364
|
-
const meta$
|
|
381
|
+
const meta$25 = {
|
|
365
382
|
type: "problem",
|
|
366
|
-
docs: { description: "A *.integration.test.ts must drive its suite through makeFeature from @systemfsoftware/effect-gherkin-spec and must not import test runners from vitest or @
|
|
383
|
+
docs: { description: "A *.integration.test.ts must drive its suite through makeFeature from @systemfsoftware/effect-gherkin-spec and must not import test runners from vitest, @effect/vitest or @systemfsoftware/vitest." },
|
|
367
384
|
schema: [],
|
|
368
385
|
messages: {
|
|
369
|
-
foreignRunner: MESSAGE$
|
|
370
|
-
missingMakeFeature: MESSAGE$
|
|
386
|
+
foreignRunner: MESSAGE$8,
|
|
387
|
+
missingMakeFeature: MESSAGE$8
|
|
371
388
|
}
|
|
372
389
|
};
|
|
373
390
|
//#endregion
|
|
@@ -387,13 +404,12 @@ const foreignRunnerNameOf = (specifier) => {
|
|
|
387
404
|
const { name } = Schema.decodeSync(ImportedIdentifier)(specifier.imported);
|
|
388
405
|
return RUNNER_NAMES.has(name) ? name : null;
|
|
389
406
|
};
|
|
390
|
-
const isBehaviourTest$1 = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
|
|
391
407
|
const behaviourTestRequiresGherkin = defineRule({
|
|
392
|
-
meta: meta$
|
|
408
|
+
meta: meta$25,
|
|
393
409
|
create(context) {
|
|
394
410
|
const basename = basenameOf(context.filename);
|
|
395
411
|
return { Program(node) {
|
|
396
|
-
if (!
|
|
412
|
+
if (!isBehaviourBasename(basename)) return;
|
|
397
413
|
let hasMakeFeature = false;
|
|
398
414
|
for (const statement of node.body) {
|
|
399
415
|
if (statement.type !== "ImportDeclaration") continue;
|
|
@@ -402,7 +418,7 @@ const behaviourTestRequiresGherkin = defineRule({
|
|
|
402
418
|
if (specifier.type !== "ImportSpecifier") continue;
|
|
403
419
|
if (isMakeFeatureSpecifier(specifier)) hasMakeFeature = true;
|
|
404
420
|
}
|
|
405
|
-
if (
|
|
421
|
+
if (FOREIGN_RUNNERS[sourceValue] !== true) continue;
|
|
406
422
|
for (const specifier of statement.specifiers) {
|
|
407
423
|
if (specifier.type !== "ImportSpecifier") continue;
|
|
408
424
|
const runnerName = foreignRunnerNameOf(specifier);
|
|
@@ -432,6 +448,145 @@ const behaviourTestRequiresGherkin = defineRule({
|
|
|
432
448
|
} };
|
|
433
449
|
}
|
|
434
450
|
});
|
|
451
|
+
const HARNESS_MEMBERS = {
|
|
452
|
+
linearizable: true,
|
|
453
|
+
sequential: true,
|
|
454
|
+
released: true
|
|
455
|
+
};
|
|
456
|
+
const LEGACY_CHECK_MEMBER = "check";
|
|
457
|
+
const HARNESS_PRESCRIPTION$2 = "import { Conformance } from @systemfsoftware/conformance-spec and express the test as Conformance.linearizable(implementation, { commands, model, run, fibers, operations }) in the scenario body";
|
|
458
|
+
const meta$24 = {
|
|
459
|
+
type: "problem",
|
|
460
|
+
docs: { description: "Conformance tests must use the @systemfsoftware/conformance-spec Conformance barrel: Conformance.linearizable, Conformance.sequential, or Conformance.released. Raw runner calls (it, test, describe, and member forms like it.effect) and direct runner imports are forbidden in *.conformance.test.ts files; the retired Linearizable.check / SequentialModel.check / Released.check shapes are rejected, and importing Conformance without invoking a check is equally non-compliant." },
|
|
461
|
+
schema: [],
|
|
462
|
+
messages: {
|
|
463
|
+
rawRunnerCall: MESSAGE$8,
|
|
464
|
+
runnerImport: MESSAGE$8,
|
|
465
|
+
missingHarnessImport: MESSAGE$8,
|
|
466
|
+
legacyHarnessCall: MESSAGE$8,
|
|
467
|
+
missingHarnessUsage: MESSAGE$8
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/rules/conformance-test-requires-harness.ts
|
|
472
|
+
const memberObjectName$1 = (callee) => callee.type === "MemberExpression" && callee.object.type === "Identifier" ? callee.object.name : void 0;
|
|
473
|
+
const memberPropertyName = (callee) => callee.type === "MemberExpression" && callee.property.type === "Identifier" ? callee.property.name : void 0;
|
|
474
|
+
const memberName = (callee) => callee.type === "Identifier" ? callee.name : memberPropertyName(callee);
|
|
475
|
+
const calleeName$1 = (callee) => callee.type === "Identifier" ? callee.name : memberObjectName$1(callee);
|
|
476
|
+
const isRawRunnerCall = (callee) => {
|
|
477
|
+
const name = calleeName$1(callee);
|
|
478
|
+
return name !== void 0 && RUNNER_NAMES.has(name);
|
|
479
|
+
};
|
|
480
|
+
const isForeignRunnerImport$1 = (node) => typeof node.source.value === "string" && FOREIGN_RUNNERS[node.source.value] === true;
|
|
481
|
+
const namesConformanceExport = (specifier) => specifier.imported.type === "Identifier" && specifier.imported.name === "Conformance";
|
|
482
|
+
const recordBindings = (node, packageBindings, conformanceBindings) => {
|
|
483
|
+
if (node.source.value !== "@systemfsoftware/conformance-spec") return;
|
|
484
|
+
for (const specifier of node.specifiers) {
|
|
485
|
+
packageBindings.add(specifier.local.name);
|
|
486
|
+
if (specifier.type === "ImportSpecifier" && namesConformanceExport(specifier)) conformanceBindings.add(specifier.local.name);
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const isHarnessInvocation = (callee, conformanceBindings) => {
|
|
490
|
+
const member = memberName(callee);
|
|
491
|
+
const root = calleeName$1(callee);
|
|
492
|
+
return member !== void 0 && HARNESS_MEMBERS[member] === true && root !== void 0 && conformanceBindings.has(root);
|
|
493
|
+
};
|
|
494
|
+
const isLegacyCheck = (callee, packageBindings) => {
|
|
495
|
+
const root = calleeName$1(callee);
|
|
496
|
+
return memberName(callee) === "check" && root !== void 0 && packageBindings.has(root);
|
|
497
|
+
};
|
|
498
|
+
const legacyCallText = (callee) => callee.type === "Identifier" ? callee.name : `${calleeName$1(callee)}.${LEGACY_CHECK_MEMBER}`;
|
|
499
|
+
const legacyCheckError = (callee) => ({
|
|
500
|
+
messageId: "legacyHarnessCall",
|
|
501
|
+
data: {
|
|
502
|
+
name: `a .${LEGACY_CHECK_MEMBER}(...) call in a conformance test file`,
|
|
503
|
+
expected: HARNESS_PRESCRIPTION$2,
|
|
504
|
+
actual: `${legacyCallText(callee)} is not a check the barrel exposes`,
|
|
505
|
+
fix: HARNESS_PRESCRIPTION$2
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
const conformanceTestRequiresHarness = defineRule({
|
|
509
|
+
meta: meta$24,
|
|
510
|
+
create(context) {
|
|
511
|
+
if (!context.filename.endsWith(".conformance.test.ts")) return {};
|
|
512
|
+
const packageBindings = /* @__PURE__ */ new Set();
|
|
513
|
+
const conformanceBindings = /* @__PURE__ */ new Set();
|
|
514
|
+
let violations = 0;
|
|
515
|
+
let hasHarnessInvocation = false;
|
|
516
|
+
return {
|
|
517
|
+
ImportDeclaration(node) {
|
|
518
|
+
if (isForeignRunnerImport$1(node)) {
|
|
519
|
+
context.report({
|
|
520
|
+
node,
|
|
521
|
+
messageId: "runnerImport",
|
|
522
|
+
data: {
|
|
523
|
+
name: `runner import from ${String(node.source.value)} in a conformance test file`,
|
|
524
|
+
expected: HARNESS_PRESCRIPTION$2,
|
|
525
|
+
actual: "a direct vitest / @effect/vitest / @systemfsoftware/vitest runner import bypasses the conformance check",
|
|
526
|
+
fix: `delete the runner import; ${HARNESS_PRESCRIPTION$2}`
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
violations += 1;
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
recordBindings(node, packageBindings, conformanceBindings);
|
|
533
|
+
},
|
|
534
|
+
CallExpression(node) {
|
|
535
|
+
if (isRawRunnerCall(node.callee)) {
|
|
536
|
+
const name = calleeName$1(node.callee) ?? "";
|
|
537
|
+
context.report({
|
|
538
|
+
node,
|
|
539
|
+
messageId: "rawRunnerCall",
|
|
540
|
+
data: {
|
|
541
|
+
name: `raw runner call (${name}) in a conformance test file`,
|
|
542
|
+
expected: HARNESS_PRESCRIPTION$2,
|
|
543
|
+
actual: `${name}(...) bypasses the conformance check`,
|
|
544
|
+
fix: `rewrite using ${HARNESS_PRESCRIPTION$2}`
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
violations += 1;
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (isHarnessInvocation(node.callee, conformanceBindings)) {
|
|
551
|
+
hasHarnessInvocation = true;
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (isLegacyCheck(node.callee, packageBindings)) {
|
|
555
|
+
context.report({
|
|
556
|
+
node,
|
|
557
|
+
...legacyCheckError(node.callee)
|
|
558
|
+
});
|
|
559
|
+
violations += 1;
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
"Program:exit"(node) {
|
|
563
|
+
if (packageBindings.size === 0) {
|
|
564
|
+
context.report({
|
|
565
|
+
node,
|
|
566
|
+
messageId: "missingHarnessImport",
|
|
567
|
+
data: {
|
|
568
|
+
name: `conformance test file without the ${CONFORMANCE_PACKAGE} import`,
|
|
569
|
+
expected: HARNESS_PRESCRIPTION$2,
|
|
570
|
+
actual: "no conformance check import found",
|
|
571
|
+
fix: HARNESS_PRESCRIPTION$2
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (!hasHarnessInvocation && violations === 0) context.report({
|
|
577
|
+
node,
|
|
578
|
+
messageId: "missingHarnessUsage",
|
|
579
|
+
data: {
|
|
580
|
+
name: `conformance test file imports ${CONFORMANCE_PACKAGE} but never invokes it`,
|
|
581
|
+
expected: HARNESS_PRESCRIPTION$2,
|
|
582
|
+
actual: "no Conformance.linearizable, Conformance.sequential, or Conformance.released call runs",
|
|
583
|
+
fix: HARNESS_PRESCRIPTION$2
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
});
|
|
435
590
|
//#endregion
|
|
436
591
|
//#region src/rules/damp-test-naming.config.ts
|
|
437
592
|
const PASCAL_CASE$1 = /^[A-Z][a-z][a-zA-Z0-9]*$/;
|
|
@@ -452,7 +607,7 @@ const INVALID_BEHAVIOR_CASE_FIX = "Convert behavior to PascalCase (e.g., throwEr
|
|
|
452
607
|
const INVALID_CONDITION_CASE_FIX = "Convert condition to PascalCase (e.g., passwordInvalid → PasswordInvalid)";
|
|
453
608
|
const EMPTY_BEHAVIOR_ACTUAL = "Empty string between Should_ and _When_";
|
|
454
609
|
const EMPTY_CONDITION_ACTUAL = "Empty string after _When_";
|
|
455
|
-
const meta$
|
|
610
|
+
const meta$23 = {
|
|
456
611
|
type: "suggestion",
|
|
457
612
|
docs: { description: "Enforce DAMP (Descriptive and Meaningful Phrases) test naming format: Should_[ExpectedBehavior]_When_[Condition]" },
|
|
458
613
|
schema: [],
|
|
@@ -532,8 +687,9 @@ const isTestFunctionCall = (node) => {
|
|
|
532
687
|
return false;
|
|
533
688
|
};
|
|
534
689
|
const dampTestNaming = defineRule({
|
|
535
|
-
meta: meta$
|
|
690
|
+
meta: meta$23,
|
|
536
691
|
create(context) {
|
|
692
|
+
if (TYPE_TEST_BASENAME.test(basenameOf(context.filename))) return {};
|
|
537
693
|
return { CallExpression(node) {
|
|
538
694
|
if (!isTestFunctionCall(node)) return;
|
|
539
695
|
const firstArg = node.arguments[0];
|
|
@@ -555,7 +711,7 @@ const dampTestNaming = defineRule({
|
|
|
555
711
|
});
|
|
556
712
|
//#endregion
|
|
557
713
|
//#region src/rules/differential-test-requires-harness.config.ts
|
|
558
|
-
const meta$
|
|
714
|
+
const meta$22 = {
|
|
559
715
|
type: "problem",
|
|
560
716
|
docs: { description: "Differential tests must use @systemfsoftware/differential-spec harness. Raw runner calls (it, test, describe, and member forms like it.effect) and direct runner imports are forbidden in *.differential.test.ts files; importing the harness without invoking it is equally non-compliant." },
|
|
561
717
|
schema: [],
|
|
@@ -568,16 +724,16 @@ const meta$20 = {
|
|
|
568
724
|
};
|
|
569
725
|
//#endregion
|
|
570
726
|
//#region src/rules/differential-test-requires-harness.ts
|
|
571
|
-
const HARNESS_PRESCRIPTION$1 = "import { Differential, Metamorphic } from @systemfsoftware/differential-spec and express the test as Differential.compare({ reference, candidate }).on(arb).assert(oracle) or Metamorphic.on(system).relation({ transformInput, assertOutput }).on(arb)";
|
|
727
|
+
const HARNESS_PRESCRIPTION$1 = "import { Differential, Metamorphic } from @systemfsoftware/differential-spec and express the test as Differential.compare({ name, reference, candidate }).on(arb).assert(oracle) or Metamorphic.on({ name, system }).relation({ transformInput, assertOutput }).on(arb)";
|
|
572
728
|
const memberObjectName = (callee) => callee.type === "MemberExpression" && callee.object.type === "Identifier" ? callee.object.name : void 0;
|
|
573
729
|
const calleeName = (callee) => callee.type === "Identifier" ? callee.name : memberObjectName(callee);
|
|
574
730
|
const recordHarnessBindings = (node, bindings) => {
|
|
575
731
|
if (node.source.value !== "@systemfsoftware/differential-spec") return;
|
|
576
732
|
for (const specifier of node.specifiers) bindings.add(specifier.local.name);
|
|
577
733
|
};
|
|
578
|
-
const isForeignRunnerImport = (node) => typeof node.source.value === "string" && FOREIGN_RUNNERS
|
|
734
|
+
const isForeignRunnerImport = (node) => typeof node.source.value === "string" && FOREIGN_RUNNERS[node.source.value] === true;
|
|
579
735
|
const differentialTestRequiresHarness = defineRule({
|
|
580
|
-
meta: meta$
|
|
736
|
+
meta: meta$22,
|
|
581
737
|
create(context) {
|
|
582
738
|
if (!context.filename.endsWith(".differential.test.ts")) return {};
|
|
583
739
|
const harnessBindings = /* @__PURE__ */ new Set();
|
|
@@ -592,7 +748,7 @@ const differentialTestRequiresHarness = defineRule({
|
|
|
592
748
|
data: {
|
|
593
749
|
name: `runner import from ${String(node.source.value)} in a differential test file`,
|
|
594
750
|
expected: HARNESS_PRESCRIPTION$1,
|
|
595
|
-
actual: "a direct vitest / @effect/vitest runner import bypasses the differential oracle",
|
|
751
|
+
actual: "a direct vitest / @effect/vitest / @systemfsoftware/vitest runner import bypasses the differential oracle",
|
|
596
752
|
fix: `delete the runner import; ${HARNESS_PRESCRIPTION$1}`
|
|
597
753
|
}
|
|
598
754
|
});
|
|
@@ -653,11 +809,11 @@ const NON_PROP_CALL_NAME = "a non-property test call inside an `import.meta.vite
|
|
|
653
809
|
const NON_PROP_CALL_EXPECTED = "only `it.prop` or `it.effect.prop` member-chain calls (standard modifiers included) with boolean predicates";
|
|
654
810
|
const NON_PROP_CALL_ACTUAL = "a bare or member-chain test call other than `it.prop`/`it.effect.prop`";
|
|
655
811
|
const NON_PROP_CALL_FIX = "delete the block — non-property in-source tests belong nowhere in `src/`; re-home a meaningful example through the cell public export as `*.integration.test.ts`, or rewrite a real invariant as `it.prop` over a schema-derived arbitrary";
|
|
656
|
-
const meta$
|
|
812
|
+
const meta$21 = {
|
|
657
813
|
type: "problem",
|
|
658
814
|
docs: { description: "In-source `if (import.meta.vitest)` blocks under src/ must contain only `it.prop` or `it.effect.prop` calls; every other test call fails — delete the block or rewrite the invariant as a property." },
|
|
659
815
|
schema: [],
|
|
660
|
-
messages: { nonPropCall: MESSAGE$
|
|
816
|
+
messages: { nonPropCall: MESSAGE$8 }
|
|
661
817
|
};
|
|
662
818
|
//#endregion
|
|
663
819
|
//#region src/rules/vitest-guard.ts
|
|
@@ -717,7 +873,7 @@ const rootNameOf = (node) => {
|
|
|
717
873
|
if (node.type === "CallExpression") return rootNameOf(node.callee);
|
|
718
874
|
};
|
|
719
875
|
const inSourceTestPropOnly = defineRule({
|
|
720
|
-
meta: meta$
|
|
876
|
+
meta: meta$21,
|
|
721
877
|
create(context) {
|
|
722
878
|
const filename = context.filename;
|
|
723
879
|
const basename = basenameOf(filename);
|
|
@@ -758,17 +914,17 @@ const NOT_MODULE_LEVEL_NAME = "a nested `import.meta.vitest` block";
|
|
|
758
914
|
const NOT_MODULE_LEVEL_EXPECTED = "the in-source test block as a direct statement of the module body";
|
|
759
915
|
const NOT_MODULE_LEVEL_ACTUAL = "an `import.meta.vitest` block nested inside another statement";
|
|
760
916
|
const NOT_MODULE_LEVEL_FIX = "move the block to module level — a nested block does not run under vitest includeSource";
|
|
761
|
-
const NO_PRIVATE_TARGET_NAME = "an `import.meta.vitest` block touching no
|
|
762
|
-
const NO_PRIVATE_TARGET_EXPECTED = "an in-source test exercising a
|
|
763
|
-
const NO_PRIVATE_TARGET_ACTUAL = "an in-source block referencing only exported or imported names";
|
|
917
|
+
const NO_PRIVATE_TARGET_NAME = "an `import.meta.vitest` block touching no module-level binding";
|
|
918
|
+
const NO_PRIVATE_TARGET_EXPECTED = "an in-source test exercising this module's own code — a private helper, or a module-level function the lawful prop's `subject` names";
|
|
919
|
+
const NO_PRIVATE_TARGET_ACTUAL = "an in-source block referencing only exported-call or imported names";
|
|
764
920
|
const NO_PRIVATE_TARGET_FIX = "test the public surface from tests/ as *.integration.test.ts; in-source blocks exist for private helpers only — if the public behaviour you meant to cover is a pure function, delete the assertion: the type system already proves it";
|
|
765
|
-
const meta$
|
|
921
|
+
const meta$20 = {
|
|
766
922
|
type: "problem",
|
|
767
923
|
docs: { description: "In-source `if (import.meta.vitest)` blocks under src/ must be at module level and exercise at least one non-exported binding; other tests belong in tests/." },
|
|
768
924
|
schema: [],
|
|
769
925
|
messages: {
|
|
770
|
-
notModuleLevel: MESSAGE$
|
|
771
|
-
noPrivateTarget: MESSAGE$
|
|
926
|
+
notModuleLevel: MESSAGE$8,
|
|
927
|
+
noPrivateTarget: MESSAGE$8
|
|
772
928
|
}
|
|
773
929
|
};
|
|
774
930
|
//#endregion
|
|
@@ -795,8 +951,25 @@ const collectPrivateNames = (body, out) => {
|
|
|
795
951
|
if (isCollectable(node)) collectDeclaration(node, out);
|
|
796
952
|
}
|
|
797
953
|
};
|
|
954
|
+
const collectModuleNames = (body, out) => {
|
|
955
|
+
for (const node of body) {
|
|
956
|
+
if (node.type === "ExportNamedDeclaration") {
|
|
957
|
+
if (node.source === null && node.declaration !== null && isCollectable(node.declaration)) collectDeclaration(node.declaration, out);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
if (node.type === "ExportDefaultDeclaration" && isCollectable(node.declaration)) {
|
|
961
|
+
collectDeclaration(node.declaration, out);
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
if (isCollectable(node)) collectDeclaration(node, out);
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
const isSubjectValue = (node) => {
|
|
968
|
+
const parent = node.parent;
|
|
969
|
+
return parent.type === "Property" && parent.computed !== true && parent.key.type === "Identifier" && parent.key.name === "subject" && parent.value === node;
|
|
970
|
+
};
|
|
798
971
|
const inSourceTestTargetsPrivate = defineRule({
|
|
799
|
-
meta: meta$
|
|
972
|
+
meta: meta$20,
|
|
800
973
|
create(context) {
|
|
801
974
|
const filename = context.filename;
|
|
802
975
|
const basename = basenameOf(filename);
|
|
@@ -804,10 +977,12 @@ const inSourceTestTargetsPrivate = defineRule({
|
|
|
804
977
|
const inTestFile = isTestFile(basename);
|
|
805
978
|
if (!underSrc || inTestFile) return {};
|
|
806
979
|
const privateNames = /* @__PURE__ */ new Set();
|
|
980
|
+
const moduleNames = /* @__PURE__ */ new Set();
|
|
807
981
|
const guards = [];
|
|
808
982
|
return {
|
|
809
983
|
Program(node) {
|
|
810
984
|
collectPrivateNames(node.body, privateNames);
|
|
985
|
+
collectModuleNames(node.body, moduleNames);
|
|
811
986
|
},
|
|
812
987
|
IfStatement(node) {
|
|
813
988
|
if (!isVitestGuard(node.test)) return;
|
|
@@ -830,7 +1005,7 @@ const inSourceTestTargetsPrivate = defineRule({
|
|
|
830
1005
|
});
|
|
831
1006
|
},
|
|
832
1007
|
Identifier(node) {
|
|
833
|
-
if (!privateNames.has(node.name)) return;
|
|
1008
|
+
if (!(privateNames.has(node.name) || moduleNames.has(node.name) && isSubjectValue(node))) return;
|
|
834
1009
|
const guard = guards.find((g) => isInsideConsequent(node, g.node.consequent));
|
|
835
1010
|
if (guard !== void 0) guard.hit = true;
|
|
836
1011
|
},
|
|
@@ -853,16 +1028,132 @@ const inSourceTestTargetsPrivate = defineRule({
|
|
|
853
1028
|
}
|
|
854
1029
|
});
|
|
855
1030
|
//#endregion
|
|
1031
|
+
//#region src/rules/model-fixture-imports-subject.config.ts
|
|
1032
|
+
const SUBJECT_IMPORT_NAME = "a *.model.ts fixture that imports the package under test";
|
|
1033
|
+
const SUBJECT_IMPORT_EXPECTED = "a pure model that imports only effect, node builtins, and harness types";
|
|
1034
|
+
const SUBJECT_IMPORT_FIX = "delete the import; the model derives its state from the declared Schema it is given, so it needs no symbol from the package it stands in for";
|
|
1035
|
+
const RELATIVE_ESCAPE_NAME = "a *.model.ts fixture that climbs out of the tests tree";
|
|
1036
|
+
const RELATIVE_ESCAPE_EXPECTED = "a relative import that stays inside the tests tree — a sibling fixture under tests/__fixtures__/";
|
|
1037
|
+
const RELATIVE_ESCAPE_FIX = "import the sibling fixture with ./<name>.js, or move the model; a model that reaches src/ is the implementation checking itself";
|
|
1038
|
+
const meta$19 = {
|
|
1039
|
+
type: "problem",
|
|
1040
|
+
docs: { description: "A *.model.ts fixture is the pure model a conformance check stands the implementation against, so it may not import the package whose tests it serves. The package is derived from the path: the directory the tests tree lives in. An import, re-export, dynamic import, or require of that package name — or of any subpath of it — is the implementation reaching its own oracle. A relative specifier is judged the same way lexically: resolved against the fixture directory, it must stay inside the tests tree, so ../../src/Lock.js is the implementation again while ./Locks.js is a sibling fixture." },
|
|
1041
|
+
schema: [],
|
|
1042
|
+
messages: {
|
|
1043
|
+
subjectImport: MESSAGE$8,
|
|
1044
|
+
relativeEscape: MESSAGE$8
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
//#endregion
|
|
1048
|
+
//#region src/rules/model-fixture-imports-subject.ts
|
|
1049
|
+
/**
|
|
1050
|
+
* The basename of the directory whose tests tree the fixture lives in. A
|
|
1051
|
+
* `*.model.ts` serves that package's tests, so the directory name is the
|
|
1052
|
+
* unscoped name of the package under test. Read lexically, the way every rule
|
|
1053
|
+
* here reads a path: one file cannot know what else is on disk (OX-TS2), and
|
|
1054
|
+
* with no tests tree in the path the subject is unknown — an unknown subject is
|
|
1055
|
+
* never reported.
|
|
1056
|
+
*/
|
|
1057
|
+
const packageDirOf = (filename) => {
|
|
1058
|
+
const dirs = directoriesOf(filename);
|
|
1059
|
+
const index = dirs.findIndex((segment) => TEST_TREE_DIRS.has(segment));
|
|
1060
|
+
return index <= 0 ? void 0 : dirs[index - 1];
|
|
1061
|
+
};
|
|
1062
|
+
/**
|
|
1063
|
+
* The unscoped, subpath-stripped head of a package specifier:
|
|
1064
|
+
* `@systemfsoftware/effect-lock/testing` -> `effect-lock`. A subpath is still
|
|
1065
|
+
* the same dependency, and admitting it would let a fixture reach the
|
|
1066
|
+
* implementation through a nested entry.
|
|
1067
|
+
*/
|
|
1068
|
+
const packageHeadOf = (source) => {
|
|
1069
|
+
const unscoped = source.startsWith("@") ? source.slice(source.indexOf("/") + 1) : source;
|
|
1070
|
+
return unscoped.includes("/") ? unscoped.slice(0, unscoped.indexOf("/")) : unscoped;
|
|
1071
|
+
};
|
|
1072
|
+
const importsSubject = (source, subjectDir) => subjectDir !== void 0 && packageHeadOf(source) === subjectDir;
|
|
1073
|
+
const specifierOf$1 = (node) => node !== void 0 && node.type === "Literal" && typeof node.value === "string" ? node.value : void 0;
|
|
1074
|
+
const isRelativeSpecifier = (source) => source.startsWith("./") || source.startsWith("../");
|
|
1075
|
+
/**
|
|
1076
|
+
* The directory stack a relative specifier resolves to against the linted
|
|
1077
|
+
* file's own directory, `..` segments collapsed. Existence is never checked:
|
|
1078
|
+
* one file cannot know what else is on disk (OX-TS2), so only the lexical
|
|
1079
|
+
* identity is decided.
|
|
1080
|
+
*/
|
|
1081
|
+
const resolveRelative = (source, filename) => {
|
|
1082
|
+
const stack = [...directoriesOf(filename)];
|
|
1083
|
+
for (const segment of source.split("/")) {
|
|
1084
|
+
if (segment === "" || segment === ".") continue;
|
|
1085
|
+
if (segment === "..") {
|
|
1086
|
+
stack.pop();
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
stack.push(segment);
|
|
1090
|
+
}
|
|
1091
|
+
return stack;
|
|
1092
|
+
};
|
|
1093
|
+
const inTestsTree = (segments) => segments.some((segment) => TEST_TREE_DIRS.has(segment));
|
|
1094
|
+
const modelFixtureImportsSubject = defineRule({
|
|
1095
|
+
meta: meta$19,
|
|
1096
|
+
create(context) {
|
|
1097
|
+
if (!basenameOf(context.filename).endsWith(".model.ts")) return {};
|
|
1098
|
+
const subjectDir = packageDirOf(context.filename);
|
|
1099
|
+
const fixtureInTestsTree = inTestsTree(directoriesOf(context.filename));
|
|
1100
|
+
const checkSource = (node, source) => {
|
|
1101
|
+
const specifier = specifierOf$1(source);
|
|
1102
|
+
if (specifier === void 0) return;
|
|
1103
|
+
if (importsSubject(specifier, subjectDir)) {
|
|
1104
|
+
context.report({
|
|
1105
|
+
node,
|
|
1106
|
+
messageId: "subjectImport",
|
|
1107
|
+
data: {
|
|
1108
|
+
name: SUBJECT_IMPORT_NAME,
|
|
1109
|
+
expected: SUBJECT_IMPORT_EXPECTED,
|
|
1110
|
+
actual: `a dependency on ${specifier} in a *.model.ts fixture`,
|
|
1111
|
+
fix: SUBJECT_IMPORT_FIX
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
if (fixtureInTestsTree && isRelativeSpecifier(specifier) && !inTestsTree(resolveRelative(specifier, context.filename))) context.report({
|
|
1117
|
+
node,
|
|
1118
|
+
messageId: "relativeEscape",
|
|
1119
|
+
data: {
|
|
1120
|
+
name: RELATIVE_ESCAPE_NAME,
|
|
1121
|
+
expected: RELATIVE_ESCAPE_EXPECTED,
|
|
1122
|
+
actual: `a relative import of ${specifier}, resolving outside the tests tree`,
|
|
1123
|
+
fix: RELATIVE_ESCAPE_FIX
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
};
|
|
1127
|
+
return {
|
|
1128
|
+
ImportDeclaration(node) {
|
|
1129
|
+
checkSource(node, node.source);
|
|
1130
|
+
},
|
|
1131
|
+
ExportNamedDeclaration(node) {
|
|
1132
|
+
if (node.source !== null) checkSource(node, node.source);
|
|
1133
|
+
},
|
|
1134
|
+
ExportAllDeclaration(node) {
|
|
1135
|
+
checkSource(node, node.source);
|
|
1136
|
+
},
|
|
1137
|
+
ImportExpression(node) {
|
|
1138
|
+
checkSource(node, node.source);
|
|
1139
|
+
},
|
|
1140
|
+
TSImportEqualsDeclaration(node) {
|
|
1141
|
+
if (node.moduleReference.type === "TSExternalModuleReference") checkSource(node, node.moduleReference.expression);
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
//#endregion
|
|
856
1147
|
//#region src/rules/no-assert-in-property.config.ts
|
|
857
|
-
const MESSAGE$
|
|
858
|
-
const meta$
|
|
1148
|
+
const MESSAGE$7 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
1149
|
+
const meta$18 = {
|
|
859
1150
|
type: "problem",
|
|
860
1151
|
docs: { description: "Property predicates (it.prop / it.effect.prop) must never call expect(...), assert*(...), or raw fc.assert/fc.check. The boolean return IS the verdict — assertions fork the failure channel. assert* remains correct in normal (non-property) tests." },
|
|
861
1152
|
schema: [],
|
|
862
1153
|
messages: {
|
|
863
|
-
expectCall: MESSAGE$
|
|
864
|
-
assertCall: MESSAGE$
|
|
865
|
-
rawFcRun: MESSAGE$
|
|
1154
|
+
expectCall: MESSAGE$7,
|
|
1155
|
+
assertCall: MESSAGE$7,
|
|
1156
|
+
rawFcRun: MESSAGE$7
|
|
866
1157
|
}
|
|
867
1158
|
};
|
|
868
1159
|
//#endregion
|
|
@@ -893,7 +1184,7 @@ const isInsidePropPredicate = (node) => {
|
|
|
893
1184
|
return isInsidePropPredicate(parent);
|
|
894
1185
|
};
|
|
895
1186
|
const noAssertInProperty = defineRule({
|
|
896
|
-
meta: meta$
|
|
1187
|
+
meta: meta$18,
|
|
897
1188
|
create(context) {
|
|
898
1189
|
return { CallExpression(node) {
|
|
899
1190
|
const callee = node.callee;
|
|
@@ -932,6 +1223,20 @@ const noAssertInProperty = defineRule({
|
|
|
932
1223
|
}
|
|
933
1224
|
});
|
|
934
1225
|
//#endregion
|
|
1226
|
+
//#region src/rules/expect-call.ts
|
|
1227
|
+
const EXPECT = "expect";
|
|
1228
|
+
const isExpectCallee = (callee) => callee.type === "Identifier" && callee.name === EXPECT;
|
|
1229
|
+
const expectCallOf = (node, acceptsMember) => {
|
|
1230
|
+
if (node.callee.type !== "MemberExpression") return void 0;
|
|
1231
|
+
let target = node.callee.object;
|
|
1232
|
+
while (target.type === "MemberExpression") {
|
|
1233
|
+
if (acceptsMember !== void 0 && acceptsMember(target) === false) return void 0;
|
|
1234
|
+
target = target.object;
|
|
1235
|
+
}
|
|
1236
|
+
if (target.type !== "CallExpression" || isExpectCallee(target.callee) === false) return void 0;
|
|
1237
|
+
return target;
|
|
1238
|
+
};
|
|
1239
|
+
//#endregion
|
|
935
1240
|
//#region src/rules/no-behaviourless-assertion.config.ts
|
|
936
1241
|
const TEST_FILE = /\.(test|spec)\.[cm]?tsx?$/;
|
|
937
1242
|
const BEHAVIOUR_NODES = {
|
|
@@ -946,7 +1251,7 @@ const SKIP_WALK_KEYS = /* @__PURE__ */ new Set([
|
|
|
946
1251
|
"range",
|
|
947
1252
|
"parent"
|
|
948
1253
|
]);
|
|
949
|
-
const meta$
|
|
1254
|
+
const meta$17 = {
|
|
950
1255
|
type: "problem",
|
|
951
1256
|
docs: { description: "Flag an assertion whose subject and expectation are both built only from imported declarations and literals. Such an assertion invokes nothing, so no change to the behaviour under test can make it fail." },
|
|
952
1257
|
schema: [],
|
|
@@ -996,15 +1301,6 @@ const dependsOnBehaviour = (node, imported) => {
|
|
|
996
1301
|
}
|
|
997
1302
|
return false;
|
|
998
1303
|
};
|
|
999
|
-
const expectCallOf = (node) => {
|
|
1000
|
-
if (node.callee.type !== "MemberExpression") return void 0;
|
|
1001
|
-
let target = node.callee.object;
|
|
1002
|
-
while (target.type === "MemberExpression") target = target.object;
|
|
1003
|
-
if (target.type !== "CallExpression") return void 0;
|
|
1004
|
-
const callee = target.callee;
|
|
1005
|
-
if (!("name" in callee && callee.name === "expect")) return void 0;
|
|
1006
|
-
return target;
|
|
1007
|
-
};
|
|
1008
1304
|
const importedNames = (program) => {
|
|
1009
1305
|
const names = /* @__PURE__ */ new Set();
|
|
1010
1306
|
for (const statement of program.body) {
|
|
@@ -1014,7 +1310,7 @@ const importedNames = (program) => {
|
|
|
1014
1310
|
return names;
|
|
1015
1311
|
};
|
|
1016
1312
|
const noBehaviourlessAssertion = defineRule({
|
|
1017
|
-
meta: meta$
|
|
1313
|
+
meta: meta$17,
|
|
1018
1314
|
create(context) {
|
|
1019
1315
|
if (!TEST_FILE.test(context.filename)) return {};
|
|
1020
1316
|
const imported = importedNames(context.sourceCode.ast);
|
|
@@ -1084,7 +1380,7 @@ const IO_SOURCE_TEST_NAME = "An in-source `import.meta.vitest` test block";
|
|
|
1084
1380
|
const IO_SOURCE_TEST_EXPECTED = "the tests of a module whose own source calls an I/O binding to live outside it — a separate test file, or a composition test with a double at the port";
|
|
1085
1381
|
const IO_SOURCE_TEST_ACTUAL = "this module calls a binding imported from a filesystem, process or network module and guards tests in-source with `import.meta.vitest`";
|
|
1086
1382
|
const IO_SOURCE_TEST_FIX = "test the module from outside its own source — a separate test file or a composition test doubling the boundary — or, when an assertion merely restates a literal the module already computes, it is a change detector: delete it. The verdict here is the file's own imports and calls, never its name";
|
|
1087
|
-
const meta$
|
|
1383
|
+
const meta$16 = {
|
|
1088
1384
|
type: "problem",
|
|
1089
1385
|
docs: { description: "Reports an in-source `import.meta.vitest` test block in a module whose own syntax shows a called, non-type import from a filesystem, process or network module. Judges only the in-source-test idiom — a module whose tests live in separate files is a no-op for this rule, whatever it imports." },
|
|
1090
1386
|
schema: [],
|
|
@@ -1105,7 +1401,7 @@ const bindingBase = (callee) => {
|
|
|
1105
1401
|
if (callee.type === "Identifier") return callee.name;
|
|
1106
1402
|
};
|
|
1107
1403
|
const noIoModuleInSourceTest = defineRule({
|
|
1108
|
-
meta: meta$
|
|
1404
|
+
meta: meta$16,
|
|
1109
1405
|
create(context) {
|
|
1110
1406
|
/** Local binding name -> specifier it was (non-type) imported from. */
|
|
1111
1407
|
const ioBindings = {};
|
|
@@ -1147,7 +1443,7 @@ const noIoModuleInSourceTest = defineRule({
|
|
|
1147
1443
|
});
|
|
1148
1444
|
//#endregion
|
|
1149
1445
|
//#region src/rules/no-nested-quantification.config.ts
|
|
1150
|
-
const MESSAGE$
|
|
1446
|
+
const MESSAGE$6 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
1151
1447
|
const Options$2 = Schema.Struct({ exempt: Schema.Array(Schema.String).pipe(Schema.annotate({ description: "File basenames this rule stays silent on. An owner who has measured the cost and accepted it names the file here; the property still runs." }), Schema.withDecodingDefaultType(Effect.succeed([]))) });
|
|
1152
1448
|
const ITERATOR_METHODS = /* @__PURE__ */ new Set([
|
|
1153
1449
|
"every",
|
|
@@ -1166,15 +1462,15 @@ const ITERATOR_METHODS = /* @__PURE__ */ new Set([
|
|
|
1166
1462
|
]);
|
|
1167
1463
|
const CONSTANT_POOL_ARBITRARIES = /* @__PURE__ */ new Set(["constant", "constantFrom"]);
|
|
1168
1464
|
const FASTCHECK_NAMESPACES = /* @__PURE__ */ new Set(["fc"]);
|
|
1169
|
-
const VIOLATION_NAME$
|
|
1465
|
+
const VIOLATION_NAME$3 = "quantification nested inside a property predicate";
|
|
1170
1466
|
const EXPECTED$2 = "per-case cost bounded by the draw, not by the draw times a second traversal — inspect a generated value with a fold whose body calls nothing, or move the inner quantifier into the generator so the shrinker can see it";
|
|
1171
1467
|
const ACTUAL$2 = "the predicate iterates a value derived from a generated parameter and calls a free function inside that loop, so cost scales with the drawn size times whatever that call costs";
|
|
1172
1468
|
const FIX$2 = "hoist the inner call out of the loop when its result does not vary per element; otherwise assert one drawn element per case and let numRuns supply the quantifier, or generate the pair and compare directly. If the cost is understood and accepted, add this file's basename to the rule's exempt option";
|
|
1173
|
-
const meta$
|
|
1469
|
+
const meta$15 = {
|
|
1174
1470
|
type: "problem",
|
|
1175
1471
|
docs: { description: "A property predicate must not quantify over its own generated value and call out again inside that loop. Per-case cost then scales with the drawn size rather than with the draw count, which is the shape Hypothesis reports as nested_given: the suite slows superlinearly as the generator widens, and a CI budget tuned on small draws times out on large ones. Iteration over a bound the generator does not control, and a fold whose body calls nothing, are both fine." },
|
|
1176
1472
|
schema: [Schema.toJsonSchemaDocument(Options$2).schema],
|
|
1177
|
-
messages: { nestedQuantification: MESSAGE$
|
|
1473
|
+
messages: { nestedQuantification: MESSAGE$6 }
|
|
1178
1474
|
};
|
|
1179
1475
|
//#endregion
|
|
1180
1476
|
//#region src/rules/no-nested-quantification.ts
|
|
@@ -1229,11 +1525,17 @@ const isConstantPool = (element) => {
|
|
|
1229
1525
|
};
|
|
1230
1526
|
const boundedPoolIndices = (call) => {
|
|
1231
1527
|
const bounded = /* @__PURE__ */ new Set();
|
|
1232
|
-
const
|
|
1233
|
-
if (
|
|
1234
|
-
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
1528
|
+
const options = call.arguments[1];
|
|
1529
|
+
if (options === void 0 || options.type !== "ObjectExpression") return bounded;
|
|
1530
|
+
for (const property of options.properties) {
|
|
1531
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
1532
|
+
if (property.key.type !== "Identifier" || property.key.name !== "of") continue;
|
|
1533
|
+
if (property.value.type !== "ArrayExpression") return bounded;
|
|
1534
|
+
property.value.elements.forEach((element, index) => {
|
|
1535
|
+
if (isConstantPool(element)) bounded.add(index);
|
|
1536
|
+
});
|
|
1537
|
+
return bounded;
|
|
1538
|
+
}
|
|
1237
1539
|
return bounded;
|
|
1238
1540
|
};
|
|
1239
1541
|
const drawnNamesIn = (predicate, bounded) => {
|
|
@@ -1241,12 +1543,12 @@ const drawnNamesIn = (predicate, bounded) => {
|
|
|
1241
1543
|
const addFrom = (value) => {
|
|
1242
1544
|
for (const name of identifiersIn(value)) drawn.add(name);
|
|
1243
1545
|
};
|
|
1244
|
-
const [
|
|
1245
|
-
if (isNode$3(
|
|
1546
|
+
const [, values, ...rest] = predicate.params;
|
|
1547
|
+
if (isNode$3(values) && values.type === "ArrayPattern") values.elements.forEach((element, index) => {
|
|
1246
1548
|
if (bounded.has(index)) return;
|
|
1247
1549
|
addFrom(element);
|
|
1248
1550
|
});
|
|
1249
|
-
else addFrom(
|
|
1551
|
+
else addFrom(values);
|
|
1250
1552
|
addFrom(rest);
|
|
1251
1553
|
visit(predicate.body, (node) => {
|
|
1252
1554
|
if (node.type !== "VariableDeclarator") return;
|
|
@@ -1306,7 +1608,7 @@ const check = (context, call, predicate) => {
|
|
|
1306
1608
|
node: iteration.node,
|
|
1307
1609
|
messageId: "nestedQuantification",
|
|
1308
1610
|
data: {
|
|
1309
|
-
name: VIOLATION_NAME$
|
|
1611
|
+
name: VIOLATION_NAME$3,
|
|
1310
1612
|
expected: EXPECTED$2,
|
|
1311
1613
|
actual: ACTUAL$2,
|
|
1312
1614
|
fix: FIX$2
|
|
@@ -1321,7 +1623,7 @@ const check = (context, call, predicate) => {
|
|
|
1321
1623
|
* and excluded; DISCHARGED_BY in the suite pins the unbounded recipe shape.
|
|
1322
1624
|
*/
|
|
1323
1625
|
const noNestedQuantification = defineRule({
|
|
1324
|
-
meta: meta$
|
|
1626
|
+
meta: meta$15,
|
|
1325
1627
|
create(context) {
|
|
1326
1628
|
const options = Schema.decodeUnknownSync(Options$2)(context.options[0] ?? {});
|
|
1327
1629
|
const exempt = new Set(options.exempt);
|
|
@@ -1342,11 +1644,11 @@ const NO_LAYER_IN_FEATURE_NAME = "a *.integration.test.ts feature with no enviro
|
|
|
1342
1644
|
const NO_LAYER_IN_FEATURE_EXPECTED = "a Feature builder chained with .withLayer(layer) or .withScenarioLayer(layer)";
|
|
1343
1645
|
const NO_LAYER_IN_FEATURE_ACTUAL = "a Feature(...) call without .withLayer or .withScenarioLayer";
|
|
1344
1646
|
const NO_LAYER_IN_FEATURE_FIX = "an integration test under WGI-CLS1 must declare its collaborator environment via .withLayer(...) or .withScenarioLayer(...). Chain .withLayer(Layer.empty) (or .withScenarioLayer(Layer.empty)) if the feature exercises in-memory collaborators without custom services, or provide the external boundary Layer it exercises (e.g. .withLayer(MyService.Live)).";
|
|
1345
|
-
const meta$
|
|
1647
|
+
const meta$14 = {
|
|
1346
1648
|
type: "problem",
|
|
1347
1649
|
docs: { description: "A *.integration.test.ts Feature builder must configure at least one environment Layer (.withLayer or .withScenarioLayer) to enforce real integration boundaries under WGI-CLS1." },
|
|
1348
1650
|
schema: [],
|
|
1349
|
-
messages: { noLayerInFeature: MESSAGE$
|
|
1651
|
+
messages: { noLayerInFeature: MESSAGE$8 }
|
|
1350
1652
|
};
|
|
1351
1653
|
//#endregion
|
|
1352
1654
|
//#region src/rules/no-pseudo-gherkin-unit-tests.ts
|
|
@@ -1354,7 +1656,6 @@ const LAYER_METHODS = {
|
|
|
1354
1656
|
withLayer: true,
|
|
1355
1657
|
withScenarioLayer: true
|
|
1356
1658
|
};
|
|
1357
|
-
const isBehaviourTest = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
|
|
1358
1659
|
const hasLayerInChain = (callNode) => {
|
|
1359
1660
|
let current = callNode;
|
|
1360
1661
|
while (current.type === "CallExpression") {
|
|
@@ -1377,9 +1678,9 @@ const findRootFeatureCall = (callNode) => {
|
|
|
1377
1678
|
return null;
|
|
1378
1679
|
};
|
|
1379
1680
|
const noPseudoGherkinUnitTests = defineRule({
|
|
1380
|
-
meta: meta$
|
|
1681
|
+
meta: meta$14,
|
|
1381
1682
|
create(context) {
|
|
1382
|
-
if (!
|
|
1683
|
+
if (!isBehaviourBasename(basenameOf(context.filename))) return {};
|
|
1383
1684
|
return { CallExpression(node) {
|
|
1384
1685
|
if (node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier") {
|
|
1385
1686
|
if (node.callee.property.name === "body") {
|
|
@@ -1401,16 +1702,16 @@ const noPseudoGherkinUnitTests = defineRule({
|
|
|
1401
1702
|
});
|
|
1402
1703
|
//#endregion
|
|
1403
1704
|
//#region src/rules/no-silent-return.config.ts
|
|
1404
|
-
const MESSAGE$
|
|
1405
|
-
const meta$
|
|
1705
|
+
const MESSAGE$5 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
1706
|
+
const meta$13 = {
|
|
1406
1707
|
type: "problem",
|
|
1407
|
-
docs: { description: "Property predicates (it.prop / it.effect.prop from @
|
|
1708
|
+
docs: { description: "Property predicates (it.prop / it.effect.prop from @systemfsoftware/vitest) must return a boolean verdict on every code path. fast-check counts undefined as success, so a bare return, a non-boolean return, or falling off the end of the body is a silent pass. Opaque values (identifiers, member expressions, calls) are trusted to be boolean; literals and operators are checked." },
|
|
1408
1709
|
schema: [],
|
|
1409
1710
|
messages: {
|
|
1410
|
-
bareReturn: MESSAGE$
|
|
1411
|
-
nonBooleanReturn: MESSAGE$
|
|
1412
|
-
missingReturn: MESSAGE$
|
|
1413
|
-
nonBooleanBody: MESSAGE$
|
|
1711
|
+
bareReturn: MESSAGE$5,
|
|
1712
|
+
nonBooleanReturn: MESSAGE$5,
|
|
1713
|
+
missingReturn: MESSAGE$5,
|
|
1714
|
+
nonBooleanBody: MESSAGE$5
|
|
1414
1715
|
}
|
|
1415
1716
|
};
|
|
1416
1717
|
//#endregion
|
|
@@ -1550,7 +1851,7 @@ const checkFn = (context, fn) => {
|
|
|
1550
1851
|
if (!Array$1.last(body.body).pipe(Option.exists(pathExits))) report(context, fn, "missingReturn", "the predicate can fall off the end without returning — undefined is a silent pass");
|
|
1551
1852
|
};
|
|
1552
1853
|
const noSilentReturn = defineRule({
|
|
1553
|
-
meta: meta$
|
|
1854
|
+
meta: meta$13,
|
|
1554
1855
|
create(context) {
|
|
1555
1856
|
return { CallExpression(node) {
|
|
1556
1857
|
if (!isPropCallee(node.callee)) return;
|
|
@@ -1579,21 +1880,21 @@ const SCHEMA_TEST_DETAIL = {
|
|
|
1579
1880
|
actual: "an authored *.schema.test.ts restating generated coverage",
|
|
1580
1881
|
fix: "delete it. The generated laws already state round-trip identity and encode stability. What they cannot state is rejection — every input they draw comes from the arbitrary the schema itself supplies — so a refusal belongs in an in-source if (import.meta.vitest) block in the schema file, never here"
|
|
1581
1882
|
};
|
|
1582
|
-
const meta$
|
|
1883
|
+
const meta$12 = {
|
|
1583
1884
|
type: "problem",
|
|
1584
1885
|
docs: { description: "Under src/, the only sanctioned test file is a single-segment <stem>.workflow.property.test.ts inside a sanctioned test directory, plus the generated schema-laws.test.ts entry point. Every other test file is banned: a kernel, policy, or schema suite becomes an in-source import.meta.vitest block, and a public-surface test moves outside src/ as an integration test. The sanctioned directory list is the sanctionedDirs option, defaulting to the one directory this repo runs." },
|
|
1585
1886
|
schema: [Schema.toJsonSchemaDocument(Options$1).schema],
|
|
1586
1887
|
messages: {
|
|
1587
|
-
testFileInSrc: MESSAGE$
|
|
1588
|
-
schemaTestInSrc: MESSAGE$
|
|
1589
|
-
propertyTestOutsideTestsDir: MESSAGE$
|
|
1888
|
+
testFileInSrc: MESSAGE$8,
|
|
1889
|
+
schemaTestInSrc: MESSAGE$8,
|
|
1890
|
+
propertyTestOutsideTestsDir: MESSAGE$8
|
|
1590
1891
|
}
|
|
1591
1892
|
};
|
|
1592
1893
|
//#endregion
|
|
1593
1894
|
//#region src/rules/no-test-file-in-src.ts
|
|
1594
1895
|
const violationOf = (basename, isPropertyTest, dir) => basename.endsWith(".schema.test.ts") ? ["schemaTestInSrc", SCHEMA_TEST_DETAIL] : isPropertyTest ? ["propertyTestOutsideTestsDir", propertyTestLocationDetail(dir)] : ["testFileInSrc", testFileInSrcDetail(dir)];
|
|
1595
1896
|
const noTestFileInSrc = defineRule({
|
|
1596
|
-
meta: meta$
|
|
1897
|
+
meta: meta$12,
|
|
1597
1898
|
create(context) {
|
|
1598
1899
|
const { sanctionedDirs } = Schema.decodeUnknownSync(Options$1)(context.options[0] ?? {});
|
|
1599
1900
|
const basename = basenameOf(context.filename);
|
|
@@ -1641,12 +1942,13 @@ const PREDICATE_SYMBOLS = /* @__PURE__ */ new Set([
|
|
|
1641
1942
|
"∘",
|
|
1642
1943
|
"∩",
|
|
1643
1944
|
"∪",
|
|
1644
|
-
"⊥"
|
|
1945
|
+
"⊥",
|
|
1946
|
+
"⊨"
|
|
1645
1947
|
]);
|
|
1646
1948
|
const NULLARY_PREDICATE_SYMBOLS = /* @__PURE__ */ new Set(["⊥"]);
|
|
1647
1949
|
const PASCAL_CASE = /^[A-Z][a-z][a-zA-Z0-9]*$/;
|
|
1648
1950
|
const DAMP_WORDS = /When|Should|Given|Then|Otherwise|After|Before/;
|
|
1649
|
-
const meta$
|
|
1951
|
+
const meta$11 = {
|
|
1650
1952
|
type: "suggestion",
|
|
1651
1953
|
docs: { description: "Enforce a complete formal-specification name for property-based tests (it.prop / it.effect.prop). Format: [ScopeSymbol][binder]_[Domain]_[PredicateSymbol][operand] (e.g., ∀x_DecodeEncode_=x, ∀l_Filter_⊆Input, →Shipped_Cancel_⊥Allowed). Both the quantifier and the predicate must carry an operand — a bare symbol specifies nothing." },
|
|
1652
1954
|
schema: [],
|
|
@@ -1656,7 +1958,7 @@ const meta$10 = {
|
|
|
1656
1958
|
incompleteScope: "Expected: a bound variable after the quantifier \"{{symbol}}\" in scope segment \"{{scope}}\" (e.g., ∀x, ∀order, ∃e, →Shipped). A property quantifies over a named input drawn from a generator; name it. A lone quantifier binds nothing and specifies no domain.",
|
|
1657
1959
|
emptyDomain: "Expected: a non-empty PascalCase domain between the two underscores in \"{{actual}}\". If this isn't a universal invariant, delete the test. Otherwise name the thing under test, e.g., ∀x_DecodeEncode_=x.",
|
|
1658
1960
|
domainLeaksDAMP: "Expected: an invariant domain, not scenario language. Actual: domain \"{{domain}}\" contains \"{{word}}\" — that describes one case, not a universal law. Delete this test. It is not a property. Find the actual invariant and write that instead.",
|
|
1659
|
-
invalidPredicateSymbol: "Expected: a relation symbol (≡ ≠ = ≤ ≥ ∈ ⊆ ⊇ → ¬ ∘ ∩ ∪ ⊥) starting the last segment of \"{{actual}}\". Actual: it ends with \"{{firstChar}}\". If this isn't a universal invariant, delete the test. Otherwise relate the output: = / ≡ (roundtrip or oracle), ⊆ / ∈ (invariant), ≠ (distinctness), ⊥ (impossibility).",
|
|
1961
|
+
invalidPredicateSymbol: "Expected: a relation symbol (≡ ≠ = ≤ ≥ ∈ ⊆ ⊇ → ¬ ∘ ∩ ∪ ⊥ ⊨) starting the last segment of \"{{actual}}\". Actual: it ends with \"{{firstChar}}\". If this isn't a universal invariant, delete the test. Otherwise relate the output: = / ≡ (roundtrip or oracle), ⊆ / ∈ (invariant), ≠ (distinctness), ⊨ (satisfies a declared model), ⊥ (impossibility).",
|
|
1660
1962
|
incompletePredicate: "Expected: an operand after the relation symbol \"{{symbol}}\" in predicate \"{{predicate}}\", naming what the output is related to: =x / ≡Oracle (roundtrip or reference), ⊆Input / ∈Ignored (invariant), ≠Zero (distinctness), ⊥Cancellable (impossibility — name the outcome that cannot occur). A bare symbol relates the output to nothing and so asserts no property."
|
|
1661
1963
|
}
|
|
1662
1964
|
};
|
|
@@ -1690,7 +1992,7 @@ const parseSegments = (name) => {
|
|
|
1690
1992
|
};
|
|
1691
1993
|
};
|
|
1692
1994
|
const pbtNaming = defineRule({
|
|
1693
|
-
meta: meta$
|
|
1995
|
+
meta: meta$11,
|
|
1694
1996
|
create(context) {
|
|
1695
1997
|
return { CallExpression(node) {
|
|
1696
1998
|
if (!isPropCall(node)) return;
|
|
@@ -1782,8 +2084,8 @@ const pbtNaming = defineRule({
|
|
|
1782
2084
|
});
|
|
1783
2085
|
//#endregion
|
|
1784
2086
|
//#region src/rules/prop-arbitrary-schema-origin.config.ts
|
|
1785
|
-
const MESSAGE$
|
|
1786
|
-
const VIOLATION_NAME$
|
|
2087
|
+
const MESSAGE$4 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
2088
|
+
const VIOLATION_NAME$2 = "a hand-built fast-check arbitrary with no schema underneath";
|
|
1787
2089
|
const EXPECTED$1 = "every in-source it.prop arbitrary derives from an Effect Schema — a schema reference, a schema-attached arbitrary annotation, or a pipe/map/oneof chain rooted in one; an input with no schema grows one first";
|
|
1788
2090
|
const ACTUAL$1 = "the arbitrary is assembled from fast-check constructors alone, so the generator can emit shapes the schema never declared and the suite stays green while exercising almost nothing";
|
|
1789
2091
|
const FIX$1 = "delete the block, or grow the schema the input needs and derive the arbitrary from it — Arbitrary.schema(schema) is the sanctioned derivation; nothing rewrites a hand-built generator into a property";
|
|
@@ -1798,13 +2100,13 @@ const SCHEMA_NAMESPACE_NAMES = {
|
|
|
1798
2100
|
};
|
|
1799
2101
|
const FASTCHECK_NAMESPACE_NAMES = { FastCheck: true };
|
|
1800
2102
|
const COMBINATOR_CALLEES = { pipe: true };
|
|
1801
|
-
const meta$
|
|
2103
|
+
const meta$10 = {
|
|
1802
2104
|
type: "problem",
|
|
1803
2105
|
docs: { description: "Inside an import.meta.vitest in-source block, every it.prop / it.effect.prop arbitrary must derive from an Effect Schema — a schema reference, a schema-attached arbitrary annotation, or a chain rooted in one. A hand-built fast-check construction with no schema underneath reports; statically opaque arbitraries (unresolved or foreign bindings) fail open into the runtime domain audit." },
|
|
1804
2106
|
schema: [],
|
|
1805
2107
|
messages: {
|
|
1806
|
-
handBuiltArbitrary: MESSAGE$
|
|
1807
|
-
stockDerivedArbitrary: MESSAGE$
|
|
2108
|
+
handBuiltArbitrary: MESSAGE$4,
|
|
2109
|
+
stockDerivedArbitrary: MESSAGE$4
|
|
1808
2110
|
}
|
|
1809
2111
|
};
|
|
1810
2112
|
//#endregion
|
|
@@ -2062,26 +2364,49 @@ const checkArbitraryFactory = (provenance, context, call) => {
|
|
|
2062
2364
|
}
|
|
2063
2365
|
});
|
|
2064
2366
|
};
|
|
2065
|
-
const
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2367
|
+
const reportHandBuilt = (provenance, context, node) => {
|
|
2368
|
+
if (provenance.verdictOf(node, 0) !== "handBuilt") return;
|
|
2369
|
+
context.report({
|
|
2370
|
+
node,
|
|
2371
|
+
messageId: "handBuiltArbitrary",
|
|
2372
|
+
data: {
|
|
2373
|
+
name: VIOLATION_NAME$2,
|
|
2374
|
+
expected: EXPECTED$1,
|
|
2375
|
+
actual: ACTUAL$1,
|
|
2376
|
+
fix: FIX$1
|
|
2377
|
+
}
|
|
2378
|
+
});
|
|
2379
|
+
};
|
|
2380
|
+
const checkArrayOf = (provenance, context, of) => {
|
|
2381
|
+
for (const element of of.elements) {
|
|
2069
2382
|
if (element === null) continue;
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2383
|
+
reportHandBuilt(provenance, context, element);
|
|
2384
|
+
}
|
|
2385
|
+
};
|
|
2386
|
+
const checkRecordOf = (provenance, context, of) => {
|
|
2387
|
+
for (const member of of.properties) {
|
|
2388
|
+
if (member.type !== "Property" || member.computed) continue;
|
|
2389
|
+
reportHandBuilt(provenance, context, member.value);
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
const checkPropCall$1 = (provenance, context, call) => {
|
|
2393
|
+
const options = call.arguments[1];
|
|
2394
|
+
if (options === void 0 || options.type !== "ObjectExpression") return;
|
|
2395
|
+
for (const property of options.properties) {
|
|
2396
|
+
if (property.type !== "Property" || property.computed) continue;
|
|
2397
|
+
if (property.key.type !== "Identifier" || property.key.name !== "of") continue;
|
|
2398
|
+
if (property.value.type === "ArrayExpression") {
|
|
2399
|
+
checkArrayOf(provenance, context, property.value);
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
if (property.value.type === "ObjectExpression") {
|
|
2403
|
+
checkRecordOf(provenance, context, property.value);
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2081
2406
|
}
|
|
2082
2407
|
};
|
|
2083
2408
|
const propArbitrarySchemaOrigin = defineRule({
|
|
2084
|
-
meta: meta$
|
|
2409
|
+
meta: meta$10,
|
|
2085
2410
|
create(context) {
|
|
2086
2411
|
const provenance = new Provenance(context.sourceCode.getScope);
|
|
2087
2412
|
const collect = (value, out) => {
|
|
@@ -2140,16 +2465,16 @@ const propArbitrarySchemaOrigin = defineRule({
|
|
|
2140
2465
|
});
|
|
2141
2466
|
//#endregion
|
|
2142
2467
|
//#region src/rules/prop-fixture-schema-origin.config.ts
|
|
2143
|
-
const MESSAGE$
|
|
2144
|
-
const VIOLATION_NAME = "a hand-rolled recursive schema fixture inside a test block";
|
|
2468
|
+
const MESSAGE$3 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
2469
|
+
const VIOLATION_NAME$1 = "a hand-rolled recursive schema fixture inside a test block";
|
|
2145
2470
|
const EXPECTED = "a recursive fixture schema enters the block through a named local builder call, or its recursion point declares its generation with a visible toCodecArbitrary derivation or a recursionBudget ceiling";
|
|
2146
2471
|
const ACTUAL = "the union and its recursion cycle are assembled inline, so the suite exercises a surrogate universe the shipped schema contract never declared and no budget gate grades";
|
|
2147
2472
|
const FIX = "hoist the members into a named builder the annotated and counterfactual fixtures share, or declare the recursion point's generation in its annotation";
|
|
2148
|
-
const meta$
|
|
2473
|
+
const meta$9 = {
|
|
2149
2474
|
type: "problem",
|
|
2150
2475
|
docs: { description: "Inside an import.meta.vitest in-source block or a *.test.ts file, a recursive schema union must enter through a named local builder or an imported helper, or its recursion point must declare its generation with a visible toCodecArbitrary derivation or recursionBudget ceiling. An inline hand-rolled recursive union reports; outside test scope the rule is silent, because production schemas are the derivation-cost rule domain." },
|
|
2151
2476
|
schema: [],
|
|
2152
|
-
messages: { handRolledRecursiveFixture: MESSAGE$
|
|
2477
|
+
messages: { handRolledRecursiveFixture: MESSAGE$3 }
|
|
2153
2478
|
};
|
|
2154
2479
|
//#endregion
|
|
2155
2480
|
//#region src/rules/prop-fixture-schema-origin.ts
|
|
@@ -2323,7 +2648,7 @@ const isNamedBuilderOrigin = (union, getScope) => {
|
|
|
2323
2648
|
return false;
|
|
2324
2649
|
};
|
|
2325
2650
|
const propFixtureSchemaOrigin = defineRule({
|
|
2326
|
-
meta: meta$
|
|
2651
|
+
meta: meta$9,
|
|
2327
2652
|
create(context) {
|
|
2328
2653
|
const getScope = context.sourceCode.getScope;
|
|
2329
2654
|
return { CallExpression(node) {
|
|
@@ -2340,7 +2665,7 @@ const propFixtureSchemaOrigin = defineRule({
|
|
|
2340
2665
|
node,
|
|
2341
2666
|
messageId: "handRolledRecursiveFixture",
|
|
2342
2667
|
data: {
|
|
2343
|
-
name: VIOLATION_NAME,
|
|
2668
|
+
name: VIOLATION_NAME$1,
|
|
2344
2669
|
expected: EXPECTED,
|
|
2345
2670
|
actual: ACTUAL,
|
|
2346
2671
|
fix: FIX
|
|
@@ -2351,7 +2676,7 @@ const propFixtureSchemaOrigin = defineRule({
|
|
|
2351
2676
|
});
|
|
2352
2677
|
//#endregion
|
|
2353
2678
|
//#region src/rules/prop-generated-law-duplicate.config.ts
|
|
2354
|
-
const MESSAGE$
|
|
2679
|
+
const MESSAGE$2 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
2355
2680
|
const COMPILER_NAME = "a runtime probe of a compile-time guarantee";
|
|
2356
2681
|
const COMPILER_EXPECTED = "the type checker owns brand and nominal-identity contracts — a dropped brand is a compile error at the consumer brand gate, not a failing property";
|
|
2357
2682
|
const COMPILER_ACTUAL = "the predicate reflects over brand symbols at runtime, so it re-asserts what tsc already enforces and passes whenever the type system passes";
|
|
@@ -2360,13 +2685,13 @@ const NO_FUNCTION_NAME = "a property that tests no function";
|
|
|
2360
2685
|
const NO_FUNCTION_EXPECTED = "every in-source property exercises domain logic — at least one call to a module-local function that is not a schema codec accessor — because a predicate that only feeds values through encode or decode tests the schema declaration, not code, and decode acceptance and refusal alike are generated or declared elsewhere";
|
|
2361
2686
|
const NO_FUNCTION_ACTUAL = "no call in the predicate reaches module code — every call is a codec accessor, a schema wrapper, or an iteration combinator — so the property cannot fail unless the declaration it restates changes meaning";
|
|
2362
2687
|
const NO_FUNCTION_FIX = "delete the prop; test the function that owns the decision — the workflow or a private helper in this module — with its input derived from a domain schema";
|
|
2363
|
-
const meta$
|
|
2688
|
+
const meta$8 = {
|
|
2364
2689
|
type: "problem",
|
|
2365
2690
|
docs: { description: "Inside an import.meta.vitest in-source block: a predicate that reflects over brand symbols re-asserts a compile-time guarantee, and a predicate containing no module-local non-codec function call tests a schema declaration instead of code. Both report." },
|
|
2366
2691
|
schema: [],
|
|
2367
2692
|
messages: {
|
|
2368
|
-
compilerDuplicate: MESSAGE$
|
|
2369
|
-
noDomainFunction: MESSAGE$
|
|
2693
|
+
compilerDuplicate: MESSAGE$2,
|
|
2694
|
+
noDomainFunction: MESSAGE$2
|
|
2370
2695
|
}
|
|
2371
2696
|
};
|
|
2372
2697
|
//#endregion
|
|
@@ -2433,6 +2758,16 @@ const NEUTRAL_METHODS = {
|
|
|
2433
2758
|
match: true,
|
|
2434
2759
|
gen: true
|
|
2435
2760
|
};
|
|
2761
|
+
/**
|
|
2762
|
+
* The fork's lawful `it.prop(name, { of, subject }, holds)` hands the function
|
|
2763
|
+
* under test to `holds` as its first parameter. Calling that parameter is
|
|
2764
|
+
* calling the subject, so it reaches this module's code by construction — even
|
|
2765
|
+
* when the parameter name would otherwise read as a codec accessor.
|
|
2766
|
+
*/
|
|
2767
|
+
const subjectParameterOf = (predicate) => {
|
|
2768
|
+
const first = predicate.params[0];
|
|
2769
|
+
return first !== void 0 && first.type === "Identifier" ? first.name : void 0;
|
|
2770
|
+
};
|
|
2436
2771
|
const isNode = (value) => value !== null && typeof value === "object" && "type" in value;
|
|
2437
2772
|
const mentionsImportMetaVitest = (value) => {
|
|
2438
2773
|
if (Array.isArray(value)) return value.some((item) => mentionsImportMetaVitest(item));
|
|
@@ -2444,9 +2779,9 @@ const mentionsImportMetaVitest = (value) => {
|
|
|
2444
2779
|
}
|
|
2445
2780
|
return false;
|
|
2446
2781
|
};
|
|
2447
|
-
const collectPredicateShape = (provenance, node, shape) => {
|
|
2782
|
+
const collectPredicateShape = (provenance, node, shape, subjectParameter) => {
|
|
2448
2783
|
if (Array.isArray(node)) {
|
|
2449
|
-
for (const item of node) collectPredicateShape(provenance, item, shape);
|
|
2784
|
+
for (const item of node) collectPredicateShape(provenance, item, shape, subjectParameter);
|
|
2450
2785
|
return;
|
|
2451
2786
|
}
|
|
2452
2787
|
if (!isNode(node)) return;
|
|
@@ -2464,12 +2799,12 @@ const collectPredicateShape = (provenance, node, shape) => {
|
|
|
2464
2799
|
if (provenance.classifyCall(name, node) === "domain" || receiverIsLocal) shape.callsDomainFunction = true;
|
|
2465
2800
|
}
|
|
2466
2801
|
} else if (callee.type === "Identifier") {
|
|
2467
|
-
if (CODEC_ACCESSORS[callee.name] !== true && NEUTRAL_METHODS[callee.name] !== true && provenance.classifyCall(callee.name, node) === "domain") shape.callsDomainFunction = true;
|
|
2802
|
+
if (subjectParameter !== void 0 && callee.name === subjectParameter || CODEC_ACCESSORS[callee.name] !== true && NEUTRAL_METHODS[callee.name] !== true && provenance.classifyCall(callee.name, node) === "domain") shape.callsDomainFunction = true;
|
|
2468
2803
|
}
|
|
2469
2804
|
}
|
|
2470
2805
|
for (const [key, child] of Object.entries(node)) {
|
|
2471
2806
|
if (key === "parent") continue;
|
|
2472
|
-
collectPredicateShape(provenance, child, shape);
|
|
2807
|
+
collectPredicateShape(provenance, child, shape, subjectParameter);
|
|
2473
2808
|
}
|
|
2474
2809
|
};
|
|
2475
2810
|
const checkPropCall = (provenance, context, call) => {
|
|
@@ -2479,7 +2814,7 @@ const checkPropCall = (provenance, context, call) => {
|
|
|
2479
2814
|
usesCompilerProbe: false,
|
|
2480
2815
|
callsDomainFunction: false
|
|
2481
2816
|
};
|
|
2482
|
-
collectPredicateShape(provenance, predicate.body, shape);
|
|
2817
|
+
collectPredicateShape(provenance, predicate.body, shape, subjectParameterOf(predicate));
|
|
2483
2818
|
if (shape.usesCompilerProbe) {
|
|
2484
2819
|
context.report({
|
|
2485
2820
|
node: call,
|
|
@@ -2506,7 +2841,7 @@ const checkPropCall = (provenance, context, call) => {
|
|
|
2506
2841
|
});
|
|
2507
2842
|
};
|
|
2508
2843
|
const propGeneratedLawDuplicate = defineRule({
|
|
2509
|
-
meta: meta$
|
|
2844
|
+
meta: meta$8,
|
|
2510
2845
|
create(context) {
|
|
2511
2846
|
const provenance = new Provenance(context.sourceCode.getScope);
|
|
2512
2847
|
const collect = (value, out) => {
|
|
@@ -2549,17 +2884,17 @@ const propGeneratedLawDuplicate = defineRule({
|
|
|
2549
2884
|
//#endregion
|
|
2550
2885
|
//#region src/rules/property-file-purity.config.ts
|
|
2551
2886
|
const PROPERTY_TEST_SUFFIX = ".property.test.ts";
|
|
2552
|
-
const MESSAGE = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
2553
|
-
const meta$
|
|
2887
|
+
const MESSAGE$1 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
2888
|
+
const meta$7 = {
|
|
2554
2889
|
type: "problem",
|
|
2555
2890
|
docs: { description: "Property tests live ONLY in *.property.test.ts files, and those files contain ONLY property tests. In a property file: no plain it()/test()/it.effect(), no raw fc.assert/fc.check/fc.property/fc.asyncProperty. In any other test file: no FastCheck import and no it.prop/it.effect.prop — move the property to a *.property.test.ts file." },
|
|
2556
2891
|
schema: [],
|
|
2557
2892
|
messages: {
|
|
2558
|
-
plainIt: MESSAGE,
|
|
2559
|
-
plainEffectIt: MESSAGE,
|
|
2560
|
-
rawFastCheck: MESSAGE,
|
|
2561
|
-
fastCheckImport: MESSAGE,
|
|
2562
|
-
propCall: MESSAGE
|
|
2893
|
+
plainIt: MESSAGE$1,
|
|
2894
|
+
plainEffectIt: MESSAGE$1,
|
|
2895
|
+
rawFastCheck: MESSAGE$1,
|
|
2896
|
+
fastCheckImport: MESSAGE$1,
|
|
2897
|
+
propCall: MESSAGE$1
|
|
2563
2898
|
}
|
|
2564
2899
|
};
|
|
2565
2900
|
//#endregion
|
|
@@ -2574,9 +2909,9 @@ const PROPERTY_FILE_KIND = {
|
|
|
2574
2909
|
suffix: PROPERTY_TEST_SUFFIX,
|
|
2575
2910
|
plainExpected: "it.prop(...) or it.effect.prop(...) — property files never mix with scenario tests",
|
|
2576
2911
|
plainFix: "move the scenario test to a plain *.test.ts file, or rewrite it as a property with arbitraries and a boolean-returning predicate",
|
|
2577
|
-
rawFcExpected: "it.prop(...) or it.effect.prop(...) from @
|
|
2912
|
+
rawFcExpected: "it.prop(...) or it.effect.prop(...) from @systemfsoftware/vitest",
|
|
2578
2913
|
rawFcActualSuffix: "bypasses the vitest/Effect integration",
|
|
2579
|
-
rawFcFix: "rewrite as it.prop(name,
|
|
2914
|
+
rawFcFix: "rewrite as it.prop(name, { of, subject, runs }, holds) returning a boolean; fc.* stays for building arbitraries (fc.pre, fc.stringMatching, ...)"
|
|
2580
2915
|
};
|
|
2581
2916
|
const DIFFERENTIAL_FILE_KIND = {
|
|
2582
2917
|
suffix: DIFFERENTIAL_SUFFIX,
|
|
@@ -2666,7 +3001,7 @@ const createScenarioFileVisitors = (context) => ({
|
|
|
2666
3001
|
});
|
|
2667
3002
|
const kindOf = (filename) => filename.endsWith(".differential.test.ts") ? DIFFERENTIAL_FILE_KIND : PROPERTY_FILE_KIND;
|
|
2668
3003
|
const propertyFilePurity = defineRule({
|
|
2669
|
-
meta: meta$
|
|
3004
|
+
meta: meta$7,
|
|
2670
3005
|
create(context) {
|
|
2671
3006
|
if (!isTestFile(basenameOf(context.filename))) return {};
|
|
2672
3007
|
if (context.filename.endsWith(".property.test.ts") || context.filename.endsWith(".differential.test.ts")) return createPropertyFileVisitors(context, kindOf(context.filename));
|
|
@@ -2682,12 +3017,12 @@ const UNSANCTIONED_CELL_FIX = "rename it <stem>.workflow.property.test.ts beside
|
|
|
2682
3017
|
const MISSING_CELL_TEST_EXPECTED = "a test for every cell suffix the consumer lists in cellsRequiringTest";
|
|
2683
3018
|
const MISSING_CELL_TEST_ACTUAL = "a declared cell whose own module carries no `if (import.meta.vitest)` block";
|
|
2684
3019
|
const MISSING_CELL_TEST_FIX = "add an `if (import.meta.vitest)` block to this module, or drop this cell from cellsRequiringTest and cover it with a colocated test in a sanctioned test directory — the rule reads the file it is given, so a sibling test file is invisible to it and a declared cell must satisfy the requirement from its own source";
|
|
2685
|
-
const meta$
|
|
3020
|
+
const meta$6 = {
|
|
2686
3021
|
type: "problem",
|
|
2687
3022
|
docs: { description: "A property test under src/ must be a single-segment <stem>.workflow.property.test.ts beside the <stem>.workflow.ts it covers; every other property-test basename is unsanctioned. A source file whose suffix names a cell listed in the cellsRequiringTest option must additionally carry an in-source vitest block; that list is empty by default, so the presence arm is opt-in per consumer." },
|
|
2688
3023
|
schema: [Schema.toJsonSchemaDocument(Options).schema],
|
|
2689
3024
|
messages: {
|
|
2690
|
-
unsanctionedCell: MESSAGE$
|
|
3025
|
+
unsanctionedCell: MESSAGE$8,
|
|
2691
3026
|
missingCellTest: ABSENCE_MESSAGE
|
|
2692
3027
|
}
|
|
2693
3028
|
};
|
|
@@ -2695,7 +3030,7 @@ const meta$5 = {
|
|
|
2695
3030
|
//#region src/rules/src-property-test-cell.ts
|
|
2696
3031
|
const carriesInSourceBlock = (body) => body.some((statement) => statement.type === "IfStatement" && isVitestGuard(statement.test));
|
|
2697
3032
|
const srcPropertyTestCell = defineRule({
|
|
2698
|
-
meta: meta$
|
|
3033
|
+
meta: meta$6,
|
|
2699
3034
|
create(context) {
|
|
2700
3035
|
const { cellsRequiringTest } = Schema.decodeUnknownSync(Options)(context.options[0] ?? {});
|
|
2701
3036
|
const filename = context.filename;
|
|
@@ -2748,7 +3083,7 @@ const testFileOutsideTestsDir = defineRule({
|
|
|
2748
3083
|
type: "problem",
|
|
2749
3084
|
docs: { description: "Test files outside src/ must live under a tests/ directory; free-standing test files have no sanctioned home." },
|
|
2750
3085
|
schema: [],
|
|
2751
|
-
messages: { strayTestFile: MESSAGE$
|
|
3086
|
+
messages: { strayTestFile: MESSAGE$8 }
|
|
2752
3087
|
},
|
|
2753
3088
|
create(context) {
|
|
2754
3089
|
if (isUnderSrc(context.filename)) return {};
|
|
@@ -2770,24 +3105,25 @@ const testFileOutsideTestsDir = defineRule({
|
|
|
2770
3105
|
});
|
|
2771
3106
|
//#endregion
|
|
2772
3107
|
//#region src/rules/test-suffix-outside-src.config.ts
|
|
2773
|
-
const UNSANCTIONED_SUFFIX_EXPECTED = "*.integration.test.ts, *.differential.test.ts, or *.
|
|
3108
|
+
const UNSANCTIONED_SUFFIX_EXPECTED = "*.integration.test.ts, *.contract.test.ts, *.differential.test.ts, *.trace.test.ts, or *.conformance.test.ts";
|
|
2774
3109
|
const UNSANCTIONED_SUFFIX_ACTUAL = "another test suffix outside src/";
|
|
2775
|
-
const UNSANCTIONED_SUFFIX_FIX = "public surface -> *.integration.test.ts; parity -> *.differential.test.ts; span graph -> *.trace.test.ts; pure-cell property -> in-source; restated literal -> delete";
|
|
3110
|
+
const UNSANCTIONED_SUFFIX_FIX = "public surface -> *.integration.test.ts; real-oracle medium -> *.contract.test.ts (contract lane); parity -> *.differential.test.ts; span graph -> *.trace.test.ts; concurrent or stateful behaviour -> *.conformance.test.ts; pure-cell property -> in-source; restated literal -> delete";
|
|
2776
3111
|
//#endregion
|
|
2777
3112
|
//#region src/rules/test-suffix-outside-src.ts
|
|
2778
3113
|
const testSuffixOutsideSrc = defineRule({
|
|
2779
3114
|
meta: {
|
|
2780
3115
|
type: "problem",
|
|
2781
|
-
docs: { description: "Outside src/, a test file ends .integration.test.ts, .differential.test.ts, or .
|
|
3116
|
+
docs: { description: "Outside src/, a test file ends .integration.test.ts, .differential.test.ts, .trace.test.ts, or .conformance.test.ts. A runner-driving package (Vitest itself, the fork, effect-spec-runtime) keeps plain *.test.ts runners and nested-run probes, so the lane taxonomy does not apply there." },
|
|
2782
3117
|
schema: [],
|
|
2783
|
-
messages: { unsanctionedSuffix: MESSAGE$
|
|
3118
|
+
messages: { unsanctionedSuffix: MESSAGE$8 }
|
|
2784
3119
|
},
|
|
2785
3120
|
create(context) {
|
|
2786
3121
|
const filename = context.filename;
|
|
2787
3122
|
if (isUnderSrc(filename)) return {};
|
|
3123
|
+
if (isRawVitestPackage(filename)) return {};
|
|
2788
3124
|
const basename = basenameOf(filename);
|
|
2789
3125
|
if (!isTestFile(basename)) return {};
|
|
2790
|
-
if (basename.endsWith(".
|
|
3126
|
+
if (isBehaviourBasename(basename) || basename.endsWith(".differential.test.ts") || basename.endsWith(".trace.test.ts") || basename.endsWith(".conformance.test.ts")) return {};
|
|
2791
3127
|
return { Program(node) {
|
|
2792
3128
|
context.report({
|
|
2793
3129
|
node,
|
|
@@ -2814,7 +3150,7 @@ const testsDirHelpersInFixtures = defineRule({
|
|
|
2814
3150
|
type: "problem",
|
|
2815
3151
|
docs: { description: "Under tests/, the only non-test modules are helpers and fixtures, and they live inside tests/__fixtures__/." },
|
|
2816
3152
|
schema: [],
|
|
2817
|
-
messages: { helperOutsideFixtures: MESSAGE$
|
|
3153
|
+
messages: { helperOutsideFixtures: MESSAGE$8 }
|
|
2818
3154
|
},
|
|
2819
3155
|
create(context) {
|
|
2820
3156
|
const filename = context.filename;
|
|
@@ -2843,11 +3179,11 @@ const testsDirHelpersInFixtures = defineRule({
|
|
|
2843
3179
|
const REACH_IN_EXPECTED = "a package name or subpath, or a sibling helper under the test tree";
|
|
2844
3180
|
const REACH_IN_ACTUAL = "a relative import that reaches src or climbs into an internal folder";
|
|
2845
3181
|
const REACH_IN_FIX = "rewrite onto the published package name when the binding is public. Delete the test when the subject is an internal";
|
|
2846
|
-
const meta$
|
|
3182
|
+
const meta$2 = {
|
|
2847
3183
|
type: "problem",
|
|
2848
3184
|
docs: { description: "Forbid package-level tests from relative-importing src or climbing into an internal folder" },
|
|
2849
3185
|
schema: [],
|
|
2850
|
-
messages: { sourceReachIn: MESSAGE$
|
|
3186
|
+
messages: { sourceReachIn: MESSAGE$8 }
|
|
2851
3187
|
};
|
|
2852
3188
|
//#endregion
|
|
2853
3189
|
//#region src/rules/tests-import-public-api.ts
|
|
@@ -2865,7 +3201,7 @@ const specifierOf = (node) => {
|
|
|
2865
3201
|
if (node.type === "Literal" && typeof node.value === "string") return node.value;
|
|
2866
3202
|
};
|
|
2867
3203
|
const testsImportPublicApi = defineRule({
|
|
2868
|
-
meta: meta$
|
|
3204
|
+
meta: meta$2,
|
|
2869
3205
|
create(context) {
|
|
2870
3206
|
if (!isInTestsImportScope(context.filename)) return {};
|
|
2871
3207
|
const reportIfForbidden = (sourceNode) => {
|
|
@@ -2923,14 +3259,14 @@ const HTTP_TERMINATION_FIX = "replace the HTTP assertion with a relation over th
|
|
|
2923
3259
|
const RAW_EMIT_EXPECTED = "spans declared once with Span.declare({ id, name, attrs }) inside the traced cell; the spec observes the decoded graph";
|
|
2924
3260
|
const RAW_EMIT_ACTUAL = "a raw span emit call inside a trace spec";
|
|
2925
3261
|
const RAW_EMIT_FIX = "delete the emit; declare the span with Span.declare in the system under test and assert on the graph the spec receives";
|
|
2926
|
-
const meta = {
|
|
3262
|
+
const meta$1 = {
|
|
2927
3263
|
type: "problem",
|
|
2928
3264
|
docs: { description: "A *.trace.test.ts must import its harness from @systemfsoftware/trace-spec and terminate cases on relation holds over the declared span graph. Raw emit calls (startSpan, spanBuilder, startActiveSpan) and HTTP status, statusText, or body assertions (expect(res.status).toBe(200), expect(res).toHaveProperty(\"status\", 200), expect(res).resolves.toMatchObject({ status: 200 }), expect(res).toEqual(expect.objectContaining({ status: 200 }))) are forbidden — replacing that altitude is what a trace spec exists for." },
|
|
2929
3265
|
schema: [],
|
|
2930
3266
|
messages: {
|
|
2931
|
-
missingHarnessImport: MESSAGE$
|
|
2932
|
-
httpTermination: MESSAGE$
|
|
2933
|
-
rawEmitCall: MESSAGE$
|
|
3267
|
+
missingHarnessImport: MESSAGE$8,
|
|
3268
|
+
httpTermination: MESSAGE$8,
|
|
3269
|
+
rawEmitCall: MESSAGE$8
|
|
2934
3270
|
}
|
|
2935
3271
|
};
|
|
2936
3272
|
//#endregion
|
|
@@ -3097,7 +3433,7 @@ const reportHttpTermination = (context, node) => {
|
|
|
3097
3433
|
});
|
|
3098
3434
|
};
|
|
3099
3435
|
const traceTestRequiresTaxonomy = defineRule({
|
|
3100
|
-
meta,
|
|
3436
|
+
meta: meta$1,
|
|
3101
3437
|
create(context) {
|
|
3102
3438
|
if (!context.filename.endsWith(".trace.test.ts")) return {};
|
|
3103
3439
|
let hasHarnessImport = false;
|
|
@@ -3126,6 +3462,66 @@ const traceTestRequiresTaxonomy = defineRule({
|
|
|
3126
3462
|
}
|
|
3127
3463
|
});
|
|
3128
3464
|
//#endregion
|
|
3465
|
+
//#region src/rules/vitest-from-systemfsoftware-vitest.config.ts
|
|
3466
|
+
const MESSAGE = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
|
|
3467
|
+
const VIOLATION_NAME = "a value import from a foreign vitest runner";
|
|
3468
|
+
const VIOLATION_EXPECTED = "every test value imported from @systemfsoftware/vitest, which re-exports all of Vitest";
|
|
3469
|
+
const VIOLATION_ACTUAL = "a named, namespace, default, side-effect or dynamic value import from vitest or @effect/vitest";
|
|
3470
|
+
const VIOLATION_FIX = "import it from @systemfsoftware/vitest instead of vitest or @effect/vitest";
|
|
3471
|
+
const meta = {
|
|
3472
|
+
type: "problem",
|
|
3473
|
+
docs: { description: "Every value a test uses from Vitest is imported from @systemfsoftware/vitest, which re-exports all of Vitest. Any value import from vitest or the upstream @effect/vitest — named, namespace, default, side-effect or dynamic — is refused; a type-only import stays legal." },
|
|
3474
|
+
schema: [],
|
|
3475
|
+
messages: { vitestImport: MESSAGE }
|
|
3476
|
+
};
|
|
3477
|
+
//#endregion
|
|
3478
|
+
//#region src/rules/vitest-from-systemfsoftware-vitest.ts
|
|
3479
|
+
const isTypeOnly = (node) => {
|
|
3480
|
+
if (node.importKind === "type") return true;
|
|
3481
|
+
if (node.specifiers.length === 0) return false;
|
|
3482
|
+
return node.specifiers.every((specifier) => specifier.type === "ImportSpecifier" && specifier.importKind === "type");
|
|
3483
|
+
};
|
|
3484
|
+
const isForeignVitestLiteral = (node) => node.type === "Literal" && typeof node.value === "string" && FOREIGN_VITEST_SOURCES[node.value] === true;
|
|
3485
|
+
const vitestFromSystemfsoftwareVitest = defineRule({
|
|
3486
|
+
meta,
|
|
3487
|
+
create(context) {
|
|
3488
|
+
if (isRawVitestPackage(context.filename)) return {};
|
|
3489
|
+
const report = (node) => {
|
|
3490
|
+
context.report({
|
|
3491
|
+
node,
|
|
3492
|
+
messageId: "vitestImport",
|
|
3493
|
+
data: {
|
|
3494
|
+
name: VIOLATION_NAME,
|
|
3495
|
+
expected: VIOLATION_EXPECTED,
|
|
3496
|
+
actual: VIOLATION_ACTUAL,
|
|
3497
|
+
fix: VIOLATION_FIX
|
|
3498
|
+
}
|
|
3499
|
+
});
|
|
3500
|
+
};
|
|
3501
|
+
return {
|
|
3502
|
+
ImportDeclaration(node) {
|
|
3503
|
+
if (FOREIGN_VITEST_SOURCES[node.source.value] !== true) return;
|
|
3504
|
+
if (isTypeOnly(node)) return;
|
|
3505
|
+
report(node);
|
|
3506
|
+
},
|
|
3507
|
+
ImportExpression(node) {
|
|
3508
|
+
if (!isForeignVitestLiteral(node.source)) return;
|
|
3509
|
+
report(node);
|
|
3510
|
+
},
|
|
3511
|
+
ExportAllDeclaration(node) {
|
|
3512
|
+
if (node.exportKind === "type") return;
|
|
3513
|
+
if (FOREIGN_VITEST_SOURCES[node.source.value] !== true) return;
|
|
3514
|
+
report(node);
|
|
3515
|
+
},
|
|
3516
|
+
ExportNamedDeclaration(node) {
|
|
3517
|
+
if (node.source === null || node.exportKind === "type") return;
|
|
3518
|
+
if (FOREIGN_VITEST_SOURCES[node.source.value] !== true) return;
|
|
3519
|
+
report(node);
|
|
3520
|
+
}
|
|
3521
|
+
};
|
|
3522
|
+
}
|
|
3523
|
+
});
|
|
3524
|
+
//#endregion
|
|
3129
3525
|
//#region src/index.ts
|
|
3130
3526
|
const PLUGIN_NAME = "@systemfsoftware/oxlint-plugin-test-discipline";
|
|
3131
3527
|
const rule = (name) => `${PLUGIN_NAME}/${name}`;
|
|
@@ -3150,9 +3546,12 @@ const recommendedRules = {
|
|
|
3150
3546
|
[rule("behaviour-exercises-use-case")]: "error",
|
|
3151
3547
|
[rule("behaviour-one-feature-per-file")]: "error",
|
|
3152
3548
|
[rule("tests-dir-helpers-in-fixtures")]: "error",
|
|
3549
|
+
[rule("vitest-from-systemfsoftware-vitest")]: "error",
|
|
3153
3550
|
[rule("no-io-module-in-source-test")]: "error",
|
|
3154
3551
|
[rule("tests-import-public-api")]: "error",
|
|
3155
3552
|
[rule("differential-test-requires-harness")]: "error",
|
|
3553
|
+
[rule("conformance-test-requires-harness")]: "error",
|
|
3554
|
+
[rule("model-fixture-imports-subject")]: "error",
|
|
3156
3555
|
[rule("no-pseudo-gherkin-unit-tests")]: "error",
|
|
3157
3556
|
[rule("ban-raw-span-name-emit")]: "error",
|
|
3158
3557
|
[rule("trace-test-requires-taxonomy")]: "error"
|
|
@@ -3181,9 +3580,12 @@ var src_default = {
|
|
|
3181
3580
|
"behaviour-one-feature-per-file": behaviourOneFeaturePerFile,
|
|
3182
3581
|
"no-pseudo-gherkin-unit-tests": noPseudoGherkinUnitTests,
|
|
3183
3582
|
"tests-dir-helpers-in-fixtures": testsDirHelpersInFixtures,
|
|
3583
|
+
"vitest-from-systemfsoftware-vitest": vitestFromSystemfsoftwareVitest,
|
|
3184
3584
|
"no-io-module-in-source-test": noIoModuleInSourceTest,
|
|
3185
3585
|
"tests-import-public-api": testsImportPublicApi,
|
|
3186
3586
|
"differential-test-requires-harness": differentialTestRequiresHarness,
|
|
3587
|
+
"conformance-test-requires-harness": conformanceTestRequiresHarness,
|
|
3588
|
+
"model-fixture-imports-subject": modelFixtureImportsSubject,
|
|
3187
3589
|
"ban-raw-span-name-emit": banRawSpanNameEmit,
|
|
3188
3590
|
"trace-test-requires-taxonomy": traceTestRequiresTaxonomy
|
|
3189
3591
|
},
|