@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/README.md +101 -35
- package/dist/ambiguity.js +126 -0
- package/dist/appium.adapter.js +399 -0
- package/dist/checker.js +0 -1
- package/dist/index.d.ts +500 -101
- package/dist/index.js +587 -464
- package/dist/intercept.js +37 -84
- package/dist/match.js +12 -1
- package/dist/oxlint.cjs +420 -117
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +420 -117
- package/dist/playwright.adapter.js +3 -127
- package/dist/queue.js +555 -0
- package/package.json +13 -4
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: "
|
|
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: "
|
|
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
|
|
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
|
|
443
|
-
facet: "
|
|
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: "
|
|
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/`/`
|
|
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: "
|
|
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
|
|
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()
|
|
778
|
-
*
|
|
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.
|
|
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$
|
|
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$
|
|
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
|
-
/**
|
|
1569
|
+
/** The only directories a `contracts/` root may hold — the provider carriers. */
|
|
1460
1570
|
const PROVIDERS = /* @__PURE__ */ new Set([
|
|
1461
1571
|
"anthropic",
|
|
1462
1572
|
"http",
|
|
1463
1573
|
"openai"
|
|
1464
1574
|
]);
|
|
1465
|
-
const
|
|
1466
|
-
/**
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
-
*
|
|
1472
|
-
*
|
|
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
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
if (!PROVIDERS.has(entry)) {
|
|
1634
|
+
context.report({
|
|
1635
|
+
data: { entry },
|
|
1636
|
+
messageId: "badProviderDir",
|
|
1493
1637
|
node
|
|
1494
1638
|
});
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1517
|
-
|
|
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
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
notDefineContract: "The default export of a contract
|
|
1530
|
-
|
|
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 export — a 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
|
-
|
|
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
|
-
|
|
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) => {
|