@systemfsoftware/oxlint-plugin-test-discipline 3.7.0 → 3.8.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 CHANGED
@@ -1,5 +1,13 @@
1
1
  # @systemfsoftware/oxlint-plugin-test-discipline
2
2
 
3
+ ## 3.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - New rule `ban-raw-span-name-emit` at error: a raw span name can no longer enter through an emit API. Calls to `startSpan`, `spanBuilder`, or `startActiveSpan` — bare or as a member call — are flagged when the first argument is a string literal or a template literal, interpolated templates included. The declared-span spelling is untouched: `Span.declare({ id, name, attrs })` and the `start(attrs)` it returns never trigger the rule, and emit calls given a dynamic name are left alone. The recommended preset enables it at error.
8
+
9
+ - New rule `trace-test-requires-taxonomy` at error: a test file named with the `trace.test` suffix is a trace spec, and a trace spec cannot quietly assert HTTP status. It requires an import binding from `@systemfsoftware/trace-spec`, forbids raw emit calls (`startSpan`, `spanBuilder`, `startActiveSpan`), and forbids terminating a case on an HTTP status or body assertion in these shapes: `expect(res.status)`, `toHaveProperty('status')` (also `statusText`, `body`, dotted paths under them, and segment arrays like `['body', 'items']`), or an object matcher (`toMatchObject`, `toEqual`, `toStrictEqual`, `expect.objectContaining`) whose object literal carries one of those keys at any depth (nested objects and array elements), also through `.resolves`, `.rejects`, and `.not`. `test-suffix-outside-src` now admits the `trace.test` suffix. The recommended preset enables the new rule at error.
10
+
3
11
  ## 3.7.0
4
12
 
5
13
  ### Minor Changes
package/README.md CHANGED
@@ -9,7 +9,7 @@ Oxlint rules enforcing property-based test laws, test placement, and test naming
9
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
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
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. |
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
13
  | `tests-dir-helpers-in-fixtures` | Under `tests/`, the only non-test modules are helpers and fixtures, and they live inside `tests/__fixtures__/`. |
14
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
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`. |
@@ -23,6 +23,7 @@ Oxlint rules enforcing property-based test laws, test placement, and test naming
23
23
  | `*.workflow.property.test.ts` | Property | none — pure core | ONLY under `src/` |
24
24
  | `*.integration.test.ts` | Behaviour | permitted, at ports only | NEVER under `src/` |
25
25
  | `*.differential.test.ts` | Differential | permitted, at ports only | NEVER under `src/` |
26
+ | `*.trace.test.ts` | Trace spec | none — observes graphs | NEVER under `src/` |
26
27
 
27
28
  ## Enrollment
28
29
 
package/dist/index.d.ts CHANGED
@@ -28,6 +28,8 @@ declare const _default: {
28
28
  'no-io-module-in-source-test': import("@oxlint/plugins").Rule;
29
29
  'tests-import-public-api': import("@oxlint/plugins").Rule;
30
30
  'differential-test-requires-harness': import("@oxlint/plugins").Rule;
31
+ 'ban-raw-span-name-emit': import("@oxlint/plugins").Rule;
32
+ 'trace-test-requires-taxonomy': import("@oxlint/plugins").Rule;
31
33
  };
32
34
  configs: {
33
35
  recommended: {
package/dist/index.mjs CHANGED
@@ -1,5 +1,74 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
2
  import { Array as Array$1, Effect, Option, Schema } from "effect";
3
+ //#region src/rules/ban-raw-span-name-emit.config.ts
4
+ const EMIT_CALLEES = [
5
+ "startActiveSpan",
6
+ "spanBuilder",
7
+ "startSpan"
8
+ ];
9
+ const EXPECTED$3 = "a span declared once with Span.declare({ id, name, attrs }) and started through the start(attrs) it returns";
10
+ const FIX$3 = "declare the span with Span.declare({ id, name, attrs }) and emit it with SomeSpan.start(attrs)";
11
+ const meta$25 = {
12
+ type: "problem",
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
+ schema: [],
15
+ messages: { banRawSpanName: "'{{name}}' is forbidden at {{callee}}. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}." }
16
+ };
17
+ //#endregion
18
+ //#region src/rules/ban-raw-span-name-emit.ts
19
+ const INTERPOLATION = "${}";
20
+ const isEmitCallee$1 = (name) => EMIT_CALLEES.some((emit) => emit === name);
21
+ const memberPropertyName = (callee) => {
22
+ if (callee.computed) return null;
23
+ if (callee.property.type !== "Identifier") return null;
24
+ return callee.property.name;
25
+ };
26
+ const calleeName$1 = (callee) => {
27
+ if (callee.type === "Identifier") return callee.name;
28
+ if (callee.type === "MemberExpression") return memberPropertyName(callee);
29
+ return null;
30
+ };
31
+ const templateName = (node) => node.quasis.map((quasi) => quasi.value.raw).join(INTERPOLATION);
32
+ const inlineName = (arg) => {
33
+ if (arg.type === "Literal") return typeof arg.value === "string" ? arg.value : null;
34
+ if (arg.type === "TemplateLiteral") return templateName(arg);
35
+ return null;
36
+ };
37
+ const argName = (arg) => {
38
+ if (arg === void 0) return null;
39
+ return inlineName(arg);
40
+ };
41
+ const inspect = (node) => {
42
+ const callee = calleeName$1(node.callee);
43
+ if (callee === null || !isEmitCallee$1(callee)) return null;
44
+ const name = argName(node.arguments[0]);
45
+ if (name === null) return null;
46
+ return {
47
+ callee,
48
+ name
49
+ };
50
+ };
51
+ const banRawSpanNameEmit = defineRule({
52
+ meta: meta$25,
53
+ create(context) {
54
+ return { CallExpression(node) {
55
+ const emitCall = inspect(node);
56
+ if (emitCall === null) return;
57
+ context.report({
58
+ node,
59
+ messageId: "banRawSpanName",
60
+ data: {
61
+ name: emitCall.name,
62
+ callee: emitCall.callee,
63
+ expected: EXPECTED$3,
64
+ actual: emitCall.name,
65
+ fix: FIX$3
66
+ }
67
+ });
68
+ } };
69
+ }
70
+ });
71
+ //#endregion
3
72
  //#region src/rules/path.config.ts
4
73
  const TEST_BASENAME = /\.(?:test|spec)\.[cm]?tsx?$/;
5
74
  const SANCTIONED_TEST_DIRS = /* @__PURE__ */ new Set(["tests"]);
@@ -28,6 +97,7 @@ const SCHEMA_SUFFIX = ".schema.test.ts";
28
97
  const WORKFLOW_TEST_BASENAME = /^[^.]+\.workflow\.property\.test\.ts$/;
29
98
  const GHERKIN_PACKAGE = "@systemfsoftware/effect-gherkin-spec";
30
99
  const DIFFERENTIAL_PACKAGE = "@systemfsoftware/differential-spec";
100
+ const TRACE_SPEC_PACKAGE = "@systemfsoftware/trace-spec";
31
101
  const FOREIGN_RUNNERS = /* @__PURE__ */ new Set(["vitest", "@effect/vitest"]);
32
102
  const RUNNER_NAMES = /* @__PURE__ */ new Set([
33
103
  "it",
@@ -42,7 +112,7 @@ const NO_SUBJECT_IMPORT_NAME = "a *.integration.test.ts that reaches no package
42
112
  const NO_SUBJECT_IMPORT_EXPECTED = "an import of the package code under test";
43
113
  const NO_SUBJECT_IMPORT_ACTUAL = "a behaviour file whose every runtime import is vitest, @effect/vitest, the gherkin spec package, effect, a Node builtin, or the file itself";
44
114
  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.";
45
- const meta$23 = {
115
+ const meta$24 = {
46
116
  type: "problem",
47
117
  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." },
48
118
  schema: [],
@@ -155,7 +225,7 @@ const DIST_SEGMENT = /(?:^|\/)dist\//;
155
225
  * path names nothing satisfies the rule the same way a real one does.
156
226
  */
157
227
  const behaviourExercisesUseCase = defineRule({
158
- meta: meta$23,
228
+ meta: meta$24,
159
229
  create(context) {
160
230
  let reached = false;
161
231
  return {
@@ -205,7 +275,7 @@ const TOO_MANY_FEATURES_NAME = "a *.integration.test.ts accumulating multiple Fe
205
275
  const TOO_MANY_FEATURES_EXPECTED = "exactly one Feature(...) — every additional one signals a junk drawer";
206
276
  const TOO_MANY_FEATURES_ACTUAL = "a behaviour file with two or more Feature(...) calls";
207
277
  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.";
208
- const meta$22 = {
278
+ const meta$23 = {
209
279
  type: "problem",
210
280
  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." },
211
281
  schema: [],
@@ -250,7 +320,7 @@ const hasAnyFeatureCall = (program) => {
250
320
  return false;
251
321
  };
252
322
  const behaviourOneFeaturePerFile = defineRule({
253
- meta: meta$22,
323
+ meta: meta$23,
254
324
  create(context) {
255
325
  return { "Program:exit"(node) {
256
326
  if (!isBehaviourTest$2(basenameOf(context.filename))) return;
@@ -291,7 +361,7 @@ const MISSING_MAKE_FEATURE_NAME = "a *.integration.test.ts without makeFeature";
291
361
  const MISSING_MAKE_FEATURE_EXPECTED = "makeFeature imported from @systemfsoftware/effect-gherkin-spec";
292
362
  const MISSING_MAKE_FEATURE_ACTUAL = "a behaviour file that never constructs a Gherkin feature";
293
363
  const MISSING_MAKE_FEATURE_FIX = "import { makeFeature } from @systemfsoftware/effect-gherkin-spec and declare `const Feature = makeFeature({ it, layer })`";
294
- const meta$21 = {
364
+ const meta$22 = {
295
365
  type: "problem",
296
366
  docs: { description: "A *.integration.test.ts must drive its suite through makeFeature from @systemfsoftware/effect-gherkin-spec and must not import test runners from vitest or @effect/vitest." },
297
367
  schema: [],
@@ -319,7 +389,7 @@ const foreignRunnerNameOf = (specifier) => {
319
389
  };
320
390
  const isBehaviourTest$1 = (basename) => basename.endsWith(INTEGRATION_SUFFIX);
321
391
  const behaviourTestRequiresGherkin = defineRule({
322
- meta: meta$21,
392
+ meta: meta$22,
323
393
  create(context) {
324
394
  const basename = basenameOf(context.filename);
325
395
  return { Program(node) {
@@ -382,7 +452,7 @@ const INVALID_BEHAVIOR_CASE_FIX = "Convert behavior to PascalCase (e.g., throwEr
382
452
  const INVALID_CONDITION_CASE_FIX = "Convert condition to PascalCase (e.g., passwordInvalid → PasswordInvalid)";
383
453
  const EMPTY_BEHAVIOR_ACTUAL = "Empty string between Should_ and _When_";
384
454
  const EMPTY_CONDITION_ACTUAL = "Empty string after _When_";
385
- const meta$20 = {
455
+ const meta$21 = {
386
456
  type: "suggestion",
387
457
  docs: { description: "Enforce DAMP (Descriptive and Meaningful Phrases) test naming format: Should_[ExpectedBehavior]_When_[Condition]" },
388
458
  schema: [],
@@ -462,7 +532,7 @@ const isTestFunctionCall = (node) => {
462
532
  return false;
463
533
  };
464
534
  const dampTestNaming = defineRule({
465
- meta: meta$20,
535
+ meta: meta$21,
466
536
  create(context) {
467
537
  return { CallExpression(node) {
468
538
  if (!isTestFunctionCall(node)) return;
@@ -485,7 +555,7 @@ const dampTestNaming = defineRule({
485
555
  });
486
556
  //#endregion
487
557
  //#region src/rules/differential-test-requires-harness.config.ts
488
- const meta$19 = {
558
+ const meta$20 = {
489
559
  type: "problem",
490
560
  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
561
  schema: [],
@@ -498,7 +568,7 @@ const meta$19 = {
498
568
  };
499
569
  //#endregion
500
570
  //#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)";
571
+ const HARNESS_PRESCRIPTION$1 = "import { Differential, Metamorphic } from @systemfsoftware/differential-spec and express the test as Differential.compare({ reference, candidate }).on(arb).assert(oracle) or Metamorphic.on(system).relation({ transformInput, assertOutput }).on(arb)";
502
572
  const memberObjectName = (callee) => callee.type === "MemberExpression" && callee.object.type === "Identifier" ? callee.object.name : void 0;
503
573
  const calleeName = (callee) => callee.type === "Identifier" ? callee.name : memberObjectName(callee);
504
574
  const recordHarnessBindings = (node, bindings) => {
@@ -507,7 +577,7 @@ const recordHarnessBindings = (node, bindings) => {
507
577
  };
508
578
  const isForeignRunnerImport = (node) => typeof node.source.value === "string" && FOREIGN_RUNNERS.has(node.source.value);
509
579
  const differentialTestRequiresHarness = defineRule({
510
- meta: meta$19,
580
+ meta: meta$20,
511
581
  create(context) {
512
582
  if (!context.filename.endsWith(".differential.test.ts")) return {};
513
583
  const harnessBindings = /* @__PURE__ */ new Set();
@@ -521,9 +591,9 @@ const differentialTestRequiresHarness = defineRule({
521
591
  messageId: "runnerImport",
522
592
  data: {
523
593
  name: `runner import from ${String(node.source.value)} in a differential test file`,
524
- expected: HARNESS_PRESCRIPTION,
594
+ expected: HARNESS_PRESCRIPTION$1,
525
595
  actual: "a direct vitest / @effect/vitest runner import bypasses the differential oracle",
526
- fix: `delete the runner import; ${HARNESS_PRESCRIPTION}`
596
+ fix: `delete the runner import; ${HARNESS_PRESCRIPTION$1}`
527
597
  }
528
598
  });
529
599
  violations += 1;
@@ -539,9 +609,9 @@ const differentialTestRequiresHarness = defineRule({
539
609
  messageId: "rawRunnerCall",
540
610
  data: {
541
611
  name: `raw runner call (${name}) in a differential test file`,
542
- expected: HARNESS_PRESCRIPTION,
612
+ expected: HARNESS_PRESCRIPTION$1,
543
613
  actual: `${name}(...) bypasses the differential oracle`,
544
- fix: `rewrite using ${HARNESS_PRESCRIPTION}`
614
+ fix: `rewrite using ${HARNESS_PRESCRIPTION$1}`
545
615
  }
546
616
  });
547
617
  violations += 1;
@@ -556,9 +626,9 @@ const differentialTestRequiresHarness = defineRule({
556
626
  messageId: "missingHarnessImport",
557
627
  data: {
558
628
  name: `differential test file without ${DIFFERENTIAL_PACKAGE} import`,
559
- expected: HARNESS_PRESCRIPTION,
629
+ expected: HARNESS_PRESCRIPTION$1,
560
630
  actual: "no differential harness import found",
561
- fix: HARNESS_PRESCRIPTION
631
+ fix: HARNESS_PRESCRIPTION$1
562
632
  }
563
633
  });
564
634
  return;
@@ -568,9 +638,9 @@ const differentialTestRequiresHarness = defineRule({
568
638
  messageId: "missingHarnessUsage",
569
639
  data: {
570
640
  name: `differential test file imports ${DIFFERENTIAL_PACKAGE} but never invokes it`,
571
- expected: HARNESS_PRESCRIPTION,
641
+ expected: HARNESS_PRESCRIPTION$1,
572
642
  actual: "the harness is imported but no Differential.compare or Metamorphic.on chain runs",
573
- fix: HARNESS_PRESCRIPTION
643
+ fix: HARNESS_PRESCRIPTION$1
574
644
  }
575
645
  });
576
646
  }
@@ -583,7 +653,7 @@ const NON_PROP_CALL_NAME = "a non-property test call inside an `import.meta.vite
583
653
  const NON_PROP_CALL_EXPECTED = "only `it.prop` or `it.effect.prop` member-chain calls (standard modifiers included) with boolean predicates";
584
654
  const NON_PROP_CALL_ACTUAL = "a bare or member-chain test call other than `it.prop`/`it.effect.prop`";
585
655
  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";
586
- const meta$18 = {
656
+ const meta$19 = {
587
657
  type: "problem",
588
658
  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." },
589
659
  schema: [],
@@ -647,7 +717,7 @@ const rootNameOf = (node) => {
647
717
  if (node.type === "CallExpression") return rootNameOf(node.callee);
648
718
  };
649
719
  const inSourceTestPropOnly = defineRule({
650
- meta: meta$18,
720
+ meta: meta$19,
651
721
  create(context) {
652
722
  const filename = context.filename;
653
723
  const basename = basenameOf(filename);
@@ -692,7 +762,7 @@ const NO_PRIVATE_TARGET_NAME = "an `import.meta.vitest` block touching no privat
692
762
  const NO_PRIVATE_TARGET_EXPECTED = "an in-source test exercising a non-exported module-level binding";
693
763
  const NO_PRIVATE_TARGET_ACTUAL = "an in-source block referencing only exported or imported names";
694
764
  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";
695
- const meta$17 = {
765
+ const meta$18 = {
696
766
  type: "problem",
697
767
  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/." },
698
768
  schema: [],
@@ -726,7 +796,7 @@ const collectPrivateNames = (body, out) => {
726
796
  }
727
797
  };
728
798
  const inSourceTestTargetsPrivate = defineRule({
729
- meta: meta$17,
799
+ meta: meta$18,
730
800
  create(context) {
731
801
  const filename = context.filename;
732
802
  const basename = basenameOf(filename);
@@ -785,7 +855,7 @@ const inSourceTestTargetsPrivate = defineRule({
785
855
  //#endregion
786
856
  //#region src/rules/no-assert-in-property.config.ts
787
857
  const MESSAGE$6 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
788
- const meta$16 = {
858
+ const meta$17 = {
789
859
  type: "problem",
790
860
  docs: { description: "Property predicates (it.prop / it.effect.prop) must never call expect(...), assert*(...), or raw fc.assert/fc.check. The boolean return IS the verdict — assertions fork the failure channel. assert* remains correct in normal (non-property) tests." },
791
861
  schema: [],
@@ -823,7 +893,7 @@ const isInsidePropPredicate = (node) => {
823
893
  return isInsidePropPredicate(parent);
824
894
  };
825
895
  const noAssertInProperty = defineRule({
826
- meta: meta$16,
896
+ meta: meta$17,
827
897
  create(context) {
828
898
  return { CallExpression(node) {
829
899
  const callee = node.callee;
@@ -876,7 +946,7 @@ const SKIP_WALK_KEYS = /* @__PURE__ */ new Set([
876
946
  "range",
877
947
  "parent"
878
948
  ]);
879
- const meta$15 = {
949
+ const meta$16 = {
880
950
  type: "problem",
881
951
  docs: { description: "Flag an assertion whose subject and expectation are both built only from imported declarations and literals. Such an assertion invokes nothing, so no change to the behaviour under test can make it fail." },
882
952
  schema: [],
@@ -944,7 +1014,7 @@ const importedNames = (program) => {
944
1014
  return names;
945
1015
  };
946
1016
  const noBehaviourlessAssertion = defineRule({
947
- meta: meta$15,
1017
+ meta: meta$16,
948
1018
  create(context) {
949
1019
  if (!TEST_FILE.test(context.filename)) return {};
950
1020
  const imported = importedNames(context.sourceCode.ast);
@@ -1014,7 +1084,7 @@ const IO_SOURCE_TEST_NAME = "An in-source `import.meta.vitest` test block";
1014
1084
  const IO_SOURCE_TEST_EXPECTED = "the tests of a module whose own source calls an I/O binding to live outside it — a separate test file, or a composition test with a double at the port";
1015
1085
  const IO_SOURCE_TEST_ACTUAL = "this module calls a binding imported from a filesystem, process or network module and guards tests in-source with `import.meta.vitest`";
1016
1086
  const IO_SOURCE_TEST_FIX = "test the module from outside its own source — a separate test file or a composition test doubling the boundary — or, when an assertion merely restates a literal the module already computes, it is a change detector: delete it. The verdict here is the file's own imports and calls, never its name";
1017
- const meta$14 = {
1087
+ const meta$15 = {
1018
1088
  type: "problem",
1019
1089
  docs: { description: "Reports an in-source `import.meta.vitest` test block in a module whose own syntax shows a called, non-type import from a filesystem, process or network module. Judges only the in-source-test idiom — a module whose tests live in separate files is a no-op for this rule, whatever it imports." },
1020
1090
  schema: [],
@@ -1035,7 +1105,7 @@ const bindingBase = (callee) => {
1035
1105
  if (callee.type === "Identifier") return callee.name;
1036
1106
  };
1037
1107
  const noIoModuleInSourceTest = defineRule({
1038
- meta: meta$14,
1108
+ meta: meta$15,
1039
1109
  create(context) {
1040
1110
  /** Local binding name -> specifier it was (non-type) imported from. */
1041
1111
  const ioBindings = {};
@@ -1100,7 +1170,7 @@ const VIOLATION_NAME$2 = "quantification nested inside a property predicate";
1100
1170
  const EXPECTED$2 = "per-case cost bounded by the draw, not by the draw times a second traversal — inspect a generated value with a fold whose body calls nothing, or move the inner quantifier into the generator so the shrinker can see it";
1101
1171
  const ACTUAL$2 = "the predicate iterates a value derived from a generated parameter and calls a free function inside that loop, so cost scales with the drawn size times whatever that call costs";
1102
1172
  const FIX$2 = "hoist the inner call out of the loop when its result does not vary per element; otherwise assert one drawn element per case and let numRuns supply the quantifier, or generate the pair and compare directly. If the cost is understood and accepted, add this file's basename to the rule's exempt option";
1103
- const meta$13 = {
1173
+ const meta$14 = {
1104
1174
  type: "problem",
1105
1175
  docs: { description: "A property predicate must not quantify over its own generated value and call out again inside that loop. Per-case cost then scales with the drawn size rather than with the draw count, which is the shape Hypothesis reports as nested_given: the suite slows superlinearly as the generator widens, and a CI budget tuned on small draws times out on large ones. Iteration over a bound the generator does not control, and a fold whose body calls nothing, are both fine." },
1106
1176
  schema: [Schema.toJsonSchemaDocument(Options$2).schema],
@@ -1251,7 +1321,7 @@ const check = (context, call, predicate) => {
1251
1321
  * and excluded; DISCHARGED_BY in the suite pins the unbounded recipe shape.
1252
1322
  */
1253
1323
  const noNestedQuantification = defineRule({
1254
- meta: meta$13,
1324
+ meta: meta$14,
1255
1325
  create(context) {
1256
1326
  const options = Schema.decodeUnknownSync(Options$2)(context.options[0] ?? {});
1257
1327
  const exempt = new Set(options.exempt);
@@ -1272,7 +1342,7 @@ const NO_LAYER_IN_FEATURE_NAME = "a *.integration.test.ts feature with no enviro
1272
1342
  const NO_LAYER_IN_FEATURE_EXPECTED = "a Feature builder chained with .withLayer(layer) or .withScenarioLayer(layer)";
1273
1343
  const NO_LAYER_IN_FEATURE_ACTUAL = "a Feature(...) call without .withLayer or .withScenarioLayer";
1274
1344
  const NO_LAYER_IN_FEATURE_FIX = "an integration test under WGI-CLS1 must declare its collaborator environment via .withLayer(...) or .withScenarioLayer(...). Chain .withLayer(Layer.empty) (or .withScenarioLayer(Layer.empty)) if the feature exercises in-memory collaborators without custom services, or provide the external boundary Layer it exercises (e.g. .withLayer(MyService.Live)).";
1275
- const meta$12 = {
1345
+ const meta$13 = {
1276
1346
  type: "problem",
1277
1347
  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." },
1278
1348
  schema: [],
@@ -1307,7 +1377,7 @@ const findRootFeatureCall = (callNode) => {
1307
1377
  return null;
1308
1378
  };
1309
1379
  const noPseudoGherkinUnitTests = defineRule({
1310
- meta: meta$12,
1380
+ meta: meta$13,
1311
1381
  create(context) {
1312
1382
  if (!isBehaviourTest(basenameOf(context.filename))) return {};
1313
1383
  return { CallExpression(node) {
@@ -1332,7 +1402,7 @@ const noPseudoGherkinUnitTests = defineRule({
1332
1402
  //#endregion
1333
1403
  //#region src/rules/no-silent-return.config.ts
1334
1404
  const MESSAGE$4 = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
1335
- const meta$11 = {
1405
+ const meta$12 = {
1336
1406
  type: "problem",
1337
1407
  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." },
1338
1408
  schema: [],
@@ -1480,7 +1550,7 @@ const checkFn = (context, fn) => {
1480
1550
  if (!Array$1.last(body.body).pipe(Option.exists(pathExits))) report(context, fn, "missingReturn", "the predicate can fall off the end without returning — undefined is a silent pass");
1481
1551
  };
1482
1552
  const noSilentReturn = defineRule({
1483
- meta: meta$11,
1553
+ meta: meta$12,
1484
1554
  create(context) {
1485
1555
  return { CallExpression(node) {
1486
1556
  if (!isPropCallee(node.callee)) return;
@@ -1509,7 +1579,7 @@ const SCHEMA_TEST_DETAIL = {
1509
1579
  actual: "an authored *.schema.test.ts restating generated coverage",
1510
1580
  fix: "delete it. The generated laws already state round-trip identity and encode stability. What they cannot state is rejection — every input they draw comes from the arbitrary the schema itself supplies — so a refusal belongs in an in-source if (import.meta.vitest) block in the schema file, never here"
1511
1581
  };
1512
- const meta$10 = {
1582
+ const meta$11 = {
1513
1583
  type: "problem",
1514
1584
  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." },
1515
1585
  schema: [Schema.toJsonSchemaDocument(Options$1).schema],
@@ -1523,7 +1593,7 @@ const meta$10 = {
1523
1593
  //#region src/rules/no-test-file-in-src.ts
1524
1594
  const violationOf = (basename, isPropertyTest, dir) => basename.endsWith(".schema.test.ts") ? ["schemaTestInSrc", SCHEMA_TEST_DETAIL] : isPropertyTest ? ["propertyTestOutsideTestsDir", propertyTestLocationDetail(dir)] : ["testFileInSrc", testFileInSrcDetail(dir)];
1525
1595
  const noTestFileInSrc = defineRule({
1526
- meta: meta$10,
1596
+ meta: meta$11,
1527
1597
  create(context) {
1528
1598
  const { sanctionedDirs } = Schema.decodeUnknownSync(Options$1)(context.options[0] ?? {});
1529
1599
  const basename = basenameOf(context.filename);
@@ -1576,7 +1646,7 @@ const PREDICATE_SYMBOLS = /* @__PURE__ */ new Set([
1576
1646
  const NULLARY_PREDICATE_SYMBOLS = /* @__PURE__ */ new Set(["⊥"]);
1577
1647
  const PASCAL_CASE = /^[A-Z][a-z][a-zA-Z0-9]*$/;
1578
1648
  const DAMP_WORDS = /When|Should|Given|Then|Otherwise|After|Before/;
1579
- const meta$9 = {
1649
+ const meta$10 = {
1580
1650
  type: "suggestion",
1581
1651
  docs: { description: "Enforce a complete formal-specification name for property-based tests (it.prop / it.effect.prop). Format: [ScopeSymbol][binder]_[Domain]_[PredicateSymbol][operand] (e.g., ∀x_DecodeEncode_=x, ∀l_Filter_⊆Input, →Shipped_Cancel_⊥Allowed). Both the quantifier and the predicate must carry an operand — a bare symbol specifies nothing." },
1582
1652
  schema: [],
@@ -1620,7 +1690,7 @@ const parseSegments = (name) => {
1620
1690
  };
1621
1691
  };
1622
1692
  const pbtNaming = defineRule({
1623
- meta: meta$9,
1693
+ meta: meta$10,
1624
1694
  create(context) {
1625
1695
  return { CallExpression(node) {
1626
1696
  if (!isPropCall(node)) return;
@@ -1728,7 +1798,7 @@ const SCHEMA_NAMESPACE_NAMES = {
1728
1798
  };
1729
1799
  const FASTCHECK_NAMESPACE_NAMES = { FastCheck: true };
1730
1800
  const COMBINATOR_CALLEES = { pipe: true };
1731
- const meta$8 = {
1801
+ const meta$9 = {
1732
1802
  type: "problem",
1733
1803
  docs: { description: "Inside an import.meta.vitest in-source block, every it.prop / it.effect.prop arbitrary must derive from an Effect Schema — a schema reference, a schema-attached arbitrary annotation, or a chain rooted in one. A hand-built fast-check construction with no schema underneath reports; statically opaque arbitraries (unresolved or foreign bindings) fail open into the runtime domain audit." },
1734
1804
  schema: [],
@@ -2011,7 +2081,7 @@ const checkPropCall$1 = (provenance, context, call) => {
2011
2081
  }
2012
2082
  };
2013
2083
  const propArbitrarySchemaOrigin = defineRule({
2014
- meta: meta$8,
2084
+ meta: meta$9,
2015
2085
  create(context) {
2016
2086
  const provenance = new Provenance(context.sourceCode.getScope);
2017
2087
  const collect = (value, out) => {
@@ -2075,7 +2145,7 @@ const VIOLATION_NAME = "a hand-rolled recursive schema fixture inside a test blo
2075
2145
  const EXPECTED = "a recursive fixture schema enters the block through a named local builder call, or its recursion point declares its generation with a visible toCodecArbitrary derivation or a recursionBudget ceiling";
2076
2146
  const ACTUAL = "the union and its recursion cycle are assembled inline, so the suite exercises a surrogate universe the shipped schema contract never declared and no budget gate grades";
2077
2147
  const FIX = "hoist the members into a named builder the annotated and counterfactual fixtures share, or declare the recursion point's generation in its annotation";
2078
- const meta$7 = {
2148
+ const meta$8 = {
2079
2149
  type: "problem",
2080
2150
  docs: { description: "Inside an import.meta.vitest in-source block or a *.test.ts file, a recursive schema union must enter through a named local builder or an imported helper, or its recursion point must declare its generation with a visible toCodecArbitrary derivation or recursionBudget ceiling. An inline hand-rolled recursive union reports; outside test scope the rule is silent, because production schemas are the derivation-cost rule domain." },
2081
2151
  schema: [],
@@ -2253,7 +2323,7 @@ const isNamedBuilderOrigin = (union, getScope) => {
2253
2323
  return false;
2254
2324
  };
2255
2325
  const propFixtureSchemaOrigin = defineRule({
2256
- meta: meta$7,
2326
+ meta: meta$8,
2257
2327
  create(context) {
2258
2328
  const getScope = context.sourceCode.getScope;
2259
2329
  return { CallExpression(node) {
@@ -2290,7 +2360,7 @@ const NO_FUNCTION_NAME = "a property that tests no function";
2290
2360
  const NO_FUNCTION_EXPECTED = "every in-source property exercises domain logic — at least one call to a module-local function that is not a schema codec accessor — because a predicate that only feeds values through encode or decode tests the schema declaration, not code, and decode acceptance and refusal alike are generated or declared elsewhere";
2291
2361
  const NO_FUNCTION_ACTUAL = "no call in the predicate reaches module code — every call is a codec accessor, a schema wrapper, or an iteration combinator — so the property cannot fail unless the declaration it restates changes meaning";
2292
2362
  const NO_FUNCTION_FIX = "delete the prop; test the function that owns the decision — the workflow or a private helper in this module — with its input derived from a domain schema";
2293
- const meta$6 = {
2363
+ const meta$7 = {
2294
2364
  type: "problem",
2295
2365
  docs: { description: "Inside an import.meta.vitest in-source block: a predicate that reflects over brand symbols re-asserts a compile-time guarantee, and a predicate containing no module-local non-codec function call tests a schema declaration instead of code. Both report." },
2296
2366
  schema: [],
@@ -2436,7 +2506,7 @@ const checkPropCall = (provenance, context, call) => {
2436
2506
  });
2437
2507
  };
2438
2508
  const propGeneratedLawDuplicate = defineRule({
2439
- meta: meta$6,
2509
+ meta: meta$7,
2440
2510
  create(context) {
2441
2511
  const provenance = new Provenance(context.sourceCode.getScope);
2442
2512
  const collect = (value, out) => {
@@ -2480,7 +2550,7 @@ const propGeneratedLawDuplicate = defineRule({
2480
2550
  //#region src/rules/property-file-purity.config.ts
2481
2551
  const PROPERTY_TEST_SUFFIX = ".property.test.ts";
2482
2552
  const MESSAGE = "{{name}} is forbidden. Expected: {{expected}}. Actual: {{actual}}. Fix: {{fix}}.";
2483
- const meta$5 = {
2553
+ const meta$6 = {
2484
2554
  type: "problem",
2485
2555
  docs: { description: "Property tests live ONLY in *.property.test.ts files, and those files contain ONLY property tests. In a property file: no plain it()/test()/it.effect(), no raw fc.assert/fc.check/fc.property/fc.asyncProperty. In any other test file: no FastCheck import and no it.prop/it.effect.prop — move the property to a *.property.test.ts file." },
2486
2556
  schema: [],
@@ -2596,7 +2666,7 @@ const createScenarioFileVisitors = (context) => ({
2596
2666
  });
2597
2667
  const kindOf = (filename) => filename.endsWith(".differential.test.ts") ? DIFFERENTIAL_FILE_KIND : PROPERTY_FILE_KIND;
2598
2668
  const propertyFilePurity = defineRule({
2599
- meta: meta$5,
2669
+ meta: meta$6,
2600
2670
  create(context) {
2601
2671
  if (!isTestFile(basenameOf(context.filename))) return {};
2602
2672
  if (context.filename.endsWith(".property.test.ts") || context.filename.endsWith(".differential.test.ts")) return createPropertyFileVisitors(context, kindOf(context.filename));
@@ -2612,7 +2682,7 @@ const UNSANCTIONED_CELL_FIX = "rename it <stem>.workflow.property.test.ts beside
2612
2682
  const MISSING_CELL_TEST_EXPECTED = "a test for every cell suffix the consumer lists in cellsRequiringTest";
2613
2683
  const MISSING_CELL_TEST_ACTUAL = "a declared cell whose own module carries no `if (import.meta.vitest)` block";
2614
2684
  const MISSING_CELL_TEST_FIX = "add an `if (import.meta.vitest)` block to this module, or drop this cell from cellsRequiringTest and cover it with a colocated test in a sanctioned test directory — the rule reads the file it is given, so a sibling test file is invisible to it and a declared cell must satisfy the requirement from its own source";
2615
- const meta$4 = {
2685
+ const meta$5 = {
2616
2686
  type: "problem",
2617
2687
  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." },
2618
2688
  schema: [Schema.toJsonSchemaDocument(Options).schema],
@@ -2625,7 +2695,7 @@ const meta$4 = {
2625
2695
  //#region src/rules/src-property-test-cell.ts
2626
2696
  const carriesInSourceBlock = (body) => body.some((statement) => statement.type === "IfStatement" && isVitestGuard(statement.test));
2627
2697
  const srcPropertyTestCell = defineRule({
2628
- meta: meta$4,
2698
+ meta: meta$5,
2629
2699
  create(context) {
2630
2700
  const { cellsRequiringTest } = Schema.decodeUnknownSync(Options)(context.options[0] ?? {});
2631
2701
  const filename = context.filename;
@@ -2700,15 +2770,15 @@ const testFileOutsideTestsDir = defineRule({
2700
2770
  });
2701
2771
  //#endregion
2702
2772
  //#region src/rules/test-suffix-outside-src.config.ts
2703
- const UNSANCTIONED_SUFFIX_EXPECTED = "exactly *.integration.test.ts or *.differential.test.ts outside src/";
2704
- const UNSANCTIONED_SUFFIX_ACTUAL = "an unsanctioned test suffix outside src/";
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";
2773
+ const UNSANCTIONED_SUFFIX_EXPECTED = "*.integration.test.ts, *.differential.test.ts, or *.trace.test.ts";
2774
+ const UNSANCTIONED_SUFFIX_ACTUAL = "another test suffix outside src/";
2775
+ const UNSANCTIONED_SUFFIX_FIX = "public surface -> *.integration.test.ts; parity -> *.differential.test.ts; span graph -> *.trace.test.ts; pure-cell property -> in-source; restated literal -> delete";
2706
2776
  //#endregion
2707
2777
  //#region src/rules/test-suffix-outside-src.ts
2708
2778
  const testSuffixOutsideSrc = defineRule({
2709
2779
  meta: {
2710
2780
  type: "problem",
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." },
2781
+ docs: { description: "Outside src/, a test file ends .integration.test.ts, .differential.test.ts, or .trace.test.ts." },
2712
2782
  schema: [],
2713
2783
  messages: { unsanctionedSuffix: MESSAGE$7 }
2714
2784
  },
@@ -2717,7 +2787,7 @@ const testSuffixOutsideSrc = defineRule({
2717
2787
  if (isUnderSrc(filename)) return {};
2718
2788
  const basename = basenameOf(filename);
2719
2789
  if (!isTestFile(basename)) return {};
2720
- if (basename.endsWith(".integration.test.ts") || basename.endsWith(".differential.test.ts")) return {};
2790
+ if (basename.endsWith(".integration.test.ts") || basename.endsWith(".differential.test.ts") || basename.endsWith(".trace.test.ts")) return {};
2721
2791
  return { Program(node) {
2722
2792
  context.report({
2723
2793
  node,
@@ -2773,7 +2843,7 @@ const testsDirHelpersInFixtures = defineRule({
2773
2843
  const REACH_IN_EXPECTED = "a package name or subpath, or a sibling helper under the test tree";
2774
2844
  const REACH_IN_ACTUAL = "a relative import that reaches src or climbs into an internal folder";
2775
2845
  const REACH_IN_FIX = "rewrite onto the published package name when the binding is public. Delete the test when the subject is an internal";
2776
- const meta = {
2846
+ const meta$1 = {
2777
2847
  type: "problem",
2778
2848
  docs: { description: "Forbid package-level tests from relative-importing src or climbing into an internal folder" },
2779
2849
  schema: [],
@@ -2795,7 +2865,7 @@ const specifierOf = (node) => {
2795
2865
  if (node.type === "Literal" && typeof node.value === "string") return node.value;
2796
2866
  };
2797
2867
  const testsImportPublicApi = defineRule({
2798
- meta,
2868
+ meta: meta$1,
2799
2869
  create(context) {
2800
2870
  if (!isInTestsImportScope(context.filename)) return {};
2801
2871
  const reportIfForbidden = (sourceNode) => {
@@ -2832,6 +2902,230 @@ const testsImportPublicApi = defineRule({
2832
2902
  }
2833
2903
  });
2834
2904
  //#endregion
2905
+ //#region src/rules/trace-test-requires-taxonomy.config.ts
2906
+ const HARNESS_PRESCRIPTION = "import { Suite, Case, Rel, Graph, Observe, Stimulus } from @systemfsoftware/trace-spec and terminate every case on a relation hold — .holds(Rel.all(Rel.exists(PlaceOrder), Rel.child(PlaceOrder, PaymentCapture)))";
2907
+ const MISSING_HARNESS_ACTUAL = "no trace-spec import found in a file named as a trace spec";
2908
+ const HTTP_MEMBERS = {
2909
+ body: true,
2910
+ status: true,
2911
+ statusText: true
2912
+ };
2913
+ const HTTP_PROPERTY_MATCHER = "toHaveProperty";
2914
+ const HTTP_OBJECT_MATCHERS = {
2915
+ objectContaining: true,
2916
+ toEqual: true,
2917
+ toMatchObject: true,
2918
+ toStrictEqual: true
2919
+ };
2920
+ const HTTP_TERMINATION_EXPECTED = "a case terminated on a relation hold — .holds(Rel.all(...)) over the declared span graph";
2921
+ const HTTP_TERMINATION_ACTUAL = "an HTTP response assertion terminating the case";
2922
+ const HTTP_TERMINATION_FIX = "replace the HTTP assertion with a relation over the observed spans, e.g. .holds(Rel.all(Rel.exists(PlaceOrder), Rel.child(PlaceOrder, PaymentCapture)))";
2923
+ const RAW_EMIT_EXPECTED = "spans declared once with Span.declare({ id, name, attrs }) inside the traced cell; the spec observes the decoded graph";
2924
+ const RAW_EMIT_ACTUAL = "a raw span emit call inside a trace spec";
2925
+ const RAW_EMIT_FIX = "delete the emit; declare the span with Span.declare in the system under test and assert on the graph the spec receives";
2926
+ const meta = {
2927
+ type: "problem",
2928
+ docs: { description: "A *.trace.test.ts must import its harness from @systemfsoftware/trace-spec and terminate cases on relation holds over the declared span graph. Raw emit calls (startSpan, spanBuilder, startActiveSpan) and HTTP status, statusText, or body assertions (expect(res.status).toBe(200), expect(res).toHaveProperty(\"status\", 200), expect(res).resolves.toMatchObject({ status: 200 }), expect(res).toEqual(expect.objectContaining({ status: 200 }))) are forbidden — replacing that altitude is what a trace spec exists for." },
2929
+ schema: [],
2930
+ messages: {
2931
+ missingHarnessImport: MESSAGE$7,
2932
+ httpTermination: MESSAGE$7,
2933
+ rawEmitCall: MESSAGE$7
2934
+ }
2935
+ };
2936
+ //#endregion
2937
+ //#region src/rules/trace-test-requires-taxonomy.ts
2938
+ const asIdentifierName = (expression) => {
2939
+ if (expression === void 0 || expression.type !== "Identifier") return null;
2940
+ return expression.name;
2941
+ };
2942
+ const isEmitCallee = (name) => EMIT_CALLEES.some((emit) => emit === name);
2943
+ const isExpectCall = (node) => node?.type === "CallExpression" && asIdentifierName(node.callee) === "expect";
2944
+ const expectCallUnder = (node) => {
2945
+ const callee = node.callee;
2946
+ if (callee.type !== "MemberExpression" || !isExpectCall(callee.object)) return null;
2947
+ return callee.object;
2948
+ };
2949
+ const memberReadName = (argument) => {
2950
+ if (argument === void 0 || argument.type !== "MemberExpression") return null;
2951
+ return asIdentifierName(argument.property);
2952
+ };
2953
+ const httpReadName = (node) => {
2954
+ const read = memberReadName(expectCallUnder(node)?.arguments[0]);
2955
+ if (read === null || HTTP_MEMBERS[read] !== true) return null;
2956
+ return read;
2957
+ };
2958
+ const memberCallName = (callee) => {
2959
+ if (callee.type !== "MemberExpression") return null;
2960
+ return callee.computed ? null : asIdentifierName(callee.property);
2961
+ };
2962
+ const emitCalleeName = (node) => {
2963
+ return asIdentifierName(node.callee) ?? memberCallName(node.callee);
2964
+ };
2965
+ const rawEmitName = (node) => {
2966
+ const name = emitCalleeName(node);
2967
+ if (name === null || !isEmitCallee(name)) return null;
2968
+ return name;
2969
+ };
2970
+ const hasHarnessBinding = (node) => node.source.value === "@systemfsoftware/trace-spec" && node.specifiers.length > 0;
2971
+ const literalValueOf = (node) => {
2972
+ if (node === void 0 || node.type !== "Literal") return null;
2973
+ return node.value;
2974
+ };
2975
+ const stringLiteralValue = (node) => {
2976
+ const value = literalValueOf(node);
2977
+ return typeof value === "string" ? value : null;
2978
+ };
2979
+ const stringElementValue = (element) => stringLiteralValue(element ?? void 0);
2980
+ const httpMemberName = (name) => HTTP_MEMBERS[name] === true ? name : null;
2981
+ const httpMemberHead = (path) => {
2982
+ const head = path.split(".")[0];
2983
+ return head === void 0 ? null : httpMemberName(head);
2984
+ };
2985
+ const propertyPathText = (node) => node?.type === "ArrayExpression" ? stringElementValue(node.elements[0]) : stringLiteralValue(node);
2986
+ const toHavePropertyMember = (node) => {
2987
+ const path = propertyPathText(node.arguments[0]);
2988
+ return path === null ? null : httpMemberHead(path);
2989
+ };
2990
+ const propertyMatchTermination = (node, matcher) => {
2991
+ const member = toHavePropertyMember(node);
2992
+ return matcher === "toHaveProperty" ? member : null;
2993
+ };
2994
+ const identifierKeyName = (key) => {
2995
+ if (key.type !== "Identifier") return null;
2996
+ return key.name;
2997
+ };
2998
+ const keyLiteralValue = (key) => {
2999
+ if (key.type !== "Literal") return null;
3000
+ return key.value;
3001
+ };
3002
+ const stringKeyName = (key) => {
3003
+ const value = keyLiteralValue(key);
3004
+ return typeof value === "string" ? value : null;
3005
+ };
3006
+ const objectKeyName = (key) => {
3007
+ const identifier = identifierKeyName(key);
3008
+ return identifier === null ? stringKeyName(key) : identifier;
3009
+ };
3010
+ const objectPropertyKey = (property) => {
3011
+ if (property.type !== "Property" || property.computed) return null;
3012
+ return property.key;
3013
+ };
3014
+ const propertyKeyName = (property) => {
3015
+ const key = objectPropertyKey(property);
3016
+ return key === null ? null : objectKeyName(key);
3017
+ };
3018
+ const httpMemberOr = (name) => name === null ? null : httpMemberName(name);
3019
+ const propertyValueOf = (property) => property.type === "Property" ? property.value : null;
3020
+ const nestedPropertyHttpKey = (property) => httpKeyOfValue(propertyValueOf(property));
3021
+ const httpKeyOfProperty = (property) => {
3022
+ const named = httpMemberOr(propertyKeyName(property));
3023
+ return named === null ? nestedPropertyHttpKey(property) : named;
3024
+ };
3025
+ const elementsHttpKey = (elements) => {
3026
+ const hit = elements.find((element) => httpKeyOfValue(element) !== null);
3027
+ return hit === void 0 ? null : httpKeyOfValue(hit);
3028
+ };
3029
+ const httpKeyOfNonObject = (value) => value?.type === "ArrayExpression" ? elementsHttpKey(value.elements) : null;
3030
+ const httpKeyOfValue = (value) => value?.type === "ObjectExpression" ? propertiesHttpKey(value.properties) : httpKeyOfNonObject(value);
3031
+ const propertiesHttpKey = (properties) => {
3032
+ const hit = properties.find((property) => httpKeyOfProperty(property) !== null);
3033
+ return hit === void 0 ? null : httpKeyOfProperty(hit);
3034
+ };
3035
+ const isObjectExpression = (node) => node.type === "ObjectExpression";
3036
+ const objectArgumentHttpKey = (node) => {
3037
+ const argument = node.arguments.find(isObjectExpression);
3038
+ return argument === void 0 ? null : propertiesHttpKey(argument.properties);
3039
+ };
3040
+ const isObjectShapeMatcher = (matcher) => HTTP_OBJECT_MATCHERS[matcher] === true;
3041
+ const objectMatchTermination = (node, matcher) => {
3042
+ const key = objectArgumentHttpKey(node);
3043
+ return isObjectShapeMatcher(matcher) ? key : null;
3044
+ };
3045
+ const isExpectIdentifier = (node) => node.type === "Identifier" && node.name === "expect";
3046
+ const isExpectReference = (node) => isExpectCall(node) || isExpectIdentifier(node);
3047
+ const chainRootsAtExpect = (node) => node.type === "MemberExpression" ? chainRootsAtExpect(node.object) : isExpectReference(node);
3048
+ const calleeRootsAtExpect = (callee) => callee.type === "MemberExpression" ? chainRootsAtExpect(callee.object) : false;
3049
+ const matcherCallName = (node) => {
3050
+ if (!calleeRootsAtExpect(node.callee)) return null;
3051
+ return memberCallName(node.callee);
3052
+ };
3053
+ const propertyMatchName = (member) => `expect(...).${HTTP_PROPERTY_MATCHER}('${member}')`;
3054
+ const objectMatchName = (matcher, key) => `expect(...).${matcher}({ ${key}: ... })`;
3055
+ const objectMatchedName = (node, matcher) => {
3056
+ const key = objectMatchTermination(node, matcher);
3057
+ return key === null ? null : objectMatchName(matcher, key);
3058
+ };
3059
+ const matchedShapeName = (node, matcher) => {
3060
+ const member = propertyMatchTermination(node, matcher);
3061
+ return member === null ? objectMatchedName(node, matcher) : propertyMatchName(member);
3062
+ };
3063
+ const shapeTerminationName = (node) => {
3064
+ const matcher = matcherCallName(node);
3065
+ return matcher === null ? null : matchedShapeName(node, matcher);
3066
+ };
3067
+ const httpTerminationName = (node) => {
3068
+ const direct = httpReadName(node);
3069
+ return direct === null ? shapeTerminationName(node) : `expect(...${direct})`;
3070
+ };
3071
+ const reportRawEmit = (context, node) => {
3072
+ const name = rawEmitName(node);
3073
+ if (name === null) return;
3074
+ context.report({
3075
+ node,
3076
+ messageId: "rawEmitCall",
3077
+ data: {
3078
+ name: `${name}(...) inside a trace spec`,
3079
+ expected: RAW_EMIT_EXPECTED,
3080
+ actual: RAW_EMIT_ACTUAL,
3081
+ fix: RAW_EMIT_FIX
3082
+ }
3083
+ });
3084
+ };
3085
+ const reportHttpTermination = (context, node) => {
3086
+ const name = httpTerminationName(node);
3087
+ if (name === null) return;
3088
+ context.report({
3089
+ node,
3090
+ messageId: "httpTermination",
3091
+ data: {
3092
+ name: `${name} inside a trace spec`,
3093
+ expected: HTTP_TERMINATION_EXPECTED,
3094
+ actual: HTTP_TERMINATION_ACTUAL,
3095
+ fix: HTTP_TERMINATION_FIX
3096
+ }
3097
+ });
3098
+ };
3099
+ const traceTestRequiresTaxonomy = defineRule({
3100
+ meta,
3101
+ create(context) {
3102
+ if (!context.filename.endsWith(".trace.test.ts")) return {};
3103
+ let hasHarnessImport = false;
3104
+ return {
3105
+ ImportDeclaration(node) {
3106
+ if (hasHarnessBinding(node)) hasHarnessImport = true;
3107
+ },
3108
+ CallExpression(node) {
3109
+ reportRawEmit(context, node);
3110
+ reportHttpTermination(context, node);
3111
+ },
3112
+ "Program:exit"(node) {
3113
+ if (hasHarnessImport) return;
3114
+ context.report({
3115
+ node,
3116
+ messageId: "missingHarnessImport",
3117
+ data: {
3118
+ name: `a *.trace.test.ts without ${TRACE_SPEC_PACKAGE}`,
3119
+ expected: HARNESS_PRESCRIPTION,
3120
+ actual: MISSING_HARNESS_ACTUAL,
3121
+ fix: HARNESS_PRESCRIPTION
3122
+ }
3123
+ });
3124
+ }
3125
+ };
3126
+ }
3127
+ });
3128
+ //#endregion
2835
3129
  //#region src/index.ts
2836
3130
  const PLUGIN_NAME = "@systemfsoftware/oxlint-plugin-test-discipline";
2837
3131
  const rule = (name) => `${PLUGIN_NAME}/${name}`;
@@ -2859,7 +3153,9 @@ const recommendedRules = {
2859
3153
  [rule("no-io-module-in-source-test")]: "error",
2860
3154
  [rule("tests-import-public-api")]: "error",
2861
3155
  [rule("differential-test-requires-harness")]: "error",
2862
- [rule("no-pseudo-gherkin-unit-tests")]: "error"
3156
+ [rule("no-pseudo-gherkin-unit-tests")]: "error",
3157
+ [rule("ban-raw-span-name-emit")]: "error",
3158
+ [rule("trace-test-requires-taxonomy")]: "error"
2863
3159
  };
2864
3160
  var src_default = {
2865
3161
  meta: { name: PLUGIN_NAME },
@@ -2887,7 +3183,9 @@ var src_default = {
2887
3183
  "tests-dir-helpers-in-fixtures": testsDirHelpersInFixtures,
2888
3184
  "no-io-module-in-source-test": noIoModuleInSourceTest,
2889
3185
  "tests-import-public-api": testsImportPublicApi,
2890
- "differential-test-requires-harness": differentialTestRequiresHarness
3186
+ "differential-test-requires-harness": differentialTestRequiresHarness,
3187
+ "ban-raw-span-name-emit": banRawSpanNameEmit,
3188
+ "trace-test-requires-taxonomy": traceTestRequiresTaxonomy
2891
3189
  },
2892
3190
  configs: { recommended: { rules: recommendedRules } }
2893
3191
  };
@@ -29,6 +29,8 @@ declare const _default: {
29
29
  'no-io-module-in-source-test': Rule;
30
30
  'tests-import-public-api': Rule;
31
31
  'differential-test-requires-harness': Rule;
32
+ 'ban-raw-span-name-emit': Rule;
33
+ 'trace-test-requires-taxonomy': Rule;
32
34
  };
33
35
  configs: {
34
36
  recommended: {
@@ -29,6 +29,8 @@ declare const _default: {
29
29
  'no-io-module-in-source-test': Rule;
30
30
  'tests-import-public-api': Rule;
31
31
  'differential-test-requires-harness': Rule;
32
+ 'ban-raw-span-name-emit': Rule;
33
+ 'trace-test-requires-taxonomy': Rule;
32
34
  };
33
35
  configs: {
34
36
  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.7.0",
4
+ "version": "3.8.0",
5
5
  "author": "Ryan Lee <drdgvhbh@gmail.com>",
6
6
  "repository": {
7
7
  "type": "git",
@@ -44,11 +44,11 @@
44
44
  "devDependencies": {
45
45
  "@microsoft/api-extractor": "^7.59.1",
46
46
  "@systemfsoftware/arethetypeswrong-cli": "^4.2.0",
47
- "@systemfsoftware/stryker-ignorer-effect-schema-declarations": "^0.1.0",
48
- "@systemfsoftware/stryker-ignorer-in-source-vitest-block": "^0.1.0",
49
- "@systemfsoftware/stryker-js": "^10.0.1",
50
- "@systemfsoftware/stryker-js-typescript-checker": "^7.0.4",
51
- "@systemfsoftware/stryker-js-vitest-runner": "^7.0.1",
47
+ "@systemfsoftware/stryker-ignorer-effect-schema-declarations": "^0.1.1",
48
+ "@systemfsoftware/stryker-ignorer-in-source-vitest-block": "^0.1.1",
49
+ "@systemfsoftware/stryker-js": "^10.1.1",
50
+ "@systemfsoftware/stryker-js-typescript-checker": "^7.0.5",
51
+ "@systemfsoftware/stryker-js-vitest-runner": "^7.1.1",
52
52
  "@systemfsoftware/stryker-test-contribution": "^3.0.3",
53
53
  "@types/node": "^26",
54
54
  "@vitest/coverage-v8": "^5",
@@ -60,9 +60,9 @@
60
60
  "typescript": "^7",
61
61
  "vitest": "^5",
62
62
  "@systemfsoftware/stryker-config": "^0.1.0",
63
- "@systemfsoftware/tsdown-config": "^0.1.0",
63
+ "@systemfsoftware/vitest-config": "^0.1.0",
64
64
  "@systemfsoftware/tsconfig": "^1.3.6",
65
- "@systemfsoftware/vitest-config": "^0.1.0"
65
+ "@systemfsoftware/tsdown-config": "^0.1.0"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "effect": "4.0.0-rc.116",