@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.cjs CHANGED
@@ -166,7 +166,7 @@ const RULE_DOCS = {
166
166
  },
167
167
  "a2-known-constructors": {
168
168
  channel: "statique",
169
- convention: "Trois constructeurs et seulement trois : `specification.api()`, `specification.jobs()`, `specification.cli(bin)` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
169
+ convention: "Quatre constructeurs et seulement quatre : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
170
170
  family: "A",
171
171
  id: "A2",
172
172
  rationale: "Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable."
@@ -436,6 +436,22 @@ const RULE_DOCS = {
436
436
  family: "J",
437
437
  id: "J5",
438
438
  rationale: "Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait."
439
+ },
440
+ "w1-scenario-pure": {
441
+ channel: "statique",
442
+ 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é.",
443
+ facet: "website",
444
+ family: "W",
445
+ id: "W1",
446
+ rationale: "Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables."
447
+ },
448
+ "w2w-user-facing-elements": {
449
+ channel: "statique",
450
+ 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.",
451
+ facet: "website",
452
+ family: "W",
453
+ id: "W2",
454
+ rationale: "Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie."
439
455
  }
440
456
  };
441
457
  /**
@@ -657,7 +673,7 @@ const a1SpecificationFile = {
657
673
  },
658
674
  meta: {
659
675
  docs: RULE_DOCS["a1-specification-file"],
660
- messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (CONVENTIONS A1)." },
676
+ messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (A1 — see docs/10-linting.md)." },
661
677
  type: "problem"
662
678
  }
663
679
  };
@@ -734,22 +750,24 @@ const a10DuplicateBinding = {
734
750
  },
735
751
  meta: {
736
752
  docs: RULE_DOCS["a10-duplicate-binding"],
737
- 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)." },
753
+ 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)." },
738
754
  type: "problem"
739
755
  }
740
756
  };
741
757
  //#endregion
742
758
  //#region src/lint/rules/a2-known-constructors.ts
743
- /** The three constructors, and only three (CONVENTIONS A2). */
759
+ /** The four constructors, and only four (A2 — see docs/10-linting.md). */
744
760
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
745
761
  "api",
746
762
  "cli",
747
- "jobs"
763
+ "jobs",
764
+ "website"
748
765
  ]);
749
766
  /**
750
- * CONVENTIONS A2 — `specification.api()`, `specification.jobs()` and
751
- * `specification.cli()` are the only members. Any other access
752
- * (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
767
+ * CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
768
+ * `specification.cli()` and `specification.website()` are the only members.
769
+ * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
770
+ * the member site.
753
771
  */
754
772
  const a2KnownConstructors = {
755
773
  create(context) {
@@ -766,7 +784,7 @@ const a2KnownConstructors = {
766
784
  },
767
785
  meta: {
768
786
  docs: RULE_DOCS["a2-known-constructors"],
769
- messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs() and specification.cli() (CONVENTIONS A2)." },
787
+ 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)." },
770
788
  type: "problem"
771
789
  }
772
790
  };
@@ -811,7 +829,7 @@ const a3NoDestructureAlias = {
811
829
  },
812
830
  meta: {
813
831
  docs: RULE_DOCS["a3-no-destructure-alias"],
814
- 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." },
832
+ 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." },
815
833
  type: "problem"
816
834
  }
817
835
  };
@@ -863,7 +881,7 @@ const a4CleanupAfterall = {
863
881
  },
864
882
  meta: {
865
883
  docs: RULE_DOCS["a4-cleanup-afterall"],
866
- messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (CONVENTIONS A4)." },
884
+ messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (A4 — see docs/10-linting.md)." },
867
885
  type: "problem"
868
886
  }
869
887
  };
@@ -893,7 +911,7 @@ const a5ModeWithServer = {
893
911
  },
894
912
  meta: {
895
913
  docs: RULE_DOCS["a5-mode-with-server"],
896
- 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)." },
914
+ 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)." },
897
915
  type: "problem"
898
916
  }
899
917
  };
@@ -934,7 +952,7 @@ const a6wRedundantComposeService = {
934
952
  },
935
953
  meta: {
936
954
  docs: RULE_DOCS["a6w-redundant-compose-service"],
937
- messages: { redundant: "composeService: \"{{name}}\" is redundant — the key \"{{key}}\" already binds to it (exact name or kebab-case derivation, CONVENTIONS A6)." },
955
+ 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)." },
938
956
  type: "suggestion"
939
957
  }
940
958
  };
@@ -998,7 +1016,7 @@ function readFileCached(path) {
998
1016
  //#endregion
999
1017
  //#region src/lint/rules/a9w-redundant-root.ts
1000
1018
  /**
1001
- * The root the convention would derive (CONVENTIONS A9): walk up from the
1019
+ * The root the convention would derive (A9 — see docs/10-linting.md): walk up from the
1002
1020
  * specification file to the first directory containing
1003
1021
  * `docker/compose.test.yaml`, else the first containing `package.json`.
1004
1022
  */
@@ -1037,7 +1055,7 @@ const a9wRedundantRoot = {
1037
1055
  },
1038
1056
  meta: {
1039
1057
  docs: RULE_DOCS["a9w-redundant-root"],
1040
- messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (CONVENTIONS A9)." },
1058
+ messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (A9 — see docs/10-linting.md)." },
1041
1059
  type: "suggestion"
1042
1060
  }
1043
1061
  };
@@ -1101,7 +1119,7 @@ const b2KnownFixtureMarker = {
1101
1119
  },
1102
1120
  meta: {
1103
1121
  docs: RULE_DOCS["b2-known-fixture-marker"],
1104
- messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (CONVENTIONS B2)." },
1122
+ messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (B2 — see docs/10-linting.md)." },
1105
1123
  type: "problem"
1106
1124
  }
1107
1125
  };
@@ -1166,8 +1184,8 @@ const b4GivenThen = {
1166
1184
  meta: {
1167
1185
  docs: RULE_DOCS["b4-given-then"],
1168
1186
  messages: {
1169
- givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (CONVENTIONS B4).",
1170
- missing: "Test is missing a `// {{marker}} -` comment (CONVENTIONS B4 — every test needs both `// Given -` and `// Then -`)."
1187
+ givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (B4 — see docs/10-linting.md).",
1188
+ missing: "Test is missing a `// {{marker}} -` comment (B4 — see docs/10-linting.md — every test needs both `// Given -` and `// Then -`)."
1171
1189
  },
1172
1190
  type: "problem"
1173
1191
  }
@@ -1208,7 +1226,7 @@ const b5AwaitUsing = {
1208
1226
  meta: {
1209
1227
  docs: RULE_DOCS["b5-await-using"],
1210
1228
  defaultOptions: [{ runners: [] }],
1211
- messages: { requireAwaitUsing: "The result of docker-aware runner \"{{runner}}\" must be bound with `await using` so its containers are disposed (CONVENTIONS B5)." },
1229
+ 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)." },
1212
1230
  schema: [{
1213
1231
  additionalProperties: false,
1214
1232
  properties: { runners: {
@@ -1251,7 +1269,7 @@ const b6wRedundantEnvUrl = {
1251
1269
  },
1252
1270
  meta: {
1253
1271
  docs: RULE_DOCS["b6w-redundant-env-url"],
1254
- 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)." },
1272
+ 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)." },
1255
1273
  type: "suggestion"
1256
1274
  }
1257
1275
  };
@@ -1280,7 +1298,7 @@ const b8KebabTrigger = {
1280
1298
  },
1281
1299
  meta: {
1282
1300
  docs: RULE_DOCS["b8-kebab-trigger"],
1283
- 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)." },
1301
+ 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)." },
1284
1302
  type: "problem"
1285
1303
  }
1286
1304
  };
@@ -1335,7 +1353,7 @@ const b9wProductCommand = {
1335
1353
  },
1336
1354
  meta: {
1337
1355
  docs: RULE_DOCS["b9w-product-command"],
1338
- 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." },
1356
+ 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." },
1339
1357
  type: "suggestion"
1340
1358
  }
1341
1359
  };
@@ -1391,9 +1409,9 @@ const c1DomainStructure = {
1391
1409
  meta: {
1392
1410
  docs: RULE_DOCS["c1-domain-structure"],
1393
1411
  messages: {
1394
- specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (CONVENTIONS C1).",
1395
- 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).",
1396
- testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (CONVENTIONS C1)."
1412
+ specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
1413
+ 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).",
1414
+ 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)."
1397
1415
  },
1398
1416
  type: "problem"
1399
1417
  }
@@ -1423,13 +1441,13 @@ const c2HttpOnlyRequests = {
1423
1441
  },
1424
1442
  meta: {
1425
1443
  docs: RULE_DOCS["c2-http-only-requests"],
1426
- messages: { notHttp: "requests/{{entry}} is not a .http file — requests/ contains only complete .http request files (CONVENTIONS C2)." },
1444
+ messages: { notHttp: "requests/{{entry}} is not a .http file — requests/ contains only complete .http request files (C2 — see docs/10-linting.md)." },
1427
1445
  type: "problem"
1428
1446
  }
1429
1447
  };
1430
1448
  //#endregion
1431
1449
  //#region src/lint/rules/c4-contract-shape.ts
1432
- /** Providers a contract file may declare (CONVENTIONS C4). */
1450
+ /** Providers a contract file may declare (C4 — see docs/10-linting.md). */
1433
1451
  const PROVIDERS = /* @__PURE__ */ new Set([
1434
1452
  "anthropic",
1435
1453
  "http",
@@ -1495,12 +1513,12 @@ const c4ContractShape = {
1495
1513
  meta: {
1496
1514
  docs: RULE_DOCS["c4-contract-shape"],
1497
1515
  messages: {
1498
- badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider ∈ openai | anthropic | http (CONVENTIONS C4).",
1499
- foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (CONVENTIONS C4).",
1500
- missingDefault: "Contract file has no `export default defineContract(...)` (CONVENTIONS C4).",
1501
- namedExport: "Contract files have no named exports — only `export default defineContract(...)` (CONVENTIONS C4).",
1502
- notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (CONVENTIONS C4).",
1503
- subfolder: "Contract file \"{{base}}\" is nested — contracts/ is flat, no subfolders (CONVENTIONS C4)."
1516
+ badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider ∈ openai | anthropic | http (C4 — see docs/10-linting.md).",
1517
+ foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (C4 — see docs/10-linting.md).",
1518
+ missingDefault: "Contract file has no `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1519
+ namedExport: "Contract files have no named exports — only `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1520
+ notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (C4 — see docs/10-linting.md).",
1521
+ subfolder: "Contract file \"{{base}}\" is nested — contracts/ is flat, no subfolders (C4 — see docs/10-linting.md)."
1504
1522
  },
1505
1523
  type: "problem"
1506
1524
  }
@@ -1551,7 +1569,7 @@ const c6ToMatchExtension = {
1551
1569
  },
1552
1570
  meta: {
1553
1571
  docs: RULE_DOCS["c6-tomatch-extension"],
1554
- 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)." },
1572
+ 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)." },
1555
1573
  type: "problem"
1556
1574
  }
1557
1575
  };
@@ -1579,7 +1597,7 @@ const c7SeedsSqlOnly = {
1579
1597
  },
1580
1598
  meta: {
1581
1599
  docs: RULE_DOCS["c7-seeds-sql-only"],
1582
- messages: { notSql: "seeds/{{entry}} is not a .sql file — seeds/ carries database state only; file state goes through .fixture() (CONVENTIONS C7)." },
1600
+ 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)." },
1583
1601
  type: "problem"
1584
1602
  }
1585
1603
  };
@@ -1668,7 +1686,7 @@ const c8ReferencedFixtureExists = {
1668
1686
  },
1669
1687
  meta: {
1670
1688
  docs: RULE_DOCS["c8-referenced-fixture-exists"],
1671
- messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its conventional {{root}}/ root (CONVENTIONS C8). Create it or fix the reference." },
1689
+ 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." },
1672
1690
  type: "problem"
1673
1691
  }
1674
1692
  };
@@ -1737,7 +1755,7 @@ const d12wResponseBodyProbe = {
1737
1755
  meta: {
1738
1756
  docs: RULE_DOCS["d12w-response-body-probe"],
1739
1757
  defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
1740
- messages: { bodyProbeCluster: "{{count}} body probes in one test — this wants a full golden: expect(result.response).toMatch('x.http') (CONVENTIONS D12)." },
1758
+ 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)." },
1741
1759
  schema: [{
1742
1760
  additionalProperties: false,
1743
1761
  properties: { threshold: {
@@ -1820,7 +1838,7 @@ const d13wUnfrozenNegativeFixture = {
1820
1838
  },
1821
1839
  meta: {
1822
1840
  docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
1823
- 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)." },
1841
+ 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)." },
1824
1842
  type: "suggestion"
1825
1843
  }
1826
1844
  };
@@ -1896,7 +1914,7 @@ const d15wStatusOnlyProbe = {
1896
1914
  },
1897
1915
  meta: {
1898
1916
  docs: RULE_DOCS["d15w-status-only-probe"],
1899
- messages: { statusOnlyProbe: "The test's only assertion(s) are status probes — pin the full response: expect(result.response).toMatch('case.http') (CONVENTIONS D11)." },
1917
+ 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)." },
1900
1918
  type: "suggestion"
1901
1919
  }
1902
1920
  };
@@ -1951,7 +1969,7 @@ const d2AwaitIoMatcher = {
1951
1969
  },
1952
1970
  meta: {
1953
1971
  docs: RULE_DOCS["d2-await-io-matcher"],
1954
- messages: { mustAwait: "expect(…).{{matcher}}(…) is IO-backed and returns a promise — it must be awaited or returned, else the assertion never runs (CONVENTIONS D2)." },
1972
+ 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)." },
1955
1973
  type: "problem"
1956
1974
  }
1957
1975
  };
@@ -2005,7 +2023,7 @@ const d2wAwaitSyncMatcher = {
2005
2023
  },
2006
2024
  meta: {
2007
2025
  docs: RULE_DOCS["d2w-await-sync-matcher"],
2008
- messages: { redundantAwait: "await on expect(…).{{matcher}}(…) is redundant — that matcher is synchronous; only IO-backed matchers (toMatchRows/toBeEmpty/toBeRunning) are awaited (CONVENTIONS D2)." },
2026
+ 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)." },
2009
2027
  type: "suggestion"
2010
2028
  }
2011
2029
  };
@@ -2082,7 +2100,7 @@ const d6wTransformTokenEquivalent = {
2082
2100
  },
2083
2101
  meta: {
2084
2102
  docs: RULE_DOCS["d6w-transform-token-equivalent"],
2085
- 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)." },
2103
+ 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)." },
2086
2104
  type: "suggestion"
2087
2105
  }
2088
2106
  };
@@ -2116,7 +2134,7 @@ const d8wTextBypass = {
2116
2134
  },
2117
2135
  meta: {
2118
2136
  docs: RULE_DOCS["d8w-text-bypass"],
2119
- 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)." },
2137
+ 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)." },
2120
2138
  type: "suggestion"
2121
2139
  }
2122
2140
  };
@@ -2199,7 +2217,7 @@ const d9wSingleUseRef = {
2199
2217
  },
2200
2218
  meta: {
2201
2219
  docs: RULE_DOCS["d9w-single-use-ref"],
2202
- 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)." },
2220
+ 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)." },
2203
2221
  type: "suggestion"
2204
2222
  }
2205
2223
  };
@@ -2227,7 +2245,7 @@ const f1NoSubpathImport = {
2227
2245
  },
2228
2246
  meta: {
2229
2247
  docs: RULE_DOCS["f1-no-subpath-import"],
2230
- messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — subpaths do not exist (CONVENTIONS F1; only the tool-facing @jterrazz/test/oxlint is exempt)." },
2248
+ 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)." },
2231
2249
  type: "problem"
2232
2250
  }
2233
2251
  };
@@ -2275,7 +2293,7 @@ const f2NoTestImportsInProd = {
2275
2293
  },
2276
2294
  meta: {
2277
2295
  docs: RULE_DOCS["f2-no-test-imports-in-prod"],
2278
- 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)." },
2296
+ 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)." },
2279
2297
  type: "problem"
2280
2298
  }
2281
2299
  };
@@ -2339,7 +2357,7 @@ const f3SpecsPublicEntry = {
2339
2357
  },
2340
2358
  meta: {
2341
2359
  docs: RULE_DOCS["f3-specs-public-entry"],
2342
- 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)." },
2360
+ 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)." },
2343
2361
  type: "problem"
2344
2362
  }
2345
2363
  };
@@ -2365,7 +2383,7 @@ const f4NoTestToTestImport = {
2365
2383
  },
2366
2384
  meta: {
2367
2385
  docs: RULE_DOCS["f4-no-test-to-test-import"],
2368
- 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)." },
2386
+ 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)." },
2369
2387
  type: "problem"
2370
2388
  }
2371
2389
  };
@@ -2391,14 +2409,14 @@ const f5FixturesOnlyFromTests = {
2391
2409
  },
2392
2410
  meta: {
2393
2411
  docs: RULE_DOCS["f5-fixtures-only-from-tests"],
2394
- messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (CONVENTIONS F5)." },
2412
+ messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (F5 — see docs/10-linting.md)." },
2395
2413
  type: "problem"
2396
2414
  }
2397
2415
  };
2398
2416
  //#endregion
2399
2417
  //#region src/lint/rules/i1-layer-boundaries.ts
2400
2418
  /**
2401
- * One folder = one external dependency (CONVENTIONS I1): the packages each
2419
+ * One folder = one external dependency (I1 — see docs/10-linting.md): the packages each
2402
2420
  * `src/integrations/<folder>/` may import.
2403
2421
  */
2404
2422
  const INTEGRATION_DEPS = {
@@ -2408,6 +2426,7 @@ const INTEGRATION_DEPS = {
2408
2426
  hono: ["hono", "@hono/node-server"],
2409
2427
  msw: ["msw"],
2410
2428
  openai: ["openai"],
2429
+ playwright: ["playwright"],
2411
2430
  postgres: ["pg"],
2412
2431
  redis: ["redis"],
2413
2432
  sqlite: ["better-sqlite3"],
@@ -2478,6 +2497,7 @@ const i1LayerBoundaries = {
2478
2497
  if (layer === "core") {
2479
2498
  if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2480
2499
  if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2500
+ if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2481
2501
  return "crossLayer";
2482
2502
  }
2483
2503
  if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
@@ -2504,10 +2524,10 @@ const i1LayerBoundaries = {
2504
2524
  meta: {
2505
2525
  docs: RULE_DOCS["i1-layer-boundaries"],
2506
2526
  messages: {
2507
- coreExternal: "core/ imports nothing external — \"{{source}}\" is not allowed (CONVENTIONS I1).",
2508
- crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the sanctioned layer edges (CONVENTIONS I1).",
2509
- foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (CONVENTIONS I1).",
2510
- lintRuntime: "The lint layer imports nothing from the framework runtime — \"{{source}}\" is outside its pure-helper whitelist (CONVENTIONS I1)."
2527
+ coreExternal: "core/ imports nothing external — \"{{source}}\" is not allowed (I1 — see docs/10-linting.md).",
2528
+ crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the sanctioned layer edges (I1 — see docs/10-linting.md).",
2529
+ foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (I1 — see docs/10-linting.md).",
2530
+ lintRuntime: "The lint layer imports nothing from the framework runtime — \"{{source}}\" is outside its pure-helper whitelist (I1 — see docs/10-linting.md)."
2511
2531
  },
2512
2532
  type: "problem"
2513
2533
  }
@@ -2560,9 +2580,9 @@ const i2SiblingTestNaming = {
2560
2580
  meta: {
2561
2581
  docs: RULE_DOCS["i2-sibling-test-naming"],
2562
2582
  messages: {
2563
- 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/.",
2564
- rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (CONVENTIONS I2).",
2565
- testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (CONVENTIONS I2)."
2583
+ 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/.",
2584
+ 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).",
2585
+ testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/10-linting.md)."
2566
2586
  },
2567
2587
  type: "problem"
2568
2588
  }
@@ -2621,9 +2641,9 @@ const i4NoViMockInSrc = {
2621
2641
  meta: {
2622
2642
  docs: RULE_DOCS["i4-no-vi-mock-in-src"],
2623
2643
  messages: {
2624
- assetImport: "A src/ module test must not import the data asset \"{{source}}\" — inline it as code or move the test to specs/ (CONVENTIONS I4).",
2625
- bannedDir: "`{{dir}}/` directories are banned under src/ — mocks and data are code: mockOf/mockOfDate inline, payloads in a *.fixtures.ts neighbour (CONVENTIONS I4).",
2626
- viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (CONVENTIONS I4)."
2644
+ 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).",
2645
+ 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).",
2646
+ viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (I4 — see docs/10-linting.md)."
2627
2647
  },
2628
2648
  type: "problem"
2629
2649
  }
@@ -2667,7 +2687,7 @@ const j1NoOnlySkip = {
2667
2687
  },
2668
2688
  meta: {
2669
2689
  docs: RULE_DOCS["j1-no-only-skip"],
2670
- messages: { forbidden: "Committed `{{runner}}.{{modifier}}` is not allowed (CONVENTIONS J1). Remove it before committing." },
2690
+ messages: { forbidden: "Committed `{{runner}}.{{modifier}}` is not allowed (J1 — see docs/10-linting.md). Remove it before committing." },
2671
2691
  type: "problem"
2672
2692
  }
2673
2693
  };
@@ -2710,8 +2730,8 @@ const j2NoSleepInSpecs = {
2710
2730
  meta: {
2711
2731
  docs: RULE_DOCS["j2-no-sleep-in-specs"],
2712
2732
  messages: {
2713
- sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (CONVENTIONS J2).",
2714
- timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (CONVENTIONS J2)."
2733
+ sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/10-linting.md).",
2734
+ timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/10-linting.md)."
2715
2735
  },
2716
2736
  type: "problem"
2717
2737
  }
@@ -2751,7 +2771,7 @@ const j3NoExpectlessTest = {
2751
2771
  },
2752
2772
  meta: {
2753
2773
  docs: RULE_DOCS["j3-no-expectless-test"],
2754
- messages: { noExpect: "Test has no `expect(…)` — a test must assert something (CONVENTIONS J3). Use `test.todo` for a pending test." },
2774
+ messages: { noExpect: "Test has no `expect(…)` — a test must assert something (J3 — see docs/10-linting.md). Use `test.todo` for a pending test." },
2755
2775
  type: "problem"
2756
2776
  }
2757
2777
  };
@@ -2787,7 +2807,7 @@ const j4UniqueTestNames = {
2787
2807
  },
2788
2808
  meta: {
2789
2809
  docs: RULE_DOCS["j4-unique-test-names"],
2790
- 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)." },
2810
+ 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)." },
2791
2811
  type: "problem"
2792
2812
  }
2793
2813
  };
@@ -2883,9 +2903,55 @@ const plugin = {
2883
2903
  },
2884
2904
  meta: {
2885
2905
  docs: RULE_DOCS["j5-lowercase-title"],
2886
- messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (CONVENTIONS J5)." },
2906
+ messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
2887
2907
  type: "problem"
2888
2908
  }
2909
+ },
2910
+ "w1-scenario-pure": {
2911
+ create(context) {
2912
+ return { CallExpression(node) {
2913
+ const callee = node.callee;
2914
+ if (!callee || memberPropertyName(callee) !== "visit") return;
2915
+ const scenario = node.arguments?.[1];
2916
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2917
+ walk(scenario, (inner) => {
2918
+ if (inner.type !== "CallExpression") return;
2919
+ const innerCallee = inner.callee;
2920
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
2921
+ messageId: "expectInScenario",
2922
+ node: inner
2923
+ });
2924
+ });
2925
+ } };
2926
+ },
2927
+ meta: {
2928
+ docs: RULE_DOCS["w1-scenario-pure"],
2929
+ 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)." },
2930
+ type: "problem"
2931
+ }
2932
+ },
2933
+ "w2w-user-facing-elements": {
2934
+ create(context) {
2935
+ return { CallExpression(node) {
2936
+ const callee = node.callee;
2937
+ if (!callee || memberPropertyName(callee) !== "visit") return;
2938
+ const scenario = node.arguments?.[1];
2939
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2940
+ walk(scenario, (inner) => {
2941
+ if (inner.type !== "CallExpression") return;
2942
+ const innerCallee = inner.callee;
2943
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "testId") context.report({
2944
+ messageId: "testIdElement",
2945
+ node: inner
2946
+ });
2947
+ });
2948
+ } };
2949
+ },
2950
+ meta: {
2951
+ docs: RULE_DOCS["w2w-user-facing-elements"],
2952
+ 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)." },
2953
+ type: "suggestion"
2954
+ }
2889
2955
  }
2890
2956
  }
2891
2957
  };
package/dist/oxlint.d.cts CHANGED
@@ -61,6 +61,11 @@ type RuleDoc = {
61
61
  channel: 'checker' | 'process' | 'runtime' | 'statique';
62
62
  /** The French normative sentence (the constitution's per-rule text, moved here). */
63
63
  convention: string;
64
+ /**
65
+ * The specification facet the rule guards — segments the catalogue like
66
+ * the constructors segment the API. `'shared'` for cross-facet rules.
67
+ */
68
+ facet?: 'api' | 'cli' | 'jobs' | 'shared' | 'website';
64
69
  /** Convention family letter, e.g. `'A'`. */
65
70
  family: string;
66
71
  /** Convention code, e.g. `'A1'`. */
package/dist/oxlint.d.ts CHANGED
@@ -61,6 +61,11 @@ type RuleDoc = {
61
61
  channel: 'checker' | 'process' | 'runtime' | 'statique';
62
62
  /** The French normative sentence (the constitution's per-rule text, moved here). */
63
63
  convention: string;
64
+ /**
65
+ * The specification facet the rule guards — segments the catalogue like
66
+ * the constructors segment the API. `'shared'` for cross-facet rules.
67
+ */
68
+ facet?: 'api' | 'cli' | 'jobs' | 'shared' | 'website';
64
69
  /** Convention family letter, e.g. `'A'`. */
65
70
  family: string;
66
71
  /** Convention code, e.g. `'A1'`. */