@jterrazz/test 10.1.0 → 12.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 +34 -20
- package/dist/appium.adapter.js +23 -10
- package/dist/checker.js +0 -1
- package/dist/index.d.ts +227 -156
- package/dist/index.js +344 -684
- package/dist/intercept.js +37 -84
- package/dist/match.js +12 -1
- package/dist/oxlint.cjs +641 -238
- package/dist/oxlint.js +641 -238
- package/dist/queue.js +555 -0
- package/package.json +1 -1
package/dist/oxlint.cjs
CHANGED
|
@@ -4,6 +4,63 @@ Object.defineProperties(exports, {
|
|
|
4
4
|
});
|
|
5
5
|
let node_path = require("node:path");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
|
+
//#region src/lint/fs-cache.ts
|
|
8
|
+
/**
|
|
9
|
+
* Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
|
|
10
|
+
*
|
|
11
|
+
* Rules only ever look at directories NEXT TO files oxlint is already
|
|
12
|
+
* visiting (a feature's `requests/`, `seeds/`, `expected/`, a sibling module
|
|
13
|
+
* file), so the probe set is small — but several rules probe the same feature
|
|
14
|
+
* directory, and oxlint lints many files per process. A module-level cache
|
|
15
|
+
* keeps each `readdir`/`stat` to one syscall per lint run.
|
|
16
|
+
*/
|
|
17
|
+
const dirListings = /* @__PURE__ */ new Map();
|
|
18
|
+
function listDirectory(dir) {
|
|
19
|
+
const cached = dirListings.get(dir);
|
|
20
|
+
if (cached !== void 0) return cached;
|
|
21
|
+
let result;
|
|
22
|
+
try {
|
|
23
|
+
result = (0, node_fs.readdirSync)(dir);
|
|
24
|
+
} catch {
|
|
25
|
+
result = null;
|
|
26
|
+
}
|
|
27
|
+
dirListings.set(dir, result);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
const kinds = /* @__PURE__ */ new Map();
|
|
31
|
+
function kindOf(path) {
|
|
32
|
+
const cached = kinds.get(path);
|
|
33
|
+
if (cached !== void 0) return cached;
|
|
34
|
+
let result;
|
|
35
|
+
try {
|
|
36
|
+
result = (0, node_fs.statSync)(path).isDirectory() ? "dir" : "file";
|
|
37
|
+
} catch {
|
|
38
|
+
result = "missing";
|
|
39
|
+
}
|
|
40
|
+
kinds.set(path, result);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
function isDirectory(path) {
|
|
44
|
+
return kindOf(path) === "dir";
|
|
45
|
+
}
|
|
46
|
+
function isFile(path) {
|
|
47
|
+
return kindOf(path) === "file";
|
|
48
|
+
}
|
|
49
|
+
const fileContents = /* @__PURE__ */ new Map();
|
|
50
|
+
/** Read a file as UTF-8, cached. Returns null when unreadable (missing/binary). */
|
|
51
|
+
function readFileCached(path) {
|
|
52
|
+
const cached = fileContents.get(path);
|
|
53
|
+
if (cached !== void 0) return cached;
|
|
54
|
+
let result;
|
|
55
|
+
try {
|
|
56
|
+
result = (0, node_fs.readFileSync)(path, "utf8");
|
|
57
|
+
} catch {
|
|
58
|
+
result = null;
|
|
59
|
+
}
|
|
60
|
+
fileContents.set(path, result);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
7
64
|
//#region src/lint/ast.ts
|
|
8
65
|
/**
|
|
9
66
|
* Shared AST helpers for the rule files. Everything here is pure and
|
|
@@ -14,6 +71,39 @@ let node_fs = require("node:fs");
|
|
|
14
71
|
function segments(path) {
|
|
15
72
|
return path.split(/[/\\]/).filter(Boolean);
|
|
16
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* The `specs/` directory a file belongs to: the NEAREST ancestor named `specs`,
|
|
76
|
+
* searched no higher than the nearest `package.json`. `undefined` when the file
|
|
77
|
+
* is not under one.
|
|
78
|
+
*
|
|
79
|
+
* One anchor for every specs-aware rule. Rules used to hand-roll the search and
|
|
80
|
+
* disagreed with each other — `lastIndexOf('specs')` (C1) took the innermost
|
|
81
|
+
* match, `indexOf('specs')` (F3) the outermost, so one nested tree read as two
|
|
82
|
+
* different facets. And any bare "is `specs` a segment of this path" test
|
|
83
|
+
* matched directories that have nothing to do with the project: a checkout
|
|
84
|
+
* living under `~/specs/` turned the whole repository into a specs tree, which
|
|
85
|
+
* silently disabled F2's protection of production code. The package boundary
|
|
86
|
+
* makes the search stop where the project does.
|
|
87
|
+
*/
|
|
88
|
+
function specsAnchor(filename) {
|
|
89
|
+
const relative = [(0, node_path.basename)(filename)];
|
|
90
|
+
let directory = (0, node_path.dirname)(filename);
|
|
91
|
+
for (;;) {
|
|
92
|
+
if ((0, node_path.basename)(directory) === "specs") return {
|
|
93
|
+
directory,
|
|
94
|
+
relative
|
|
95
|
+
};
|
|
96
|
+
if (isFile((0, node_path.join)(directory, "package.json"))) return;
|
|
97
|
+
const parent = (0, node_path.dirname)(directory);
|
|
98
|
+
if (parent === directory) return;
|
|
99
|
+
relative.unshift((0, node_path.basename)(directory));
|
|
100
|
+
directory = parent;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Is this file inside a `specs/` tree? — the predicate form of {@link specsAnchor}. */
|
|
104
|
+
function isUnderSpecs(filename) {
|
|
105
|
+
return specsAnchor(filename) !== void 0;
|
|
106
|
+
}
|
|
17
107
|
/** The string value of a plain string literal (or a template with no holes). */
|
|
18
108
|
function stringValue(node) {
|
|
19
109
|
if (node === void 0) return;
|
|
@@ -250,11 +340,25 @@ const RULE_DOCS = {
|
|
|
250
340
|
},
|
|
251
341
|
"c1-domain-structure": {
|
|
252
342
|
channel: "statique",
|
|
253
|
-
convention: "
|
|
343
|
+
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`.",
|
|
254
344
|
family: "C",
|
|
255
345
|
id: "C1",
|
|
256
346
|
rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
|
|
257
347
|
},
|
|
348
|
+
"c10-contracts-boundary": {
|
|
349
|
+
channel: "statique",
|
|
350
|
+
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`.",
|
|
351
|
+
family: "C",
|
|
352
|
+
id: "C10",
|
|
353
|
+
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."
|
|
354
|
+
},
|
|
355
|
+
"c11-contract-data-pairing": {
|
|
356
|
+
channel: "statique",
|
|
357
|
+
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.",
|
|
358
|
+
family: "C",
|
|
359
|
+
id: "C11",
|
|
360
|
+
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."
|
|
361
|
+
},
|
|
258
362
|
"c2-http-only-requests": {
|
|
259
363
|
channel: "statique",
|
|
260
364
|
convention: "`requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur.",
|
|
@@ -264,10 +368,10 @@ const RULE_DOCS = {
|
|
|
264
368
|
},
|
|
265
369
|
"c4-contract-shape": {
|
|
266
370
|
channel: "statique",
|
|
267
|
-
convention: "
|
|
371
|
+
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
372
|
family: "C",
|
|
269
373
|
id: "C4",
|
|
270
|
-
rationale: "Une
|
|
374
|
+
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
375
|
},
|
|
272
376
|
"c6-tomatch-extension": {
|
|
273
377
|
channel: "statique",
|
|
@@ -383,10 +487,10 @@ const RULE_DOCS = {
|
|
|
383
487
|
},
|
|
384
488
|
"i1-layer-boundaries": {
|
|
385
489
|
channel: "statique",
|
|
386
|
-
convention: "
|
|
490
|
+
convention: "Le projet DÉCLARE ses couches sous `src/` via l’option `layers` (paquets autorisés, imports internes, un dossier = une dépendance, seams) ; tout import hors des arêtes déclarées est une erreur. Sans carte de couches, la règle est inerte.",
|
|
387
491
|
family: "I",
|
|
388
492
|
id: "I1",
|
|
389
|
-
rationale: "
|
|
493
|
+
rationale: "Une architecture appartient au projet : des frontières déclarées se vérifient, une architecture supposée se contourne."
|
|
390
494
|
},
|
|
391
495
|
"i2-sibling-test-naming": {
|
|
392
496
|
channel: "statique",
|
|
@@ -397,7 +501,7 @@ const RULE_DOCS = {
|
|
|
397
501
|
},
|
|
398
502
|
"i4-no-vi-mock-in-src": {
|
|
399
503
|
channel: "statique",
|
|
400
|
-
convention: "Sous `src/`, `vi.mock`, `__mocks__/`, `__fixtures__/` et l’import d’un asset
|
|
504
|
+
convention: "Sous `src/`, `vi.mock`, `__mocks__/`, `__fixtures__/` et l’import d’un asset de données (`.json`, `.sql`, `.yaml`, …) depuis un `.test.ts` sont interdits ; un spécifieur pointé (`./dashboard.post`) reste du code.",
|
|
401
505
|
family: "I",
|
|
402
506
|
id: "I4",
|
|
403
507
|
rationale: "Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec."
|
|
@@ -494,7 +598,7 @@ const CHECKER_PASSES = [
|
|
|
494
598
|
},
|
|
495
599
|
{
|
|
496
600
|
channel: "checker",
|
|
497
|
-
convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`
|
|
601
|
+
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
602
|
family: "C",
|
|
499
603
|
id: "C9",
|
|
500
604
|
name: "c9-dead-fixtures",
|
|
@@ -976,79 +1080,43 @@ const a6wRedundantComposeService = {
|
|
|
976
1080
|
}
|
|
977
1081
|
};
|
|
978
1082
|
//#endregion
|
|
979
|
-
//#region src/
|
|
1083
|
+
//#region src/core/specification/shared/resolve.ts
|
|
1084
|
+
/** What makes a directory a project root — a manifest, or a test stack. */
|
|
1085
|
+
const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
|
|
980
1086
|
/**
|
|
981
|
-
*
|
|
1087
|
+
* The NEAREST ancestor of `startDir` (itself included) carrying a root marker,
|
|
1088
|
+
* `undefined` when none does.
|
|
982
1089
|
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
1090
|
+
* Both markers are probed at each ancestor, in ONE walk. Two walks — the
|
|
1091
|
+
* compose file all the way up, then `package.json` all the way up — made the
|
|
1092
|
+
* further directory win: in a workspace, a `docker/compose.test.yaml` at the
|
|
1093
|
+
* repository root outranked the `package.json` of the very package being
|
|
1094
|
+
* tested, and every path the runner resolved was measured from the wrong unit.
|
|
1095
|
+
* The unit is the nearest thing that declares itself a project.
|
|
1096
|
+
*
|
|
1097
|
+
* The existence probe is injected so the lint layer can pass its cached one
|
|
1098
|
+
* (A9's `a9w-redundant-root` derives the same root, and must derive it the
|
|
1099
|
+
* same way).
|
|
988
1100
|
*/
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
} catch {
|
|
997
|
-
result = null;
|
|
998
|
-
}
|
|
999
|
-
dirListings.set(dir, result);
|
|
1000
|
-
return result;
|
|
1001
|
-
}
|
|
1002
|
-
const kinds = /* @__PURE__ */ new Map();
|
|
1003
|
-
function kindOf(path) {
|
|
1004
|
-
const cached = kinds.get(path);
|
|
1005
|
-
if (cached !== void 0) return cached;
|
|
1006
|
-
let result;
|
|
1007
|
-
try {
|
|
1008
|
-
result = (0, node_fs.statSync)(path).isDirectory() ? "dir" : "file";
|
|
1009
|
-
} catch {
|
|
1010
|
-
result = "missing";
|
|
1011
|
-
}
|
|
1012
|
-
kinds.set(path, result);
|
|
1013
|
-
return result;
|
|
1014
|
-
}
|
|
1015
|
-
function isDirectory(path) {
|
|
1016
|
-
return kindOf(path) === "dir";
|
|
1017
|
-
}
|
|
1018
|
-
function isFile(path) {
|
|
1019
|
-
return kindOf(path) === "file";
|
|
1020
|
-
}
|
|
1021
|
-
const fileContents = /* @__PURE__ */ new Map();
|
|
1022
|
-
/** Read a file as UTF-8, cached. Returns null when unreadable (missing/binary). */
|
|
1023
|
-
function readFileCached(path) {
|
|
1024
|
-
const cached = fileContents.get(path);
|
|
1025
|
-
if (cached !== void 0) return cached;
|
|
1026
|
-
let result;
|
|
1027
|
-
try {
|
|
1028
|
-
result = (0, node_fs.readFileSync)(path, "utf8");
|
|
1029
|
-
} catch {
|
|
1030
|
-
result = null;
|
|
1101
|
+
function findRoot(startDir, exists) {
|
|
1102
|
+
let dir = startDir;
|
|
1103
|
+
for (;;) {
|
|
1104
|
+
if (ROOT_MARKERS.some((marker) => exists((0, node_path.resolve)(dir, marker)))) return dir;
|
|
1105
|
+
const parent = (0, node_path.dirname)(dir);
|
|
1106
|
+
if (parent === dir) return;
|
|
1107
|
+
dir = parent;
|
|
1031
1108
|
}
|
|
1032
|
-
fileContents.set(path, result);
|
|
1033
|
-
return result;
|
|
1034
1109
|
}
|
|
1035
1110
|
//#endregion
|
|
1036
1111
|
//#region src/lint/rules/a9w-redundant-root.ts
|
|
1037
1112
|
/**
|
|
1038
|
-
* The root the convention would derive (A9 — see docs/10-linting.md):
|
|
1039
|
-
*
|
|
1040
|
-
* `
|
|
1113
|
+
* The root the convention would derive (A9 — see docs/10-linting.md): the
|
|
1114
|
+
* framework's own walk-up, run against this layer's cached fs probe. The rule
|
|
1115
|
+
* flags a `root` that the runner would have found by itself, so the two MUST
|
|
1116
|
+
* be the same function — a private copy of the walk drifted from it before.
|
|
1041
1117
|
*/
|
|
1042
1118
|
function derivedRoot(startDir) {
|
|
1043
|
-
|
|
1044
|
-
let dir = startDir;
|
|
1045
|
-
for (;;) {
|
|
1046
|
-
if (isFile((0, node_path.join)(dir, probe))) return dir;
|
|
1047
|
-
const parent = (0, node_path.dirname)(dir);
|
|
1048
|
-
if (parent === dir) break;
|
|
1049
|
-
dir = parent;
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1119
|
+
return findRoot(startDir, isFile);
|
|
1052
1120
|
}
|
|
1053
1121
|
/**
|
|
1054
1122
|
* CONVENTIONS A9 (warning) — `root` is an override reserved for cases the
|
|
@@ -1380,8 +1448,16 @@ const b9wProductCommand = {
|
|
|
1380
1448
|
//#region src/lint/rules/c1-domain-structure.ts
|
|
1381
1449
|
const TEST_SUFFIX = ".test.ts";
|
|
1382
1450
|
const SPECIFICATION_SUFFIX = ".specification.ts";
|
|
1451
|
+
const DEFAULT_DEPTH = "facet-domain";
|
|
1383
1452
|
/**
|
|
1384
|
-
* CONVENTIONS C1' —
|
|
1453
|
+
* CONVENTIONS C1' — the shape of a spec tree, DECLARED by the consumer.
|
|
1454
|
+
*
|
|
1455
|
+
* A project states which shape its `specs/` tree has; the rule checks that one.
|
|
1456
|
+
* Before this option there was a single shape and the only way out was `off`,
|
|
1457
|
+
* which took the whole rule with it — a tree with a different, equally
|
|
1458
|
+
* deliberate shape had no way to be checked at all.
|
|
1459
|
+
*
|
|
1460
|
+
* `depth: 'facet-domain'` (the default, and the historical behaviour)
|
|
1385
1461
|
*
|
|
1386
1462
|
* A facet (`specs/<facet>/` — api, jobs, cli, integrations, lint, …) is the
|
|
1387
1463
|
* master folder. It carries its runner(s) at its ROOT
|
|
@@ -1389,36 +1465,66 @@ const SPECIFICATION_SUFFIX = ".specification.ts";
|
|
|
1389
1465
|
* product command/area with one or more `<aspect>.test.ts` files plus their
|
|
1390
1466
|
* shared asset dirs.
|
|
1391
1467
|
*
|
|
1392
|
-
*
|
|
1393
|
-
* - a `*.test.ts` must sit at facet/domain depth — exactly
|
|
1468
|
+
* - a `*.test.ts` sits at facet/domain depth — exactly
|
|
1394
1469
|
* `specs/<facet>/<domain>/<file>.test.ts`. A test directly at the facet root
|
|
1395
1470
|
* (or nested deeper than a domain) is rejected.
|
|
1396
|
-
* - a `*.specification.ts`
|
|
1471
|
+
* - a `*.specification.ts` sits at the facet root — exactly
|
|
1397
1472
|
* `specs/<facet>/<file>.specification.ts`, never inside a domain.
|
|
1398
1473
|
*
|
|
1399
|
-
*
|
|
1474
|
+
* `depth: 'mirror'`
|
|
1475
|
+
*
|
|
1476
|
+
* The tree mirrors something outside itself (a command tree, a source tree), so
|
|
1477
|
+
* the depth is that structure's and no fixed number can name it. A `*.test.ts`
|
|
1478
|
+
* sits at ANY depth of at least one directory under `specs/`, and is NAMED
|
|
1479
|
+
* AFTER the directory holding it — `<dir>/<dir>.test.ts`. That naming is what
|
|
1480
|
+
* keeps the shape checkable: one test per mirrored node, no test loose at the
|
|
1481
|
+
* specs root. Specification files are unconstrained: a mirror has no facet
|
|
1482
|
+
* level to anchor them to.
|
|
1483
|
+
*
|
|
1484
|
+
* `depth: 'off'` — no placement check; the tree's shape is guarded elsewhere.
|
|
1485
|
+
*
|
|
1486
|
+
* Module tests under `src/` follow the neighbour rule (I2) and are out of scope
|
|
1487
|
+
* in every mode.
|
|
1400
1488
|
*/
|
|
1401
1489
|
const c1DomainStructure = {
|
|
1402
1490
|
create(context) {
|
|
1491
|
+
const depth = context.options[0]?.depth ?? DEFAULT_DEPTH;
|
|
1492
|
+
if (depth === "off") return {};
|
|
1403
1493
|
return { Program(node) {
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1406
|
-
const
|
|
1407
|
-
|
|
1408
|
-
|
|
1494
|
+
const anchor = specsAnchor(context.filename);
|
|
1495
|
+
if (anchor === void 0) return;
|
|
1496
|
+
const base = anchor.relative.at(-1) ?? "";
|
|
1497
|
+
const nesting = anchor.relative.length - 1;
|
|
1498
|
+
if (depth === "mirror") {
|
|
1499
|
+
if (!base.endsWith(TEST_SUFFIX)) return;
|
|
1500
|
+
if (nesting < 1) {
|
|
1501
|
+
context.report({
|
|
1502
|
+
messageId: "testAtSpecsRoot",
|
|
1503
|
+
node
|
|
1504
|
+
});
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
const directory = anchor.relative.at(-2) ?? "";
|
|
1508
|
+
if (base.slice(0, -8) !== directory) context.report({
|
|
1509
|
+
data: { directory },
|
|
1510
|
+
messageId: "testNotMirroringDirectory",
|
|
1511
|
+
node
|
|
1512
|
+
});
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1409
1515
|
if (base.endsWith(TEST_SUFFIX)) {
|
|
1410
|
-
if (
|
|
1516
|
+
if (nesting < 2) context.report({
|
|
1411
1517
|
messageId: "testAtFacetRoot",
|
|
1412
1518
|
node
|
|
1413
1519
|
});
|
|
1414
|
-
else if (
|
|
1520
|
+
else if (nesting > 2) context.report({
|
|
1415
1521
|
messageId: "testTooDeep",
|
|
1416
1522
|
node
|
|
1417
1523
|
});
|
|
1418
1524
|
return;
|
|
1419
1525
|
}
|
|
1420
1526
|
if (base.endsWith(SPECIFICATION_SUFFIX)) {
|
|
1421
|
-
if (
|
|
1527
|
+
if (nesting !== 1) context.report({
|
|
1422
1528
|
messageId: "specNotAtFacetRoot",
|
|
1423
1529
|
node
|
|
1424
1530
|
});
|
|
@@ -1426,18 +1532,119 @@ const c1DomainStructure = {
|
|
|
1426
1532
|
} };
|
|
1427
1533
|
},
|
|
1428
1534
|
meta: {
|
|
1535
|
+
defaultOptions: [{ depth: DEFAULT_DEPTH }],
|
|
1429
1536
|
docs: RULE_DOCS["c1-domain-structure"],
|
|
1430
1537
|
messages: {
|
|
1431
1538
|
specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/10-linting.md).",
|
|
1432
1539
|
testAtFacetRoot: "A `*.test.ts` must live in a domain folder: `specs/<facet>/<domain>/<aspect>.test.ts` — tests directly at the facet root are forbidden (C1 — see docs/10-linting.md).",
|
|
1540
|
+
testAtSpecsRoot: "A `*.test.ts` must live in a directory under specs/ — with `depth: \"mirror\"` the tree mirrors a structure, and the specs root mirrors nothing (C1 — see docs/10-linting.md).",
|
|
1541
|
+
testNotMirroringDirectory: "A `*.test.ts` must be named after the directory holding it — `{{directory}}/{{directory}}.test.ts` — with `depth: \"mirror\"` (C1 — see docs/10-linting.md).",
|
|
1433
1542
|
testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (C1 — see docs/10-linting.md)."
|
|
1434
1543
|
},
|
|
1544
|
+
schema: [{
|
|
1545
|
+
additionalProperties: false,
|
|
1546
|
+
properties: { depth: {
|
|
1547
|
+
enum: [
|
|
1548
|
+
"facet-domain",
|
|
1549
|
+
"mirror",
|
|
1550
|
+
"off"
|
|
1551
|
+
],
|
|
1552
|
+
type: "string"
|
|
1553
|
+
} },
|
|
1554
|
+
type: "object"
|
|
1555
|
+
}],
|
|
1556
|
+
type: "problem"
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
//#endregion
|
|
1560
|
+
//#region src/lint/rules/c10-contracts-boundary.ts
|
|
1561
|
+
/** A specifier reaching into a provider folder — the internal half of contracts/. */
|
|
1562
|
+
const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
|
|
1563
|
+
/**
|
|
1564
|
+
* CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
|
|
1565
|
+
* a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
|
|
1566
|
+
* named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
|
|
1567
|
+
* and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
|
|
1568
|
+
* importing a unit is an error — a test routes through the facade, so a
|
|
1569
|
+
* scenario is named once, next to the world it derives from.
|
|
1570
|
+
*/
|
|
1571
|
+
const c10ContractsBoundary = {
|
|
1572
|
+
create(context) {
|
|
1573
|
+
const parts = segments(context.physicalFilename);
|
|
1574
|
+
if (!parts.includes("specs")) return {};
|
|
1575
|
+
if (parts.slice(0, -1).includes("contracts")) return {};
|
|
1576
|
+
return importSourceVisitor(({ node, source }) => {
|
|
1577
|
+
if (INTERNAL_UNIT.test(source)) context.report({
|
|
1578
|
+
data: { source },
|
|
1579
|
+
messageId: "internal",
|
|
1580
|
+
node
|
|
1581
|
+
});
|
|
1582
|
+
});
|
|
1583
|
+
},
|
|
1584
|
+
meta: {
|
|
1585
|
+
docs: RULE_DOCS["c10-contracts-boundary"],
|
|
1586
|
+
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)." },
|
|
1587
|
+
type: "problem"
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
//#endregion
|
|
1591
|
+
//#region src/lint/rules/c11-contract-data-pairing.ts
|
|
1592
|
+
const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
|
|
1593
|
+
/** The provider folders that may carry data files. */
|
|
1594
|
+
const PROVIDERS$1 = [
|
|
1595
|
+
"anthropic",
|
|
1596
|
+
"http",
|
|
1597
|
+
"openai"
|
|
1598
|
+
];
|
|
1599
|
+
/** Data files: served payloads and matched inputs, both owned by a contract. */
|
|
1600
|
+
const DATA_SUFFIXES = [".request.ts", ".response.json"];
|
|
1601
|
+
/** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
|
|
1602
|
+
function ownerOf(entry) {
|
|
1603
|
+
return `${entry.slice(0, entry.indexOf("."))}.ts`;
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
|
|
1607
|
+
* `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
|
|
1608
|
+
* pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
|
|
1609
|
+
* up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
|
|
1610
|
+
* file with no owner is dead weight no test can reach — reported on the feature's
|
|
1611
|
+
* test file, since oxlint never visits a `.json`.
|
|
1612
|
+
*/
|
|
1613
|
+
const c11ContractDataPairing = {
|
|
1614
|
+
create(context) {
|
|
1615
|
+
const file = context.physicalFilename;
|
|
1616
|
+
if (!TEST_FILE$11.test(file) || !segments(file).includes("specs")) return {};
|
|
1617
|
+
return { Program(node) {
|
|
1618
|
+
const contractsDir = (0, node_path.join)((0, node_path.dirname)(file), "contracts");
|
|
1619
|
+
for (const provider of PROVIDERS$1) {
|
|
1620
|
+
const entries = listDirectory((0, node_path.join)(contractsDir, provider));
|
|
1621
|
+
if (entries === null) continue;
|
|
1622
|
+
const present = new Set(entries);
|
|
1623
|
+
for (const entry of entries) {
|
|
1624
|
+
if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
|
|
1625
|
+
const owner = ownerOf(entry);
|
|
1626
|
+
if (!present.has(owner)) context.report({
|
|
1627
|
+
data: {
|
|
1628
|
+
entry,
|
|
1629
|
+
owner,
|
|
1630
|
+
provider
|
|
1631
|
+
},
|
|
1632
|
+
messageId: "orphan",
|
|
1633
|
+
node
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
} };
|
|
1638
|
+
},
|
|
1639
|
+
meta: {
|
|
1640
|
+
docs: RULE_DOCS["c11-contract-data-pairing"],
|
|
1641
|
+
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)." },
|
|
1435
1642
|
type: "problem"
|
|
1436
1643
|
}
|
|
1437
1644
|
};
|
|
1438
1645
|
//#endregion
|
|
1439
1646
|
//#region src/lint/rules/c2-http-only-requests.ts
|
|
1440
|
-
const TEST_FILE$
|
|
1647
|
+
const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
|
|
1441
1648
|
/**
|
|
1442
1649
|
* CONVENTIONS C2 — `requests/` contains only `.http` files (a request file is
|
|
1443
1650
|
* a COMPLETE request: method, path, headers, body). Anchored on the feature's
|
|
@@ -1448,7 +1655,7 @@ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
|
|
|
1448
1655
|
const c2HttpOnlyRequests = {
|
|
1449
1656
|
create(context) {
|
|
1450
1657
|
const file = context.physicalFilename;
|
|
1451
|
-
if (!TEST_FILE$
|
|
1658
|
+
if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
|
|
1452
1659
|
return { Program(node) {
|
|
1453
1660
|
const requestsDir = (0, node_path.join)((0, node_path.dirname)(file), "requests");
|
|
1454
1661
|
for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
|
|
@@ -1466,65 +1673,217 @@ const c2HttpOnlyRequests = {
|
|
|
1466
1673
|
};
|
|
1467
1674
|
//#endregion
|
|
1468
1675
|
//#region src/lint/rules/c4-contract-shape.ts
|
|
1469
|
-
/**
|
|
1676
|
+
/** The only directories a `contracts/` root may hold — the provider carriers. */
|
|
1470
1677
|
const PROVIDERS = /* @__PURE__ */ new Set([
|
|
1471
1678
|
"anthropic",
|
|
1472
1679
|
"http",
|
|
1473
1680
|
"openai"
|
|
1474
1681
|
]);
|
|
1475
|
-
const
|
|
1476
|
-
/**
|
|
1477
|
-
|
|
1478
|
-
|
|
1682
|
+
const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
|
|
1683
|
+
/** A file oxlint itself visits — the AST half of the rule reports on those. */
|
|
1684
|
+
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
1685
|
+
/** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
|
|
1686
|
+
const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
|
|
1687
|
+
/** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
|
|
1688
|
+
const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
|
|
1689
|
+
/** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
|
|
1690
|
+
const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
|
|
1691
|
+
/** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
|
|
1692
|
+
const RESPONSE_DATA_FILE = /\.response\.json$/;
|
|
1693
|
+
/** The name a specifier/declaration exports under, when it is `default`. */
|
|
1694
|
+
function exportsDefault(node) {
|
|
1695
|
+
return (node.specifiers ?? []).some((specifier) => {
|
|
1696
|
+
const exported = specifier.exported;
|
|
1697
|
+
return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
/** Is this node a `defineContract(...)` call? */
|
|
1701
|
+
function isDefineContract(node) {
|
|
1702
|
+
const callee = node.callee;
|
|
1703
|
+
return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
|
|
1704
|
+
}
|
|
1705
|
+
/** A default export that may legitimately produce a contract (value or factory). */
|
|
1706
|
+
function producesContract(declaration) {
|
|
1707
|
+
if (declaration === void 0) return false;
|
|
1708
|
+
return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
|
|
1479
1709
|
}
|
|
1480
1710
|
/**
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
* anthropic, http }`, a single `export default defineContract(...)`, no named
|
|
1484
|
-
* exports, and imports only from the public entry. The runtime channel (the
|
|
1485
|
-
* loader) only catches a bad default export; this rule closes the rest.
|
|
1711
|
+
* The provider a `request:` value implies, when the builder is written as
|
|
1712
|
+
* `<provider>.<verb>(…)` — the only statically decidable form.
|
|
1486
1713
|
*/
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1714
|
+
function requestProvider(request) {
|
|
1715
|
+
if (request?.type !== "CallExpression") return;
|
|
1716
|
+
const callee = request.callee;
|
|
1717
|
+
if (callee?.type !== "MemberExpression") return;
|
|
1718
|
+
const object = callee.object;
|
|
1719
|
+
return object?.type === "Identifier" ? object.name : void 0;
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* The layout half — walks the feature's `contracts/` tree from the test file
|
|
1723
|
+
* that owns it. Covers what oxlint never visits: a stray `.json` at the root,
|
|
1724
|
+
* a directory that is not a provider, a nested folder or a foreign extension
|
|
1725
|
+
* inside a provider folder.
|
|
1726
|
+
*/
|
|
1727
|
+
function checkLayout(context, node, contractsDir) {
|
|
1728
|
+
const entries = listDirectory(contractsDir);
|
|
1729
|
+
if (entries === null) return;
|
|
1730
|
+
for (const entry of entries) {
|
|
1731
|
+
const path = (0, node_path.join)(contractsDir, entry);
|
|
1732
|
+
if (!isDirectory(path)) {
|
|
1733
|
+
if (!SOURCE_FILE.test(entry)) context.report({
|
|
1734
|
+
data: { entry },
|
|
1735
|
+
messageId: "rootEntry",
|
|
1497
1736
|
node
|
|
1498
1737
|
});
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1738
|
+
continue;
|
|
1739
|
+
}
|
|
1740
|
+
if (!PROVIDERS.has(entry)) {
|
|
1741
|
+
context.report({
|
|
1742
|
+
data: { entry },
|
|
1743
|
+
messageId: "badProviderDir",
|
|
1503
1744
|
node
|
|
1504
1745
|
});
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
for (const child of listDirectory(path) ?? []) if (isDirectory((0, node_path.join)(path, child))) context.report({
|
|
1749
|
+
data: {
|
|
1750
|
+
child,
|
|
1751
|
+
provider: entry
|
|
1752
|
+
},
|
|
1753
|
+
messageId: "providerSubfolder",
|
|
1754
|
+
node
|
|
1755
|
+
});
|
|
1756
|
+
else if (!/\.[cm]?ts$/u.test(child) && !RESPONSE_DATA_FILE.test(child)) context.report({
|
|
1757
|
+
data: {
|
|
1758
|
+
child,
|
|
1759
|
+
provider: entry
|
|
1760
|
+
},
|
|
1761
|
+
messageId: "providerEntry",
|
|
1762
|
+
node
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
/** The facade: `<kebab>.contracts.ts`, default-exporting a composition. */
|
|
1767
|
+
function checkComposite(context, program, base) {
|
|
1768
|
+
if (!COMPOSITE_FILE.test(base)) {
|
|
1769
|
+
context.report({
|
|
1770
|
+
data: { base },
|
|
1771
|
+
messageId: "rootFile",
|
|
1772
|
+
node: program
|
|
1773
|
+
});
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
let hasDefault = false;
|
|
1777
|
+
let composed = false;
|
|
1778
|
+
walk(program, (node) => {
|
|
1779
|
+
if (node.type === "ExportDefaultDeclaration") hasDefault = true;
|
|
1780
|
+
else if (node.type === "ExportAllDeclaration" || node.type === "ExportNamedDeclaration") {
|
|
1781
|
+
if (exportsDefault(node)) {
|
|
1518
1782
|
hasDefault = true;
|
|
1519
|
-
|
|
1520
|
-
const callee = declaration?.type === "CallExpression" ? declaration.callee : void 0;
|
|
1521
|
-
if (callee?.type !== "Identifier" || callee.name !== "defineContract") context.report({
|
|
1522
|
-
messageId: "notDefineContract",
|
|
1523
|
-
node: statement
|
|
1524
|
-
});
|
|
1783
|
+
composed = stringValue(node.source) !== void 0 || composed;
|
|
1525
1784
|
}
|
|
1526
|
-
|
|
1527
|
-
|
|
1785
|
+
} else if (node.type === "CallExpression") {
|
|
1786
|
+
const callee = node.callee;
|
|
1787
|
+
if (callee?.type === "Identifier" && callee.name === "defineContracts" || callee !== void 0 && memberPropertyName(callee) === "with") composed = true;
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
if (!hasDefault) context.report({
|
|
1791
|
+
data: { base },
|
|
1792
|
+
messageId: "missingComposite",
|
|
1793
|
+
node: program
|
|
1794
|
+
});
|
|
1795
|
+
else if (!composed) context.report({
|
|
1796
|
+
data: { base },
|
|
1797
|
+
messageId: "notDefineContracts",
|
|
1798
|
+
node: program
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
/** A unit contract under its provider folder. */
|
|
1802
|
+
function checkUnit(context, program, base, provider) {
|
|
1803
|
+
if (REQUEST_DATA_FILE.test(base)) return;
|
|
1804
|
+
if (!UNIT_FILE.test(base)) {
|
|
1805
|
+
context.report({
|
|
1806
|
+
data: { base },
|
|
1807
|
+
messageId: "badName",
|
|
1808
|
+
node: program
|
|
1809
|
+
});
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
let hasDefault = false;
|
|
1813
|
+
let wellFormed = false;
|
|
1814
|
+
walk(program, (node) => {
|
|
1815
|
+
if (node.type === "ExportDefaultDeclaration") {
|
|
1816
|
+
hasDefault = true;
|
|
1817
|
+
wellFormed = producesContract(node.declaration) || wellFormed;
|
|
1818
|
+
} else if (node.type === "ExportNamedDeclaration" && exportsDefault(node)) {
|
|
1819
|
+
hasDefault = true;
|
|
1820
|
+
wellFormed = true;
|
|
1821
|
+
}
|
|
1822
|
+
if (isDefineContract(node)) {
|
|
1823
|
+
const argument = node.arguments?.[0];
|
|
1824
|
+
const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
|
|
1825
|
+
if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
|
|
1826
|
+
data: {
|
|
1827
|
+
declared,
|
|
1828
|
+
provider
|
|
1829
|
+
},
|
|
1830
|
+
messageId: "providerMismatch",
|
|
1831
|
+
node
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
if (!hasDefault) context.report({
|
|
1836
|
+
data: { base },
|
|
1837
|
+
messageId: "missingDefault",
|
|
1838
|
+
node: program
|
|
1839
|
+
});
|
|
1840
|
+
else if (!wellFormed) context.report({
|
|
1841
|
+
data: { base },
|
|
1842
|
+
messageId: "notDefineContract",
|
|
1843
|
+
node: program
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* CONVENTIONS C4 — the structure of a feature's `contracts/` tree. The ROOT
|
|
1848
|
+
* holds only `*.contracts.ts` facades (default export = a `defineContracts`
|
|
1849
|
+
* composition, named exports = scenario factories) and the provider
|
|
1850
|
+
* directories `http` / `openai` / `anthropic`. A provider directory holds
|
|
1851
|
+
* `<kebab>.ts` unit contracts (default export = `defineContract(...)` or a
|
|
1852
|
+
* factory returning one) plus their sibling data (`*.response.json`,
|
|
1853
|
+
* `*.request.ts`) — the FOLDER carries the provider, so a unit whose `request`
|
|
1854
|
+
* builder names another provider is an error.
|
|
1855
|
+
*
|
|
1856
|
+
* Two halves: the AST half reports on each contract file it visits, the layout
|
|
1857
|
+
* half walks the tree from the feature's test file (oxlint never visits a
|
|
1858
|
+
* `.json` fixture or an empty directory).
|
|
1859
|
+
*/
|
|
1860
|
+
const c4ContractShape = {
|
|
1861
|
+
create(context) {
|
|
1862
|
+
const file = context.physicalFilename;
|
|
1863
|
+
const parts = segments(file);
|
|
1864
|
+
if (!parts.includes("specs")) return {};
|
|
1865
|
+
const contractsIndex = parts.lastIndexOf("contracts");
|
|
1866
|
+
if (contractsIndex === -1) {
|
|
1867
|
+
if (!TEST_FILE$9.test(file)) return {};
|
|
1868
|
+
return { Program(node) {
|
|
1869
|
+
checkLayout(context, node, (0, node_path.join)((0, node_path.dirname)(file), "contracts"));
|
|
1870
|
+
} };
|
|
1871
|
+
}
|
|
1872
|
+
const base = parts.at(-1) ?? "";
|
|
1873
|
+
const depth = parts.length - 1 - contractsIndex;
|
|
1874
|
+
if (depth === 1) return { Program(node) {
|
|
1875
|
+
checkComposite(context, node, base);
|
|
1876
|
+
} };
|
|
1877
|
+
if (depth === 2) {
|
|
1878
|
+
const provider = parts[contractsIndex + 1];
|
|
1879
|
+
return PROVIDERS.has(provider) ? { Program(node) {
|
|
1880
|
+
checkUnit(context, node, base, provider);
|
|
1881
|
+
} } : {};
|
|
1882
|
+
}
|
|
1883
|
+
return { Program(node) {
|
|
1884
|
+
context.report({
|
|
1885
|
+
data: { base },
|
|
1886
|
+
messageId: "tooDeep",
|
|
1528
1887
|
node
|
|
1529
1888
|
});
|
|
1530
1889
|
} };
|
|
@@ -1532,12 +1891,18 @@ const c4ContractShape = {
|
|
|
1532
1891
|
meta: {
|
|
1533
1892
|
docs: RULE_DOCS["c4-contract-shape"],
|
|
1534
1893
|
messages: {
|
|
1535
|
-
badName: "Contract
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
notDefineContract: "The default export of a contract
|
|
1540
|
-
|
|
1894
|
+
badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts — the folder already carries the provider (C4 — see docs/10-linting.md).",
|
|
1895
|
+
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).",
|
|
1896
|
+
missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/10-linting.md).",
|
|
1897
|
+
missingDefault: "Contract unit \"{{base}}\" has no default export — a unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/10-linting.md).",
|
|
1898
|
+
notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/10-linting.md).",
|
|
1899
|
+
notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/10-linting.md).",
|
|
1900
|
+
providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/10-linting.md).",
|
|
1901
|
+
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).",
|
|
1902
|
+
providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/10-linting.md).",
|
|
1903
|
+
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).",
|
|
1904
|
+
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).",
|
|
1905
|
+
tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/10-linting.md)."
|
|
1541
1906
|
},
|
|
1542
1907
|
type: "problem"
|
|
1543
1908
|
}
|
|
@@ -1637,22 +2002,9 @@ const VERB_ROOTS = {
|
|
|
1637
2002
|
function looksLikePath(value) {
|
|
1638
2003
|
return value.length > 0 && !/\s/u.test(value);
|
|
1639
2004
|
}
|
|
1640
|
-
/** Nearest ancestor directory named `specs` — the pool root for `$FIXTURES/`. */
|
|
1641
|
-
function specsRoot(file) {
|
|
1642
|
-
let dir = (0, node_path.dirname)(file);
|
|
1643
|
-
for (;;) {
|
|
1644
|
-
if ((0, node_path.basename)(dir) === "specs") return dir;
|
|
1645
|
-
const parent = (0, node_path.dirname)(dir);
|
|
1646
|
-
if (parent === dir) return;
|
|
1647
|
-
dir = parent;
|
|
1648
|
-
}
|
|
1649
|
-
}
|
|
1650
2005
|
/** Resolve the on-disk target a `.fixture()` literal points at, if determinable. */
|
|
1651
|
-
function resolveFixture(argument, featureDir,
|
|
1652
|
-
if (argument.startsWith("$FIXTURES/"))
|
|
1653
|
-
const root = specsRoot(file);
|
|
1654
|
-
return root === void 0 ? void 0 : (0, node_path.join)(root, "fixtures", argument.slice(10));
|
|
1655
|
-
}
|
|
2006
|
+
function resolveFixture(argument, featureDir, specsDir) {
|
|
2007
|
+
if (argument.startsWith("$FIXTURES/")) return (0, node_path.join)(specsDir, "fixtures", argument.slice(10));
|
|
1656
2008
|
if (argument.startsWith("$")) return;
|
|
1657
2009
|
return (0, node_path.join)(featureDir, "fixtures", argument);
|
|
1658
2010
|
}
|
|
@@ -1667,7 +2019,8 @@ function resolveFixture(argument, featureDir, file) {
|
|
|
1667
2019
|
const c8ReferencedFixtureExists = {
|
|
1668
2020
|
create(context) {
|
|
1669
2021
|
const file = context.physicalFilename;
|
|
1670
|
-
|
|
2022
|
+
const anchor = specsAnchor(file);
|
|
2023
|
+
if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
|
|
1671
2024
|
const featureDir = (0, node_path.dirname)(file);
|
|
1672
2025
|
return { CallExpression(node) {
|
|
1673
2026
|
const callee = node.callee;
|
|
@@ -1677,7 +2030,7 @@ const c8ReferencedFixtureExists = {
|
|
|
1677
2030
|
if (verb === "fixture") {
|
|
1678
2031
|
const value = stringValue(args[0]);
|
|
1679
2032
|
if (value === void 0 || !looksLikePath(value)) return;
|
|
1680
|
-
const target = resolveFixture(value.replace(/\/+$/, ""), featureDir,
|
|
2033
|
+
const target = resolveFixture(value.replace(/\/+$/, ""), featureDir, anchor.directory);
|
|
1681
2034
|
if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
|
|
1682
2035
|
data: {
|
|
1683
2036
|
path: value,
|
|
@@ -2290,7 +2643,7 @@ const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
|
|
|
2290
2643
|
const f2NoTestImportsInProd = {
|
|
2291
2644
|
create(context) {
|
|
2292
2645
|
const file = context.filename;
|
|
2293
|
-
if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) ||
|
|
2646
|
+
if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) || isUnderSpecs(file)) return {};
|
|
2294
2647
|
return { ...importSourceVisitor(({ node, source }) => {
|
|
2295
2648
|
if (source === TOOL_SUBPATH$1) return;
|
|
2296
2649
|
if (source === "vitest" || source.startsWith("vitest/")) context.report({
|
|
@@ -2348,10 +2701,9 @@ const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
|
|
|
2348
2701
|
*/
|
|
2349
2702
|
const f3SpecsPublicEntry = {
|
|
2350
2703
|
create(context) {
|
|
2351
|
-
const
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
const underIntegrations = parts[specsIndex + 1] === "integrations";
|
|
2704
|
+
const anchor = specsAnchor(context.filename);
|
|
2705
|
+
if (anchor === void 0) return {};
|
|
2706
|
+
const underIntegrations = anchor.relative[0] === "integrations";
|
|
2355
2707
|
return { ...importSourceVisitor(({ node, source }) => {
|
|
2356
2708
|
if (source.startsWith(`${PACKAGE}/`)) {
|
|
2357
2709
|
if (source === TOOL_SUBPATH) return;
|
|
@@ -2434,36 +2786,6 @@ const f5FixturesOnlyFromTests = {
|
|
|
2434
2786
|
};
|
|
2435
2787
|
//#endregion
|
|
2436
2788
|
//#region src/lint/rules/i1-layer-boundaries.ts
|
|
2437
|
-
/**
|
|
2438
|
-
* One folder = one external dependency (I1 — see docs/10-linting.md): the packages each
|
|
2439
|
-
* `src/integrations/<folder>/` may import.
|
|
2440
|
-
*/
|
|
2441
|
-
const INTEGRATION_DEPS = {
|
|
2442
|
-
anthropic: ["@anthropic-ai/sdk"],
|
|
2443
|
-
appium: ["webdriverio"],
|
|
2444
|
-
compose: ["yaml"],
|
|
2445
|
-
docker: [],
|
|
2446
|
-
hono: ["hono", "@hono/node-server"],
|
|
2447
|
-
msw: ["msw"],
|
|
2448
|
-
openai: ["openai"],
|
|
2449
|
-
playwright: ["playwright"],
|
|
2450
|
-
postgres: ["pg"],
|
|
2451
|
-
redis: ["redis"],
|
|
2452
|
-
sqlite: ["better-sqlite3"],
|
|
2453
|
-
testcontainers: ["testcontainers"]
|
|
2454
|
-
};
|
|
2455
|
-
/** External packages the vitest layer (ALL runner coupling) may import. */
|
|
2456
|
-
const VITEST_DEPS = [
|
|
2457
|
-
"vitest",
|
|
2458
|
-
"vitest-mock-extended",
|
|
2459
|
-
"mockdate"
|
|
2460
|
-
];
|
|
2461
|
-
/** The pure core helpers the tool-facing lint layer may reach (I1). */
|
|
2462
|
-
const LINT_CORE_WHITELIST = /* @__PURE__ */ new Set([
|
|
2463
|
-
"core/matching/match",
|
|
2464
|
-
"core/specification/shared/binding",
|
|
2465
|
-
"core/specification/shared/fixtures"
|
|
2466
|
-
]);
|
|
2467
2789
|
const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/;
|
|
2468
2790
|
function matchesPackage(source, packages) {
|
|
2469
2791
|
return packages.some((name) => source === name || source.startsWith(`${name}/`));
|
|
@@ -2474,64 +2796,68 @@ function pathInsideSrc(path) {
|
|
|
2474
2796
|
const srcIndex = parts.lastIndexOf("src");
|
|
2475
2797
|
return srcIndex === -1 ? void 0 : parts.slice(srcIndex + 1).join("/");
|
|
2476
2798
|
}
|
|
2477
|
-
/** Strip a `.js`/`.ts`-style extension for
|
|
2799
|
+
/** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
|
|
2478
2800
|
function withoutExtension(path) {
|
|
2479
2801
|
return path.replace(/\.[cm]?[jt]sx?$/, "");
|
|
2480
2802
|
}
|
|
2803
|
+
/** Does `target` match one of the declared paths (prefix, or exact module)? */
|
|
2804
|
+
function matchesPath(target, patterns) {
|
|
2805
|
+
return patterns.some((pattern) => pattern.endsWith("/") ? target.startsWith(pattern) : withoutExtension(target) === withoutExtension(pattern));
|
|
2806
|
+
}
|
|
2481
2807
|
/**
|
|
2482
|
-
* CONVENTIONS I1 — the
|
|
2808
|
+
* CONVENTIONS I1 — the layers under `src/` and their sanctioned edges, DECLARED
|
|
2809
|
+
* by the project:
|
|
2810
|
+
*
|
|
2811
|
+
* 'jterrazz/i1-layer-boundaries': ['error', {
|
|
2812
|
+
* layers: {
|
|
2813
|
+
* core: { imports: ['core/', 'integrations/docker/'] },
|
|
2814
|
+
* integrations: { folders: { postgres: ['pg'] }, imports: ['core/'] },
|
|
2815
|
+
* },
|
|
2816
|
+
* }]
|
|
2483
2817
|
*
|
|
2484
|
-
*
|
|
2485
|
-
*
|
|
2486
|
-
*
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2489
|
-
*
|
|
2490
|
-
*
|
|
2491
|
-
* - `lint/` — zero runtime imports: no external packages, and from `core/`
|
|
2492
|
-
* only the pure helpers (token list, case conversions, fixture markers).
|
|
2818
|
+
* With no layer map the rule is INERT. It used to ship the framework's own
|
|
2819
|
+
* architecture as its law — four layers named `core` / `integrations` / `lint`
|
|
2820
|
+
* / `vitest`, and the framework's own dependency table — and applied it to every
|
|
2821
|
+
* consumer that enabled the catalogue. A consumer with those directory names
|
|
2822
|
+
* was judged against a map describing a different package; a consumer with any
|
|
2823
|
+
* other architecture got a rule that could never say anything true. An
|
|
2824
|
+
* architecture is the project's to state.
|
|
2493
2825
|
*
|
|
2494
|
-
*
|
|
2495
|
-
*
|
|
2826
|
+
* Per layer: `packages` are the external dependencies it may import, `imports`
|
|
2827
|
+
* the paths inside `src/` it may reach (a `foo/` prefix, or an exact module
|
|
2828
|
+
* path), `folders` expresses "one folder = one external dependency" (each child
|
|
2829
|
+
* folder may import its own packages and its own files), and `seams` opens a
|
|
2830
|
+
* named module to an extra edge — the lazy import a layer allows exactly once.
|
|
2831
|
+
*
|
|
2832
|
+
* A file under a layer the map does not name is out of scope, as are module
|
|
2833
|
+
* tests and `*.fixtures.ts` files (F2/I4 govern those), and any file outside
|
|
2834
|
+
* `src/`.
|
|
2496
2835
|
*/
|
|
2497
2836
|
const i1LayerBoundaries = {
|
|
2498
2837
|
create(context) {
|
|
2838
|
+
const layers = context.options[0]?.layers;
|
|
2839
|
+
if (layers === void 0 || Object.keys(layers).length === 0) return {};
|
|
2499
2840
|
const file = context.physicalFilename;
|
|
2500
2841
|
if (TEST_OR_FIXTURES.test(file)) return {};
|
|
2501
2842
|
const inside = pathInsideSrc(file);
|
|
2502
2843
|
const layer = inside?.split("/")[0];
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
const
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
if (layer === "lint") return "lintRuntime";
|
|
2513
|
-
if (layer === "vitest") return matchesPackage(source, VITEST_DEPS) ? null : "foreignDependency";
|
|
2514
|
-
return matchesPackage(source, INTEGRATION_DEPS[integrationFolder ?? ""] ?? []) ? null : "foreignDependency";
|
|
2515
|
-
};
|
|
2516
|
-
const checkInternal = (target) => {
|
|
2517
|
-
if (layer === "core") {
|
|
2518
|
-
if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
|
|
2519
|
-
if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
|
|
2520
|
-
if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
|
|
2521
|
-
if (target.startsWith("integrations/appium/") && inside === "core/specification/mobile/start-mobile.ts") return null;
|
|
2522
|
-
return "crossLayer";
|
|
2523
|
-
}
|
|
2524
|
-
if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
|
|
2525
|
-
if (layer === "vitest") return target.startsWith("core/") || target.startsWith("vitest/") || target.startsWith("integrations/docker/") ? null : "crossLayer";
|
|
2526
|
-
return target.startsWith("lint/") || LINT_CORE_WHITELIST.has(withoutExtension(target)) ? null : "lintRuntime";
|
|
2844
|
+
const rules = layer === void 0 ? void 0 : layers[layer];
|
|
2845
|
+
if (inside === void 0 || layer === void 0 || rules === void 0) return {};
|
|
2846
|
+
const folder = rules.folders === void 0 ? void 0 : inside.split("/")[1];
|
|
2847
|
+
const ownPackages = [...rules.packages ?? [], ...rules.folders?.[folder ?? ""] ?? []];
|
|
2848
|
+
const ownImports = [...rules.imports ?? [], ...rules.seams?.[inside] ?? []];
|
|
2849
|
+
const allowsExternal = (source) => matchesPackage(source, ownPackages);
|
|
2850
|
+
const allowsInternal = (target) => {
|
|
2851
|
+
if (matchesPath(target, ownImports)) return true;
|
|
2852
|
+
return folder !== void 0 && target.startsWith(`${layer}/${folder}/`);
|
|
2527
2853
|
};
|
|
2528
2854
|
return { ...importSourceVisitor(({ node, source }) => {
|
|
2529
2855
|
if (source.startsWith("node:")) return;
|
|
2530
2856
|
let messageId;
|
|
2531
2857
|
if (source.startsWith(".")) {
|
|
2532
2858
|
const target = pathInsideSrc((0, node_path.resolve)((0, node_path.dirname)(file), source));
|
|
2533
|
-
messageId = target === void 0 ? "crossLayer" :
|
|
2534
|
-
} else messageId =
|
|
2859
|
+
messageId = target === void 0 || !allowsInternal(target) ? "crossLayer" : null;
|
|
2860
|
+
} else messageId = allowsExternal(source) ? null : "foreignDependency";
|
|
2535
2861
|
if (messageId !== null) context.report({
|
|
2536
2862
|
data: {
|
|
2537
2863
|
layer,
|
|
@@ -2543,13 +2869,47 @@ const i1LayerBoundaries = {
|
|
|
2543
2869
|
}) };
|
|
2544
2870
|
},
|
|
2545
2871
|
meta: {
|
|
2872
|
+
defaultOptions: [{ layers: {} }],
|
|
2546
2873
|
docs: RULE_DOCS["i1-layer-boundaries"],
|
|
2547
2874
|
messages: {
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
foreignDependency: "\"{{source}}\" is not this folder's own dependency — one integrations folder = one external dependency (I1 — see docs/10-linting.md).",
|
|
2551
|
-
lintRuntime: "The lint layer imports nothing from the framework runtime — \"{{source}}\" is outside its pure-helper whitelist (I1 — see docs/10-linting.md)."
|
|
2875
|
+
crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/10-linting.md).",
|
|
2876
|
+
foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/10-linting.md)."
|
|
2552
2877
|
},
|
|
2878
|
+
schema: [{
|
|
2879
|
+
additionalProperties: false,
|
|
2880
|
+
properties: { layers: {
|
|
2881
|
+
additionalProperties: {
|
|
2882
|
+
additionalProperties: false,
|
|
2883
|
+
properties: {
|
|
2884
|
+
folders: {
|
|
2885
|
+
additionalProperties: {
|
|
2886
|
+
items: { type: "string" },
|
|
2887
|
+
type: "array"
|
|
2888
|
+
},
|
|
2889
|
+
type: "object"
|
|
2890
|
+
},
|
|
2891
|
+
imports: {
|
|
2892
|
+
items: { type: "string" },
|
|
2893
|
+
type: "array"
|
|
2894
|
+
},
|
|
2895
|
+
packages: {
|
|
2896
|
+
items: { type: "string" },
|
|
2897
|
+
type: "array"
|
|
2898
|
+
},
|
|
2899
|
+
seams: {
|
|
2900
|
+
additionalProperties: {
|
|
2901
|
+
items: { type: "string" },
|
|
2902
|
+
type: "array"
|
|
2903
|
+
},
|
|
2904
|
+
type: "object"
|
|
2905
|
+
}
|
|
2906
|
+
},
|
|
2907
|
+
type: "object"
|
|
2908
|
+
},
|
|
2909
|
+
type: "object"
|
|
2910
|
+
} },
|
|
2911
|
+
type: "object"
|
|
2912
|
+
}],
|
|
2553
2913
|
type: "problem"
|
|
2554
2914
|
}
|
|
2555
2915
|
};
|
|
@@ -2611,10 +2971,48 @@ const i2SiblingTestNaming = {
|
|
|
2611
2971
|
//#endregion
|
|
2612
2972
|
//#region src/lint/rules/i4-no-vi-mock-in-src.ts
|
|
2613
2973
|
const TEST_FILE = /\.test\.[cm]?[jt]sx?$/;
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2974
|
+
const DATA_ASSET = new RegExp(`\\.(?:${[
|
|
2975
|
+
"avif",
|
|
2976
|
+
"bin",
|
|
2977
|
+
"bmp",
|
|
2978
|
+
"csv",
|
|
2979
|
+
"gif",
|
|
2980
|
+
"graphql",
|
|
2981
|
+
"gql",
|
|
2982
|
+
"gz",
|
|
2983
|
+
"htm",
|
|
2984
|
+
"html",
|
|
2985
|
+
"ico",
|
|
2986
|
+
"ini",
|
|
2987
|
+
"jpeg",
|
|
2988
|
+
"jpg",
|
|
2989
|
+
"json",
|
|
2990
|
+
"json5",
|
|
2991
|
+
"jsonc",
|
|
2992
|
+
"jsonl",
|
|
2993
|
+
"md",
|
|
2994
|
+
"mdx",
|
|
2995
|
+
"ndjson",
|
|
2996
|
+
"pdf",
|
|
2997
|
+
"png",
|
|
2998
|
+
"proto",
|
|
2999
|
+
"sql",
|
|
3000
|
+
"svg",
|
|
3001
|
+
"tar",
|
|
3002
|
+
"toml",
|
|
3003
|
+
"tsv",
|
|
3004
|
+
"txt",
|
|
3005
|
+
"wasm",
|
|
3006
|
+
"webp",
|
|
3007
|
+
"xml",
|
|
3008
|
+
"yaml",
|
|
3009
|
+
"yml",
|
|
3010
|
+
"zip"
|
|
3011
|
+
].join("|")})$`, "i");
|
|
3012
|
+
/** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
|
|
3013
|
+
function withoutSuffix(source) {
|
|
3014
|
+
return source.replace(/[?#].*$/, "");
|
|
3015
|
+
}
|
|
2618
3016
|
/**
|
|
2619
3017
|
* CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
|
|
2620
3018
|
* `mockOf`/`mockOfDate` inline, large payloads in a `*.fixtures.ts` neighbour.
|
|
@@ -2622,8 +3020,11 @@ const HAS_EXTENSION = /\.[a-z0-9]+$/i;
|
|
|
2622
3020
|
*
|
|
2623
3021
|
* - `vi.mock(…)` calls (module mocking) in any file;
|
|
2624
3022
|
* - files living in a `__mocks__/` or `__fixtures__/` directory;
|
|
2625
|
-
* - a `*.test.ts` importing a
|
|
3023
|
+
* - a `*.test.ts` importing a known data asset (`.json`, `.txt`, `.sql`, …) —
|
|
2626
3024
|
* a test needing a real file is a specification and belongs in `specs/`.
|
|
3025
|
+
*
|
|
3026
|
+
* A specifier whose extension is not on the data list is CODE, dotted or not:
|
|
3027
|
+
* `<subject>.<role>` module names are a naming convention, not a file type.
|
|
2627
3028
|
*/
|
|
2628
3029
|
const i4NoViMockInSrc = {
|
|
2629
3030
|
create(context) {
|
|
@@ -2651,7 +3052,7 @@ const i4NoViMockInSrc = {
|
|
|
2651
3052
|
}
|
|
2652
3053
|
};
|
|
2653
3054
|
if (isTest) Object.assign(visitor, importSourceVisitor(({ node, source }) => {
|
|
2654
|
-
if (
|
|
3055
|
+
if (DATA_ASSET.test(withoutSuffix(source))) context.report({
|
|
2655
3056
|
data: { source },
|
|
2656
3057
|
messageId: "assetImport",
|
|
2657
3058
|
node
|
|
@@ -2722,7 +3123,7 @@ const j1NoOnlySkip = {
|
|
|
2722
3123
|
*/
|
|
2723
3124
|
const j2NoSleepInSpecs = {
|
|
2724
3125
|
create(context) {
|
|
2725
|
-
if (!
|
|
3126
|
+
if (!isUnderSpecs(context.filename)) return {};
|
|
2726
3127
|
return {
|
|
2727
3128
|
CallExpression(node) {
|
|
2728
3129
|
const callee = node.callee;
|
|
@@ -2954,6 +3355,8 @@ const plugin = {
|
|
|
2954
3355
|
"b8-kebab-trigger": b8KebabTrigger,
|
|
2955
3356
|
"b9w-product-command": b9wProductCommand,
|
|
2956
3357
|
"c1-domain-structure": c1DomainStructure,
|
|
3358
|
+
"c10-contracts-boundary": c10ContractsBoundary,
|
|
3359
|
+
"c11-contract-data-pairing": c11ContractDataPairing,
|
|
2957
3360
|
"c2-http-only-requests": c2HttpOnlyRequests,
|
|
2958
3361
|
"c4-contract-shape": c4ContractShape,
|
|
2959
3362
|
"c6-tomatch-extension": c6ToMatchExtension,
|