@jterrazz/test 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/oxlint.js CHANGED
@@ -162,7 +162,7 @@ const RULE_DOCS = {
162
162
  },
163
163
  "a2-known-constructors": {
164
164
  channel: "statique",
165
- convention: "Trois constructeurs et seulement trois : `specification.api()`, `specification.jobs()`, `specification.cli(bin)` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
165
+ convention: "Quatre constructeurs et seulement quatre : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
166
166
  family: "A",
167
167
  id: "A2",
168
168
  rationale: "Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable."
@@ -432,6 +432,22 @@ const RULE_DOCS = {
432
432
  family: "J",
433
433
  id: "J5",
434
434
  rationale: "Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait."
435
+ },
436
+ "w1-scenario-pure": {
437
+ channel: "statique",
438
+ convention: "Un scénario de visite est le When : le visiteur agit, la capture reflète l’état final ; aucun `expect()` dans le callback — les assertions vivent dans le Then, sur le résultat retourné.",
439
+ facet: "website",
440
+ family: "W",
441
+ id: "W1",
442
+ rationale: "Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables."
443
+ },
444
+ "w2w-user-facing-elements": {
445
+ channel: "statique",
446
+ convention: "Les éléments d’un scénario sont user-facing (`button`, `link`, `field`, `heading`, `content`) ; `testId()` est l’unique échappatoire et déclenche un avertissement.",
447
+ facet: "website",
448
+ family: "W",
449
+ id: "W2",
450
+ rationale: "Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie."
435
451
  }
436
452
  };
437
453
  /**
@@ -653,7 +669,7 @@ const a1SpecificationFile = {
653
669
  },
654
670
  meta: {
655
671
  docs: RULE_DOCS["a1-specification-file"],
656
- messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (CONVENTIONS A1)." },
672
+ messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (A1 — see docs/10-linting.md)." },
657
673
  type: "problem"
658
674
  }
659
675
  };
@@ -730,22 +746,24 @@ const a10DuplicateBinding = {
730
746
  },
731
747
  meta: {
732
748
  docs: RULE_DOCS["a10-duplicate-binding"],
733
- messages: { duplicate: "Service keys \"{{first}}\" and \"{{key}}\" both bind to compose service \"{{binding}}\" — one shadows the other; rename a key or set distinct composeService values (CONVENTIONS A10)." },
749
+ messages: { duplicate: "Service keys \"{{first}}\" and \"{{key}}\" both bind to compose service \"{{binding}}\" — one shadows the other; rename a key or set distinct composeService values (A10 — see docs/10-linting.md)." },
734
750
  type: "problem"
735
751
  }
736
752
  };
737
753
  //#endregion
738
754
  //#region src/lint/rules/a2-known-constructors.ts
739
- /** The three constructors, and only three (CONVENTIONS A2). */
755
+ /** The four constructors, and only four (A2 — see docs/10-linting.md). */
740
756
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
741
757
  "api",
742
758
  "cli",
743
- "jobs"
759
+ "jobs",
760
+ "website"
744
761
  ]);
745
762
  /**
746
- * CONVENTIONS A2 — `specification.api()`, `specification.jobs()` and
747
- * `specification.cli()` are the only members. Any other access
748
- * (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
763
+ * CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
764
+ * `specification.cli()` and `specification.website()` are the only members.
765
+ * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
766
+ * the member site.
749
767
  */
750
768
  const a2KnownConstructors = {
751
769
  create(context) {
@@ -762,7 +780,7 @@ const a2KnownConstructors = {
762
780
  },
763
781
  meta: {
764
782
  docs: RULE_DOCS["a2-known-constructors"],
765
- messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs() and specification.cli() (CONVENTIONS A2)." },
783
+ messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli() and specification.website() (A2 — see docs/10-linting.md)." },
766
784
  type: "problem"
767
785
  }
768
786
  };
@@ -807,7 +825,7 @@ const a3NoDestructureAlias = {
807
825
  },
808
826
  meta: {
809
827
  docs: RULE_DOCS["a3-no-destructure-alias"],
810
- messages: { aliased: "Destructure the specification result with its canonical name: `{{key}}`, not `{{key}}: {{alias}}` (CONVENTIONS A3). Rename at the import site if a different local name is needed." },
828
+ messages: { aliased: "Destructure the specification result with its canonical name: `{{key}}`, not `{{key}}: {{alias}}` (A3 — see docs/10-linting.md). Rename at the import site if a different local name is needed." },
811
829
  type: "problem"
812
830
  }
813
831
  };
@@ -859,7 +877,7 @@ const a4CleanupAfterall = {
859
877
  },
860
878
  meta: {
861
879
  docs: RULE_DOCS["a4-cleanup-afterall"],
862
- messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (CONVENTIONS A4)." },
880
+ messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (A4 — see docs/10-linting.md)." },
863
881
  type: "problem"
864
882
  }
865
883
  };
@@ -889,7 +907,7 @@ const a5ModeWithServer = {
889
907
  },
890
908
  meta: {
891
909
  docs: RULE_DOCS["a5-mode-with-server"],
892
- messages: { hardcodedMode: "`mode` must not be hardcoded when `server` is defined — the node/compose switch lives in vitest.config.ts via `env: { TEST_MODE: \"compose\" }` (CONVENTIONS A5)." },
910
+ messages: { hardcodedMode: "`mode` must not be hardcoded when `server` is defined — the node/compose switch lives in vitest.config.ts via `env: { TEST_MODE: \"compose\" }` (A5 — see docs/10-linting.md)." },
893
911
  type: "problem"
894
912
  }
895
913
  };
@@ -930,7 +948,7 @@ const a6wRedundantComposeService = {
930
948
  },
931
949
  meta: {
932
950
  docs: RULE_DOCS["a6w-redundant-compose-service"],
933
- messages: { redundant: "composeService: \"{{name}}\" is redundant — the key \"{{key}}\" already binds to it (exact name or kebab-case derivation, CONVENTIONS A6)." },
951
+ messages: { redundant: "composeService: \"{{name}}\" is redundant — the key \"{{key}}\" already binds to it (exact name or kebab-case derivation, A6 — see docs/10-linting.md)." },
934
952
  type: "suggestion"
935
953
  }
936
954
  };
@@ -994,7 +1012,7 @@ function readFileCached(path) {
994
1012
  //#endregion
995
1013
  //#region src/lint/rules/a9w-redundant-root.ts
996
1014
  /**
997
- * The root the convention would derive (CONVENTIONS A9): walk up from the
1015
+ * The root the convention would derive (A9 — see docs/10-linting.md): walk up from the
998
1016
  * specification file to the first directory containing
999
1017
  * `docker/compose.test.yaml`, else the first containing `package.json`.
1000
1018
  */
@@ -1033,7 +1051,7 @@ const a9wRedundantRoot = {
1033
1051
  },
1034
1052
  meta: {
1035
1053
  docs: RULE_DOCS["a9w-redundant-root"],
1036
- messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (CONVENTIONS A9)." },
1054
+ messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (A9 — see docs/10-linting.md)." },
1037
1055
  type: "suggestion"
1038
1056
  }
1039
1057
  };
@@ -1097,7 +1115,7 @@ const b2KnownFixtureMarker = {
1097
1115
  },
1098
1116
  meta: {
1099
1117
  docs: RULE_DOCS["b2-known-fixture-marker"],
1100
- messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (CONVENTIONS B2)." },
1118
+ messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (B2 — see docs/10-linting.md)." },
1101
1119
  type: "problem"
1102
1120
  }
1103
1121
  };
@@ -1162,8 +1180,8 @@ const b4GivenThen = {
1162
1180
  meta: {
1163
1181
  docs: RULE_DOCS["b4-given-then"],
1164
1182
  messages: {
1165
- givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (CONVENTIONS B4).",
1166
- missing: "Test is missing a `// {{marker}} -` comment (CONVENTIONS B4 — every test needs both `// Given -` and `// Then -`)."
1183
+ givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (B4 — see docs/10-linting.md).",
1184
+ missing: "Test is missing a `// {{marker}} -` comment (B4 — see docs/10-linting.md — every test needs both `// Given -` and `// Then -`)."
1167
1185
  },
1168
1186
  type: "problem"
1169
1187
  }
@@ -1204,7 +1222,7 @@ const b5AwaitUsing = {
1204
1222
  meta: {
1205
1223
  docs: RULE_DOCS["b5-await-using"],
1206
1224
  defaultOptions: [{ runners: [] }],
1207
- messages: { requireAwaitUsing: "The result of docker-aware runner \"{{runner}}\" must be bound with `await using` so its containers are disposed (CONVENTIONS B5)." },
1225
+ messages: { requireAwaitUsing: "The result of docker-aware runner \"{{runner}}\" must be bound with `await using` so its containers are disposed (B5 — see docs/10-linting.md)." },
1208
1226
  schema: [{
1209
1227
  additionalProperties: false,
1210
1228
  properties: { runners: {
@@ -1247,7 +1265,7 @@ const b6wRedundantEnvUrl = {
1247
1265
  },
1248
1266
  meta: {
1249
1267
  docs: RULE_DOCS["b6w-redundant-env-url"],
1250
- messages: { redundant: ".env({ {{key}}: ….connectionString }) is redundant — the framework already injects <KEY>_URL (and the DATABASE_URL/REDIS_URL aliases) for every service (CONVENTIONS B6)." },
1268
+ messages: { redundant: ".env({ {{key}}: ….connectionString }) is redundant — the framework already injects <KEY>_URL (and the DATABASE_URL/REDIS_URL aliases) for every service (B6 — see docs/10-linting.md)." },
1251
1269
  type: "suggestion"
1252
1270
  }
1253
1271
  };
@@ -1276,7 +1294,7 @@ const b8KebabTrigger = {
1276
1294
  },
1277
1295
  meta: {
1278
1296
  docs: RULE_DOCS["b8-kebab-trigger"],
1279
- messages: { notKebab: "Job name \"{{name}}\" must be a stable kebab-case identifier (e.g. \"nightly-report\") — it is a contract between the app and its tests (CONVENTIONS B8)." },
1297
+ messages: { notKebab: "Job name \"{{name}}\" must be a stable kebab-case identifier (e.g. \"nightly-report\") — it is a contract between the app and its tests (B8 — see docs/10-linting.md)." },
1280
1298
  type: "problem"
1281
1299
  }
1282
1300
  };
@@ -1331,7 +1349,7 @@ const b9wProductCommand = {
1331
1349
  },
1332
1350
  meta: {
1333
1351
  docs: RULE_DOCS["b9w-product-command"],
1334
- messages: { thirdPartyBinary: "specification.cli() points at a third-party binary in node_modules/.bin — a spec should exercise your product command (cli.exec(\"check\"), …), not the tool underneath (CONVENTIONS B9). Suppress with a reason if the product genuinely is this binary." },
1352
+ messages: { thirdPartyBinary: "specification.cli() points at a third-party binary in node_modules/.bin — a spec should exercise your product command (cli.exec(\"check\"), …), not the tool underneath (B9 — see docs/10-linting.md). Suppress with a reason if the product genuinely is this binary." },
1335
1353
  type: "suggestion"
1336
1354
  }
1337
1355
  };
@@ -1387,9 +1405,9 @@ const c1DomainStructure = {
1387
1405
  meta: {
1388
1406
  docs: RULE_DOCS["c1-domain-structure"],
1389
1407
  messages: {
1390
- specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (CONVENTIONS C1).",
1391
- testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (CONVENTIONS C1).",
1392
- testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (CONVENTIONS C1)."
1408
+ specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
1409
+ testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (C1 — see docs/10-linting.md).",
1410
+ testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (C1 — see docs/10-linting.md)."
1393
1411
  },
1394
1412
  type: "problem"
1395
1413
  }
@@ -1419,13 +1437,13 @@ const c2HttpOnlyRequests = {
1419
1437
  },
1420
1438
  meta: {
1421
1439
  docs: RULE_DOCS["c2-http-only-requests"],
1422
- messages: { notHttp: "requests/{{entry}} is not a .http file — requests/ contains only complete .http request files (CONVENTIONS C2)." },
1440
+ messages: { notHttp: "requests/{{entry}} is not a .http file — requests/ contains only complete .http request files (C2 — see docs/10-linting.md)." },
1423
1441
  type: "problem"
1424
1442
  }
1425
1443
  };
1426
1444
  //#endregion
1427
1445
  //#region src/lint/rules/c4-contract-shape.ts
1428
- /** Providers a contract file may declare (CONVENTIONS C4). */
1446
+ /** Providers a contract file may declare (C4 — see docs/10-linting.md). */
1429
1447
  const PROVIDERS = /* @__PURE__ */ new Set([
1430
1448
  "anthropic",
1431
1449
  "http",
@@ -1491,12 +1509,12 @@ const c4ContractShape = {
1491
1509
  meta: {
1492
1510
  docs: RULE_DOCS["c4-contract-shape"],
1493
1511
  messages: {
1494
- badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider ∈ openai | anthropic | http (CONVENTIONS C4).",
1495
- foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (CONVENTIONS C4).",
1496
- missingDefault: "Contract file has no `export default defineContract(...)` (CONVENTIONS C4).",
1497
- namedExport: "Contract files have no named exports — only `export default defineContract(...)` (CONVENTIONS C4).",
1498
- notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (CONVENTIONS C4).",
1499
- subfolder: "Contract file \"{{base}}\" is nested — contracts/ is flat, no subfolders (CONVENTIONS C4)."
1512
+ badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider ∈ openai | anthropic | http (C4 — see docs/10-linting.md).",
1513
+ foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (C4 — see docs/10-linting.md).",
1514
+ missingDefault: "Contract file has no `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1515
+ namedExport: "Contract files have no named exports — only `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1516
+ notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (C4 — see docs/10-linting.md).",
1517
+ subfolder: "Contract file \"{{base}}\" is nested — contracts/ is flat, no subfolders (C4 — see docs/10-linting.md)."
1500
1518
  },
1501
1519
  type: "problem"
1502
1520
  }
@@ -1547,7 +1565,7 @@ const c6ToMatchExtension = {
1547
1565
  },
1548
1566
  meta: {
1549
1567
  docs: RULE_DOCS["c6-tomatch-extension"],
1550
- messages: { missingExtension: "toMatch(\"{{name}}\") is missing its extension — the extension is part of the fixture name (\"help.txt\", never \"help\"); only directory-tree snapshots (expected/<name>/) omit it (CONVENTIONS C6)." },
1568
+ messages: { missingExtension: "toMatch(\"{{name}}\") is missing its extension — the extension is part of the fixture name (\"help.txt\", never \"help\"); only directory-tree snapshots (expected/<name>/) omit it (C6 — see docs/10-linting.md)." },
1551
1569
  type: "problem"
1552
1570
  }
1553
1571
  };
@@ -1575,7 +1593,7 @@ const c7SeedsSqlOnly = {
1575
1593
  },
1576
1594
  meta: {
1577
1595
  docs: RULE_DOCS["c7-seeds-sql-only"],
1578
- messages: { notSql: "seeds/{{entry}} is not a .sql file — seeds/ carries database state only; file state goes through .fixture() (CONVENTIONS C7)." },
1596
+ messages: { notSql: "seeds/{{entry}} is not a .sql file — seeds/ carries database state only; file state goes through .fixture() (C7 — see docs/10-linting.md)." },
1579
1597
  type: "problem"
1580
1598
  }
1581
1599
  };
@@ -1664,7 +1682,7 @@ const c8ReferencedFixtureExists = {
1664
1682
  },
1665
1683
  meta: {
1666
1684
  docs: RULE_DOCS["c8-referenced-fixture-exists"],
1667
- messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its conventional {{root}}/ root (CONVENTIONS C8). Create it or fix the reference." },
1685
+ messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its conventional {{root}}/ root (C8 — see docs/10-linting.md). Create it or fix the reference." },
1668
1686
  type: "problem"
1669
1687
  }
1670
1688
  };
@@ -1733,7 +1751,7 @@ const d12wResponseBodyProbe = {
1733
1751
  meta: {
1734
1752
  docs: RULE_DOCS["d12w-response-body-probe"],
1735
1753
  defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
1736
- messages: { bodyProbeCluster: "{{count}} body probes in one test — this wants a full golden: expect(result.response).toMatch('x.http') (CONVENTIONS D12)." },
1754
+ messages: { bodyProbeCluster: "{{count}} body probes in one test — this wants a full golden: expect(result.response).toMatch('x.http') (D12 — see docs/10-linting.md)." },
1737
1755
  schema: [{
1738
1756
  additionalProperties: false,
1739
1757
  properties: { threshold: {
@@ -1816,7 +1834,7 @@ const d13wUnfrozenNegativeFixture = {
1816
1834
  },
1817
1835
  meta: {
1818
1836
  docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
1819
- messages: { unfrozenNegativeFixture: "This toMatch asserts a mismatch (wrapped in expect(…).toThrow/.rejects) — pass { frozen: true } so TEST_UPDATE=1 never overwrites the deliberately-wrong fixture (CONVENTIONS D13)." },
1837
+ messages: { unfrozenNegativeFixture: "This toMatch asserts a mismatch (wrapped in expect(…).toThrow/.rejects) — pass { frozen: true } so TEST_UPDATE=1 never overwrites the deliberately-wrong fixture (D13 — see docs/10-linting.md)." },
1820
1838
  type: "suggestion"
1821
1839
  }
1822
1840
  };
@@ -1892,7 +1910,7 @@ const d15wStatusOnlyProbe = {
1892
1910
  },
1893
1911
  meta: {
1894
1912
  docs: RULE_DOCS["d15w-status-only-probe"],
1895
- messages: { statusOnlyProbe: "The test's only assertion(s) are status probes — pin the full response: expect(result.response).toMatch('case.http') (CONVENTIONS D11)." },
1913
+ messages: { statusOnlyProbe: "The test's only assertion(s) are status probes — pin the full response: expect(result.response).toMatch('case.http') (D11 — see docs/10-linting.md)." },
1896
1914
  type: "suggestion"
1897
1915
  }
1898
1916
  };
@@ -1947,7 +1965,7 @@ const d2AwaitIoMatcher = {
1947
1965
  },
1948
1966
  meta: {
1949
1967
  docs: RULE_DOCS["d2-await-io-matcher"],
1950
- messages: { mustAwait: "expect(…).{{matcher}}(…) is IO-backed and returns a promise — it must be awaited or returned, else the assertion never runs (CONVENTIONS D2)." },
1968
+ messages: { mustAwait: "expect(…).{{matcher}}(…) is IO-backed and returns a promise — it must be awaited or returned, else the assertion never runs (D2 — see docs/10-linting.md)." },
1951
1969
  type: "problem"
1952
1970
  }
1953
1971
  };
@@ -2001,7 +2019,7 @@ const d2wAwaitSyncMatcher = {
2001
2019
  },
2002
2020
  meta: {
2003
2021
  docs: RULE_DOCS["d2w-await-sync-matcher"],
2004
- messages: { redundantAwait: "await on expect(…).{{matcher}}(…) is redundant — that matcher is synchronous; only IO-backed matchers (toMatchRows/toBeEmpty/toBeRunning) are awaited (CONVENTIONS D2)." },
2022
+ messages: { redundantAwait: "await on expect(…).{{matcher}}(…) is redundant — that matcher is synchronous; only IO-backed matchers (toMatchRows/toBeEmpty/toBeRunning) are awaited (D2 — see docs/10-linting.md)." },
2005
2023
  type: "suggestion"
2006
2024
  }
2007
2025
  };
@@ -2078,7 +2096,7 @@ const d6wTransformTokenEquivalent = {
2078
2096
  },
2079
2097
  meta: {
2080
2098
  docs: RULE_DOCS["d6w-transform-token-equivalent"],
2081
- messages: { tokenEquivalent: "This transform only rewrites output into known token literals — write the tokens in the expected/ fixture instead; transform is an escape hatch for uncovered noise (CONVENTIONS D6)." },
2099
+ messages: { tokenEquivalent: "This transform only rewrites output into known token literals — write the tokens in the expected/ fixture instead; transform is an escape hatch for uncovered noise (D6 — see docs/10-linting.md)." },
2082
2100
  type: "suggestion"
2083
2101
  }
2084
2102
  };
@@ -2112,7 +2130,7 @@ const d8wTextBypass = {
2112
2130
  },
2113
2131
  meta: {
2114
2132
  docs: RULE_DOCS["d8w-text-bypass"],
2115
- messages: { textBypass: "expect(x.text).{{matcher}}(…) asserts on the raw stream — prefer the typed accessor subject (expect(x)) so the token grammar and fixture resolution apply (CONVENTIONS D8)." },
2133
+ messages: { textBypass: "expect(x.text).{{matcher}}(…) asserts on the raw stream — prefer the typed accessor subject (expect(x)) so the token grammar and fixture resolution apply (D8 — see docs/10-linting.md)." },
2116
2134
  type: "suggestion"
2117
2135
  }
2118
2136
  };
@@ -2195,7 +2213,7 @@ const d9wSingleUseRef = {
2195
2213
  },
2196
2214
  meta: {
2197
2215
  docs: RULE_DOCS["d9w-single-use-ref"],
2198
- messages: { singleUse: "Capture ref \"{{ref}}\" is used only once in this spec — a ref earns its name by asserting equality across occurrences; use a plain matcher instead (CONVENTIONS D9)." },
2216
+ messages: { singleUse: "Capture ref \"{{ref}}\" is used only once in this spec — a ref earns its name by asserting equality across occurrences; use a plain matcher instead (D9 — see docs/10-linting.md)." },
2199
2217
  type: "suggestion"
2200
2218
  }
2201
2219
  };
@@ -2223,7 +2241,7 @@ const f1NoSubpathImport = {
2223
2241
  },
2224
2242
  meta: {
2225
2243
  docs: RULE_DOCS["f1-no-subpath-import"],
2226
- messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — subpaths do not exist (CONVENTIONS F1; only the tool-facing @jterrazz/test/oxlint is exempt)." },
2244
+ messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — subpaths do not exist (F1 — see docs/10-linting.md; only the tool-facing @jterrazz/test/oxlint is exempt)." },
2227
2245
  type: "problem"
2228
2246
  }
2229
2247
  };
@@ -2271,7 +2289,7 @@ const f2NoTestImportsInProd = {
2271
2289
  },
2272
2290
  meta: {
2273
2291
  docs: RULE_DOCS["f2-no-test-imports-in-prod"],
2274
- messages: { testImport: "Production code must not import the test artefact \"{{source}}\" — test imports are only legal from specs/, *.test.ts, *.fixtures.ts or *.specification.ts (CONVENTIONS F2)." },
2292
+ messages: { testImport: "Production code must not import the test artefact \"{{source}}\" — test imports are only legal from specs/, *.test.ts, *.fixtures.ts or *.specification.ts (F2 — see docs/10-linting.md)." },
2275
2293
  type: "problem"
2276
2294
  }
2277
2295
  };
@@ -2335,7 +2353,7 @@ const f3SpecsPublicEntry = {
2335
2353
  },
2336
2354
  meta: {
2337
2355
  docs: RULE_DOCS["f3-specs-public-entry"],
2338
- messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (CONVENTIONS F3; only @jterrazz/test/oxlint, and src/integrations/** from specs/integrations/, are exempt)." },
2356
+ messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/10-linting.md; only @jterrazz/test/oxlint, and src/integrations/** from specs/integrations/, are exempt)." },
2339
2357
  type: "problem"
2340
2358
  }
2341
2359
  };
@@ -2361,7 +2379,7 @@ const f4NoTestToTestImport = {
2361
2379
  },
2362
2380
  meta: {
2363
2381
  docs: RULE_DOCS["f4-no-test-to-test-import"],
2364
- messages: { testToTest: "A test file must not import another test file (\"{{source}}\") — share data via a *.fixtures.ts neighbour or a *.specification.ts runner (CONVENTIONS F4)." },
2382
+ messages: { testToTest: "A test file must not import another test file (\"{{source}}\") — share data via a *.fixtures.ts neighbour or a *.specification.ts runner (F4 — see docs/10-linting.md)." },
2365
2383
  type: "problem"
2366
2384
  }
2367
2385
  };
@@ -2387,14 +2405,14 @@ const f5FixturesOnlyFromTests = {
2387
2405
  },
2388
2406
  meta: {
2389
2407
  docs: RULE_DOCS["f5-fixtures-only-from-tests"],
2390
- messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (CONVENTIONS F5)." },
2408
+ messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (F5 — see docs/10-linting.md)." },
2391
2409
  type: "problem"
2392
2410
  }
2393
2411
  };
2394
2412
  //#endregion
2395
2413
  //#region src/lint/rules/i1-layer-boundaries.ts
2396
2414
  /**
2397
- * One folder = one external dependency (CONVENTIONS I1): the packages each
2415
+ * One folder = one external dependency (I1 — see docs/10-linting.md): the packages each
2398
2416
  * `src/integrations/<folder>/` may import.
2399
2417
  */
2400
2418
  const INTEGRATION_DEPS = {
@@ -2404,6 +2422,7 @@ const INTEGRATION_DEPS = {
2404
2422
  hono: ["hono", "@hono/node-server"],
2405
2423
  msw: ["msw"],
2406
2424
  openai: ["openai"],
2425
+ playwright: ["playwright"],
2407
2426
  postgres: ["pg"],
2408
2427
  redis: ["redis"],
2409
2428
  sqlite: ["better-sqlite3"],
@@ -2474,6 +2493,7 @@ const i1LayerBoundaries = {
2474
2493
  if (layer === "core") {
2475
2494
  if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2476
2495
  if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2496
+ if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2477
2497
  return "crossLayer";
2478
2498
  }
2479
2499
  if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
@@ -2500,10 +2520,10 @@ const i1LayerBoundaries = {
2500
2520
  meta: {
2501
2521
  docs: RULE_DOCS["i1-layer-boundaries"],
2502
2522
  messages: {
2503
- coreExternal: "core/ imports nothing external — \"{{source}}\" is not allowed (CONVENTIONS I1).",
2504
- crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the sanctioned layer edges (CONVENTIONS I1).",
2505
- foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (CONVENTIONS I1).",
2506
- lintRuntime: "The lint layer imports nothing from the framework runtime — \"{{source}}\" is outside its pure-helper whitelist (CONVENTIONS I1)."
2523
+ coreExternal: "core/ imports nothing external — \"{{source}}\" is not allowed (I1 — see docs/10-linting.md).",
2524
+ crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the sanctioned layer edges (I1 — see docs/10-linting.md).",
2525
+ foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (I1 — see docs/10-linting.md).",
2526
+ lintRuntime: "The lint layer imports nothing from the framework runtime — \"{{source}}\" is outside its pure-helper whitelist (I1 — see docs/10-linting.md)."
2507
2527
  },
2508
2528
  type: "problem"
2509
2529
  }
@@ -2556,9 +2576,9 @@ const i2SiblingTestNaming = {
2556
2576
  meta: {
2557
2577
  docs: RULE_DOCS["i2-sibling-test-naming"],
2558
2578
  messages: {
2559
- orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (CONVENTIONS I2). A test needing more than its module is a specification: move it to specs/.",
2560
- rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (CONVENTIONS I2).",
2561
- testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (CONVENTIONS I2)."
2579
+ orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (I2 — see docs/10-linting.md). A test needing more than its module is a specification: move it to specs/.",
2580
+ rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (I2 — see docs/10-linting.md).",
2581
+ testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/10-linting.md)."
2562
2582
  },
2563
2583
  type: "problem"
2564
2584
  }
@@ -2617,9 +2637,9 @@ const i4NoViMockInSrc = {
2617
2637
  meta: {
2618
2638
  docs: RULE_DOCS["i4-no-vi-mock-in-src"],
2619
2639
  messages: {
2620
- assetImport: "A src/ module test must not import the data asset \"{{source}}\" — inline it as code or move the test to specs/ (CONVENTIONS I4).",
2621
- bannedDir: "`{{dir}}/` directories are banned under src/ — mocks and data are code: mockOf/mockOfDate inline, payloads in a *.fixtures.ts neighbour (CONVENTIONS I4).",
2622
- viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (CONVENTIONS I4)."
2640
+ assetImport: "A src/ module test must not import the data asset \"{{source}}\" — inline it as code or move the test to specs/ (I4 — see docs/10-linting.md).",
2641
+ bannedDir: "`{{dir}}/` directories are banned under src/ — mocks and data are code: mockOf/mockOfDate inline, payloads in a *.fixtures.ts neighbour (I4 — see docs/10-linting.md).",
2642
+ viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (I4 — see docs/10-linting.md)."
2623
2643
  },
2624
2644
  type: "problem"
2625
2645
  }
@@ -2663,7 +2683,7 @@ const j1NoOnlySkip = {
2663
2683
  },
2664
2684
  meta: {
2665
2685
  docs: RULE_DOCS["j1-no-only-skip"],
2666
- messages: { forbidden: "Committed `{{runner}}.{{modifier}}` is not allowed (CONVENTIONS J1). Remove it before committing." },
2686
+ messages: { forbidden: "Committed `{{runner}}.{{modifier}}` is not allowed (J1 — see docs/10-linting.md). Remove it before committing." },
2667
2687
  type: "problem"
2668
2688
  }
2669
2689
  };
@@ -2706,8 +2726,8 @@ const j2NoSleepInSpecs = {
2706
2726
  meta: {
2707
2727
  docs: RULE_DOCS["j2-no-sleep-in-specs"],
2708
2728
  messages: {
2709
- sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (CONVENTIONS J2).",
2710
- timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (CONVENTIONS J2)."
2729
+ sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/10-linting.md).",
2730
+ timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/10-linting.md)."
2711
2731
  },
2712
2732
  type: "problem"
2713
2733
  }
@@ -2747,7 +2767,7 @@ const j3NoExpectlessTest = {
2747
2767
  },
2748
2768
  meta: {
2749
2769
  docs: RULE_DOCS["j3-no-expectless-test"],
2750
- messages: { noExpect: "Test has no `expect(…)` — a test must assert something (CONVENTIONS J3). Use `test.todo` for a pending test." },
2770
+ messages: { noExpect: "Test has no `expect(…)` — a test must assert something (J3 — see docs/10-linting.md). Use `test.todo` for a pending test." },
2751
2771
  type: "problem"
2752
2772
  }
2753
2773
  };
@@ -2783,7 +2803,7 @@ const j4UniqueTestNames = {
2783
2803
  },
2784
2804
  meta: {
2785
2805
  docs: RULE_DOCS["j4-unique-test-names"],
2786
- messages: { duplicate: "Duplicate test name \"{{name}}\" in this file — the test name is the sole description of behaviour, so it must be unique (CONVENTIONS J4)." },
2806
+ messages: { duplicate: "Duplicate test name \"{{name}}\" in this file — the test name is the sole description of behaviour, so it must be unique (J4 — see docs/10-linting.md)." },
2787
2807
  type: "problem"
2788
2808
  }
2789
2809
  };
@@ -2879,9 +2899,55 @@ const plugin = {
2879
2899
  },
2880
2900
  meta: {
2881
2901
  docs: RULE_DOCS["j5-lowercase-title"],
2882
- messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (CONVENTIONS J5)." },
2902
+ messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
2883
2903
  type: "problem"
2884
2904
  }
2905
+ },
2906
+ "w1-scenario-pure": {
2907
+ create(context) {
2908
+ return { CallExpression(node) {
2909
+ const callee = node.callee;
2910
+ if (!callee || memberPropertyName(callee) !== "visit") return;
2911
+ const scenario = node.arguments?.[1];
2912
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2913
+ walk(scenario, (inner) => {
2914
+ if (inner.type !== "CallExpression") return;
2915
+ const innerCallee = inner.callee;
2916
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
2917
+ messageId: "expectInScenario",
2918
+ node: inner
2919
+ });
2920
+ });
2921
+ } };
2922
+ },
2923
+ meta: {
2924
+ docs: RULE_DOCS["w1-scenario-pure"],
2925
+ messages: { expectInScenario: "No expect() inside a visit scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/10-linting.md)." },
2926
+ type: "problem"
2927
+ }
2928
+ },
2929
+ "w2w-user-facing-elements": {
2930
+ create(context) {
2931
+ return { CallExpression(node) {
2932
+ const callee = node.callee;
2933
+ if (!callee || memberPropertyName(callee) !== "visit") return;
2934
+ const scenario = node.arguments?.[1];
2935
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2936
+ walk(scenario, (inner) => {
2937
+ if (inner.type !== "CallExpression") return;
2938
+ const innerCallee = inner.callee;
2939
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "testId") context.report({
2940
+ messageId: "testIdElement",
2941
+ node: inner
2942
+ });
2943
+ });
2944
+ } };
2945
+ },
2946
+ meta: {
2947
+ docs: RULE_DOCS["w2w-user-facing-elements"],
2948
+ messages: { testIdElement: "Prefer a user-facing element (button, link, field, heading, content) over testId() — the escape hatch hides what the user actually sees (W2 — see docs/10-linting.md)." },
2949
+ type: "suggestion"
2950
+ }
2885
2951
  }
2886
2952
  }
2887
2953
  };