@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.js
CHANGED
|
@@ -162,7 +162,7 @@ const RULE_DOCS = {
|
|
|
162
162
|
},
|
|
163
163
|
"a2-known-constructors": {
|
|
164
164
|
channel: "statique",
|
|
165
|
-
convention: "
|
|
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: "
|
|
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
|
|
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
|
|
439
|
-
facet: "
|
|
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: "
|
|
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/`/`
|
|
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: "
|
|
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
|
|
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()
|
|
774
|
-
*
|
|
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.
|
|
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$
|
|
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$
|
|
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
|
-
/**
|
|
1565
|
+
/** The only directories a `contracts/` root may hold — the provider carriers. */
|
|
1456
1566
|
const PROVIDERS = /* @__PURE__ */ new Set([
|
|
1457
1567
|
"anthropic",
|
|
1458
1568
|
"http",
|
|
1459
1569
|
"openai"
|
|
1460
1570
|
]);
|
|
1461
|
-
const
|
|
1462
|
-
/**
|
|
1463
|
-
|
|
1464
|
-
|
|
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
|
-
*
|
|
1468
|
-
*
|
|
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
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
if (!PROVIDERS.has(entry)) {
|
|
1630
|
+
context.report({
|
|
1631
|
+
data: { entry },
|
|
1632
|
+
messageId: "badProviderDir",
|
|
1489
1633
|
node
|
|
1490
1634
|
});
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1513
|
-
|
|
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
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
notDefineContract: "The default export of a contract
|
|
1526
|
-
|
|
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 export — a 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
|
-
|
|
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
|
-
|
|
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) => {
|