@systemfsoftware/oxlint-plugin-test-discipline 3.5.1 → 3.6.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 ADDED
@@ -0,0 +1,11 @@
1
+ # @systemfsoftware/oxlint-plugin-test-discipline
2
+
3
+ ## 3.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - New rule `differential-test-requires-harness` (recommended, error): a `.differential.test.ts` file must import and invoke `@systemfsoftware/differential-spec` (`Differential.compare` / `Metamorphic.on`) and must not call raw test runners. `test-suffix-outside-src` now sanctions the `.differential.test.ts` altitude alongside `.integration.test.ts`, and `property-file-purity` treats differential files as property files.
8
+
9
+ - Publish standalone domain-aligned oxlint plugins and shareable configurations for Effect Schema, DMMF workflows, Effect Platform runtime entrypoints, test discipline, and Cell Architecture.
10
+
11
+ Consolidates and supersedes the 12 retired legacy packages (`@systemfsoftware/all`, `@systemfsoftware/oxlint-config`, `@systemfsoftware/oxlint-plugin`, `@systemfsoftware/oxlint-plugin-recommended`, `@systemfsoftware/oxlint-plugin-effect-dmmf`, `@systemfsoftware/oxlint-plugin-effect-native`, `@systemfsoftware/oxlint-plugin-effect-entrypoint`, `@systemfsoftware/oxlint-plugin-effect-workflow`, `@systemfsoftware/oxlint-plugin-property-testing`, `@systemfsoftware/oxlint-plugin-structure`, `@systemfsoftware/oxlint-plugin-tag-discipline`, `@systemfsoftware/oxlint-plugin-test-hygiene`, `@systemfsoftware/oxlint-plugin-test-placement`).
package/README.md CHANGED
@@ -4,23 +4,25 @@ 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` — the one behaviour suffix. |
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
- | `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. |
19
-
20
- | suffix | layer | doubles | location |
21
- | ----------------------------- | --------- | ------------------------ | ------------------ |
22
- | `*.workflow.property.test.ts` | Property | none — pure core | ONLY under `src/` |
23
- | `*.integration.test.ts` | Behaviour | permitted, at ports only | NEVER under `src/` |
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` or `.differential.test.ts` — the two 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
+
21
+ | suffix | layer | doubles | location |
22
+ | ----------------------------- | ------------ | ------------------------ | ------------------ |
23
+ | `*.workflow.property.test.ts` | Property | none pure core | ONLY under `src/` |
24
+ | `*.integration.test.ts` | Behaviour | permitted, at ports only | NEVER under `src/` |
25
+ | `*.differential.test.ts` | Differential | permitted, at ports only | NEVER under `src/` |
24
26
 
25
27
  ## Enrollment
26
28
 
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ declare const _default: {
27
27
  'tests-dir-helpers-in-fixtures': import("@oxlint/plugins").Rule;
28
28
  'no-io-module-in-source-test': import("@oxlint/plugins").Rule;
29
29
  'tests-import-public-api': import("@oxlint/plugins").Rule;
30
+ 'differential-test-requires-harness': import("@oxlint/plugins").Rule;
30
31
  };
31
32
  configs: {
32
33
  recommended: {
package/dist/index.mjs CHANGED
@@ -12,6 +12,7 @@ const NESTED_TEST_DIR = "__tests__";
12
12
  const TEST_TREE_DIRS = /* @__PURE__ */ new Set([...SANCTIONED_TEST_DIRS, NESTED_TEST_DIR]);
13
13
  const PROPERTY_SUFFIX = ".property.test.ts";
14
14
  const INTEGRATION_SUFFIX = ".integration.test.ts";
15
+ const DIFFERENTIAL_SUFFIX = ".differential.test.ts";
15
16
  /**
16
17
  * Forbidden outright. A schema's laws are generated, so a hand-written
17
18
  * `*.schema.test.ts` only ever restates coverage that already exists.
@@ -26,6 +27,7 @@ const SCHEMA_SUFFIX = ".schema.test.ts";
26
27
  */
27
28
  const WORKFLOW_TEST_BASENAME = /^[^.]+\.workflow\.property\.test\.ts$/;
28
29
  const GHERKIN_PACKAGE = "@systemfsoftware/effect-gherkin-spec";
30
+ const DIFFERENTIAL_PACKAGE = "@systemfsoftware/differential-spec";
29
31
  const FOREIGN_RUNNERS = /* @__PURE__ */ new Set(["vitest", "@effect/vitest"]);
30
32
  const RUNNER_NAMES = /* @__PURE__ */ new Set([
31
33
  "it",
@@ -40,7 +42,7 @@ const NO_SUBJECT_IMPORT_NAME = "a *.integration.test.ts that reaches no package
40
42
  const NO_SUBJECT_IMPORT_EXPECTED = "an import of the package code under test";
41
43
  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";
42
44
  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.";
43
- const meta$22 = {
45
+ const meta$23 = {
44
46
  type: "problem",
45
47
  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." },
46
48
  schema: [],
@@ -153,7 +155,7 @@ const DIST_SEGMENT = /(?:^|\/)dist\//;
153
155
  * path names nothing satisfies the rule the same way a real one does.
154
156
  */
155
157
  const behaviourExercisesUseCase = defineRule({
156
- meta: meta$22,
158
+ meta: meta$23,
157
159
  create(context) {
158
160
  let reached = false;
159
161
  return {
@@ -203,7 +205,7 @@ const TOO_MANY_FEATURES_NAME = "a *.integration.test.ts accumulating multiple Fe
203
205
  const TOO_MANY_FEATURES_EXPECTED = "exactly one Feature(...) — every additional one signals a junk drawer";
204
206
  const TOO_MANY_FEATURES_ACTUAL = "a behaviour file with two or more Feature(...) calls";
205
207
  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.";
206
- const meta$21 = {
208
+ const meta$22 = {
207
209
  type: "problem",
208
210
  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." },
209
211
  schema: [],
@@ -248,7 +250,7 @@ const hasAnyFeatureCall = (program) => {
248
250
  return false;
249
251
  };
250
252
  const behaviourOneFeaturePerFile = defineRule({
251
- meta: meta$21,
253
+ meta: meta$22,
252
254
  create(context) {
253
255
  return { "Program:exit"(node) {
254
256
  if (!isBehaviourTest$2(basenameOf(context.filename))) return;
@@ -289,7 +291,7 @@ const MISSING_MAKE_FEATURE_NAME = "a *.integration.test.ts without makeFeature";
289
291
  const MISSING_MAKE_FEATURE_EXPECTED = "makeFeature imported from @systemfsoftware/effect-gherkin-spec";
290
292
  const MISSING_MAKE_FEATURE_ACTUAL = "a behaviour file that never constructs a Gherkin feature";
291
293
  const MISSING_MAKE_FEATURE_FIX = "import { makeFeature } from @systemfsoftware/effect-gherkin-spec and declare `const Feature = makeFeature({ it, layer })`";
292
- const meta$20 = {
294
+ const meta$21 = {
293
295
  type: "problem",
294
296
  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." },
295
297
  schema: [],
@@ -317,7 +319,7 @@ const foreignRunnerNameOf = (specifier) => {
317
319
  };
318
320
  const isBehaviourTest$1 = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
319
321
  const behaviourTestRequiresGherkin = defineRule({
320
- meta: meta$20,
322
+ meta: meta$21,
321
323
  create(context) {
322
324
  const basename = basenameOf(context.filename);
323
325
  return { Program(node) {
@@ -380,7 +382,7 @@ const INVALID_BEHAVIOR_CASE_FIX = "Convert behavior to PascalCase (e.g., throwEr
380
382
  const INVALID_CONDITION_CASE_FIX = "Convert condition to PascalCase (e.g., passwordInvalid → PasswordInvalid)";
381
383
  const EMPTY_BEHAVIOR_ACTUAL = "Empty string between Should_ and _When_";
382
384
  const EMPTY_CONDITION_ACTUAL = "Empty string after _When_";
383
- const meta$19 = {
385
+ const meta$20 = {
384
386
  type: "suggestion",
385
387
  docs: { description: "Enforce DAMP (Descriptive and Meaningful Phrases) test naming format: Should_[ExpectedBehavior]_When_[Condition]" },
386
388
  schema: [],
@@ -460,7 +462,7 @@ const isTestFunctionCall = (node) => {
460
462
  return false;
461
463
  };
462
464
  const dampTestNaming = defineRule({
463
- meta: meta$19,
465
+ meta: meta$20,
464
466
  create(context) {
465
467
  return { CallExpression(node) {
466
468
  if (!isTestFunctionCall(node)) return;
@@ -482,6 +484,100 @@ const dampTestNaming = defineRule({
482
484
  }
483
485
  });
484
486
  //#endregion
487
+ //#region src/rules/differential-test-requires-harness.config.ts
488
+ const meta$19 = {
489
+ type: "problem",
490
+ 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." },
491
+ schema: [],
492
+ messages: {
493
+ rawRunnerCall: "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.",
494
+ runnerImport: "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.",
495
+ missingHarnessImport: "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.",
496
+ missingHarnessUsage: "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}."
497
+ }
498
+ };
499
+ //#endregion
500
+ //#region src/rules/differential-test-requires-harness.ts
501
+ const HARNESS_PRESCRIPTION = "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)";
502
+ const memberObjectName = (callee) => callee.type === "MemberExpression" && callee.object.type === "Identifier" ? callee.object.name : void 0;
503
+ const calleeName = (callee) => callee.type === "Identifier" ? callee.name : memberObjectName(callee);
504
+ const recordHarnessBindings = (node, bindings) => {
505
+ if (node.source.value !== "@systemfsoftware/differential-spec") return;
506
+ for (const specifier of node.specifiers) bindings.add(specifier.local.name);
507
+ };
508
+ const isForeignRunnerImport = (node) => typeof node.source.value === "string" && FOREIGN_RUNNERS.has(node.source.value);
509
+ const differentialTestRequiresHarness = defineRule({
510
+ meta: meta$19,
511
+ create(context) {
512
+ if (!context.filename.endsWith(".differential.test.ts")) return {};
513
+ const harnessBindings = /* @__PURE__ */ new Set();
514
+ let violations = 0;
515
+ let hasHarnessInvocation = false;
516
+ return {
517
+ ImportDeclaration(node) {
518
+ if (isForeignRunnerImport(node)) {
519
+ context.report({
520
+ node,
521
+ messageId: "runnerImport",
522
+ data: {
523
+ name: `runner import from ${String(node.source.value)} in a differential test file`,
524
+ expected: HARNESS_PRESCRIPTION,
525
+ actual: "a direct vitest / @effect/vitest runner import bypasses the differential oracle",
526
+ fix: `delete the runner import; ${HARNESS_PRESCRIPTION}`
527
+ }
528
+ });
529
+ violations += 1;
530
+ return;
531
+ }
532
+ recordHarnessBindings(node, harnessBindings);
533
+ },
534
+ CallExpression(node) {
535
+ const name = calleeName(node.callee);
536
+ if (name !== void 0 && RUNNER_NAMES.has(name)) {
537
+ context.report({
538
+ node,
539
+ messageId: "rawRunnerCall",
540
+ data: {
541
+ name: `raw runner call (${name}) in a differential test file`,
542
+ expected: HARNESS_PRESCRIPTION,
543
+ actual: `${name}(...) bypasses the differential oracle`,
544
+ fix: `rewrite using ${HARNESS_PRESCRIPTION}`
545
+ }
546
+ });
547
+ violations += 1;
548
+ return;
549
+ }
550
+ if (name !== void 0 && harnessBindings.has(name)) hasHarnessInvocation = true;
551
+ },
552
+ "Program:exit"(node) {
553
+ if (harnessBindings.size === 0) {
554
+ context.report({
555
+ node,
556
+ messageId: "missingHarnessImport",
557
+ data: {
558
+ name: `differential test file without ${DIFFERENTIAL_PACKAGE} import`,
559
+ expected: HARNESS_PRESCRIPTION,
560
+ actual: "no differential harness import found",
561
+ fix: HARNESS_PRESCRIPTION
562
+ }
563
+ });
564
+ return;
565
+ }
566
+ if (!hasHarnessInvocation && violations === 0) context.report({
567
+ node,
568
+ messageId: "missingHarnessUsage",
569
+ data: {
570
+ name: `differential test file imports ${DIFFERENTIAL_PACKAGE} but never invokes it`,
571
+ expected: HARNESS_PRESCRIPTION,
572
+ actual: "the harness is imported but no Differential.compare or Metamorphic.on chain runs",
573
+ fix: HARNESS_PRESCRIPTION
574
+ }
575
+ });
576
+ }
577
+ };
578
+ }
579
+ });
580
+ //#endregion
485
581
  //#region src/rules/in-source-test-prop-only.config.ts
486
582
  const NON_PROP_CALL_NAME = "a non-property test call inside an `import.meta.vitest` block";
487
583
  const NON_PROP_CALL_EXPECTED = "only `it.prop` or `it.effect.prop` member-chain calls (standard modifiers included) with boolean predicates";
@@ -2327,7 +2423,8 @@ const checkPropCall = (provenance, context, call) => {
2327
2423
  });
2328
2424
  return;
2329
2425
  }
2330
- if (!shape.callsDomainFunction) context.report({
2426
+ if (shape.callsDomainFunction) return;
2427
+ context.report({
2331
2428
  node: call,
2332
2429
  messageId: "noDomainFunction",
2333
2430
  data: {
@@ -2403,45 +2500,64 @@ const RAW_FC_METHODS = /* @__PURE__ */ new Set([
2403
2500
  "property",
2404
2501
  "asyncProperty"
2405
2502
  ]);
2406
- const reportPlain = (context, node, messageId, actual) => {
2503
+ const PROPERTY_FILE_KIND = {
2504
+ suffix: PROPERTY_TEST_SUFFIX,
2505
+ plainExpected: "it.prop(...) or it.effect.prop(...) — property files never mix with scenario tests",
2506
+ plainFix: "move the scenario test to a plain *.test.ts file, or rewrite it as a property with arbitraries and a boolean-returning predicate",
2507
+ rawFcExpected: "it.prop(...) or it.effect.prop(...) from @effect/vitest",
2508
+ rawFcActualSuffix: "bypasses the vitest/Effect integration",
2509
+ rawFcFix: "rewrite as it.prop(name, [arbitraries], predicate) returning a boolean; fc.* stays for building arbitraries (fc.pre, fc.stringMatching, ...)"
2510
+ };
2511
+ const DIFFERENTIAL_FILE_KIND = {
2512
+ suffix: DIFFERENTIAL_SUFFIX,
2513
+ plainExpected: "the differential harness — Differential.compare(...).on(arb).assert(oracle) or Metamorphic.on(system).relation(...).on(arb)",
2514
+ plainFix: "rewrite via @systemfsoftware/differential-spec; scenario tests do not belong in a differential file",
2515
+ rawFcExpected: "the harness owns fast-check — pass an fc.Arbitrary to the harness, never call fc.assert/fc.check directly",
2516
+ rawFcActualSuffix: "bypasses the differential harness oracle",
2517
+ rawFcFix: "build the arbitrary in tests/__fixtures__ and hand it to Differential.compare(...).on(arb) or Metamorphic.on(...).relation(...).on(arb)"
2518
+ };
2519
+ const reportPlain = (context, node, messageId, actual, kind) => {
2407
2520
  context.report({
2408
2521
  node,
2409
2522
  messageId,
2410
2523
  data: {
2411
- name: `scenario test (${actual}) in a ${PROPERTY_TEST_SUFFIX} file`,
2412
- expected: "it.prop(...) or it.effect.prop(...) — property files never mix with scenario tests",
2524
+ name: `scenario test (${actual}) in a ${kind.suffix} file`,
2525
+ expected: kind.plainExpected,
2413
2526
  actual: `${actual} runs a single example, not a property`,
2414
- fix: "move the scenario test to a plain *.test.ts file, or rewrite it as a property with arbitraries and a boolean-returning predicate"
2527
+ fix: kind.plainFix
2528
+ }
2529
+ });
2530
+ };
2531
+ const reportRawFc = (context, node, method, kind) => {
2532
+ context.report({
2533
+ node,
2534
+ messageId: "rawFastCheck",
2535
+ data: {
2536
+ name: `raw fc.${method}(...) in a ${kind.suffix} file`,
2537
+ expected: kind.rawFcExpected,
2538
+ actual: `fc.${method}(...) ${kind.rawFcActualSuffix}`,
2539
+ fix: kind.rawFcFix
2415
2540
  }
2416
2541
  });
2417
2542
  };
2418
- const createPropertyFileVisitors = (context) => ({ CallExpression(node) {
2543
+ const createPropertyFileVisitors = (context, kind) => ({ CallExpression(node) {
2419
2544
  const callee = node.callee;
2420
2545
  if (callee.type === "Identifier") {
2421
- if (callee.name === "it" || callee.name === "test") reportPlain(context, node, "plainIt", `${callee.name}(...)`);
2546
+ if (callee.name === "it" || callee.name === "test") reportPlain(context, node, "plainIt", `${callee.name}(...)`, kind);
2422
2547
  return;
2423
2548
  }
2424
2549
  if (callee.type !== "MemberExpression" || callee.property.type !== "Identifier") return;
2425
2550
  const object = callee.object;
2426
2551
  if (object.type === "Identifier" && object.name === "fc" && RAW_FC_METHODS.has(callee.property.name)) {
2427
- context.report({
2428
- node,
2429
- messageId: "rawFastCheck",
2430
- data: {
2431
- name: `raw fc.${callee.property.name}(...) in a ${PROPERTY_TEST_SUFFIX} file`,
2432
- expected: "it.prop(...) or it.effect.prop(...) from @effect/vitest",
2433
- actual: `fc.${callee.property.name}(...) bypasses the vitest/Effect integration`,
2434
- fix: "rewrite as it.prop(name, [arbitraries], predicate) returning a boolean; fc.* stays for building arbitraries (fc.pre, fc.stringMatching, ...)"
2435
- }
2436
- });
2552
+ reportRawFc(context, node, callee.property.name, kind);
2437
2553
  return;
2438
2554
  }
2439
2555
  if (object.type === "Identifier" && object.name === "it") {
2440
- if (callee.property.name === "effect") reportPlain(context, node, "plainEffectIt", "it.effect(...)");
2441
- else if (PROP_MODIFIERS.has(callee.property.name)) reportPlain(context, node, "plainIt", `it.${callee.property.name}(...)`);
2556
+ if (callee.property.name === "effect") reportPlain(context, node, "plainEffectIt", "it.effect(...)", kind);
2557
+ else if (PROP_MODIFIERS.has(callee.property.name)) reportPlain(context, node, "plainIt", `it.${callee.property.name}(...)`, kind);
2442
2558
  return;
2443
2559
  }
2444
- if (PROP_MODIFIERS.has(callee.property.name) && object.type === "MemberExpression" && object.object.type === "Identifier" && object.object.name === "it" && object.property.type === "Identifier" && object.property.name === "effect") reportPlain(context, node, "plainEffectIt", `it.effect.${callee.property.name}(...)`);
2560
+ if (PROP_MODIFIERS.has(callee.property.name) && object.type === "MemberExpression" && object.object.type === "Identifier" && object.object.name === "it" && object.property.type === "Identifier" && object.property.name === "effect") reportPlain(context, node, "plainEffectIt", `it.effect.${callee.property.name}(...)`, kind);
2445
2561
  } });
2446
2562
  const FAST_CHECK_IMPORT_DATA = {
2447
2563
  name: "FastCheck import in a scenario test file",
@@ -2478,11 +2594,12 @@ const createScenarioFileVisitors = (context) => ({
2478
2594
  });
2479
2595
  }
2480
2596
  });
2597
+ const kindOf = (filename) => filename.endsWith(".differential.test.ts") ? DIFFERENTIAL_FILE_KIND : PROPERTY_FILE_KIND;
2481
2598
  const propertyFilePurity = defineRule({
2482
2599
  meta: meta$5,
2483
2600
  create(context) {
2484
- if (!Array$1.last(context.filename.split("/")).pipe(Option.exists((base) => base.includes(".test.") || base.includes(".spec.")))) return {};
2485
- if (context.filename.endsWith(".property.test.ts")) return createPropertyFileVisitors(context);
2601
+ if (!isTestFile(basenameOf(context.filename))) return {};
2602
+ if (context.filename.endsWith(".property.test.ts") || context.filename.endsWith(".differential.test.ts")) return createPropertyFileVisitors(context, kindOf(context.filename));
2486
2603
  return createScenarioFileVisitors(context);
2487
2604
  }
2488
2605
  });
@@ -2583,26 +2700,25 @@ const testFileOutsideTestsDir = defineRule({
2583
2700
  });
2584
2701
  //#endregion
2585
2702
  //#region src/rules/test-suffix-outside-src.config.ts
2586
- const UNSANCTIONED_SUFFIX_EXPECTED = "exactly *.integration.test.ts outside src/";
2703
+ const UNSANCTIONED_SUFFIX_EXPECTED = "exactly *.integration.test.ts or *.differential.test.ts outside src/";
2587
2704
  const UNSANCTIONED_SUFFIX_ACTUAL = "an unsanctioned test suffix outside src/";
2588
- const UNSANCTIONED_SUFFIX_FIX = "name what this file exercises. Every scenario restates a literal from a pure cell (a lookup-table entry, a constant, a mapping) -> it is a change detector, not a test: delete it. It drives the package through its public surface -> rename it *.integration.test.ts, the one behaviour suffix, whether or not a layer doubles at a port. It is a property over a pure cell -> it does not belong outside src/: convert it to an in-source if (import.meta.vitest) block in the module it covers";
2705
+ const UNSANCTIONED_SUFFIX_FIX = "name what this file exercises. Every scenario restates a literal from a pure cell (a lookup-table entry, a constant, a mapping) -> it is a change detector, not a test: delete it. It drives the package through its public surface -> rename it *.integration.test.ts, the one behaviour suffix, whether or not a layer doubles at a port. It proves parity or a metamorphic relation between implementations -> rename it *.differential.test.ts and express it through @systemfsoftware/differential-spec. It is a property over a pure cell -> it does not belong outside src/: convert it to an in-source if (import.meta.vitest) block in the module it covers";
2589
2706
  //#endregion
2590
2707
  //#region src/rules/test-suffix-outside-src.ts
2591
2708
  const testSuffixOutsideSrc = defineRule({
2592
2709
  meta: {
2593
2710
  type: "problem",
2594
- docs: { description: "Outside src/, a test file must end .integration.test.ts the one behaviour suffix. Whether the layer doubles at a port is a judgement the suffix no longer encodes." },
2711
+ docs: { description: "Outside src/, a test file must end .integration.test.ts or .differential.test.ts. Integration is the behaviour suffix; differential is the parity/metamorphic suffix driven by @systemfsoftware/differential-spec. Whether the layer doubles at a port is a judgement the suffix no longer encodes." },
2595
2712
  schema: [],
2596
2713
  messages: { unsanctionedSuffix: MESSAGE$7 }
2597
2714
  },
2598
2715
  create(context) {
2599
2716
  const filename = context.filename;
2717
+ if (isUnderSrc(filename)) return {};
2600
2718
  const basename = basenameOf(filename);
2601
- const suffixIsAllowed = basename.endsWith(INTEGRATION_SUFFIX);
2719
+ if (!isTestFile(basename)) return {};
2720
+ if (basename.endsWith(".integration.test.ts") || basename.endsWith(".differential.test.ts")) return {};
2602
2721
  return { Program(node) {
2603
- if (isUnderSrc(filename)) return;
2604
- if (!isTestFile(basename)) return;
2605
- if (suffixIsAllowed) return;
2606
2722
  context.report({
2607
2723
  node,
2608
2724
  messageId: "unsanctionedSuffix",
@@ -2741,7 +2857,8 @@ const recommendedRules = {
2741
2857
  [rule("behaviour-one-feature-per-file")]: "error",
2742
2858
  [rule("tests-dir-helpers-in-fixtures")]: "error",
2743
2859
  [rule("no-io-module-in-source-test")]: "error",
2744
- [rule("tests-import-public-api")]: "error"
2860
+ [rule("tests-import-public-api")]: "error",
2861
+ [rule("differential-test-requires-harness")]: "error"
2745
2862
  };
2746
2863
  var src_default = {
2747
2864
  meta: { name: PLUGIN_NAME },
@@ -2768,7 +2885,8 @@ var src_default = {
2768
2885
  "no-pseudo-gherkin-unit-tests": noPseudoGherkinUnitTests,
2769
2886
  "tests-dir-helpers-in-fixtures": testsDirHelpersInFixtures,
2770
2887
  "no-io-module-in-source-test": noIoModuleInSourceTest,
2771
- "tests-import-public-api": testsImportPublicApi
2888
+ "tests-import-public-api": testsImportPublicApi,
2889
+ "differential-test-requires-harness": differentialTestRequiresHarness
2772
2890
  },
2773
2891
  configs: { recommended: { rules: recommendedRules } }
2774
2892
  };
@@ -28,6 +28,7 @@ declare const _default: {
28
28
  'tests-dir-helpers-in-fixtures': Rule;
29
29
  'no-io-module-in-source-test': Rule;
30
30
  'tests-import-public-api': Rule;
31
+ 'differential-test-requires-harness': Rule;
31
32
  };
32
33
  configs: {
33
34
  recommended: {
@@ -28,6 +28,7 @@ declare const _default: {
28
28
  'tests-dir-helpers-in-fixtures': Rule;
29
29
  'no-io-module-in-source-test': Rule;
30
30
  'tests-import-public-api': Rule;
31
+ 'differential-test-requires-harness': Rule;
31
32
  };
32
33
  configs: {
33
34
  recommended: {
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.5.1",
4
+ "version": "3.6.0",
5
5
  "author": "Ryan Lee <drdgvhbh@gmail.com>",
6
6
  "repository": {
7
7
  "type": "git",
@@ -59,10 +59,10 @@
59
59
  "tsdown": "^0.23.0",
60
60
  "typescript": "^7",
61
61
  "vitest": "^5",
62
- "@systemfsoftware/stryker-config": "^0.1.0",
62
+ "@systemfsoftware/tsconfig": "^1.3.6",
63
63
  "@systemfsoftware/tsdown-config": "^0.1.0",
64
64
  "@systemfsoftware/vitest-config": "^0.1.0",
65
- "@systemfsoftware/tsconfig": "^1.3.6"
65
+ "@systemfsoftware/stryker-config": "^0.1.0"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "effect": "4.0.0-rc.116",
@@ -1,32 +0,0 @@
1
- import { Rule } from '@oxlint/plugins';
2
-
3
- declare const _default: {
4
- meta: {
5
- name: string;
6
- };
7
- rules: {
8
- 'in-source-test-prop-only': Rule;
9
- 'in-source-test-targets-private': Rule;
10
- 'no-test-file-in-src': Rule;
11
- 'src-property-test-cell': Rule;
12
- 'test-file-outside-tests-dir': Rule;
13
- 'test-suffix-outside-src': Rule;
14
- 'behaviour-test-requires-gherkin': Rule;
15
- 'behaviour-exercises-use-case': Rule;
16
- 'behaviour-one-feature-per-file': Rule;
17
- 'no-pseudo-gherkin-unit-tests': Rule;
18
- 'tests-dir-helpers-in-fixtures': Rule;
19
- 'no-io-module-in-source-test': Rule;
20
- 'tests-import-public-api': Rule;
21
- };
22
- configs: {
23
- recommended: {
24
- rules: {
25
- readonly [x: string]: "error";
26
- };
27
- };
28
- };
29
- };
30
- export default _default;
31
-
32
- export { }
@@ -1,32 +0,0 @@
1
- import { Rule } from '@oxlint/plugins';
2
-
3
- declare const _default: {
4
- meta: {
5
- name: string;
6
- };
7
- rules: {
8
- 'in-source-test-prop-only': Rule;
9
- 'in-source-test-targets-private': Rule;
10
- 'no-test-file-in-src': Rule;
11
- 'src-property-test-cell': Rule;
12
- 'test-file-outside-tests-dir': Rule;
13
- 'test-suffix-outside-src': Rule;
14
- 'behaviour-test-requires-gherkin': Rule;
15
- 'behaviour-exercises-use-case': Rule;
16
- 'behaviour-one-feature-per-file': Rule;
17
- 'no-pseudo-gherkin-unit-tests': Rule;
18
- 'tests-dir-helpers-in-fixtures': Rule;
19
- 'no-io-module-in-source-test': Rule;
20
- 'tests-import-public-api': Rule;
21
- };
22
- configs: {
23
- recommended: {
24
- rules: {
25
- readonly [x: string]: "error";
26
- };
27
- };
28
- };
29
- };
30
- export default _default;
31
-
32
- export { }