@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.cjs CHANGED
@@ -166,7 +166,7 @@ const RULE_DOCS = {
166
166
  },
167
167
  "a2-known-constructors": {
168
168
  channel: "statique",
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.",
169
+ 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.",
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."
@@ -255,6 +255,20 @@ const RULE_DOCS = {
255
255
  id: "C1",
256
256
  rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
257
257
  },
258
+ "c10-contracts-boundary": {
259
+ channel: "statique",
260
+ 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`.",
261
+ family: "C",
262
+ id: "C10",
263
+ 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."
264
+ },
265
+ "c11-contract-data-pairing": {
266
+ channel: "statique",
267
+ 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.",
268
+ family: "C",
269
+ id: "C11",
270
+ 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."
271
+ },
258
272
  "c2-http-only-requests": {
259
273
  channel: "statique",
260
274
  convention: "`requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur.",
@@ -264,10 +278,10 @@ const RULE_DOCS = {
264
278
  },
265
279
  "c4-contract-shape": {
266
280
  channel: "statique",
267
- 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.",
281
+ 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`.",
268
282
  family: "C",
269
283
  id: "C4",
270
- rationale: "Une forme figée rend les contrats découvrables et typables sans convention locale."
284
+ 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."
271
285
  },
272
286
  "c6-tomatch-extension": {
273
287
  channel: "statique",
@@ -439,16 +453,16 @@ const RULE_DOCS = {
439
453
  },
440
454
  "w1-scenario-pure": {
441
455
  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",
456
+ 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é.",
457
+ facet: "shared",
444
458
  family: "W",
445
459
  id: "W1",
446
460
  rationale: "Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables."
447
461
  },
448
462
  "w2w-user-facing-elements": {
449
463
  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",
464
+ 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.",
465
+ facet: "shared",
452
466
  family: "W",
453
467
  id: "W2",
454
468
  rationale: "Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie."
@@ -494,7 +508,7 @@ const CHECKER_PASSES = [
494
508
  },
495
509
  {
496
510
  channel: "checker",
497
- 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).",
511
+ 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).",
498
512
  family: "C",
499
513
  id: "C9",
500
514
  name: "c9-dead-fixtures",
@@ -573,13 +587,22 @@ const RUNTIME_RULES = [
573
587
  },
574
588
  {
575
589
  channel: "runtime",
576
- 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 ».",
577
- facet: "website",
590
+ 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).",
591
+ facet: "shared",
578
592
  family: "W",
579
593
  id: "W3",
580
594
  name: "w3-unambiguous-element",
581
595
  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."
582
596
  },
597
+ {
598
+ channel: "runtime",
599
+ 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(…), …)`.",
600
+ facet: "mobile",
601
+ family: "W",
602
+ id: "W4",
603
+ name: "w4-no-landmarks-on-mobile",
604
+ rationale: "Un seul vocabulaire garde l’API mémorisable ; refuser explicitement la partie sans équivalent iOS évite une approximation silencieuse."
605
+ },
583
606
  {
584
607
  channel: "runtime",
585
608
  convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
@@ -765,18 +788,19 @@ const a10DuplicateBinding = {
765
788
  };
766
789
  //#endregion
767
790
  //#region src/lint/rules/a2-known-constructors.ts
768
- /** The four constructors, and only four (A2 — see docs/10-linting.md). */
791
+ /** The five constructors, and only five (A2 — see docs/10-linting.md). */
769
792
  const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
770
793
  "api",
771
794
  "cli",
772
795
  "jobs",
796
+ "mobile",
773
797
  "website"
774
798
  ]);
775
799
  /**
776
800
  * CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
777
- * `specification.cli()` and `specification.website()` are the only members.
778
- * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
779
- * the member site.
801
+ * `specification.cli()`, `specification.website()` and
802
+ * `specification.mobile()` are the only members. Any other access
803
+ * (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
780
804
  */
781
805
  const a2KnownConstructors = {
782
806
  create(context) {
@@ -793,7 +817,7 @@ const a2KnownConstructors = {
793
817
  },
794
818
  meta: {
795
819
  docs: RULE_DOCS["a2-known-constructors"],
796
- 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)." },
820
+ 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)." },
797
821
  type: "problem"
798
822
  }
799
823
  };
@@ -1426,8 +1450,94 @@ const c1DomainStructure = {
1426
1450
  }
1427
1451
  };
1428
1452
  //#endregion
1453
+ //#region src/lint/rules/c10-contracts-boundary.ts
1454
+ /** A specifier reaching into a provider folder — the internal half of contracts/. */
1455
+ const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
1456
+ /**
1457
+ * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
1458
+ * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
1459
+ * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
1460
+ * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
1461
+ * importing a unit is an error — a test routes through the facade, so a
1462
+ * scenario is named once, next to the world it derives from.
1463
+ */
1464
+ const c10ContractsBoundary = {
1465
+ create(context) {
1466
+ const parts = segments(context.physicalFilename);
1467
+ if (!parts.includes("specs")) return {};
1468
+ if (parts.slice(0, -1).includes("contracts")) return {};
1469
+ return importSourceVisitor(({ node, source }) => {
1470
+ if (INTERNAL_UNIT.test(source)) context.report({
1471
+ data: { source },
1472
+ messageId: "internal",
1473
+ node
1474
+ });
1475
+ });
1476
+ },
1477
+ meta: {
1478
+ docs: RULE_DOCS["c10-contracts-boundary"],
1479
+ 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)." },
1480
+ type: "problem"
1481
+ }
1482
+ };
1483
+ //#endregion
1484
+ //#region src/lint/rules/c11-contract-data-pairing.ts
1485
+ const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1486
+ /** The provider folders that may carry data files. */
1487
+ const PROVIDERS$1 = [
1488
+ "anthropic",
1489
+ "http",
1490
+ "openai"
1491
+ ];
1492
+ /** Data files: served payloads and matched inputs, both owned by a contract. */
1493
+ const DATA_SUFFIXES = [".request.ts", ".response.json"];
1494
+ /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
1495
+ function ownerOf(entry) {
1496
+ return `${entry.slice(0, entry.indexOf("."))}.ts`;
1497
+ }
1498
+ /**
1499
+ * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
1500
+ * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
1501
+ * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
1502
+ * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
1503
+ * file with no owner is dead weight no test can reach — reported on the feature's
1504
+ * test file, since oxlint never visits a `.json`.
1505
+ */
1506
+ const c11ContractDataPairing = {
1507
+ create(context) {
1508
+ const file = context.physicalFilename;
1509
+ if (!TEST_FILE$11.test(file) || !segments(file).includes("specs")) return {};
1510
+ return { Program(node) {
1511
+ const contractsDir = (0, node_path.join)((0, node_path.dirname)(file), "contracts");
1512
+ for (const provider of PROVIDERS$1) {
1513
+ const entries = listDirectory((0, node_path.join)(contractsDir, provider));
1514
+ if (entries === null) continue;
1515
+ const present = new Set(entries);
1516
+ for (const entry of entries) {
1517
+ if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
1518
+ const owner = ownerOf(entry);
1519
+ if (!present.has(owner)) context.report({
1520
+ data: {
1521
+ entry,
1522
+ owner,
1523
+ provider
1524
+ },
1525
+ messageId: "orphan",
1526
+ node
1527
+ });
1528
+ }
1529
+ }
1530
+ } };
1531
+ },
1532
+ meta: {
1533
+ docs: RULE_DOCS["c11-contract-data-pairing"],
1534
+ 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)." },
1535
+ type: "problem"
1536
+ }
1537
+ };
1538
+ //#endregion
1429
1539
  //#region src/lint/rules/c2-http-only-requests.ts
1430
- const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1540
+ const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
1431
1541
  /**
1432
1542
  * CONVENTIONS C2 — `requests/` contains only `.http` files (a request file is
1433
1543
  * a COMPLETE request: method, path, headers, body). Anchored on the feature's
@@ -1438,7 +1548,7 @@ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1438
1548
  const c2HttpOnlyRequests = {
1439
1549
  create(context) {
1440
1550
  const file = context.physicalFilename;
1441
- if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
1551
+ if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
1442
1552
  return { Program(node) {
1443
1553
  const requestsDir = (0, node_path.join)((0, node_path.dirname)(file), "requests");
1444
1554
  for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
@@ -1456,65 +1566,217 @@ const c2HttpOnlyRequests = {
1456
1566
  };
1457
1567
  //#endregion
1458
1568
  //#region src/lint/rules/c4-contract-shape.ts
1459
- /** Providers a contract file may declare (C4 see docs/10-linting.md). */
1569
+ /** The only directories a `contracts/` root may holdthe provider carriers. */
1460
1570
  const PROVIDERS = /* @__PURE__ */ new Set([
1461
1571
  "anthropic",
1462
1572
  "http",
1463
1573
  "openai"
1464
1574
  ]);
1465
- const CONTRACT_NAME = /^[A-Za-z0-9][\w-]*\.(?<provider>[a-z]+)\.[cm]?ts$/;
1466
- /** Is the import source the framework's public entry (`@jterrazz/test` / `src/index`)? */
1467
- function isPublicEntry(source) {
1468
- return source === "@jterrazz/test" || source.endsWith("/src/index.js") || source.endsWith("/src/index.ts");
1575
+ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1576
+ /** A file oxlint itself visits — the AST half of the rule reports on those. */
1577
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1578
+ /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1579
+ const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1580
+ /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1581
+ const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1582
+ /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1583
+ const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1584
+ /** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
1585
+ const RESPONSE_DATA_FILE = /\.response\.json$/;
1586
+ /** The name a specifier/declaration exports under, when it is `default`. */
1587
+ function exportsDefault(node) {
1588
+ return (node.specifiers ?? []).some((specifier) => {
1589
+ const exported = specifier.exported;
1590
+ return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1591
+ });
1592
+ }
1593
+ /** Is this node a `defineContract(...)` call? */
1594
+ function isDefineContract(node) {
1595
+ const callee = node.callee;
1596
+ return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1597
+ }
1598
+ /** A default export that may legitimately produce a contract (value or factory). */
1599
+ function producesContract(declaration) {
1600
+ if (declaration === void 0) return false;
1601
+ return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1469
1602
  }
1470
1603
  /**
1471
- * CONVENTIONS C4 a contract file (`contracts/<name>.<provider>.ts`) has a
1472
- * rigid shape: flat under `contracts/` (no subfolders), `provider { openai,
1473
- * anthropic, http }`, a single `export default defineContract(...)`, no named
1474
- * exports, and imports only from the public entry. The runtime channel (the
1475
- * loader) only catches a bad default export; this rule closes the rest.
1604
+ * The provider a `request:` value implies, when the builder is written as
1605
+ * `<provider>.<verb>(…)` the only statically decidable form.
1476
1606
  */
1477
- const c4ContractShape = {
1478
- create(context) {
1479
- const parts = segments(context.physicalFilename);
1480
- const contractsIndex = parts.lastIndexOf("contracts");
1481
- if (contractsIndex === -1 || contractsIndex >= parts.length - 1 || !parts.includes("specs")) return {};
1482
- const base = parts.at(-1) ?? "";
1483
- return { Program(node) {
1484
- if (contractsIndex !== parts.length - 2) context.report({
1485
- data: { base },
1486
- messageId: "subfolder",
1607
+ function requestProvider(request) {
1608
+ if (request?.type !== "CallExpression") return;
1609
+ const callee = request.callee;
1610
+ if (callee?.type !== "MemberExpression") return;
1611
+ const object = callee.object;
1612
+ return object?.type === "Identifier" ? object.name : void 0;
1613
+ }
1614
+ /**
1615
+ * The layout half — walks the feature's `contracts/` tree from the test file
1616
+ * that owns it. Covers what oxlint never visits: a stray `.json` at the root,
1617
+ * a directory that is not a provider, a nested folder or a foreign extension
1618
+ * inside a provider folder.
1619
+ */
1620
+ function checkLayout(context, node, contractsDir) {
1621
+ const entries = listDirectory(contractsDir);
1622
+ if (entries === null) return;
1623
+ for (const entry of entries) {
1624
+ const path = (0, node_path.join)(contractsDir, entry);
1625
+ if (!isDirectory(path)) {
1626
+ if (!SOURCE_FILE.test(entry)) context.report({
1627
+ data: { entry },
1628
+ messageId: "rootEntry",
1487
1629
  node
1488
1630
  });
1489
- const match = CONTRACT_NAME.exec(base);
1490
- if (match === null || !PROVIDERS.has(match.groups?.provider ?? "")) context.report({
1491
- data: { base },
1492
- messageId: "badName",
1631
+ continue;
1632
+ }
1633
+ if (!PROVIDERS.has(entry)) {
1634
+ context.report({
1635
+ data: { entry },
1636
+ messageId: "badProviderDir",
1493
1637
  node
1494
1638
  });
1495
- let hasDefault = false;
1496
- for (const statement of node.body ?? []) if (statement.type === "ImportDeclaration") {
1497
- const source = stringValue(statement.source);
1498
- if (source !== void 0 && !isPublicEntry(source)) context.report({
1499
- data: { source },
1500
- messageId: "foreignImport",
1501
- node: statement
1502
- });
1503
- } else if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") context.report({
1504
- messageId: "namedExport",
1505
- node: statement
1506
- });
1507
- else if (statement.type === "ExportDefaultDeclaration") {
1639
+ continue;
1640
+ }
1641
+ for (const child of listDirectory(path) ?? []) if (isDirectory((0, node_path.join)(path, child))) context.report({
1642
+ data: {
1643
+ child,
1644
+ provider: entry
1645
+ },
1646
+ messageId: "providerSubfolder",
1647
+ node
1648
+ });
1649
+ else if (!/\.[cm]?ts$/u.test(child) && !RESPONSE_DATA_FILE.test(child)) context.report({
1650
+ data: {
1651
+ child,
1652
+ provider: entry
1653
+ },
1654
+ messageId: "providerEntry",
1655
+ node
1656
+ });
1657
+ }
1658
+ }
1659
+ /** The facade: `<kebab>.contracts.ts`, default-exporting a composition. */
1660
+ function checkComposite(context, program, base) {
1661
+ if (!COMPOSITE_FILE.test(base)) {
1662
+ context.report({
1663
+ data: { base },
1664
+ messageId: "rootFile",
1665
+ node: program
1666
+ });
1667
+ return;
1668
+ }
1669
+ let hasDefault = false;
1670
+ let composed = false;
1671
+ walk(program, (node) => {
1672
+ if (node.type === "ExportDefaultDeclaration") hasDefault = true;
1673
+ else if (node.type === "ExportAllDeclaration" || node.type === "ExportNamedDeclaration") {
1674
+ if (exportsDefault(node)) {
1508
1675
  hasDefault = true;
1509
- const declaration = statement.declaration;
1510
- const callee = declaration?.type === "CallExpression" ? declaration.callee : void 0;
1511
- if (callee?.type !== "Identifier" || callee.name !== "defineContract") context.report({
1512
- messageId: "notDefineContract",
1513
- node: statement
1514
- });
1676
+ composed = stringValue(node.source) !== void 0 || composed;
1515
1677
  }
1516
- if (!hasDefault) context.report({
1517
- messageId: "missingDefault",
1678
+ } else if (node.type === "CallExpression") {
1679
+ const callee = node.callee;
1680
+ if (callee?.type === "Identifier" && callee.name === "defineContracts" || callee !== void 0 && memberPropertyName(callee) === "with") composed = true;
1681
+ }
1682
+ });
1683
+ if (!hasDefault) context.report({
1684
+ data: { base },
1685
+ messageId: "missingComposite",
1686
+ node: program
1687
+ });
1688
+ else if (!composed) context.report({
1689
+ data: { base },
1690
+ messageId: "notDefineContracts",
1691
+ node: program
1692
+ });
1693
+ }
1694
+ /** A unit contract under its provider folder. */
1695
+ function checkUnit(context, program, base, provider) {
1696
+ if (REQUEST_DATA_FILE.test(base)) return;
1697
+ if (!UNIT_FILE.test(base)) {
1698
+ context.report({
1699
+ data: { base },
1700
+ messageId: "badName",
1701
+ node: program
1702
+ });
1703
+ return;
1704
+ }
1705
+ let hasDefault = false;
1706
+ let wellFormed = false;
1707
+ walk(program, (node) => {
1708
+ if (node.type === "ExportDefaultDeclaration") {
1709
+ hasDefault = true;
1710
+ wellFormed = producesContract(node.declaration) || wellFormed;
1711
+ } else if (node.type === "ExportNamedDeclaration" && exportsDefault(node)) {
1712
+ hasDefault = true;
1713
+ wellFormed = true;
1714
+ }
1715
+ if (isDefineContract(node)) {
1716
+ const argument = node.arguments?.[0];
1717
+ const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
1718
+ if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
1719
+ data: {
1720
+ declared,
1721
+ provider
1722
+ },
1723
+ messageId: "providerMismatch",
1724
+ node
1725
+ });
1726
+ }
1727
+ });
1728
+ if (!hasDefault) context.report({
1729
+ data: { base },
1730
+ messageId: "missingDefault",
1731
+ node: program
1732
+ });
1733
+ else if (!wellFormed) context.report({
1734
+ data: { base },
1735
+ messageId: "notDefineContract",
1736
+ node: program
1737
+ });
1738
+ }
1739
+ /**
1740
+ * CONVENTIONS C4 — the structure of a feature's `contracts/` tree. The ROOT
1741
+ * holds only `*.contracts.ts` facades (default export = a `defineContracts`
1742
+ * composition, named exports = scenario factories) and the provider
1743
+ * directories `http` / `openai` / `anthropic`. A provider directory holds
1744
+ * `<kebab>.ts` unit contracts (default export = `defineContract(...)` or a
1745
+ * factory returning one) plus their sibling data (`*.response.json`,
1746
+ * `*.request.ts`) — the FOLDER carries the provider, so a unit whose `request`
1747
+ * builder names another provider is an error.
1748
+ *
1749
+ * Two halves: the AST half reports on each contract file it visits, the layout
1750
+ * half walks the tree from the feature's test file (oxlint never visits a
1751
+ * `.json` fixture or an empty directory).
1752
+ */
1753
+ const c4ContractShape = {
1754
+ create(context) {
1755
+ const file = context.physicalFilename;
1756
+ const parts = segments(file);
1757
+ if (!parts.includes("specs")) return {};
1758
+ const contractsIndex = parts.lastIndexOf("contracts");
1759
+ if (contractsIndex === -1) {
1760
+ if (!TEST_FILE$9.test(file)) return {};
1761
+ return { Program(node) {
1762
+ checkLayout(context, node, (0, node_path.join)((0, node_path.dirname)(file), "contracts"));
1763
+ } };
1764
+ }
1765
+ const base = parts.at(-1) ?? "";
1766
+ const depth = parts.length - 1 - contractsIndex;
1767
+ if (depth === 1) return { Program(node) {
1768
+ checkComposite(context, node, base);
1769
+ } };
1770
+ if (depth === 2) {
1771
+ const provider = parts[contractsIndex + 1];
1772
+ return PROVIDERS.has(provider) ? { Program(node) {
1773
+ checkUnit(context, node, base, provider);
1774
+ } } : {};
1775
+ }
1776
+ return { Program(node) {
1777
+ context.report({
1778
+ data: { base },
1779
+ messageId: "tooDeep",
1518
1780
  node
1519
1781
  });
1520
1782
  } };
@@ -1522,12 +1784,18 @@ const c4ContractShape = {
1522
1784
  meta: {
1523
1785
  docs: RULE_DOCS["c4-contract-shape"],
1524
1786
  messages: {
1525
- badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider openai | anthropic | http (C4 — see docs/10-linting.md).",
1526
- foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (C4 — see docs/10-linting.md).",
1527
- missingDefault: "Contract file has no `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1528
- namedExport: "Contract files have no named exportsonly `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1529
- notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (C4 — see docs/10-linting.md).",
1530
- subfolder: "Contract file \"{{base}}\" is nested contracts/ is flat, no subfolders (C4 — see docs/10-linting.md)."
1787
+ badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts the folder already carries the provider (C4 — see docs/10-linting.md).",
1788
+ 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).",
1789
+ missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/10-linting.md).",
1790
+ missingDefault: "Contract unit \"{{base}}\" has no default exporta unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/10-linting.md).",
1791
+ notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/10-linting.md).",
1792
+ notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/10-linting.md).",
1793
+ providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/10-linting.md).",
1794
+ 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).",
1795
+ providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/10-linting.md).",
1796
+ 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).",
1797
+ 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).",
1798
+ tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/10-linting.md)."
1531
1799
  },
1532
1800
  type: "problem"
1533
1801
  }
@@ -2430,6 +2698,7 @@ const f5FixturesOnlyFromTests = {
2430
2698
  */
2431
2699
  const INTEGRATION_DEPS = {
2432
2700
  anthropic: ["@anthropic-ai/sdk"],
2701
+ appium: ["webdriverio"],
2433
2702
  compose: ["yaml"],
2434
2703
  docker: [],
2435
2704
  hono: ["hono", "@hono/node-server"],
@@ -2507,6 +2776,7 @@ const i1LayerBoundaries = {
2507
2776
  if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
2508
2777
  if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
2509
2778
  if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
2779
+ if (target.startsWith("integrations/appium/") && inside === "core/specification/mobile/start-mobile.ts") return null;
2510
2780
  return "crossLayer";
2511
2781
  }
2512
2782
  if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
@@ -2832,6 +3102,82 @@ const TITLED = /* @__PURE__ */ new Set([
2832
3102
  * `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
2833
3103
  * misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
2834
3104
  const ALL_CAPS_IDENTIFIER = /^[A-Z][A-Z0-9_]*$/u;
3105
+ /**
3106
+ * CONVENTIONS J5 — the first character of a `test('…')` / `describe('…')` title
3107
+ * literal must be lowercase.
3108
+ *
3109
+ * The test name is the sole description of behaviour (B3): a sentence fragment,
3110
+ * lowercase-led like prose (`'rejects an unknown job'`). Exempt are titles
3111
+ * whose first WORD is an identifier-shaped all-caps/underscored token
3112
+ * (`'VALID_CATEGORIES is rejected'`, `'HTTP is upgraded'`, `'DI wires …'`) —
3113
+ * lowercasing a named symbol would misspell it — and titles opening on a
3114
+ * non-letter (a `$`, a `{`, a `%s` placeholder). Only lowercase-able prose
3115
+ * first words are enforced. Covers the modifier and parametrized forms
3116
+ * (`test.only`, `describe.each(...)(...)`) via the chain root.
3117
+ */
3118
+ const j5LowercaseTitle = {
3119
+ create(context) {
3120
+ return { CallExpression(node) {
3121
+ const callee = node.callee;
3122
+ const root = chainRootName(callee);
3123
+ if (root === void 0 || !TITLED.has(root)) return;
3124
+ const title = stringValue(node.arguments?.[0]);
3125
+ if (title === void 0) return;
3126
+ const first = [...title][0];
3127
+ if (first === void 0 || !/\p{L}/u.test(first)) return;
3128
+ const firstWord = title.split(/\s+/u)[0];
3129
+ if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
3130
+ if (first !== first.toLocaleLowerCase()) context.report({
3131
+ data: { runner: root },
3132
+ messageId: "uppercase",
3133
+ node
3134
+ });
3135
+ } };
3136
+ },
3137
+ meta: {
3138
+ docs: RULE_DOCS["j5-lowercase-title"],
3139
+ messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
3140
+ type: "problem"
3141
+ }
3142
+ };
3143
+ //#endregion
3144
+ //#region src/lint/rules/w1-scenario-pure.ts
3145
+ /** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
3146
+ const SCENARIO_ACTIONS$1 = /* @__PURE__ */ new Set(["open", "visit"]);
3147
+ /**
3148
+ * CONVENTIONS W1 — a visit (or mobile open) scenario is the When: the
3149
+ * visitor interacts, the capture reflects the final state, and assertions
3150
+ * live in the Then on the returned result. An `expect()` inside the scenario
3151
+ * callback is flagged.
3152
+ */
3153
+ const w1ScenarioPure = {
3154
+ create(context) {
3155
+ return { CallExpression(node) {
3156
+ const callee = node.callee;
3157
+ const member = callee ? memberPropertyName(callee) : void 0;
3158
+ if (member === void 0 || !SCENARIO_ACTIONS$1.has(member)) return;
3159
+ const scenario = node.arguments?.[1];
3160
+ if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
3161
+ walk(scenario, (inner) => {
3162
+ if (inner.type !== "CallExpression") return;
3163
+ const innerCallee = inner.callee;
3164
+ if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
3165
+ messageId: "expectInScenario",
3166
+ node: inner
3167
+ });
3168
+ });
3169
+ } };
3170
+ },
3171
+ meta: {
3172
+ docs: RULE_DOCS["w1-scenario-pure"],
3173
+ 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)." },
3174
+ type: "problem"
3175
+ }
3176
+ };
3177
+ //#endregion
3178
+ //#region src/lint/rules/w2w-user-facing-elements.ts
3179
+ /** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
3180
+ const SCENARIO_ACTIONS = /* @__PURE__ */ new Set(["open", "visit"]);
2835
3181
  //#endregion
2836
3182
  //#region src/lint/plugin.ts
2837
3183
  /**
@@ -2866,6 +3212,8 @@ const plugin = {
2866
3212
  "b8-kebab-trigger": b8KebabTrigger,
2867
3213
  "b9w-product-command": b9wProductCommand,
2868
3214
  "c1-domain-structure": c1DomainStructure,
3215
+ "c10-contracts-boundary": c10ContractsBoundary,
3216
+ "c11-contract-data-pairing": c11ContractDataPairing,
2869
3217
  "c2-http-only-requests": c2HttpOnlyRequests,
2870
3218
  "c4-contract-shape": c4ContractShape,
2871
3219
  "c6-tomatch-extension": c6ToMatchExtension,
@@ -2891,59 +3239,14 @@ const plugin = {
2891
3239
  "j2-no-sleep-in-specs": j2NoSleepInSpecs,
2892
3240
  "j3-no-expectless-test": j3NoExpectlessTest,
2893
3241
  "j4-unique-test-names": j4UniqueTestNames,
2894
- "j5-lowercase-title": {
2895
- create(context) {
2896
- return { CallExpression(node) {
2897
- const callee = node.callee;
2898
- const root = chainRootName(callee);
2899
- if (root === void 0 || !TITLED.has(root)) return;
2900
- const title = stringValue(node.arguments?.[0]);
2901
- if (title === void 0) return;
2902
- const first = [...title][0];
2903
- if (first === void 0 || !/\p{L}/u.test(first)) return;
2904
- const firstWord = title.split(/\s+/u)[0];
2905
- if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
2906
- if (first !== first.toLocaleLowerCase()) context.report({
2907
- data: { runner: root },
2908
- messageId: "uppercase",
2909
- node
2910
- });
2911
- } };
2912
- },
2913
- meta: {
2914
- docs: RULE_DOCS["j5-lowercase-title"],
2915
- messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
2916
- type: "problem"
2917
- }
2918
- },
2919
- "w1-scenario-pure": {
2920
- create(context) {
2921
- return { CallExpression(node) {
2922
- const callee = node.callee;
2923
- if (!callee || memberPropertyName(callee) !== "visit") return;
2924
- const scenario = node.arguments?.[1];
2925
- if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2926
- walk(scenario, (inner) => {
2927
- if (inner.type !== "CallExpression") return;
2928
- const innerCallee = inner.callee;
2929
- if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
2930
- messageId: "expectInScenario",
2931
- node: inner
2932
- });
2933
- });
2934
- } };
2935
- },
2936
- meta: {
2937
- docs: RULE_DOCS["w1-scenario-pure"],
2938
- 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)." },
2939
- type: "problem"
2940
- }
2941
- },
3242
+ "j5-lowercase-title": j5LowercaseTitle,
3243
+ "w1-scenario-pure": w1ScenarioPure,
2942
3244
  "w2w-user-facing-elements": {
2943
3245
  create(context) {
2944
3246
  return { CallExpression(node) {
2945
3247
  const callee = node.callee;
2946
- if (!callee || memberPropertyName(callee) !== "visit") return;
3248
+ const member = callee ? memberPropertyName(callee) : void 0;
3249
+ if (member === void 0 || !SCENARIO_ACTIONS.has(member)) return;
2947
3250
  const scenario = node.arguments?.[1];
2948
3251
  if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
2949
3252
  walk(scenario, (inner) => {