@systemfsoftware/oxlint-plugin-test-discipline 3.9.0 → 4.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @systemfsoftware/oxlint-plugin-test-discipline
2
2
 
3
+ ## 4.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - `in-source-test-targets-private`'s message now names an exported operation in the `it.prop` `subject` slot as a legal target for an in-source block, beside a private helper.
8
+
9
+ ## 4.0.0
10
+
11
+ ### Minor Changes
12
+
13
+ - Contract suites are now a recognised kind of behaviour test. A test file with the `contract` suffix may live beside integration suites, and the rules hold it to the same obligations: exactly one Gherkin feature, no bare test runner, and an import of the package it proves.
14
+
3
15
  ## 3.9.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -4,21 +4,22 @@ Oxlint rules enforcing property-based test laws, test placement, and test naming
4
4
 
5
5
  ## Rules
6
6
 
7
- | Rule | What it enforces |
8
- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
- | `no-test-file-in-src` | 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. |
10
- | `src-property-test-cell` | A property test under `src/` must be a single-segment `<stem>.workflow.property.test.ts` beside the `<stem>.workflow.ts` it covers. A source file whose suffix names a cell listed in `cellsRequiringTest` must also carry an in-source vitest block; that list is empty by default. |
11
- | `test-file-outside-tests-dir` | A test file outside `src/` must live under `tests/`. |
12
- | `test-suffix-outside-src` | Outside `src/`, a test file must end `.integration.test.ts`, `.differential.test.ts`, or `.trace.test.ts` — the three behaviour suffixes. |
13
- | `tests-dir-helpers-in-fixtures` | Under `tests/`, the only non-test modules are helpers and fixtures, and they live inside `tests/__fixtures__/`. |
14
- | `no-io-module-in-source-test` | An in-source `import.meta.vitest` test block is forbidden in a module that performs I/O — decided from the module's own syntax (a non-type import from a filesystem, process or network module, called at least once), never from its filename. Only the in-source-test idiom is judged: a module whose tests live in separate files is a no-op for this rule. |
15
- | `behaviour-test-requires-gherkin` | A `.integration.test.ts` must import `makeFeature` from `@systemfsoftware/effect-gherkin-spec` and must not import test runners directly from `vitest` or `@effect/vitest`. |
16
- | `behaviour-exercises-use-case` | A `.integration.test.ts` must reach at least one shell entry — an executor/handler/adapter/store, the package `main`/`mod`/`index`, or a non-foundation package — so it drives a use case. |
17
- | `behaviour-one-feature-per-file` | A `.integration.test.ts` must contain exactly one `Feature(...)` call; zero or two-or-more is the junk-drawer signal. |
18
- | `differential-test-requires-harness` | A `.differential.test.ts` must import `@systemfsoftware/differential-spec` and invoke its harness; direct `vitest`/`@effect/vitest` imports and raw runner calls (`it`, `test`, `describe`, `it.effect`, and aliases) are forbidden, and importing the harness without invoking it is equally non-compliant. |
19
- | `tests-import-public-api` | Every file under the package-root `tests/` or `__tests__/` trees, regardless of basename, may not relative-import `src` or climb into an `internal` folder. Import the published package name or a sibling helper. |
20
- | `expect-boolean-predicate` | An `expect(...)` asserted with `toBe(true)`, `toBe(false)`, `toEqual(true)` or `toEqual(false)` must wrap a boolean the code under test returned. A predicate call, comparison, negation or `&&` / `\|\|` evaluated in the test is refused, with the `toSatisfy` or `toEqual` rewrite as the fix. |
21
- | `vitest-from-effect-vitest` | Every value a test uses from Vitest is imported from `@effect/vitest`, which re-exports all of Vitest. Any value import from `vitest` — named, namespace, default, side-effect or dynamic — is refused; a type-only import stays legal. Exempt: the packages that drive Vitest's own runner instead of the fork (`vitest` itself, `vitest-conformance`, and `effect-spec-runtime`, whose `Register` calls Vitest's option-taking collector). |
7
+ | Rule | What it enforces |
8
+ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9
+ | `no-test-file-in-src` | 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. |
10
+ | `src-property-test-cell` | A property test under `src/` must be a single-segment `<stem>.workflow.property.test.ts` beside the `<stem>.workflow.ts` it covers. A source file whose suffix names a cell listed in `cellsRequiringTest` must also carry an in-source vitest block; that list is empty by default. |
11
+ | `test-file-outside-tests-dir` | A test file outside `src/` must live under `tests/`. |
12
+ | `test-suffix-outside-src` | Outside `src/`, a test file must end `.integration.test.ts`, `.differential.test.ts`, `.trace.test.ts`, or `.conformance.test.ts` — the four sanctioned lane suffixes. A runner-driving package (`vitest`, `vitest-conformance`, `effect-spec-runtime`) is exempt: its plain `*.test.ts` runners and nested-run probes are not consumer lanes. |
13
+ | `tests-dir-helpers-in-fixtures` | Under `tests/`, the only non-test modules are helpers and fixtures, and they live inside `tests/__fixtures__/`. |
14
+ | `no-io-module-in-source-test` | An in-source `import.meta.vitest` test block is forbidden in a module that performs I/O — decided from the module's own syntax (a non-type import from a filesystem, process or network module, called at least once), never from its filename. Only the in-source-test idiom is judged: a module whose tests live in separate files is a no-op for this rule. |
15
+ | `behaviour-test-requires-gherkin` | A `.integration.test.ts` must import `makeFeature` from `@systemfsoftware/effect-gherkin-spec` and must not import test runners directly from `vitest`, `@effect/vitest` or `@systemfsoftware/vitest`. |
16
+ | `behaviour-exercises-use-case` | A `.integration.test.ts` must reach at least one shell entry — an executor/handler/adapter/store, the package `main`/`mod`/`index`, or a non-foundation package — so it drives a use case. |
17
+ | `behaviour-one-feature-per-file` | A `.integration.test.ts` must contain exactly one `Feature(...)` call; zero or two-or-more is the junk-drawer signal. |
18
+ | `differential-test-requires-harness` | A `.differential.test.ts` must import `@systemfsoftware/differential-spec` and invoke its harness; direct `vitest`/`@effect/vitest`/`@systemfsoftware/vitest` imports and raw runner calls (`it`, `test`, `describe`, `it.effect`, and aliases) are forbidden, and importing the harness without invoking it is equally non-compliant. The harness is expressed as `Differential.compare({ name, reference, candidate })` or `Metamorphic.on({ name, system })`. |
19
+ | `conformance-test-requires-harness` | A `.conformance.test.ts` must import `Conformance` from `@systemfsoftware/conformance-spec` and invoke one of its checks — `Conformance.linearizable`, `Conformance.sequential`, or `Conformance.released`; direct `vitest`/`@effect/vitest`/`@systemfsoftware/vitest` imports, raw runner calls (`it`, `test`, `describe`, `it.effect`, and aliases), and the retired `Linearizable.check` / `SequentialModel.check` / `Released.check` shapes are forbidden, and importing the barrel without invoking a check is equally non-compliant. |
20
+ | `model-fixture-imports-subject` | A `*.model.ts` fixture may not import the package whose tests it serves. The package is read from the fixture's path — the directory its tests tree lives in — and an import, re-export, dynamic import, or require of that 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's directory it must stay inside the tests tree, so `../../src/Lock.js` is reported and `./Locks.js` is a sibling fixture. `effect`, node builtins, and other packages' names stay allowed. |
21
+ | `vitest-from-systemfsoftware-vitest` | 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. Exempt: the packages that drive Vitest's own runner instead of the fork (`vitest` itself, `vitest-conformance`, and `effect-spec-runtime`, whose `Register` calls Vitest's option-taking collector). |
22
+ | `tests-import-public-api` | Every file under the package-root `tests/` or `__tests__/` trees, regardless of basename, may not relative-import `src` or climb into an `internal` folder. Import the published package name or a sibling helper. |
22
23
 
23
24
  | suffix | layer | doubles | location |
24
25
  | ----------------------------- | ------------ | ------------------------ | ------------------ |
@@ -26,10 +27,11 @@ Oxlint rules enforcing property-based test laws, test placement, and test naming
26
27
  | `*.integration.test.ts` | Behaviour | permitted, at ports only | NEVER under `src/` |
27
28
  | `*.differential.test.ts` | Differential | permitted, at ports only | NEVER under `src/` |
28
29
  | `*.trace.test.ts` | Trace spec | none — observes graphs | NEVER under `src/` |
30
+ | `*.conformance.test.ts` | Conformance | permitted, at ports only | NEVER under `src/` |
29
31
 
30
32
  ## Enrollment
31
33
 
32
- `@systemfsoftware/oxlint-config-recommended` loads this plugin in `jsPlugins` and turns on `expect-boolean-predicate` and `vitest-from-effect-vitest` at `error` in its default config. The rest of `configs.recommended` is exported from that preset as `rules`, but the default config does not enable it, so those rules fire only in a package that spreads it into its own `rules`.
34
+ `@systemfsoftware/oxlint-config-recommended` loads this plugin in `jsPlugins` and spreads `configs.recommended` into its default `rules` at `error`, so every rule above fires in every package that extends the preset.
33
35
 
34
36
  ## Testing
35
37
 
package/dist/index.d.ts CHANGED
@@ -25,11 +25,12 @@ declare const _default: {
25
25
  'behaviour-one-feature-per-file': import("@oxlint/plugins").Rule;
26
26
  'no-pseudo-gherkin-unit-tests': import("@oxlint/plugins").Rule;
27
27
  'tests-dir-helpers-in-fixtures': import("@oxlint/plugins").Rule;
28
- 'expect-boolean-predicate': import("@oxlint/plugins").Rule;
29
- 'vitest-from-effect-vitest': import("@oxlint/plugins").Rule;
28
+ 'vitest-from-systemfsoftware-vitest': import("@oxlint/plugins").Rule;
30
29
  'no-io-module-in-source-test': import("@oxlint/plugins").Rule;
31
30
  'tests-import-public-api': import("@oxlint/plugins").Rule;
32
31
  'differential-test-requires-harness': import("@oxlint/plugins").Rule;
32
+ 'conformance-test-requires-harness': import("@oxlint/plugins").Rule;
33
+ 'model-fixture-imports-subject': import("@oxlint/plugins").Rule;
33
34
  'ban-raw-span-name-emit': import("@oxlint/plugins").Rule;
34
35
  'trace-test-requires-taxonomy': import("@oxlint/plugins").Rule;
35
36
  };
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$27 = {
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$27 = {
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$1 = (callee) => {
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$1(node.callee);
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$27,
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,8 +97,19 @@ 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 FOREIGN_RUNNERS = /* @__PURE__ */ new Set(["vitest", "@effect/vitest"]);
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. */
102
113
  const RAW_VITEST_PACKAGES = {
103
114
  "effect-spec-runtime": true,
104
115
  "vitest": true,
@@ -109,19 +120,19 @@ const RUNNER_NAMES = /* @__PURE__ */ new Set([
109
120
  "test",
110
121
  "describe"
111
122
  ]);
112
- const MESSAGE$9 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
123
+ const MESSAGE$8 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
113
124
  const ABSENCE_MESSAGE = "{{name}} is untested. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
114
125
  //#endregion
115
126
  //#region src/rules/behaviour-exercises-use-case.config.ts
116
127
  const NO_SUBJECT_IMPORT_NAME = "a *.integration.test.ts that reaches no package code";
117
128
  const NO_SUBJECT_IMPORT_EXPECTED = "an import of the package code under test";
118
- 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";
119
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.";
120
- const meta$26 = {
131
+ const meta$27 = {
121
132
  type: "problem",
122
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." },
123
134
  schema: [],
124
- messages: { noSubjectImport: MESSAGE$9 }
135
+ messages: { noSubjectImport: MESSAGE$8 }
125
136
  };
126
137
  //#endregion
127
138
  //#region src/rules/path.ts
@@ -141,13 +152,14 @@ const isInTestsImportScope = (filename) => {
141
152
  };
142
153
  const isInConfiguredTestDir = (filename, dirs) => directoriesOf(filename).some((segment) => dirs.includes(segment));
143
154
  const isTestFile = (basename) => TEST_BASENAME.test(basename);
155
+ const isBehaviourBasename = (basename) => basename.endsWith(".integration.test.ts") || basename.endsWith(".contract.test.ts");
144
156
  const CELL_SUFFIX = /\.([^.]+)\.[cm]?tsx?$/;
145
157
  const cellOf = (basename) => CELL_SUFFIX.exec(basename)?.[1];
146
158
  //#endregion
147
159
  //#region src/rules/behaviour-exercises-use-case.ts
148
160
  const EFFECT_PACKAGE = "effect";
149
161
  const FOUNDATION_PACKAGES = /* @__PURE__ */ new Set([
150
- ...FOREIGN_RUNNERS,
162
+ ...Object.keys(FOREIGN_RUNNERS),
151
163
  GHERKIN_PACKAGE,
152
164
  EFFECT_PACKAGE
153
165
  ]);
@@ -170,7 +182,6 @@ const isFoundationImport = (source, filename) => {
170
182
  if (source === "@systemfsoftware/effect-gherkin-spec" && isGherkinPackageTree(filename)) return false;
171
183
  return FOUNDATION_PACKAGES.has(source) || source.startsWith(`${EFFECT_PACKAGE}/`) || source.startsWith("node:");
172
184
  };
173
- const isBehaviourTest$3 = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
174
185
  /** The stem of a path with its final extension stripped, for identity comparisons. */
175
186
  const stemOf = (file) => file.replace(/\.[^/]+$/, "");
176
187
  /**
@@ -222,16 +233,17 @@ const DIST_SEGMENT = /(?:^|\/)dist\//;
222
233
  * name documents it for the reader without pretending to be evidence.
223
234
  *
224
235
  * What counts as reaching: a runtime import from anything that is not the
225
- * scaffolding - vitest, @effect/vitest, the gherkin spec package, effect or a
226
- * subpath of it, or a Node builtin - and not the test file itself, or a dynamic
227
- * `import(...)` of such a source. Type-only specifiers are erased and never
228
- * count; a side-effect import (`import "./x.js"`, `import {} from "./x.js"`)
229
- * executes its module and counts for whatever that module is. Whether a named
230
- * module actually exists is not observable from one file, so an import whose
231
- * path names nothing satisfies the rule the same way a real one does.
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.
232
244
  */
233
245
  const behaviourExercisesUseCase = defineRule({
234
- meta: meta$26,
246
+ meta: meta$27,
235
247
  create(context) {
236
248
  let reached = false;
237
249
  return {
@@ -246,7 +258,7 @@ const behaviourExercisesUseCase = defineRule({
246
258
  if (typeof node.value === "string" && DIST_SEGMENT.test(node.value)) reached = true;
247
259
  },
248
260
  "Program:exit"(node) {
249
- if (!isBehaviourTest$3(basenameOf(context.filename))) return;
261
+ if (!isBehaviourBasename(basenameOf(context.filename))) return;
250
262
  if (reached) return;
251
263
  for (const statement of node.body) {
252
264
  if (statement.type !== "ImportDeclaration") continue;
@@ -281,13 +293,13 @@ const TOO_MANY_FEATURES_NAME = "a *.integration.test.ts accumulating multiple Fe
281
293
  const TOO_MANY_FEATURES_EXPECTED = "exactly one Feature(...) — every additional one signals a junk drawer";
282
294
  const TOO_MANY_FEATURES_ACTUAL = "a behaviour file with two or more Feature(...) calls";
283
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.";
284
- const meta$25 = {
296
+ const meta$26 = {
285
297
  type: "problem",
286
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." },
287
299
  schema: [],
288
300
  messages: {
289
- tooFewFeatures: MESSAGE$9,
290
- tooManyFeatures: MESSAGE$9
301
+ tooFewFeatures: MESSAGE$8,
302
+ tooManyFeatures: MESSAGE$8
291
303
  }
292
304
  };
293
305
  //#endregion
@@ -306,7 +318,6 @@ const isFeatureCallStatement = (statement) => {
306
318
  if (statement.expression.type !== "CallExpression") return false;
307
319
  return rootCalleeName(statement.expression.callee) === "Feature";
308
320
  };
309
- const isBehaviourTest$2 = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
310
321
  const findSecondFeatureCall = (program) => {
311
322
  let seen = 0;
312
323
  for (const statement of program.body) {
@@ -326,10 +337,10 @@ const hasAnyFeatureCall = (program) => {
326
337
  return false;
327
338
  };
328
339
  const behaviourOneFeaturePerFile = defineRule({
329
- meta: meta$25,
340
+ meta: meta$26,
330
341
  create(context) {
331
342
  return { "Program:exit"(node) {
332
- if (!isBehaviourTest$2(basenameOf(context.filename))) return;
343
+ if (!isBehaviourBasename(basenameOf(context.filename))) return;
333
344
  const excess = findSecondFeatureCall(node);
334
345
  if (excess !== null) {
335
346
  const total = countFeatureCalls(node);
@@ -361,19 +372,19 @@ const behaviourOneFeaturePerFile = defineRule({
361
372
  //#endregion
362
373
  //#region src/rules/behaviour-test-requires-gherkin.config.ts
363
374
  const FOREIGN_RUNNER_EXPECTED = "it and layer imported from @systemfsoftware/effect-gherkin-spec";
364
- const FOREIGN_RUNNER_ACTUAL = "a test runner imported directly from vitest or @effect/vitest in a behaviour file";
365
- const FOREIGN_RUNNER_FIX = "import { it, layer } from @systemfsoftware/effect-gherkin-spec and build the suite with makeFeature({ it, layer })";
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 })";
366
377
  const MISSING_MAKE_FEATURE_NAME = "a *.integration.test.ts without makeFeature";
367
378
  const MISSING_MAKE_FEATURE_EXPECTED = "makeFeature imported from @systemfsoftware/effect-gherkin-spec";
368
379
  const MISSING_MAKE_FEATURE_ACTUAL = "a behaviour file that never constructs a Gherkin feature";
369
380
  const MISSING_MAKE_FEATURE_FIX = "import { makeFeature } from @systemfsoftware/effect-gherkin-spec and declare `const Feature = makeFeature({ it, layer })`";
370
- const meta$24 = {
381
+ const meta$25 = {
371
382
  type: "problem",
372
- 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 @effect/vitest." },
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." },
373
384
  schema: [],
374
385
  messages: {
375
- foreignRunner: MESSAGE$9,
376
- missingMakeFeature: MESSAGE$9
386
+ foreignRunner: MESSAGE$8,
387
+ missingMakeFeature: MESSAGE$8
377
388
  }
378
389
  };
379
390
  //#endregion
@@ -393,13 +404,12 @@ const foreignRunnerNameOf = (specifier) => {
393
404
  const { name } = Schema.decodeSync(ImportedIdentifier)(specifier.imported);
394
405
  return RUNNER_NAMES.has(name) ? name : null;
395
406
  };
396
- const isBehaviourTest$1 = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
397
407
  const behaviourTestRequiresGherkin = defineRule({
398
- meta: meta$24,
408
+ meta: meta$25,
399
409
  create(context) {
400
410
  const basename = basenameOf(context.filename);
401
411
  return { Program(node) {
402
- if (!isBehaviourTest$1(basename)) return;
412
+ if (!isBehaviourBasename(basename)) return;
403
413
  let hasMakeFeature = false;
404
414
  for (const statement of node.body) {
405
415
  if (statement.type !== "ImportDeclaration") continue;
@@ -408,7 +418,7 @@ const behaviourTestRequiresGherkin = defineRule({
408
418
  if (specifier.type !== "ImportSpecifier") continue;
409
419
  if (isMakeFeatureSpecifier(specifier)) hasMakeFeature = true;
410
420
  }
411
- if (!FOREIGN_RUNNERS.has(sourceValue)) continue;
421
+ if (FOREIGN_RUNNERS[sourceValue] !== true) continue;
412
422
  for (const specifier of statement.specifiers) {
413
423
  if (specifier.type !== "ImportSpecifier") continue;
414
424
  const runnerName = foreignRunnerNameOf(specifier);
@@ -438,6 +448,145 @@ const behaviourTestRequiresGherkin = defineRule({
438
448
  } };
439
449
  }
440
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
+ });
441
590
  //#endregion
442
591
  //#region src/rules/damp-test-naming.config.ts
443
592
  const PASCAL_CASE$1 = /^[A-Z][a-z][a-zA-Z0-9]*$/;
@@ -540,6 +689,7 @@ const isTestFunctionCall = (node) => {
540
689
  const dampTestNaming = defineRule({
541
690
  meta: meta$23,
542
691
  create(context) {
692
+ if (TYPE_TEST_BASENAME.test(basenameOf(context.filename))) return {};
543
693
  return { CallExpression(node) {
544
694
  if (!isTestFunctionCall(node)) return;
545
695
  const firstArg = node.arguments[0];
@@ -574,14 +724,14 @@ const meta$22 = {
574
724
  };
575
725
  //#endregion
576
726
  //#region src/rules/differential-test-requires-harness.ts
577
- 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)";
578
728
  const memberObjectName = (callee) => callee.type === "MemberExpression" && callee.object.type === "Identifier" ? callee.object.name : void 0;
579
729
  const calleeName = (callee) => callee.type === "Identifier" ? callee.name : memberObjectName(callee);
580
730
  const recordHarnessBindings = (node, bindings) => {
581
731
  if (node.source.value !== "@systemfsoftware/differential-spec") return;
582
732
  for (const specifier of node.specifiers) bindings.add(specifier.local.name);
583
733
  };
584
- const isForeignRunnerImport = (node) => typeof node.source.value === "string" && FOREIGN_RUNNERS.has(node.source.value);
734
+ const isForeignRunnerImport = (node) => typeof node.source.value === "string" && FOREIGN_RUNNERS[node.source.value] === true;
585
735
  const differentialTestRequiresHarness = defineRule({
586
736
  meta: meta$22,
587
737
  create(context) {
@@ -598,7 +748,7 @@ const differentialTestRequiresHarness = defineRule({
598
748
  data: {
599
749
  name: `runner import from ${String(node.source.value)} in a differential test file`,
600
750
  expected: HARNESS_PRESCRIPTION$1,
601
- 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",
602
752
  fix: `delete the runner import; ${HARNESS_PRESCRIPTION$1}`
603
753
  }
604
754
  });
@@ -654,97 +804,16 @@ const differentialTestRequiresHarness = defineRule({
654
804
  }
655
805
  });
656
806
  //#endregion
657
- //#region src/rules/expect-boolean-predicate.config.ts
658
- const MESSAGE$8 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
659
- const BOOLEAN_MATCHERS = {
660
- toBe: true,
661
- toEqual: true
662
- };
663
- const COMPARISON_OPERATORS = {
664
- "===": true,
665
- "!==": true,
666
- "==": true,
667
- "!=": true,
668
- "<": true,
669
- "<=": true,
670
- ">": true,
671
- ">=": true,
672
- instanceof: true,
673
- in: true
674
- };
675
- const VIOLATION_NAME$4 = "a predicate evaluated in the test asserted against true or false";
676
- const VIOLATION_EXPECTED$1 = "expect(...) around a boolean the code under test returned";
677
- const VIOLATION_ACTUAL$1 = "expect(...) wraps a call, a comparison, a `!` or an `&&` / `||` evaluated in the test, so a failure reports `expected false to be true` instead of the values that disagreed";
678
- const VIOLATION_FIX$1 = "rewrite a predicate call as expect(x).toSatisfy(P) — e.g. expect(s.reading).toSatisfy(Result.isFailure) — and a comparison as expect(a).toEqual(b)";
679
- const meta$21 = {
680
- type: "problem",
681
- docs: { description: "An expect(...) asserted with toBe(true|false) or toEqual(true|false) must wrap a boolean the code under test returned. A predicate call, comparison, negation or && / || evaluated in the test is refused, with the toSatisfy or toEqual rewrite as the fix." },
682
- schema: [],
683
- messages: { booleanPredicate: MESSAGE$8 }
684
- };
685
- //#endregion
686
- //#region src/rules/expect-call.ts
687
- const EXPECT = "expect";
688
- const isExpectCallee = (callee) => callee.type === "Identifier" && callee.name === EXPECT;
689
- const expectCallOf = (node, acceptsMember) => {
690
- if (node.callee.type !== "MemberExpression") return void 0;
691
- let target = node.callee.object;
692
- while (target.type === "MemberExpression") {
693
- if (acceptsMember !== void 0 && acceptsMember(target) === false) return void 0;
694
- target = target.object;
695
- }
696
- if (target.type !== "CallExpression" || isExpectCallee(target.callee) === false) return void 0;
697
- return target;
698
- };
699
- //#endregion
700
- //#region src/rules/expect-boolean-predicate.ts
701
- const isNotMember = (member) => member.property.type === "Identifier" && member.property.name === "not";
702
- const isBooleanLiteral = (node) => node !== void 0 && node.type === "Literal" && typeof node.value === "boolean";
703
- /** The ARG positions `expect` exposes that no subject can fill by accident. */
704
- const isEvaluatedInTest = (arg) => {
705
- switch (arg.type) {
706
- case "CallExpression": return true;
707
- case "UnaryExpression": return arg.operator === "!";
708
- case "LogicalExpression": return arg.operator === "&&" || arg.operator === "||";
709
- case "BinaryExpression": return COMPARISON_OPERATORS[arg.operator] === true;
710
- default: return false;
711
- }
712
- };
713
- const expectBooleanPredicate = defineRule({
714
- meta: meta$21,
715
- create(context) {
716
- return { CallExpression(node) {
717
- if (node.callee.type !== "MemberExpression" || node.callee.property.type !== "Identifier") return;
718
- if (BOOLEAN_MATCHERS[node.callee.property.name] !== true) return;
719
- if (!isBooleanLiteral(node.arguments[0])) return;
720
- const receiver = expectCallOf(node, isNotMember);
721
- if (receiver === void 0) return;
722
- const arg = receiver.arguments[0];
723
- if (arg === void 0 || !isEvaluatedInTest(arg)) return;
724
- context.report({
725
- node,
726
- messageId: "booleanPredicate",
727
- data: {
728
- name: VIOLATION_NAME$4,
729
- expected: VIOLATION_EXPECTED$1,
730
- actual: VIOLATION_ACTUAL$1,
731
- fix: VIOLATION_FIX$1
732
- }
733
- });
734
- } };
735
- }
736
- });
737
- //#endregion
738
807
  //#region src/rules/in-source-test-prop-only.config.ts
739
808
  const NON_PROP_CALL_NAME = "a non-property test call inside an `import.meta.vitest` block";
740
809
  const NON_PROP_CALL_EXPECTED = "only `it.prop` or `it.effect.prop` member-chain calls (standard modifiers included) with boolean predicates";
741
810
  const NON_PROP_CALL_ACTUAL = "a bare or member-chain test call other than `it.prop`/`it.effect.prop`";
742
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";
743
- const meta$20 = {
812
+ const meta$21 = {
744
813
  type: "problem",
745
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." },
746
815
  schema: [],
747
- messages: { nonPropCall: MESSAGE$9 }
816
+ messages: { nonPropCall: MESSAGE$8 }
748
817
  };
749
818
  //#endregion
750
819
  //#region src/rules/vitest-guard.ts
@@ -804,7 +873,7 @@ const rootNameOf = (node) => {
804
873
  if (node.type === "CallExpression") return rootNameOf(node.callee);
805
874
  };
806
875
  const inSourceTestPropOnly = defineRule({
807
- meta: meta$20,
876
+ meta: meta$21,
808
877
  create(context) {
809
878
  const filename = context.filename;
810
879
  const basename = basenameOf(filename);
@@ -845,17 +914,17 @@ const NOT_MODULE_LEVEL_NAME = "a nested `import.meta.vitest` block";
845
914
  const NOT_MODULE_LEVEL_EXPECTED = "the in-source test block as a direct statement of the module body";
846
915
  const NOT_MODULE_LEVEL_ACTUAL = "an `import.meta.vitest` block nested inside another statement";
847
916
  const NOT_MODULE_LEVEL_FIX = "move the block to module level — a nested block does not run under vitest includeSource";
848
- const NO_PRIVATE_TARGET_NAME = "an `import.meta.vitest` block touching no private binding";
849
- const NO_PRIVATE_TARGET_EXPECTED = "an in-source test exercising a non-exported module-level binding";
850
- const NO_PRIVATE_TARGET_ACTUAL = "an in-source block referencing only exported or imported names";
851
- 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";
852
- const meta$19 = {
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 an exported operation named in the `it.prop` `subject` slot";
919
+ const NO_PRIVATE_TARGET_ACTUAL = "an in-source block referencing only exported-call or imported names";
920
+ const NO_PRIVATE_TARGET_FIX = "touch this module. A private helper is referenced directly; an exported operation is named in the `it.prop` `subject` slot beside its declaration — that is the one home for its laws and the file whose mutants they run. What the block cannot hold is a behaviour binding no module-level name: test the public surface from tests/ as *.integration.test.ts instead";
921
+ const meta$20 = {
853
922
  type: "problem",
854
- 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/." },
923
+ docs: { description: "In-source `if (import.meta.vitest)` blocks under src/ must be at module level and exercise this module's own code — a private helper, or an exported operation named in the `it.prop` `subject` slot; a public-surface test belongs in tests/ as an integration test." },
855
924
  schema: [],
856
925
  messages: {
857
- notModuleLevel: MESSAGE$9,
858
- noPrivateTarget: MESSAGE$9
926
+ notModuleLevel: MESSAGE$8,
927
+ noPrivateTarget: MESSAGE$8
859
928
  }
860
929
  };
861
930
  //#endregion
@@ -882,8 +951,25 @@ const collectPrivateNames = (body, out) => {
882
951
  if (isCollectable(node)) collectDeclaration(node, out);
883
952
  }
884
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
+ };
885
971
  const inSourceTestTargetsPrivate = defineRule({
886
- meta: meta$19,
972
+ meta: meta$20,
887
973
  create(context) {
888
974
  const filename = context.filename;
889
975
  const basename = basenameOf(filename);
@@ -891,10 +977,12 @@ const inSourceTestTargetsPrivate = defineRule({
891
977
  const inTestFile = isTestFile(basename);
892
978
  if (!underSrc || inTestFile) return {};
893
979
  const privateNames = /* @__PURE__ */ new Set();
980
+ const moduleNames = /* @__PURE__ */ new Set();
894
981
  const guards = [];
895
982
  return {
896
983
  Program(node) {
897
984
  collectPrivateNames(node.body, privateNames);
985
+ collectModuleNames(node.body, moduleNames);
898
986
  },
899
987
  IfStatement(node) {
900
988
  if (!isVitestGuard(node.test)) return;
@@ -917,7 +1005,7 @@ const inSourceTestTargetsPrivate = defineRule({
917
1005
  });
918
1006
  },
919
1007
  Identifier(node) {
920
- if (!privateNames.has(node.name)) return;
1008
+ if (!(privateNames.has(node.name) || moduleNames.has(node.name) && isSubjectValue(node))) return;
921
1009
  const guard = guards.find((g) => isInsideConsequent(node, g.node.consequent));
922
1010
  if (guard !== void 0) guard.hit = true;
923
1011
  },
@@ -940,6 +1028,122 @@ const inSourceTestTargetsPrivate = defineRule({
940
1028
  }
941
1029
  });
942
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
943
1147
  //#region src/rules/no-assert-in-property.config.ts
944
1148
  const MESSAGE$7 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
945
1149
  const meta$18 = {
@@ -1019,6 +1223,20 @@ const noAssertInProperty = defineRule({
1019
1223
  }
1020
1224
  });
1021
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
1022
1240
  //#region src/rules/no-behaviourless-assertion.config.ts
1023
1241
  const TEST_FILE = /\.(test|spec)\.[cm]?tsx?$/;
1024
1242
  const BEHAVIOUR_NODES = {
@@ -1430,7 +1648,7 @@ const meta$14 = {
1430
1648
  type: "problem",
1431
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." },
1432
1650
  schema: [],
1433
- messages: { noLayerInFeature: MESSAGE$9 }
1651
+ messages: { noLayerInFeature: MESSAGE$8 }
1434
1652
  };
1435
1653
  //#endregion
1436
1654
  //#region src/rules/no-pseudo-gherkin-unit-tests.ts
@@ -1438,7 +1656,6 @@ const LAYER_METHODS = {
1438
1656
  withLayer: true,
1439
1657
  withScenarioLayer: true
1440
1658
  };
1441
- const isBehaviourTest = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
1442
1659
  const hasLayerInChain = (callNode) => {
1443
1660
  let current = callNode;
1444
1661
  while (current.type === "CallExpression") {
@@ -1463,7 +1680,7 @@ const findRootFeatureCall = (callNode) => {
1463
1680
  const noPseudoGherkinUnitTests = defineRule({
1464
1681
  meta: meta$14,
1465
1682
  create(context) {
1466
- if (!isBehaviourTest(basenameOf(context.filename))) return {};
1683
+ if (!isBehaviourBasename(basenameOf(context.filename))) return {};
1467
1684
  return { CallExpression(node) {
1468
1685
  if (node.callee.type === "MemberExpression" && node.callee.property.type === "Identifier") {
1469
1686
  if (node.callee.property.name === "body") {
@@ -1488,7 +1705,7 @@ const noPseudoGherkinUnitTests = defineRule({
1488
1705
  const MESSAGE$5 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
1489
1706
  const meta$13 = {
1490
1707
  type: "problem",
1491
- docs: { description: "Property predicates (it.prop / it.effect.prop from @effect/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." },
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." },
1492
1709
  schema: [],
1493
1710
  messages: {
1494
1711
  bareReturn: MESSAGE$5,
@@ -1668,9 +1885,9 @@ const meta$12 = {
1668
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." },
1669
1886
  schema: [Schema.toJsonSchemaDocument(Options$1).schema],
1670
1887
  messages: {
1671
- testFileInSrc: MESSAGE$9,
1672
- schemaTestInSrc: MESSAGE$9,
1673
- propertyTestOutsideTestsDir: MESSAGE$9
1888
+ testFileInSrc: MESSAGE$8,
1889
+ schemaTestInSrc: MESSAGE$8,
1890
+ propertyTestOutsideTestsDir: MESSAGE$8
1674
1891
  }
1675
1892
  };
1676
1893
  //#endregion
@@ -1725,7 +1942,8 @@ const PREDICATE_SYMBOLS = /* @__PURE__ */ new Set([
1725
1942
  "∘",
1726
1943
  "∩",
1727
1944
  "∪",
1728
- "⊥"
1945
+ "⊥",
1946
+ "⊨"
1729
1947
  ]);
1730
1948
  const NULLARY_PREDICATE_SYMBOLS = /* @__PURE__ */ new Set(["⊥"]);
1731
1949
  const PASCAL_CASE = /^[A-Z][a-z][a-zA-Z0-9]*$/;
@@ -1740,7 +1958,7 @@ const meta$11 = {
1740
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.",
1741
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.",
1742
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.",
1743
- 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).",
1744
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."
1745
1963
  }
1746
1964
  };
@@ -2540,6 +2758,16 @@ const NEUTRAL_METHODS = {
2540
2758
  match: true,
2541
2759
  gen: true
2542
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
+ };
2543
2771
  const isNode = (value) => value !== null && typeof value === "object" && "type" in value;
2544
2772
  const mentionsImportMetaVitest = (value) => {
2545
2773
  if (Array.isArray(value)) return value.some((item) => mentionsImportMetaVitest(item));
@@ -2551,9 +2779,9 @@ const mentionsImportMetaVitest = (value) => {
2551
2779
  }
2552
2780
  return false;
2553
2781
  };
2554
- const collectPredicateShape = (provenance, node, shape) => {
2782
+ const collectPredicateShape = (provenance, node, shape, subjectParameter) => {
2555
2783
  if (Array.isArray(node)) {
2556
- for (const item of node) collectPredicateShape(provenance, item, shape);
2784
+ for (const item of node) collectPredicateShape(provenance, item, shape, subjectParameter);
2557
2785
  return;
2558
2786
  }
2559
2787
  if (!isNode(node)) return;
@@ -2571,12 +2799,12 @@ const collectPredicateShape = (provenance, node, shape) => {
2571
2799
  if (provenance.classifyCall(name, node) === "domain" || receiverIsLocal) shape.callsDomainFunction = true;
2572
2800
  }
2573
2801
  } else if (callee.type === "Identifier") {
2574
- 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;
2575
2803
  }
2576
2804
  }
2577
2805
  for (const [key, child] of Object.entries(node)) {
2578
2806
  if (key === "parent") continue;
2579
- collectPredicateShape(provenance, child, shape);
2807
+ collectPredicateShape(provenance, child, shape, subjectParameter);
2580
2808
  }
2581
2809
  };
2582
2810
  const checkPropCall = (provenance, context, call) => {
@@ -2586,7 +2814,7 @@ const checkPropCall = (provenance, context, call) => {
2586
2814
  usesCompilerProbe: false,
2587
2815
  callsDomainFunction: false
2588
2816
  };
2589
- collectPredicateShape(provenance, predicate.body, shape);
2817
+ collectPredicateShape(provenance, predicate.body, shape, subjectParameterOf(predicate));
2590
2818
  if (shape.usesCompilerProbe) {
2591
2819
  context.report({
2592
2820
  node: call,
@@ -2681,7 +2909,7 @@ const PROPERTY_FILE_KIND = {
2681
2909
  suffix: PROPERTY_TEST_SUFFIX,
2682
2910
  plainExpected: "it.prop(...) or it.effect.prop(...) — property files never mix with scenario tests",
2683
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",
2684
- rawFcExpected: "it.prop(...) or it.effect.prop(...) from @effect/vitest",
2912
+ rawFcExpected: "it.prop(...) or it.effect.prop(...) from @systemfsoftware/vitest",
2685
2913
  rawFcActualSuffix: "bypasses the vitest/Effect integration",
2686
2914
  rawFcFix: "rewrite as it.prop(name, { of, subject, runs }, holds) returning a boolean; fc.* stays for building arbitraries (fc.pre, fc.stringMatching, ...)"
2687
2915
  };
@@ -2794,7 +3022,7 @@ const meta$6 = {
2794
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." },
2795
3023
  schema: [Schema.toJsonSchemaDocument(Options).schema],
2796
3024
  messages: {
2797
- unsanctionedCell: MESSAGE$9,
3025
+ unsanctionedCell: MESSAGE$8,
2798
3026
  missingCellTest: ABSENCE_MESSAGE
2799
3027
  }
2800
3028
  };
@@ -2855,7 +3083,7 @@ const testFileOutsideTestsDir = defineRule({
2855
3083
  type: "problem",
2856
3084
  docs: { description: "Test files outside src/ must live under a tests/ directory; free-standing test files have no sanctioned home." },
2857
3085
  schema: [],
2858
- messages: { strayTestFile: MESSAGE$9 }
3086
+ messages: { strayTestFile: MESSAGE$8 }
2859
3087
  },
2860
3088
  create(context) {
2861
3089
  if (isUnderSrc(context.filename)) return {};
@@ -2877,24 +3105,25 @@ const testFileOutsideTestsDir = defineRule({
2877
3105
  });
2878
3106
  //#endregion
2879
3107
  //#region src/rules/test-suffix-outside-src.config.ts
2880
- const UNSANCTIONED_SUFFIX_EXPECTED = "*.integration.test.ts, *.differential.test.ts, or *.trace.test.ts";
3108
+ const UNSANCTIONED_SUFFIX_EXPECTED = "*.integration.test.ts, *.contract.test.ts, *.differential.test.ts, *.trace.test.ts, or *.conformance.test.ts";
2881
3109
  const UNSANCTIONED_SUFFIX_ACTUAL = "another test suffix outside src/";
2882
- 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";
2883
3111
  //#endregion
2884
3112
  //#region src/rules/test-suffix-outside-src.ts
2885
3113
  const testSuffixOutsideSrc = defineRule({
2886
3114
  meta: {
2887
3115
  type: "problem",
2888
- docs: { description: "Outside src/, a test file ends .integration.test.ts, .differential.test.ts, or .trace.test.ts." },
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." },
2889
3117
  schema: [],
2890
- messages: { unsanctionedSuffix: MESSAGE$9 }
3118
+ messages: { unsanctionedSuffix: MESSAGE$8 }
2891
3119
  },
2892
3120
  create(context) {
2893
3121
  const filename = context.filename;
2894
3122
  if (isUnderSrc(filename)) return {};
3123
+ if (isRawVitestPackage(filename)) return {};
2895
3124
  const basename = basenameOf(filename);
2896
3125
  if (!isTestFile(basename)) return {};
2897
- if (basename.endsWith(".integration.test.ts") || basename.endsWith(".differential.test.ts") || basename.endsWith(".trace.test.ts")) return {};
3126
+ if (isBehaviourBasename(basename) || basename.endsWith(".differential.test.ts") || basename.endsWith(".trace.test.ts") || basename.endsWith(".conformance.test.ts")) return {};
2898
3127
  return { Program(node) {
2899
3128
  context.report({
2900
3129
  node,
@@ -2921,7 +3150,7 @@ const testsDirHelpersInFixtures = defineRule({
2921
3150
  type: "problem",
2922
3151
  docs: { description: "Under tests/, the only non-test modules are helpers and fixtures, and they live inside tests/__fixtures__/." },
2923
3152
  schema: [],
2924
- messages: { helperOutsideFixtures: MESSAGE$9 }
3153
+ messages: { helperOutsideFixtures: MESSAGE$8 }
2925
3154
  },
2926
3155
  create(context) {
2927
3156
  const filename = context.filename;
@@ -2954,7 +3183,7 @@ const meta$2 = {
2954
3183
  type: "problem",
2955
3184
  docs: { description: "Forbid package-level tests from relative-importing src or climbing into an internal folder" },
2956
3185
  schema: [],
2957
- messages: { sourceReachIn: MESSAGE$9 }
3186
+ messages: { sourceReachIn: MESSAGE$8 }
2958
3187
  };
2959
3188
  //#endregion
2960
3189
  //#region src/rules/tests-import-public-api.ts
@@ -3035,9 +3264,9 @@ const meta$1 = {
3035
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." },
3036
3265
  schema: [],
3037
3266
  messages: {
3038
- missingHarnessImport: MESSAGE$9,
3039
- httpTermination: MESSAGE$9,
3040
- rawEmitCall: MESSAGE$9
3267
+ missingHarnessImport: MESSAGE$8,
3268
+ httpTermination: MESSAGE$8,
3269
+ rawEmitCall: MESSAGE$8
3041
3270
  }
3042
3271
  };
3043
3272
  //#endregion
@@ -3233,27 +3462,27 @@ const traceTestRequiresTaxonomy = defineRule({
3233
3462
  }
3234
3463
  });
3235
3464
  //#endregion
3236
- //#region src/rules/vitest-from-effect-vitest.config.ts
3465
+ //#region src/rules/vitest-from-systemfsoftware-vitest.config.ts
3237
3466
  const MESSAGE = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
3238
- const VIOLATION_NAME = "a value import from vitest";
3239
- const VIOLATION_EXPECTED = "every test value imported from @effect/vitest, which re-exports all of Vitest";
3240
- const VIOLATION_ACTUAL = "a named, namespace, default, side-effect or dynamic value import from vitest";
3241
- const VIOLATION_FIX = "import it from @effect/vitest instead of vitest";
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";
3242
3471
  const meta = {
3243
3472
  type: "problem",
3244
- docs: { description: "Every value a test uses from Vitest is imported from @effect/vitest, which re-exports all of Vitest. Any value import from vitest — named, namespace, default, side-effect or dynamic — is refused; a type-only import stays legal." },
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." },
3245
3474
  schema: [],
3246
3475
  messages: { vitestImport: MESSAGE }
3247
3476
  };
3248
3477
  //#endregion
3249
- //#region src/rules/vitest-from-effect-vitest.ts
3478
+ //#region src/rules/vitest-from-systemfsoftware-vitest.ts
3250
3479
  const isTypeOnly = (node) => {
3251
3480
  if (node.importKind === "type") return true;
3252
3481
  if (node.specifiers.length === 0) return false;
3253
3482
  return node.specifiers.every((specifier) => specifier.type === "ImportSpecifier" && specifier.importKind === "type");
3254
3483
  };
3255
- const isVitestLiteral = (node) => node.type === "Literal" && typeof node.value === "string" && node.value === "vitest";
3256
- const vitestFromEffectVitest = defineRule({
3484
+ const isForeignVitestLiteral = (node) => node.type === "Literal" && typeof node.value === "string" && FOREIGN_VITEST_SOURCES[node.value] === true;
3485
+ const vitestFromSystemfsoftwareVitest = defineRule({
3257
3486
  meta,
3258
3487
  create(context) {
3259
3488
  if (isRawVitestPackage(context.filename)) return {};
@@ -3271,12 +3500,22 @@ const vitestFromEffectVitest = defineRule({
3271
3500
  };
3272
3501
  return {
3273
3502
  ImportDeclaration(node) {
3274
- if (node.source.value !== "vitest") return;
3503
+ if (FOREIGN_VITEST_SOURCES[node.source.value] !== true) return;
3275
3504
  if (isTypeOnly(node)) return;
3276
3505
  report(node);
3277
3506
  },
3278
3507
  ImportExpression(node) {
3279
- if (!isVitestLiteral(node.source)) return;
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;
3280
3519
  report(node);
3281
3520
  }
3282
3521
  };
@@ -3307,11 +3546,12 @@ const recommendedRules = {
3307
3546
  [rule("behaviour-exercises-use-case")]: "error",
3308
3547
  [rule("behaviour-one-feature-per-file")]: "error",
3309
3548
  [rule("tests-dir-helpers-in-fixtures")]: "error",
3310
- [rule("expect-boolean-predicate")]: "error",
3311
- [rule("vitest-from-effect-vitest")]: "error",
3549
+ [rule("vitest-from-systemfsoftware-vitest")]: "error",
3312
3550
  [rule("no-io-module-in-source-test")]: "error",
3313
3551
  [rule("tests-import-public-api")]: "error",
3314
3552
  [rule("differential-test-requires-harness")]: "error",
3553
+ [rule("conformance-test-requires-harness")]: "error",
3554
+ [rule("model-fixture-imports-subject")]: "error",
3315
3555
  [rule("no-pseudo-gherkin-unit-tests")]: "error",
3316
3556
  [rule("ban-raw-span-name-emit")]: "error",
3317
3557
  [rule("trace-test-requires-taxonomy")]: "error"
@@ -3340,11 +3580,12 @@ var src_default = {
3340
3580
  "behaviour-one-feature-per-file": behaviourOneFeaturePerFile,
3341
3581
  "no-pseudo-gherkin-unit-tests": noPseudoGherkinUnitTests,
3342
3582
  "tests-dir-helpers-in-fixtures": testsDirHelpersInFixtures,
3343
- "expect-boolean-predicate": expectBooleanPredicate,
3344
- "vitest-from-effect-vitest": vitestFromEffectVitest,
3583
+ "vitest-from-systemfsoftware-vitest": vitestFromSystemfsoftwareVitest,
3345
3584
  "no-io-module-in-source-test": noIoModuleInSourceTest,
3346
3585
  "tests-import-public-api": testsImportPublicApi,
3347
3586
  "differential-test-requires-harness": differentialTestRequiresHarness,
3587
+ "conformance-test-requires-harness": conformanceTestRequiresHarness,
3588
+ "model-fixture-imports-subject": modelFixtureImportsSubject,
3348
3589
  "ban-raw-span-name-emit": banRawSpanNameEmit,
3349
3590
  "trace-test-requires-taxonomy": traceTestRequiresTaxonomy
3350
3591
  },
@@ -26,11 +26,12 @@ declare const _default: {
26
26
  'behaviour-one-feature-per-file': Rule;
27
27
  'no-pseudo-gherkin-unit-tests': Rule;
28
28
  'tests-dir-helpers-in-fixtures': Rule;
29
- 'expect-boolean-predicate': Rule;
30
- 'vitest-from-effect-vitest': Rule;
29
+ 'vitest-from-systemfsoftware-vitest': Rule;
31
30
  'no-io-module-in-source-test': Rule;
32
31
  'tests-import-public-api': Rule;
33
32
  'differential-test-requires-harness': Rule;
33
+ 'conformance-test-requires-harness': Rule;
34
+ 'model-fixture-imports-subject': Rule;
34
35
  'ban-raw-span-name-emit': Rule;
35
36
  'trace-test-requires-taxonomy': Rule;
36
37
  };
@@ -26,11 +26,12 @@ declare const _default: {
26
26
  'behaviour-one-feature-per-file': Rule;
27
27
  'no-pseudo-gherkin-unit-tests': Rule;
28
28
  'tests-dir-helpers-in-fixtures': Rule;
29
- 'expect-boolean-predicate': Rule;
30
- 'vitest-from-effect-vitest': Rule;
29
+ 'vitest-from-systemfsoftware-vitest': Rule;
31
30
  'no-io-module-in-source-test': Rule;
32
31
  'tests-import-public-api': Rule;
33
32
  'differential-test-requires-harness': Rule;
33
+ 'conformance-test-requires-harness': Rule;
34
+ 'model-fixture-imports-subject': Rule;
34
35
  'ban-raw-span-name-emit': Rule;
35
36
  'trace-test-requires-taxonomy': Rule;
36
37
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@systemfsoftware/oxlint-plugin-test-discipline",
3
3
  "license": "Apache-2.0",
4
- "version": "3.9.0",
4
+ "version": "4.0.1",
5
5
  "author": "Ryan Lee <drdgvhbh@gmail.com>",
6
6
  "repository": {
7
7
  "type": "git",
@@ -45,12 +45,12 @@
45
45
  "@microsoft/api-extractor": "^7.59.1",
46
46
  "@systemfsoftware/arethetypeswrong-cli": "^4.2.0",
47
47
  "@systemfsoftware/stryker-config": "^0.1.0",
48
- "@systemfsoftware/stryker-ignorer-effect-schema-declarations": "^0.1.1",
49
- "@systemfsoftware/stryker-ignorer-in-source-vitest-block": "^0.1.1",
50
- "@systemfsoftware/stryker-js": "^10.1.1",
51
- "@systemfsoftware/stryker-js-typescript-checker": "^7.0.5",
52
- "@systemfsoftware/stryker-js-vitest-runner": "^7.1.1",
53
- "@systemfsoftware/stryker-test-contribution": "^3.0.3",
48
+ "@systemfsoftware/stryker-ignorer-effect-schema-declarations": "latest",
49
+ "@systemfsoftware/stryker-ignorer-in-source-vitest-block": "latest",
50
+ "@systemfsoftware/stryker-js": "latest",
51
+ "@systemfsoftware/stryker-js-typescript-checker": "latest",
52
+ "@systemfsoftware/stryker-js-vitest-runner": "latest",
53
+ "@systemfsoftware/stryker-test-contribution": "latest",
54
54
  "@systemfsoftware/tsconfig": "^2.0.1",
55
55
  "@systemfsoftware/tsdown-config": "^0.1.0",
56
56
  "@systemfsoftware/vitest-config": "^0.1.0",
@@ -69,6 +69,7 @@
69
69
  "typescript": ">=5.0.0"
70
70
  },
71
71
  "publishConfig": {
72
+ "access": "public",
72
73
  "provenance": true
73
74
  },
74
75
  "scripts": {