@jterrazz/test 10.0.0 → 11.0.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: "Quatre constructeurs et seulement quatre : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
165
+ convention: "Cinq constructeurs et seulement cinq : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()`, `specification.mobile()` ; 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."
@@ -251,6 +251,20 @@ const RULE_DOCS = {
251
251
  id: "C1",
252
252
  rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
253
253
  },
254
+ "c10-contracts-boundary": {
255
+ channel: "statique",
256
+ convention: "Hors de `specs/**/contracts/`, un import qui résout dans un dossier de provider (`contracts/{http,openai,anthropic}/…`) est une erreur : un test n’importe que les façades `*.contracts.ts`.",
257
+ family: "C",
258
+ id: "C10",
259
+ rationale: "Les contrats unitaires sont des détails de composition ; passer par la façade garde chaque scénario nommé au même endroit que le monde dont il dérive."
260
+ },
261
+ "c11-contract-data-pairing": {
262
+ channel: "statique",
263
+ convention: "Dans un dossier de provider, tout `*.response.json` et tout `*.request.ts` a un frère `<souche>.ts` — la souche est le nom jusqu’au PREMIER point (`events.fr.response.json` → `events.ts`) ; une donnée orpheline est une erreur.",
264
+ family: "C",
265
+ id: "C11",
266
+ rationale: "Une donnée n’existe que servie par un contrat — un fichier sans propriétaire est du poids mort qu’aucun test ne charge."
267
+ },
254
268
  "c2-http-only-requests": {
255
269
  channel: "statique",
256
270
  convention: "`requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur.",
@@ -260,10 +274,10 @@ const RULE_DOCS = {
260
274
  },
261
275
  "c4-contract-shape": {
262
276
  channel: "statique",
263
- convention: "Un fichier de `contracts/` respecte `<nom>.<provider>.ts` (provider openai|anthropic|http), à plat, `export default defineContract(...)`, imports depuis le point d’entrée public.",
277
+ convention: "Sous `specs/**/contracts/` : la RACINE ne porte que des façades `*.contracts.ts` (export default construit par `defineContracts(...)` ou une re-export de composition ; exports nommés = scénarios) et les dossiers de provider `http` | `openai` | `anthropic`. Un contrat unitaire est `<provider>/<nom-kebab>.ts` avec un `export default defineContract(...)` (ou une factory qui en retourne un), et son builder de `request` doit désigner le provider de son dossier. Un dossier de provider est plat et ne contient que des `*.ts` et des `*.response.json`.",
264
278
  family: "C",
265
279
  id: "C4",
266
- rationale: "Une forme figée rend les contrats découvrables et typables sans convention locale."
280
+ rationale: "Une arborescence figée sépare la façade publique des unités internes et fait porter le provider par le DOSSIER — les noms de fichiers redeviennent du métier, et la donnée volumineuse se range à côté du contrat qui la sert."
267
281
  },
268
282
  "c6-tomatch-extension": {
269
283
  channel: "statique",
@@ -435,16 +449,16 @@ const RULE_DOCS = {
435
449
  },
436
450
  "w1-scenario-pure": {
437
451
  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",
452
+ convention: "Un scénario (`.visit()` website, `.open()` mobile) 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é.",
453
+ facet: "shared",
440
454
  family: "W",
441
455
  id: "W1",
442
456
  rationale: "Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables."
443
457
  },
444
458
  "w2w-user-facing-elements": {
445
459
  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",
460
+ convention: "Les éléments d’un scénario sont user-facing (`button`, `link`, `field`, `heading`, `content` ; côté mobile `button`, `field`, `content`) ; `testId()` est l’unique échappatoire et déclenche un avertissement.",
461
+ facet: "shared",
448
462
  family: "W",
449
463
  id: "W2",
450
464
  rationale: "Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie."
@@ -490,7 +504,7 @@ const CHECKER_PASSES = [
490
504
  },
491
505
  {
492
506
  channel: "checker",
493
- convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`intercepts/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` est orphelin (warning si argument non littéral).",
507
+ convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` est orphelin (warning si argument non littéral).",
494
508
  family: "C",
495
509
  id: "C9",
496
510
  name: "c9-dead-fixtures",
@@ -569,13 +583,22 @@ const RUNTIME_RULES = [
569
583
  },
570
584
  {
571
585
  channel: "runtime",
572
- convention: "Un descripteur d’élément doit désigner exactement UN élément : si plusieurs correspondent, l’action est refusée avec une erreur qui énumère les candidats et propose les réécritures (`within(...)`, `{ exact: true }`). Le framework n’agit jamais sur « le premier ».",
573
- facet: "website",
586
+ convention: "Un descripteur d’élément doit désigner exactement UN élément quand un verbe AGIT dessus (`click`/`tap`/`fill`) : si plusieurs correspondent, l’action est refusée avec une erreur qui énumère les candidats et propose les réécritures (`within(...)`, `{ exact: true }`). Le framework n’agit jamais sur « le premier ». Vaut pour les deux facettes à scénario : website et mobile ; sur mobile, `see()` — qui n’agit sur rien — est satisfait par n’importe quel match visible (l’arbre XCUITest duplique légitimement un label entre conteneur et enfant).",
587
+ facet: "shared",
574
588
  family: "W",
575
589
  id: "W3",
576
590
  name: "w3-unambiguous-element",
577
591
  rationale: "Agir sur le premier match rend le spec vert alors que le visiteur clique autre chose, et rien ne le signale jamais — une ambiguïté est une faute d’écriture, pas un cas à arbitrer par l’ordre du DOM. Le comptage n’existe qu’à l’exécution : aucune analyse statique ne peut le faire, d’où le canal runtime."
578
592
  },
593
+ {
594
+ channel: "runtime",
595
+ convention: "Le vocabulaire d’éléments est UNIQUE entre les facettes website et mobile ; un landmark ARIA (`main()`, `navigation()`…) passé à un verbe mobile est refusé à l’exécution — un écran iOS n’a pas de régions ARIA ; scoper avec `within(testId(…), …)`.",
596
+ facet: "mobile",
597
+ family: "W",
598
+ id: "W4",
599
+ name: "w4-no-landmarks-on-mobile",
600
+ rationale: "Un seul vocabulaire garde l’API mémorisable ; refuser explicitement la partie sans équivalent iOS évite une approximation silencieuse."
601
+ },
579
602
  {
580
603
  channel: "runtime",
581
604
  convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
@@ -761,18 +784,19 @@ const a10DuplicateBinding = {
761
784
  };
762
785
  //#endregion
763
786
  //#region src/lint/rules/a2-known-constructors.ts
764
- /** The four constructors, and only four (A2 — see docs/10-linting.md). */
787
+ /** The five constructors, and only five (A2 — see docs/10-linting.md). */
765
788
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
766
789
  "api",
767
790
  "cli",
768
791
  "jobs",
792
+ "mobile",
769
793
  "website"
770
794
  ]);
771
795
  /**
772
796
  * CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
773
- * `specification.cli()` and `specification.website()` are the only members.
774
- * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
775
- * the member site.
797
+ * `specification.cli()`, `specification.website()` and
798
+ * `specification.mobile()` are the only members. Any other access
799
+ * (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
776
800
  */
777
801
  const a2KnownConstructors = {
778
802
  create(context) {
@@ -789,7 +813,7 @@ const a2KnownConstructors = {
789
813
  },
790
814
  meta: {
791
815
  docs: RULE_DOCS["a2-known-constructors"],
792
- 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)." },
816
+ messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli(), specification.website() and specification.mobile() (A2 — see docs/10-linting.md)." },
793
817
  type: "problem"
794
818
  }
795
819
  };
@@ -1422,8 +1446,94 @@ const c1DomainStructure = {
1422
1446
  }
1423
1447
  };
1424
1448
  //#endregion
1449
+ //#region src/lint/rules/c10-contracts-boundary.ts
1450
+ /** A specifier reaching into a provider folder — the internal half of contracts/. */
1451
+ const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
1452
+ /**
1453
+ * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
1454
+ * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
1455
+ * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
1456
+ * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
1457
+ * importing a unit is an error — a test routes through the facade, so a
1458
+ * scenario is named once, next to the world it derives from.
1459
+ */
1460
+ const c10ContractsBoundary = {
1461
+ create(context) {
1462
+ const parts = segments(context.physicalFilename);
1463
+ if (!parts.includes("specs")) return {};
1464
+ if (parts.slice(0, -1).includes("contracts")) return {};
1465
+ return importSourceVisitor(({ node, source }) => {
1466
+ if (INTERNAL_UNIT.test(source)) context.report({
1467
+ data: { source },
1468
+ messageId: "internal",
1469
+ node
1470
+ });
1471
+ });
1472
+ },
1473
+ meta: {
1474
+ docs: RULE_DOCS["c10-contracts-boundary"],
1475
+ messages: { internal: "Import \"{{source}}\" reaches into a provider folder — only contracts/*.contracts.ts is importable from a test; add a named scenario export there instead (C10 — see docs/10-linting.md)." },
1476
+ type: "problem"
1477
+ }
1478
+ };
1479
+ //#endregion
1480
+ //#region src/lint/rules/c11-contract-data-pairing.ts
1481
+ const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1482
+ /** The provider folders that may carry data files. */
1483
+ const PROVIDERS$1 = [
1484
+ "anthropic",
1485
+ "http",
1486
+ "openai"
1487
+ ];
1488
+ /** Data files: served payloads and matched inputs, both owned by a contract. */
1489
+ const DATA_SUFFIXES = [".request.ts", ".response.json"];
1490
+ /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
1491
+ function ownerOf(entry) {
1492
+ return `${entry.slice(0, entry.indexOf("."))}.ts`;
1493
+ }
1494
+ /**
1495
+ * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
1496
+ * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
1497
+ * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
1498
+ * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
1499
+ * file with no owner is dead weight no test can reach — reported on the feature's
1500
+ * test file, since oxlint never visits a `.json`.
1501
+ */
1502
+ const c11ContractDataPairing = {
1503
+ create(context) {
1504
+ const file = context.physicalFilename;
1505
+ if (!TEST_FILE$11.test(file) || !segments(file).includes("specs")) return {};
1506
+ return { Program(node) {
1507
+ const contractsDir = join(dirname(file), "contracts");
1508
+ for (const provider of PROVIDERS$1) {
1509
+ const entries = listDirectory(join(contractsDir, provider));
1510
+ if (entries === null) continue;
1511
+ const present = new Set(entries);
1512
+ for (const entry of entries) {
1513
+ if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
1514
+ const owner = ownerOf(entry);
1515
+ if (!present.has(owner)) context.report({
1516
+ data: {
1517
+ entry,
1518
+ owner,
1519
+ provider
1520
+ },
1521
+ messageId: "orphan",
1522
+ node
1523
+ });
1524
+ }
1525
+ }
1526
+ } };
1527
+ },
1528
+ meta: {
1529
+ docs: RULE_DOCS["c11-contract-data-pairing"],
1530
+ messages: { orphan: "contracts/{{provider}}/{{entry}} has no owning contract — data pairs with the sibling {{owner}} (stem = name up to the first dot) (C11 — see docs/10-linting.md)." },
1531
+ type: "problem"
1532
+ }
1533
+ };
1534
+ //#endregion
1425
1535
  //#region src/lint/rules/c2-http-only-requests.ts
1426
- const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1536
+ const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
1427
1537
  /**
1428
1538
  * CONVENTIONS C2 — `requests/` contains only `.http` files (a request file is
1429
1539
  * a COMPLETE request: method, path, headers, body). Anchored on the feature's
@@ -1434,7 +1544,7 @@ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1434
1544
  const c2HttpOnlyRequests = {
1435
1545
  create(context) {
1436
1546
  const file = context.physicalFilename;
1437
- if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
1547
+ if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
1438
1548
  return { Program(node) {
1439
1549
  const requestsDir = join(dirname(file), "requests");
1440
1550
  for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
@@ -1452,65 +1562,217 @@ const c2HttpOnlyRequests = {
1452
1562
  };
1453
1563
  //#endregion
1454
1564
  //#region src/lint/rules/c4-contract-shape.ts
1455
- /** Providers a contract file may declare (C4 see docs/10-linting.md). */
1565
+ /** The only directories a `contracts/` root may holdthe provider carriers. */
1456
1566
  const PROVIDERS = /* @__PURE__ */ new Set([
1457
1567
  "anthropic",
1458
1568
  "http",
1459
1569
  "openai"
1460
1570
  ]);
1461
- const CONTRACT_NAME = /^[A-Za-z0-9][\w-]*\.(?<provider>[a-z]+)\.[cm]?ts$/;
1462
- /** Is the import source the framework's public entry (`@jterrazz/test` / `src/index`)? */
1463
- function isPublicEntry(source) {
1464
- return source === "@jterrazz/test" || source.endsWith("/src/index.js") || source.endsWith("/src/index.ts");
1571
+ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1572
+ /** A file oxlint itself visits — the AST half of the rule reports on those. */
1573
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1574
+ /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1575
+ const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1576
+ /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1577
+ const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1578
+ /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1579
+ const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1580
+ /** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
1581
+ const RESPONSE_DATA_FILE = /\.response\.json$/;
1582
+ /** The name a specifier/declaration exports under, when it is `default`. */
1583
+ function exportsDefault(node) {
1584
+ return (node.specifiers ?? []).some((specifier) => {
1585
+ const exported = specifier.exported;
1586
+ return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1587
+ });
1588
+ }
1589
+ /** Is this node a `defineContract(...)` call? */
1590
+ function isDefineContract(node) {
1591
+ const callee = node.callee;
1592
+ return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1593
+ }
1594
+ /** A default export that may legitimately produce a contract (value or factory). */
1595
+ function producesContract(declaration) {
1596
+ if (declaration === void 0) return false;
1597
+ return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1465
1598
  }
1466
1599
  /**
1467
- * CONVENTIONS C4 a contract file (`contracts/<name>.<provider>.ts`) has a
1468
- * rigid shape: flat under `contracts/` (no subfolders), `provider { openai,
1469
- * anthropic, http }`, a single `export default defineContract(...)`, no named
1470
- * exports, and imports only from the public entry. The runtime channel (the
1471
- * loader) only catches a bad default export; this rule closes the rest.
1600
+ * The provider a `request:` value implies, when the builder is written as
1601
+ * `<provider>.<verb>(…)` the only statically decidable form.
1472
1602
  */
1473
- const c4ContractShape = {
1474
- create(context) {
1475
- const parts = segments(context.physicalFilename);
1476
- const contractsIndex = parts.lastIndexOf("contracts");
1477
- if (contractsIndex === -1 || contractsIndex >= parts.length - 1 || !parts.includes("specs")) return {};
1478
- const base = parts.at(-1) ?? "";
1479
- return { Program(node) {
1480
- if (contractsIndex !== parts.length - 2) context.report({
1481
- data: { base },
1482
- messageId: "subfolder",
1603
+ function requestProvider(request) {
1604
+ if (request?.type !== "CallExpression") return;
1605
+ const callee = request.callee;
1606
+ if (callee?.type !== "MemberExpression") return;
1607
+ const object = callee.object;
1608
+ return object?.type === "Identifier" ? object.name : void 0;
1609
+ }
1610
+ /**
1611
+ * The layout half — walks the feature's `contracts/` tree from the test file
1612
+ * that owns it. Covers what oxlint never visits: a stray `.json` at the root,
1613
+ * a directory that is not a provider, a nested folder or a foreign extension
1614
+ * inside a provider folder.
1615
+ */
1616
+ function checkLayout(context, node, contractsDir) {
1617
+ const entries = listDirectory(contractsDir);
1618
+ if (entries === null) return;
1619
+ for (const entry of entries) {
1620
+ const path = join(contractsDir, entry);
1621
+ if (!isDirectory(path)) {
1622
+ if (!SOURCE_FILE.test(entry)) context.report({
1623
+ data: { entry },
1624
+ messageId: "rootEntry",
1483
1625
  node
1484
1626
  });
1485
- const match = CONTRACT_NAME.exec(base);
1486
- if (match === null || !PROVIDERS.has(match.groups?.provider ?? "")) context.report({
1487
- data: { base },
1488
- messageId: "badName",
1627
+ continue;
1628
+ }
1629
+ if (!PROVIDERS.has(entry)) {
1630
+ context.report({
1631
+ data: { entry },
1632
+ messageId: "badProviderDir",
1489
1633
  node
1490
1634
  });
1491
- let hasDefault = false;
1492
- for (const statement of node.body ?? []) if (statement.type === "ImportDeclaration") {
1493
- const source = stringValue(statement.source);
1494
- if (source !== void 0 && !isPublicEntry(source)) context.report({
1495
- data: { source },
1496
- messageId: "foreignImport",
1497
- node: statement
1498
- });
1499
- } else if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") context.report({
1500
- messageId: "namedExport",
1501
- node: statement
1502
- });
1503
- else if (statement.type === "ExportDefaultDeclaration") {
1635
+ continue;
1636
+ }
1637
+ for (const child of listDirectory(path) ?? []) if (isDirectory(join(path, child))) context.report({
1638
+ data: {
1639
+ child,
1640
+ provider: entry
1641
+ },
1642
+ messageId: "providerSubfolder",
1643
+ node
1644
+ });
1645
+ else if (!/\.[cm]?ts$/u.test(child) && !RESPONSE_DATA_FILE.test(child)) context.report({
1646
+ data: {
1647
+ child,
1648
+ provider: entry
1649
+ },
1650
+ messageId: "providerEntry",
1651
+ node
1652
+ });
1653
+ }
1654
+ }
1655
+ /** The facade: `<kebab>.contracts.ts`, default-exporting a composition. */
1656
+ function checkComposite(context, program, base) {
1657
+ if (!COMPOSITE_FILE.test(base)) {
1658
+ context.report({
1659
+ data: { base },
1660
+ messageId: "rootFile",
1661
+ node: program
1662
+ });
1663
+ return;
1664
+ }
1665
+ let hasDefault = false;
1666
+ let composed = false;
1667
+ walk(program, (node) => {
1668
+ if (node.type === "ExportDefaultDeclaration") hasDefault = true;
1669
+ else if (node.type === "ExportAllDeclaration" || node.type === "ExportNamedDeclaration") {
1670
+ if (exportsDefault(node)) {
1504
1671
  hasDefault = true;
1505
- const declaration = statement.declaration;
1506
- const callee = declaration?.type === "CallExpression" ? declaration.callee : void 0;
1507
- if (callee?.type !== "Identifier" || callee.name !== "defineContract") context.report({
1508
- messageId: "notDefineContract",
1509
- node: statement
1510
- });
1672
+ composed = stringValue(node.source) !== void 0 || composed;
1511
1673
  }
1512
- if (!hasDefault) context.report({
1513
- messageId: "missingDefault",
1674
+ } else if (node.type === "CallExpression") {
1675
+ const callee = node.callee;
1676
+ if (callee?.type === "Identifier" && callee.name === "defineContracts" || callee !== void 0 && memberPropertyName(callee) === "with") composed = true;
1677
+ }
1678
+ });
1679
+ if (!hasDefault) context.report({
1680
+ data: { base },
1681
+ messageId: "missingComposite",
1682
+ node: program
1683
+ });
1684
+ else if (!composed) context.report({
1685
+ data: { base },
1686
+ messageId: "notDefineContracts",
1687
+ node: program
1688
+ });
1689
+ }
1690
+ /** A unit contract under its provider folder. */
1691
+ function checkUnit(context, program, base, provider) {
1692
+ if (REQUEST_DATA_FILE.test(base)) return;
1693
+ if (!UNIT_FILE.test(base)) {
1694
+ context.report({
1695
+ data: { base },
1696
+ messageId: "badName",
1697
+ node: program
1698
+ });
1699
+ return;
1700
+ }
1701
+ let hasDefault = false;
1702
+ let wellFormed = false;
1703
+ walk(program, (node) => {
1704
+ if (node.type === "ExportDefaultDeclaration") {
1705
+ hasDefault = true;
1706
+ wellFormed = producesContract(node.declaration) || wellFormed;
1707
+ } else if (node.type === "ExportNamedDeclaration" && exportsDefault(node)) {
1708
+ hasDefault = true;
1709
+ wellFormed = true;
1710
+ }
1711
+ if (isDefineContract(node)) {
1712
+ const argument = node.arguments?.[0];
1713
+ const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
1714
+ if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
1715
+ data: {
1716
+ declared,
1717
+ provider
1718
+ },
1719
+ messageId: "providerMismatch",
1720
+ node
1721
+ });
1722
+ }
1723
+ });
1724
+ if (!hasDefault) context.report({
1725
+ data: { base },
1726
+ messageId: "missingDefault",
1727
+ node: program
1728
+ });
1729
+ else if (!wellFormed) context.report({
1730
+ data: { base },
1731
+ messageId: "notDefineContract",
1732
+ node: program
1733
+ });
1734
+ }
1735
+ /**
1736
+ * CONVENTIONS C4 — the structure of a feature's `contracts/` tree. The ROOT
1737
+ * holds only `*.contracts.ts` facades (default export = a `defineContracts`
1738
+ * composition, named exports = scenario factories) and the provider
1739
+ * directories `http` / `openai` / `anthropic`. A provider directory holds
1740
+ * `<kebab>.ts` unit contracts (default export = `defineContract(...)` or a
1741
+ * factory returning one) plus their sibling data (`*.response.json`,
1742
+ * `*.request.ts`) — the FOLDER carries the provider, so a unit whose `request`
1743
+ * builder names another provider is an error.
1744
+ *
1745
+ * Two halves: the AST half reports on each contract file it visits, the layout
1746
+ * half walks the tree from the feature's test file (oxlint never visits a
1747
+ * `.json` fixture or an empty directory).
1748
+ */
1749
+ const c4ContractShape = {
1750
+ create(context) {
1751
+ const file = context.physicalFilename;
1752
+ const parts = segments(file);
1753
+ if (!parts.includes("specs")) return {};
1754
+ const contractsIndex = parts.lastIndexOf("contracts");
1755
+ if (contractsIndex === -1) {
1756
+ if (!TEST_FILE$9.test(file)) return {};
1757
+ return { Program(node) {
1758
+ checkLayout(context, node, join(dirname(file), "contracts"));
1759
+ } };
1760
+ }
1761
+ const base = parts.at(-1) ?? "";
1762
+ const depth = parts.length - 1 - contractsIndex;
1763
+ if (depth === 1) return { Program(node) {
1764
+ checkComposite(context, node, base);
1765
+ } };
1766
+ if (depth === 2) {
1767
+ const provider = parts[contractsIndex + 1];
1768
+ return PROVIDERS.has(provider) ? { Program(node) {
1769
+ checkUnit(context, node, base, provider);
1770
+ } } : {};
1771
+ }
1772
+ return { Program(node) {
1773
+ context.report({
1774
+ data: { base },
1775
+ messageId: "tooDeep",
1514
1776
  node
1515
1777
  });
1516
1778
  } };
@@ -1518,12 +1780,18 @@ const c4ContractShape = {
1518
1780
  meta: {
1519
1781
  docs: RULE_DOCS["c4-contract-shape"],
1520
1782
  messages: {
1521
- badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider openai | anthropic | http (C4 — see docs/10-linting.md).",
1522
- foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (C4 — see docs/10-linting.md).",
1523
- missingDefault: "Contract file has no `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1524
- namedExport: "Contract files have no named exportsonly `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1525
- notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (C4 — see docs/10-linting.md).",
1526
- subfolder: "Contract file \"{{base}}\" is nested contracts/ is flat, no subfolders (C4 — see docs/10-linting.md)."
1783
+ badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts the folder already carries the provider (C4 — see docs/10-linting.md).",
1784
+ badProviderDir: "contracts/{{entry}}/ is not a provider directory — a contracts/ root holds only http/, openai/, anthropic/ and *.contracts.ts files (C4 — see docs/10-linting.md).",
1785
+ missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/10-linting.md).",
1786
+ missingDefault: "Contract unit \"{{base}}\" has no default exporta unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/10-linting.md).",
1787
+ notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/10-linting.md).",
1788
+ notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/10-linting.md).",
1789
+ providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/10-linting.md).",
1790
+ providerMismatch: "This contract lives in contracts/{{provider}}/ but its request is a `{{declared}}.*` builder — the folder carries the provider (C4 — see docs/10-linting.md).",
1791
+ providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/10-linting.md).",
1792
+ rootEntry: "contracts/{{entry}} is not a *.contracts.ts facade — data files live in a provider folder next to their contract (C4 — see docs/10-linting.md).",
1793
+ rootFile: "Contract file \"{{base}}\" sits at the contracts/ root, which holds only *.contracts.ts facades — a unit contract belongs in http/, openai/ or anthropic/ (C4 — see docs/10-linting.md).",
1794
+ tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/10-linting.md)."
1527
1795
  },
1528
1796
  type: "problem"
1529
1797
  }
@@ -2426,6 +2694,7 @@ const f5FixturesOnlyFromTests = {
2426
2694
  */
2427
2695
  const INTEGRATION_DEPS = {
2428
2696
  anthropic: ["@anthropic-ai/sdk"],
2697
+ appium: ["webdriverio"],
2429
2698
  compose: ["yaml"],
2430
2699
  docker: [],
2431
2700
  hono: ["hono", "@hono/node-server"],
@@ -2503,6 +2772,7 @@ const i1LayerBoundaries = {
2503
2772
  if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2504
2773
  if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2505
2774
  if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2775
+ if (target.startsWith("integrations/appium/") && inside === "core/specification/mobile/start-mobile.ts") return null;
2506
2776
  return "crossLayer";
2507
2777
  }
2508
2778
  if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
@@ -2828,6 +3098,82 @@ const TITLED = /* @__PURE__ */ new Set([
2828
3098
  * `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
2829
3099
  * misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
2830
3100
  const ALL_CAPS_IDENTIFIER = /^[A-Z][A-Z0-9_]*$/u;
3101
+ /**
3102
+ * CONVENTIONS J5 — the first character of a `test('…')` / `describe('…')` title
3103
+ * literal must be lowercase.
3104
+ *
3105
+ * The test name is the sole description of behaviour (B3): a sentence fragment,
3106
+ * lowercase-led like prose (`'rejects an unknown job'`). Exempt are titles
3107
+ * whose first WORD is an identifier-shaped all-caps/underscored token
3108
+ * (`'VALID_CATEGORIES is rejected'`, `'HTTP is upgraded'`, `'DI wires …'`) —
3109
+ * lowercasing a named symbol would misspell it — and titles opening on a
3110
+ * non-letter (a `$`, a `{`, a `%s` placeholder). Only lowercase-able prose
3111
+ * first words are enforced. Covers the modifier and parametrized forms
3112
+ * (`test.only`, `describe.each(...)(...)`) via the chain root.
3113
+ */
3114
+ const j5LowercaseTitle = {
3115
+ create(context) {
3116
+ return { CallExpression(node) {
3117
+ const callee = node.callee;
3118
+ const root = chainRootName(callee);
3119
+ if (root === void 0 || !TITLED.has(root)) return;
3120
+ const title = stringValue(node.arguments?.[0]);
3121
+ if (title === void 0) return;
3122
+ const first = [...title][0];
3123
+ if (first === void 0 || !/\p{L}/u.test(first)) return;
3124
+ const firstWord = title.split(/\s+/u)[0];
3125
+ if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
3126
+ if (first !== first.toLocaleLowerCase()) context.report({
3127
+ data: { runner: root },
3128
+ messageId: "uppercase",
3129
+ node
3130
+ });
3131
+ } };
3132
+ },
3133
+ meta: {
3134
+ docs: RULE_DOCS["j5-lowercase-title"],
3135
+ messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
3136
+ type: "problem"
3137
+ }
3138
+ };
3139
+ //#endregion
3140
+ //#region src/lint/rules/w1-scenario-pure.ts
3141
+ /** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
3142
+ const SCENARIO_ACTIONS$1 = /* @__PURE__ */ new Set(["open", "visit"]);
3143
+ /**
3144
+ * CONVENTIONS W1 — a visit (or mobile open) scenario is the When: the
3145
+ * visitor interacts, the capture reflects the final state, and assertions
3146
+ * live in the Then on the returned result. An `expect()` inside the scenario
3147
+ * callback is flagged.
3148
+ */
3149
+ const w1ScenarioPure = {
3150
+ create(context) {
3151
+ return { CallExpression(node) {
3152
+ const callee = node.callee;
3153
+ const member = callee ? memberPropertyName(callee) : void 0;
3154
+ if (member === void 0 || !SCENARIO_ACTIONS$1.has(member)) return;
3155
+ const scenario = node.arguments?.[1];
3156
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
3157
+ walk(scenario, (inner) => {
3158
+ if (inner.type !== "CallExpression") return;
3159
+ const innerCallee = inner.callee;
3160
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
3161
+ messageId: "expectInScenario",
3162
+ node: inner
3163
+ });
3164
+ });
3165
+ } };
3166
+ },
3167
+ meta: {
3168
+ docs: RULE_DOCS["w1-scenario-pure"],
3169
+ messages: { expectInScenario: "No expect() inside a scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/10-linting.md)." },
3170
+ type: "problem"
3171
+ }
3172
+ };
3173
+ //#endregion
3174
+ //#region src/lint/rules/w2w-user-facing-elements.ts
3175
+ /** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
3176
+ const SCENARIO_ACTIONS = /* @__PURE__ */ new Set(["open", "visit"]);
2831
3177
  //#endregion
2832
3178
  //#region src/lint/plugin.ts
2833
3179
  /**
@@ -2862,6 +3208,8 @@ const plugin = {
2862
3208
  "b8-kebab-trigger": b8KebabTrigger,
2863
3209
  "b9w-product-command": b9wProductCommand,
2864
3210
  "c1-domain-structure": c1DomainStructure,
3211
+ "c10-contracts-boundary": c10ContractsBoundary,
3212
+ "c11-contract-data-pairing": c11ContractDataPairing,
2865
3213
  "c2-http-only-requests": c2HttpOnlyRequests,
2866
3214
  "c4-contract-shape": c4ContractShape,
2867
3215
  "c6-tomatch-extension": c6ToMatchExtension,
@@ -2887,59 +3235,14 @@ const plugin = {
2887
3235
  "j2-no-sleep-in-specs": j2NoSleepInSpecs,
2888
3236
  "j3-no-expectless-test": j3NoExpectlessTest,
2889
3237
  "j4-unique-test-names": j4UniqueTestNames,
2890
- "j5-lowercase-title": {
2891
- create(context) {
2892
- return { CallExpression(node) {
2893
- const callee = node.callee;
2894
- const root = chainRootName(callee);
2895
- if (root === void 0 || !TITLED.has(root)) return;
2896
- const title = stringValue(node.arguments?.[0]);
2897
- if (title === void 0) return;
2898
- const first = [...title][0];
2899
- if (first === void 0 || !/\p{L}/u.test(first)) return;
2900
- const firstWord = title.split(/\s+/u)[0];
2901
- if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
2902
- if (first !== first.toLocaleLowerCase()) context.report({
2903
- data: { runner: root },
2904
- messageId: "uppercase",
2905
- node
2906
- });
2907
- } };
2908
- },
2909
- meta: {
2910
- docs: RULE_DOCS["j5-lowercase-title"],
2911
- messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
2912
- type: "problem"
2913
- }
2914
- },
2915
- "w1-scenario-pure": {
2916
- create(context) {
2917
- return { CallExpression(node) {
2918
- const callee = node.callee;
2919
- if (!callee || memberPropertyName(callee) !== "visit") return;
2920
- const scenario = node.arguments?.[1];
2921
- if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2922
- walk(scenario, (inner) => {
2923
- if (inner.type !== "CallExpression") return;
2924
- const innerCallee = inner.callee;
2925
- if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
2926
- messageId: "expectInScenario",
2927
- node: inner
2928
- });
2929
- });
2930
- } };
2931
- },
2932
- meta: {
2933
- docs: RULE_DOCS["w1-scenario-pure"],
2934
- 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)." },
2935
- type: "problem"
2936
- }
2937
- },
3238
+ "j5-lowercase-title": j5LowercaseTitle,
3239
+ "w1-scenario-pure": w1ScenarioPure,
2938
3240
  "w2w-user-facing-elements": {
2939
3241
  create(context) {
2940
3242
  return { CallExpression(node) {
2941
3243
  const callee = node.callee;
2942
- if (!callee || memberPropertyName(callee) !== "visit") return;
3244
+ const member = callee ? memberPropertyName(callee) : void 0;
3245
+ if (member === void 0 || !SCENARIO_ACTIONS.has(member)) return;
2943
3246
  const scenario = node.arguments?.[1];
2944
3247
  if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2945
3248
  walk(scenario, (inner) => {