@jterrazz/test 14.3.0 → 15.1.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 +11 -11
- package/dist/ambiguity.js +2 -2
- package/dist/appium.adapter.js +34 -25
- package/dist/artifacts.js +503 -525
- package/dist/checker.js +74 -73
- package/dist/index.d.ts +587 -660
- package/dist/index.js +191 -188
- package/dist/match.js +6 -16
- package/dist/oxlint.cjs +681 -873
- package/dist/oxlint.d.cts +7066 -17
- package/dist/oxlint.d.ts +7066 -17
- package/dist/oxlint.js +681 -873
- package/dist/playwright.adapter.js +36 -14
- package/dist/queue.js +27 -28
- package/dist/spec-document.js +19 -25
- package/dist/vitest.d.ts +6 -6
- package/dist/vitest.js +2 -2
- package/package.json +10 -6
package/dist/oxlint.cjs
CHANGED
|
@@ -4,7 +4,6 @@ Object.defineProperties(exports, {
|
|
|
4
4
|
});
|
|
5
5
|
let node_path = require("node:path");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
|
-
let node_url = require("node:url");
|
|
8
7
|
//#region src/lint/fs-cache.ts
|
|
9
8
|
/**
|
|
10
9
|
* Cached filesystem probes for the fs-anchored rules (a9w, c2, c6, c7, i2).
|
|
@@ -70,7 +69,7 @@ function readFileCached(path) {
|
|
|
70
69
|
*/
|
|
71
70
|
/** Split a path into its non-empty segments (posix or win separators). */
|
|
72
71
|
function segments(path) {
|
|
73
|
-
return path.split(/[/\\]/).filter(Boolean);
|
|
72
|
+
return path.split(/[/\\]/u).filter(Boolean);
|
|
74
73
|
}
|
|
75
74
|
/**
|
|
76
75
|
* The `specs/` directory a file belongs to: the NEAREST ancestor named `specs`,
|
|
@@ -112,7 +111,8 @@ function stringValue(node) {
|
|
|
112
111
|
if (node.type === "TemplateLiteral") {
|
|
113
112
|
const expressions = node.expressions;
|
|
114
113
|
const quasis = node.quasis;
|
|
115
|
-
|
|
114
|
+
const onlyQuasi = quasis?.[0];
|
|
115
|
+
if (expressions?.length === 0 && quasis?.length === 1 && onlyQuasi !== void 0) return onlyQuasi.value?.cooked;
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
/** The property name of a non-computed member expression, if identifiable. */
|
|
@@ -198,21 +198,10 @@ function isTestCallee(callee) {
|
|
|
198
198
|
if (callee.type === "CallExpression") return isTestCallee(callee.callee);
|
|
199
199
|
return false;
|
|
200
200
|
}
|
|
201
|
-
/** Does the test callee chain carry a modifier member with the given name? */
|
|
202
|
-
function testCalleeHasModifier(callee, modifier) {
|
|
203
|
-
if (callee === void 0) return false;
|
|
204
|
-
if (callee.type === "MemberExpression") return memberPropertyName(callee) === modifier || testCalleeHasModifier(callee.object, modifier);
|
|
205
|
-
if (callee.type === "CallExpression") return testCalleeHasModifier(callee.callee, modifier);
|
|
206
|
-
return false;
|
|
207
|
-
}
|
|
208
201
|
/** The callback argument of a test call (arrow or function expression), if any. */
|
|
209
202
|
function findTestCallback(args) {
|
|
210
203
|
return args.find((arg) => arg.type === "ArrowFunctionExpression" || arg.type === "FunctionExpression");
|
|
211
204
|
}
|
|
212
|
-
/** The static string name of a test call (`test('name', …)`), if a literal. */
|
|
213
|
-
function testName(callNode) {
|
|
214
|
-
return stringValue((callNode.arguments ?? [])[0]);
|
|
215
|
-
}
|
|
216
205
|
/**
|
|
217
206
|
* Visitor fragment collecting every import-like source: static imports,
|
|
218
207
|
* dynamic `import()`, and re-exports. Rules spread this into their visitor.
|
|
@@ -239,6 +228,26 @@ function importSourceVisitor(onSource) {
|
|
|
239
228
|
* The **statique** channel — one entry per shipped `jterrazz/*` rule. Each rule
|
|
240
229
|
* file attaches its entry as `meta.docs`, so a rule and its normative text can
|
|
241
230
|
* never drift (the completeness meta-test asserts every rule carries one).
|
|
231
|
+
*
|
|
232
|
+
* **What this channel does NOT own.** A convention oxlint's own `vitest` plugin
|
|
233
|
+
* already enforces belongs to that plugin, not here — `@jterrazz/typescript` v10
|
|
234
|
+
* turns the plugin on over the test globs in every profile, so a consumer of the
|
|
235
|
+
* preset gets it without wiring. Four hygiene rules left the channel on that
|
|
236
|
+
* ground (J1, J3, J4, J5 → `vitest/no-focused-tests` + `vitest/no-disabled-tests`,
|
|
237
|
+
* `vitest/expect-expect`, `vitest/no-identical-title`, `vitest/prefer-lowercase-title`).
|
|
238
|
+
* `prefer-lowercase-title` is stricter than J5 was: a title opening on an all-caps
|
|
239
|
+
* identifier (`HTTP 404 …`, `DI …`) now fails, and the rulebook carries no
|
|
240
|
+
* `allowedPrefixes` — a title starts lowercase, whatever its first word. The
|
|
241
|
+
* rest are behaviour-identical on this package's own fixtures. Only J2 (the
|
|
242
|
+
* arbitrary-sleep ban, which has no upstream counterpart) stays statique.
|
|
243
|
+
*
|
|
244
|
+
* **Severity.** The estate's rulebook has no warn tier: a rule is on at `error`
|
|
245
|
+
* or off with a recorded reason, and that law binds `@jterrazz/typescript`'s
|
|
246
|
+
* rulebook. This channel is its one documented exception, and it is narrow — an
|
|
247
|
+
* id shaped `<family><n>w-…` is a REDUNDANCY heuristic (a `root` the walk would
|
|
248
|
+
* have found, a probe cluster that wants a golden), a reading of taste that
|
|
249
|
+
* would block a legitimate shape if it failed the build. Every hard convention
|
|
250
|
+
* here is an `error`; `plugin.test.ts` holds the split.
|
|
242
251
|
*/
|
|
243
252
|
const RULE_DOCS = {
|
|
244
253
|
"a1-specification-file": {
|
|
@@ -474,7 +483,7 @@ const RULE_DOCS = {
|
|
|
474
483
|
},
|
|
475
484
|
"f3-specs-public-entry": {
|
|
476
485
|
channel: "statique",
|
|
477
|
-
convention: "Depuis `specs/`, seul l’import en profondeur des INTERNES du framework est interdit : un chemin relatif résolvant dans `src/{
|
|
486
|
+
convention: "Depuis `specs/`, seul l’import en profondeur des INTERNES du framework est interdit : un chemin relatif résolvant dans `src/{specification,integrations,vitest,lint}/` du dépôt du framework, ou tout `@jterrazz/test/<subpath>` autre que `@jterrazz/test/oxlint`. Les imports de la source de SA PROPRE app par un consommateur sont toujours permis (c’est le motif documenté) ; seul `specs/integrations/` peut importer en profondeur `src/integrations/**`.",
|
|
478
487
|
family: "F",
|
|
479
488
|
id: "F3",
|
|
480
489
|
rationale: "Tester par la surface publique garde les specs découplées des chemins internes du framework, sans gêner le consommateur qui importe sa propre app."
|
|
@@ -514,13 +523,6 @@ const RULE_DOCS = {
|
|
|
514
523
|
id: "I4",
|
|
515
524
|
rationale: "Dans les tests de module, mocks et données sont du CODE (`mockOf`, `*.fixtures.ts`) ; un vrai fichier appelle une spec."
|
|
516
525
|
},
|
|
517
|
-
"j1-no-only-skip": {
|
|
518
|
-
channel: "statique",
|
|
519
|
-
convention: "Aucun `.only` / `.skip` committé (`describe.only`, `test.only`, `test.skip`).",
|
|
520
|
-
family: "J",
|
|
521
|
-
id: "J1",
|
|
522
|
-
rationale: "Un `.only`/`.skip` oublié désactive silencieusement une partie de la suite."
|
|
523
|
-
},
|
|
524
526
|
"j2-no-sleep-in-specs": {
|
|
525
527
|
channel: "statique",
|
|
526
528
|
convention: "Aucun sleep arbitraire (`setTimeout`/`setInterval`/`Atomics.wait`) sous `specs/**` — la synchronisation passe par `waitFor`.",
|
|
@@ -528,27 +530,6 @@ const RULE_DOCS = {
|
|
|
528
530
|
id: "J2",
|
|
529
531
|
rationale: "Un sleep fixe rend les tests lents et instables ; attendre une condition est déterministe."
|
|
530
532
|
},
|
|
531
|
-
"j3-no-expectless-test": {
|
|
532
|
-
channel: "statique",
|
|
533
|
-
convention: "Un `test(...)` avec callback contient au moins un `expect(…)` ; `test.todo` (sans callback) est ignoré.",
|
|
534
|
-
family: "J",
|
|
535
|
-
id: "J3",
|
|
536
|
-
rationale: "Un test sans assertion est mort ou muet — il passe toujours sans rien vérifier."
|
|
537
|
-
},
|
|
538
|
-
"j4-unique-test-names": {
|
|
539
|
-
channel: "statique",
|
|
540
|
-
convention: "Deux tests d’un même fichier ne partagent pas un nom littéral (`.each` ignoré).",
|
|
541
|
-
family: "J",
|
|
542
|
-
id: "J4",
|
|
543
|
-
rationale: "Le nom du test est son unique description — deux noms identiques rendent un échec ambigu."
|
|
544
|
-
},
|
|
545
|
-
"j5-lowercase-title": {
|
|
546
|
-
channel: "statique",
|
|
547
|
-
convention: "La première lettre d’un titre `test()`/`describe()`/`it()` littéral est en minuscule. Exemptés : les titres dont le premier MOT est un identifiant tout en majuscules/underscores (`VALID_CATEGORIES`, `HTTP`, `DI`) et ceux démarrant sur un non-lettre — seuls les premiers mots de prose minusculisables sont contraints.",
|
|
548
|
-
family: "J",
|
|
549
|
-
id: "J5",
|
|
550
|
-
rationale: "Un titre est un fragment de prose, pas une phrase — la casse minuscule le garde fragmentaire ; minusculiser un symbole nommé le mal-orthographierait."
|
|
551
|
-
},
|
|
552
533
|
"w1-scenario-pure": {
|
|
553
534
|
channel: "statique",
|
|
554
535
|
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é.",
|
|
@@ -907,90 +888,13 @@ const a1SpecificationFile = {
|
|
|
907
888
|
},
|
|
908
889
|
meta: {
|
|
909
890
|
docs: RULE_DOCS["a1-specification-file"],
|
|
910
|
-
messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (A1 — see docs/
|
|
911
|
-
type: "problem"
|
|
912
|
-
}
|
|
913
|
-
};
|
|
914
|
-
//#endregion
|
|
915
|
-
//#region src/core/specification/shared/binding.ts
|
|
916
|
-
/**
|
|
917
|
-
* Compose-binding resolution and the case conversions it shares with the
|
|
918
|
-
* automatic env injection (CONVENTIONS A6 / B6).
|
|
919
|
-
*
|
|
920
|
-
* A services-record key is written in the natural TypeScript style
|
|
921
|
-
* (`analyticsDb`), while a compose service name is kebab-case
|
|
922
|
-
* (`analytics-db`). These helpers bridge the two vocabularies deterministically
|
|
923
|
-
* so the record key stays the single source of truth and `composeService`
|
|
924
|
-
* remains a rare escape hatch.
|
|
925
|
-
*/
|
|
926
|
-
/**
|
|
927
|
-
* Convert a record key to its kebab-case form: camelCase boundaries become
|
|
928
|
-
* `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
|
|
929
|
-
*/
|
|
930
|
-
function toKebabCase(key) {
|
|
931
|
-
return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>").toLowerCase();
|
|
932
|
-
}
|
|
933
|
-
//#endregion
|
|
934
|
-
//#region src/lint/rules/a10-duplicate-binding.ts
|
|
935
|
-
/** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
|
|
936
|
-
function canonicalBinding(entry, key) {
|
|
937
|
-
const factory = entry.value;
|
|
938
|
-
if (factory?.type === "CallExpression") {
|
|
939
|
-
const options = factory.arguments?.[0];
|
|
940
|
-
if (options?.type === "ObjectExpression") {
|
|
941
|
-
const explicit = stringValue(findProperty(options, "composeService")?.value);
|
|
942
|
-
if (explicit !== void 0) return explicit;
|
|
943
|
-
}
|
|
944
|
-
}
|
|
945
|
-
return toKebabCase(key);
|
|
946
|
-
}
|
|
947
|
-
/**
|
|
948
|
-
* CONVENTIONS A10 — within one `services` record, two keys that derive (or
|
|
949
|
-
* pin via `composeService`) the SAME compose service name are a silent
|
|
950
|
-
* collision: the record is a map, and the second binding would shadow the
|
|
951
|
-
* first. Detected on the same kebab derivation the runtime binding uses
|
|
952
|
-
* (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
|
|
953
|
-
* `composeService` values.
|
|
954
|
-
*/
|
|
955
|
-
const a10DuplicateBinding = {
|
|
956
|
-
create(context) {
|
|
957
|
-
return { CallExpression(node) {
|
|
958
|
-
if (specificationMember(node) === void 0) return;
|
|
959
|
-
for (const argument of node.arguments ?? []) {
|
|
960
|
-
const record = findProperty(argument, "services")?.value;
|
|
961
|
-
if (record?.type !== "ObjectExpression") continue;
|
|
962
|
-
const seen = /* @__PURE__ */ new Map();
|
|
963
|
-
for (const entry of record.properties ?? []) {
|
|
964
|
-
const key = propertyKeyName(entry);
|
|
965
|
-
if (key === void 0) continue;
|
|
966
|
-
const binding = canonicalBinding(entry, key);
|
|
967
|
-
const first = seen.get(binding);
|
|
968
|
-
if (first !== void 0) {
|
|
969
|
-
context.report({
|
|
970
|
-
data: {
|
|
971
|
-
binding,
|
|
972
|
-
first,
|
|
973
|
-
key
|
|
974
|
-
},
|
|
975
|
-
messageId: "duplicate",
|
|
976
|
-
node: entry
|
|
977
|
-
});
|
|
978
|
-
continue;
|
|
979
|
-
}
|
|
980
|
-
seen.set(binding, key);
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
} };
|
|
984
|
-
},
|
|
985
|
-
meta: {
|
|
986
|
-
docs: RULE_DOCS["a10-duplicate-binding"],
|
|
987
|
-
messages: { duplicate: "Service keys \"{{first}}\" and \"{{key}}\" both bind to compose service \"{{binding}}\" — one shadows the other; rename a key or set distinct composeService values (A10 — see docs/10-linting.md)." },
|
|
891
|
+
messages: { outsideSpecification: "specification.{{member}}() must be called from a `*.specification.ts` file under specs/ (A1 — see docs/13-linting.md)." },
|
|
988
892
|
type: "problem"
|
|
989
893
|
}
|
|
990
894
|
};
|
|
991
895
|
//#endregion
|
|
992
896
|
//#region src/lint/rules/a2-known-constructors.ts
|
|
993
|
-
/** The five constructors, and only five (A2 — see docs/
|
|
897
|
+
/** The five constructors, and only five (A2 — see docs/13-linting.md). */
|
|
994
898
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
995
899
|
"api",
|
|
996
900
|
"cli",
|
|
@@ -1019,7 +923,7 @@ const a2KnownConstructors = {
|
|
|
1019
923
|
},
|
|
1020
924
|
meta: {
|
|
1021
925
|
docs: RULE_DOCS["a2-known-constructors"],
|
|
1022
|
-
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/
|
|
926
|
+
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/13-linting.md)." },
|
|
1023
927
|
type: "problem"
|
|
1024
928
|
}
|
|
1025
929
|
};
|
|
@@ -1064,7 +968,7 @@ const a3NoDestructureAlias = {
|
|
|
1064
968
|
},
|
|
1065
969
|
meta: {
|
|
1066
970
|
docs: RULE_DOCS["a3-no-destructure-alias"],
|
|
1067
|
-
messages: { aliased: "Destructure the specification result with its canonical name: `{{key}}`, not `{{key}}: {{alias}}` (A3 — see docs/
|
|
971
|
+
messages: { aliased: "Destructure the specification result with its canonical name: `{{key}}`, not `{{key}}: {{alias}}` (A3 — see docs/13-linting.md). Rename at the import site if a different local name is needed." },
|
|
1068
972
|
type: "problem"
|
|
1069
973
|
}
|
|
1070
974
|
};
|
|
@@ -1116,7 +1020,7 @@ const a4CleanupAfterall = {
|
|
|
1116
1020
|
},
|
|
1117
1021
|
meta: {
|
|
1118
1022
|
docs: RULE_DOCS["a4-cleanup-afterall"],
|
|
1119
|
-
messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (A4 — see docs/
|
|
1023
|
+
messages: { unregistered: "`cleanup` is destructured but never passed to afterAll — add `afterAll(cleanup)` to the specification file (A4 — see docs/13-linting.md)." },
|
|
1120
1024
|
type: "problem"
|
|
1121
1025
|
}
|
|
1122
1026
|
};
|
|
@@ -1146,11 +1050,30 @@ const a5ModeWithServer = {
|
|
|
1146
1050
|
},
|
|
1147
1051
|
meta: {
|
|
1148
1052
|
docs: RULE_DOCS["a5-mode-with-server"],
|
|
1149
|
-
messages: { hardcodedMode: "`mode` must not be hardcoded when `server` is defined — the node/compose switch lives in vitest.config.ts via `env: { TEST_MODE: \"compose\" }` (A5 — see docs/
|
|
1053
|
+
messages: { hardcodedMode: "`mode` must not be hardcoded when `server` is defined — the node/compose switch lives in vitest.config.ts via `env: { TEST_MODE: \"compose\" }` (A5 — see docs/13-linting.md)." },
|
|
1150
1054
|
type: "problem"
|
|
1151
1055
|
}
|
|
1152
1056
|
};
|
|
1153
1057
|
//#endregion
|
|
1058
|
+
//#region src/specification/facets/_common/binding.ts
|
|
1059
|
+
/**
|
|
1060
|
+
* Compose-binding resolution and the case conversions it shares with the
|
|
1061
|
+
* automatic env injection (CONVENTIONS A6 / B6).
|
|
1062
|
+
*
|
|
1063
|
+
* A services-record key is written in the natural TypeScript style
|
|
1064
|
+
* (`analyticsDb`), while a compose service name is kebab-case
|
|
1065
|
+
* (`analytics-db`). These helpers bridge the two vocabularies deterministically
|
|
1066
|
+
* so the record key stays the single source of truth and `composeService`
|
|
1067
|
+
* remains a rare escape hatch.
|
|
1068
|
+
*/
|
|
1069
|
+
/**
|
|
1070
|
+
* Convert a record key to its kebab-case form: camelCase boundaries become
|
|
1071
|
+
* `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
|
|
1072
|
+
*/
|
|
1073
|
+
function toKebabCase(key) {
|
|
1074
|
+
return key.replaceAll(/(?<lower>[a-z0-9])(?<upper>[A-Z])/gu, "$<lower>-$<upper>").toLowerCase();
|
|
1075
|
+
}
|
|
1076
|
+
//#endregion
|
|
1154
1077
|
//#region src/lint/rules/a6w-redundant-compose-service.ts
|
|
1155
1078
|
/**
|
|
1156
1079
|
* CONVENTIONS A6 (warning) — `composeService:` is the escape hatch for
|
|
@@ -1187,12 +1110,12 @@ const a6wRedundantComposeService = {
|
|
|
1187
1110
|
},
|
|
1188
1111
|
meta: {
|
|
1189
1112
|
docs: RULE_DOCS["a6w-redundant-compose-service"],
|
|
1190
|
-
messages: { redundant: "composeService: \"{{name}}\" is redundant — the key \"{{key}}\" already binds to it (exact name or kebab-case derivation, A6 — see docs/
|
|
1113
|
+
messages: { redundant: "composeService: \"{{name}}\" is redundant — the key \"{{key}}\" already binds to it (exact name or kebab-case derivation, A6 — see docs/13-linting.md)." },
|
|
1191
1114
|
type: "suggestion"
|
|
1192
1115
|
}
|
|
1193
1116
|
};
|
|
1194
1117
|
//#endregion
|
|
1195
|
-
//#region src/
|
|
1118
|
+
//#region src/specification/facets/_common/resolve.ts
|
|
1196
1119
|
/** What makes a directory a project root — a manifest, or a test stack. */
|
|
1197
1120
|
const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
|
|
1198
1121
|
/**
|
|
@@ -1222,7 +1145,7 @@ function findRoot(startDir, exists) {
|
|
|
1222
1145
|
//#endregion
|
|
1223
1146
|
//#region src/lint/rules/a9w-redundant-root.ts
|
|
1224
1147
|
/**
|
|
1225
|
-
* The root the convention would derive (A9 — see docs/
|
|
1148
|
+
* The root the convention would derive (A9 — see docs/13-linting.md): the
|
|
1226
1149
|
* framework's own walk-up, run against this layer's cached fs probe. The rule
|
|
1227
1150
|
* flags a `root` that the runner would have found by itself, so the two MUST
|
|
1228
1151
|
* be the same function — a private copy of the walk drifted from it before.
|
|
@@ -1254,12 +1177,70 @@ const a9wRedundantRoot = {
|
|
|
1254
1177
|
},
|
|
1255
1178
|
meta: {
|
|
1256
1179
|
docs: RULE_DOCS["a9w-redundant-root"],
|
|
1257
|
-
messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (A9 — see docs/
|
|
1180
|
+
messages: { redundant: "root: \"{{root}}\" is redundant — walking up from the specification file already resolves to that directory (A9 — see docs/13-linting.md)." },
|
|
1258
1181
|
type: "suggestion"
|
|
1259
1182
|
}
|
|
1260
1183
|
};
|
|
1261
1184
|
//#endregion
|
|
1262
|
-
//#region src/
|
|
1185
|
+
//#region src/lint/rules/a10-duplicate-binding.ts
|
|
1186
|
+
/** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
|
|
1187
|
+
function canonicalBinding(entry, key) {
|
|
1188
|
+
const factory = entry.value;
|
|
1189
|
+
if (factory?.type === "CallExpression") {
|
|
1190
|
+
const options = factory.arguments?.[0];
|
|
1191
|
+
if (options?.type === "ObjectExpression") {
|
|
1192
|
+
const explicit = stringValue(findProperty(options, "composeService")?.value);
|
|
1193
|
+
if (explicit !== void 0) return explicit;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return toKebabCase(key);
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* CONVENTIONS A10 — within one `services` record, two keys that derive (or
|
|
1200
|
+
* pin via `composeService`) the SAME compose service name are a silent
|
|
1201
|
+
* collision: the record is a map, and the second binding would shadow the
|
|
1202
|
+
* first. Detected on the same kebab derivation the runtime binding uses
|
|
1203
|
+
* (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
|
|
1204
|
+
* `composeService` values.
|
|
1205
|
+
*/
|
|
1206
|
+
const a10DuplicateBinding = {
|
|
1207
|
+
create(context) {
|
|
1208
|
+
return { CallExpression(node) {
|
|
1209
|
+
if (specificationMember(node) === void 0) return;
|
|
1210
|
+
for (const argument of node.arguments ?? []) {
|
|
1211
|
+
const record = findProperty(argument, "services")?.value;
|
|
1212
|
+
if (record?.type !== "ObjectExpression") continue;
|
|
1213
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1214
|
+
for (const entry of record.properties ?? []) {
|
|
1215
|
+
const key = propertyKeyName(entry);
|
|
1216
|
+
if (key === void 0) continue;
|
|
1217
|
+
const binding = canonicalBinding(entry, key);
|
|
1218
|
+
const first = seen.get(binding);
|
|
1219
|
+
if (first !== void 0) {
|
|
1220
|
+
context.report({
|
|
1221
|
+
data: {
|
|
1222
|
+
binding,
|
|
1223
|
+
first,
|
|
1224
|
+
key
|
|
1225
|
+
},
|
|
1226
|
+
messageId: "duplicate",
|
|
1227
|
+
node: entry
|
|
1228
|
+
});
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
seen.set(binding, key);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
} };
|
|
1235
|
+
},
|
|
1236
|
+
meta: {
|
|
1237
|
+
docs: RULE_DOCS["a10-duplicate-binding"],
|
|
1238
|
+
messages: { duplicate: "Service keys \"{{first}}\" and \"{{key}}\" both bind to compose service \"{{binding}}\" — one shadows the other; rename a key or set distinct composeService values (A10 — see docs/13-linting.md)." },
|
|
1239
|
+
type: "problem"
|
|
1240
|
+
}
|
|
1241
|
+
};
|
|
1242
|
+
//#endregion
|
|
1243
|
+
//#region src/specification/facets/_common/ground.ts
|
|
1263
1244
|
/**
|
|
1264
1245
|
* The GROUND a row of specs stands on — the naming law of a spec tree.
|
|
1265
1246
|
*
|
|
@@ -1300,7 +1281,7 @@ const RENAMED_GROUND_DIRS = {
|
|
|
1300
1281
|
seeds: GROUND_SEEDS
|
|
1301
1282
|
};
|
|
1302
1283
|
//#endregion
|
|
1303
|
-
//#region src/
|
|
1284
|
+
//#region src/specification/facets/_common/fixtures.ts
|
|
1304
1285
|
/**
|
|
1305
1286
|
* Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
|
|
1306
1287
|
*
|
|
@@ -1347,7 +1328,7 @@ const b2KnownFixtureMarker = {
|
|
|
1347
1328
|
const args = node.arguments ?? [];
|
|
1348
1329
|
const path = leadingText(args[0]);
|
|
1349
1330
|
if (path === void 0 || !path.startsWith("$")) return;
|
|
1350
|
-
const marker = path.replace(
|
|
1331
|
+
const marker = path.replace(/\/+$/u, "").split("/")[0] ?? "";
|
|
1351
1332
|
if (!KNOWN.has(marker)) context.report({
|
|
1352
1333
|
data: {
|
|
1353
1334
|
known: [...KNOWN_FIXTURE_MARKERS].join(", "),
|
|
@@ -1360,7 +1341,7 @@ const b2KnownFixtureMarker = {
|
|
|
1360
1341
|
},
|
|
1361
1342
|
meta: {
|
|
1362
1343
|
docs: RULE_DOCS["b2-known-fixture-marker"],
|
|
1363
|
-
messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (B2 — see docs/
|
|
1344
|
+
messages: { unknownMarker: "Unknown fixture marker \"{{marker}}\" — known markers: {{known}}. A path without a marker is feature-local (B2 — see docs/13-linting.md)." },
|
|
1364
1345
|
type: "problem"
|
|
1365
1346
|
}
|
|
1366
1347
|
};
|
|
@@ -1425,8 +1406,8 @@ const b4GivenThen = {
|
|
|
1425
1406
|
meta: {
|
|
1426
1407
|
docs: RULE_DOCS["b4-given-then"],
|
|
1427
1408
|
messages: {
|
|
1428
|
-
givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (B4 — see docs/
|
|
1429
|
-
missing: "Test is missing a `// {{marker}} -` comment (B4 — see docs/
|
|
1409
|
+
givenAfterThen: "The `// Given -` marker comes after `// Then -` — Given describes the setup and must precede Then (B4 — see docs/13-linting.md).",
|
|
1410
|
+
missing: "Test is missing a `// {{marker}} -` comment (B4 — see docs/13-linting.md — every test needs both `// Given -` and `// Then -`)."
|
|
1430
1411
|
},
|
|
1431
1412
|
type: "problem"
|
|
1432
1413
|
}
|
|
@@ -1467,7 +1448,7 @@ const b5AwaitUsing = {
|
|
|
1467
1448
|
meta: {
|
|
1468
1449
|
docs: RULE_DOCS["b5-await-using"],
|
|
1469
1450
|
defaultOptions: [{ runners: [] }],
|
|
1470
|
-
messages: { requireAwaitUsing: "The result of docker-aware runner \"{{runner}}\" must be bound with `await using` so its containers are disposed (B5 — see docs/
|
|
1451
|
+
messages: { requireAwaitUsing: "The result of docker-aware runner \"{{runner}}\" must be bound with `await using` so its containers are disposed (B5 — see docs/13-linting.md)." },
|
|
1471
1452
|
schema: [{
|
|
1472
1453
|
additionalProperties: false,
|
|
1473
1454
|
properties: { runners: {
|
|
@@ -1481,7 +1462,7 @@ const b5AwaitUsing = {
|
|
|
1481
1462
|
};
|
|
1482
1463
|
//#endregion
|
|
1483
1464
|
//#region src/lint/rules/b6w-redundant-env-url.ts
|
|
1484
|
-
const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL
|
|
1465
|
+
const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/u;
|
|
1485
1466
|
/**
|
|
1486
1467
|
* CONVENTIONS B6 (warning) — in `cli` mode with services, the framework
|
|
1487
1468
|
* already injects `<KEY>_URL` for every service (plus the unambiguous
|
|
@@ -1510,14 +1491,14 @@ const b6wRedundantEnvUrl = {
|
|
|
1510
1491
|
},
|
|
1511
1492
|
meta: {
|
|
1512
1493
|
docs: RULE_DOCS["b6w-redundant-env-url"],
|
|
1513
|
-
messages: { redundant: ".env({ {{key}}: ….connectionString }) is redundant — the framework already injects <KEY>_URL (and the DATABASE_URL/REDIS_URL aliases) for every service (B6 — see docs/
|
|
1494
|
+
messages: { redundant: ".env({ {{key}}: ….connectionString }) is redundant — the framework already injects <KEY>_URL (and the DATABASE_URL/REDIS_URL aliases) for every service (B6 — see docs/13-linting.md)." },
|
|
1514
1495
|
type: "suggestion"
|
|
1515
1496
|
}
|
|
1516
1497
|
};
|
|
1517
1498
|
//#endregion
|
|
1518
1499
|
//#region src/lint/rules/b8-kebab-trigger.ts
|
|
1519
1500
|
/** Stable kebab-case job identifier: `nightly-report`, `send-welcome-emails`. */
|
|
1520
|
-
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)
|
|
1501
|
+
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
1521
1502
|
/**
|
|
1522
1503
|
* CONVENTIONS B8 — a job `name` passed to `.trigger()` is a stable kebab-case
|
|
1523
1504
|
* identifier; it is a contract between the app and its tests. Only string
|
|
@@ -1539,7 +1520,7 @@ const b8KebabTrigger = {
|
|
|
1539
1520
|
},
|
|
1540
1521
|
meta: {
|
|
1541
1522
|
docs: RULE_DOCS["b8-kebab-trigger"],
|
|
1542
|
-
messages: { notKebab: "Job name \"{{name}}\" must be a stable kebab-case identifier (e.g. \"nightly-report\") — it is a contract between the app and its tests (B8 — see docs/
|
|
1523
|
+
messages: { notKebab: "Job name \"{{name}}\" must be a stable kebab-case identifier (e.g. \"nightly-report\") — it is a contract between the app and its tests (B8 — see docs/13-linting.md)." },
|
|
1543
1524
|
type: "problem"
|
|
1544
1525
|
}
|
|
1545
1526
|
};
|
|
@@ -1594,7 +1575,7 @@ const b9wProductCommand = {
|
|
|
1594
1575
|
},
|
|
1595
1576
|
meta: {
|
|
1596
1577
|
docs: RULE_DOCS["b9w-product-command"],
|
|
1597
|
-
messages: { thirdPartyBinary: "specification.cli() points at a third-party binary in node_modules/.bin — a spec should exercise your product command (cli.exec(\"check\"), …), not the tool underneath (B9 — see docs/
|
|
1578
|
+
messages: { thirdPartyBinary: "specification.cli() points at a third-party binary in node_modules/.bin — a spec should exercise your product command (cli.exec(\"check\"), …), not the tool underneath (B9 — see docs/13-linting.md). Suppress with a reason if the product genuinely is this binary." },
|
|
1598
1579
|
type: "suggestion"
|
|
1599
1580
|
}
|
|
1600
1581
|
};
|
|
@@ -1747,14 +1728,14 @@ const c1DomainStructure = {
|
|
|
1747
1728
|
defaultOptions: [{ depth: DEFAULT_DEPTH }],
|
|
1748
1729
|
docs: RULE_DOCS["c1-domain-structure"],
|
|
1749
1730
|
messages: {
|
|
1750
|
-
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/
|
|
1751
|
-
specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/
|
|
1752
|
-
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/
|
|
1753
|
-
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/
|
|
1754
|
-
testNotMirroringDirectory: "A `*.test.ts` must be named after the directory holding it — `{{directory}}/{{directory}}.test.ts` — with `depth: \"mirror\"` (C1 — see docs/
|
|
1755
|
-
testOutsideFacet: "A `*.test.ts` must live in a facet folder — `specs/<facet>/<aspect>.test.ts`, or one domain deeper: with `depth: \"facet\"` the specs root holds facets, not tests (C1 — see docs/
|
|
1756
|
-
testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (C1 — see docs/
|
|
1757
|
-
testTooDeepForFacet: "A `*.test.ts` must sit at the facet root or one domain folder down — `specs/<facet>/<aspect>.test.ts` or `specs/<facet>/<domain>/<aspect>.test.ts`: with `depth: \"facet\"` nothing nests deeper (C1 — see docs/
|
|
1731
|
+
specInGround: "A spec must not live under \"{{ground}}/\" — a leading underscore marks GROUND (what the specs of a row stand on: _fixtures/, _expected/, _requests/, _seeds/), never a domain (C1 — see docs/13-linting.md). Ground that is CODE keeps its own unit test: `<module>.test.ts` NEXT to the `<module>.ts` it tests (I2), and nothing else.",
|
|
1732
|
+
specNotAtFacetRoot: "A `*.specification.ts` must sit at the facet root: `specs/<facet>/<name>.specification.ts` (C1 — see docs/13-linting.md).",
|
|
1733
|
+
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/13-linting.md).",
|
|
1734
|
+
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/13-linting.md).",
|
|
1735
|
+
testNotMirroringDirectory: "A `*.test.ts` must be named after the directory holding it — `{{directory}}/{{directory}}.test.ts` — with `depth: \"mirror\"` (C1 — see docs/13-linting.md).",
|
|
1736
|
+
testOutsideFacet: "A `*.test.ts` must live in a facet folder — `specs/<facet>/<aspect>.test.ts`, or one domain deeper: with `depth: \"facet\"` the specs root holds facets, not tests (C1 — see docs/13-linting.md).",
|
|
1737
|
+
testTooDeep: "A `*.test.ts` must sit at facet/domain depth: `specs/<facet>/<domain>/<aspect>.test.ts` — no deeper nesting (C1 — see docs/13-linting.md).",
|
|
1738
|
+
testTooDeepForFacet: "A `*.test.ts` must sit at the facet root or one domain folder down — `specs/<facet>/<aspect>.test.ts` or `specs/<facet>/<domain>/<aspect>.test.ts`: with `depth: \"facet\"` nothing nests deeper (C1 — see docs/13-linting.md)."
|
|
1758
1739
|
},
|
|
1759
1740
|
schema: [{
|
|
1760
1741
|
additionalProperties: false,
|
|
@@ -1773,212 +1754,71 @@ const c1DomainStructure = {
|
|
|
1773
1754
|
}
|
|
1774
1755
|
};
|
|
1775
1756
|
//#endregion
|
|
1776
|
-
//#region src/lint/rules/
|
|
1777
|
-
|
|
1778
|
-
const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
|
|
1757
|
+
//#region src/lint/rules/c2-http-only-requests.ts
|
|
1758
|
+
const TEST_FILE$12 = /\.test\.[cm]?[jt]sx?$/u;
|
|
1779
1759
|
/**
|
|
1780
|
-
* CONVENTIONS
|
|
1781
|
-
* a
|
|
1782
|
-
*
|
|
1783
|
-
*
|
|
1784
|
-
*
|
|
1785
|
-
* scenario is named once, next to the world it derives from.
|
|
1760
|
+
* CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
|
|
1761
|
+
* a COMPLETE request: method, path, headers, body). Anchored on the feature's
|
|
1762
|
+
* visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
|
|
1763
|
+
* `_requests/` directory is probed (cached readdir) and any non-`.http` entry
|
|
1764
|
+
* is reported on the test file.
|
|
1786
1765
|
*/
|
|
1787
|
-
const
|
|
1766
|
+
const c2HttpOnlyRequests = {
|
|
1788
1767
|
create(context) {
|
|
1789
|
-
const
|
|
1790
|
-
if (!
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
if (
|
|
1794
|
-
data: {
|
|
1795
|
-
messageId: "
|
|
1768
|
+
const file = context.physicalFilename;
|
|
1769
|
+
if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
|
|
1770
|
+
return { Program(node) {
|
|
1771
|
+
const requestsDir = (0, node_path.join)((0, node_path.dirname)(file), GROUND_REQUESTS);
|
|
1772
|
+
for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
|
|
1773
|
+
data: { entry },
|
|
1774
|
+
messageId: "notHttp",
|
|
1796
1775
|
node
|
|
1797
1776
|
});
|
|
1798
|
-
}
|
|
1777
|
+
} };
|
|
1799
1778
|
},
|
|
1800
1779
|
meta: {
|
|
1801
|
-
docs: RULE_DOCS["
|
|
1802
|
-
messages: {
|
|
1780
|
+
docs: RULE_DOCS["c2-http-only-requests"],
|
|
1781
|
+
messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/13-linting.md)." },
|
|
1803
1782
|
type: "problem"
|
|
1804
1783
|
}
|
|
1805
1784
|
};
|
|
1806
1785
|
//#endregion
|
|
1807
|
-
//#region src/lint/rules/
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
const PROVIDERS$1 = [
|
|
1786
|
+
//#region src/lint/rules/c4-contract-shape.ts
|
|
1787
|
+
/** The only directories a `contracts/` root may hold — the provider carriers. */
|
|
1788
|
+
const PROVIDERS$1 = /* @__PURE__ */ new Set([
|
|
1811
1789
|
"anthropic",
|
|
1812
1790
|
"http",
|
|
1813
1791
|
"openai"
|
|
1814
|
-
];
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1792
|
+
]);
|
|
1793
|
+
const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/u;
|
|
1794
|
+
/** A file oxlint itself visits — the AST half of the rule reports on those. */
|
|
1795
|
+
const SOURCE_FILE = /\.[cm]?[jt]sx?$/u;
|
|
1796
|
+
/** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
|
|
1797
|
+
const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
|
|
1798
|
+
/** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
|
|
1799
|
+
const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
|
|
1800
|
+
/** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
|
|
1801
|
+
const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
|
|
1802
|
+
/** The name a specifier/declaration exports under, when it is `default`. */
|
|
1803
|
+
function exportsDefault(node) {
|
|
1804
|
+
return (node.specifiers ?? []).some((specifier) => {
|
|
1805
|
+
const exported = specifier.exported;
|
|
1806
|
+
return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
/** Is this node a `defineContract(...)` call? */
|
|
1810
|
+
function isDefineContract(node) {
|
|
1811
|
+
const callee = node.callee;
|
|
1812
|
+
return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
|
|
1813
|
+
}
|
|
1814
|
+
/** A default export that may legitimately produce a contract (value or factory). */
|
|
1815
|
+
function producesContract(declaration) {
|
|
1816
|
+
if (declaration === void 0) return false;
|
|
1817
|
+
return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
|
|
1820
1818
|
}
|
|
1821
1819
|
/**
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1824
|
-
* pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
|
|
1825
|
-
* up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
|
|
1826
|
-
* file with no owner is dead weight no test can reach — reported on the feature's
|
|
1827
|
-
* test file, since oxlint never visits a `.json`.
|
|
1828
|
-
*/
|
|
1829
|
-
const c11ContractDataPairing = {
|
|
1830
|
-
create(context) {
|
|
1831
|
-
const file = context.physicalFilename;
|
|
1832
|
-
if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
|
|
1833
|
-
return { Program(node) {
|
|
1834
|
-
const contractsDir = (0, node_path.join)((0, node_path.dirname)(file), "contracts");
|
|
1835
|
-
for (const provider of PROVIDERS$1) {
|
|
1836
|
-
const entries = listDirectory((0, node_path.join)(contractsDir, provider));
|
|
1837
|
-
if (entries === null) continue;
|
|
1838
|
-
const present = new Set(entries);
|
|
1839
|
-
for (const entry of entries) {
|
|
1840
|
-
if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
|
|
1841
|
-
const owner = ownerOf(entry);
|
|
1842
|
-
if (!present.has(owner)) context.report({
|
|
1843
|
-
data: {
|
|
1844
|
-
entry,
|
|
1845
|
-
owner,
|
|
1846
|
-
provider
|
|
1847
|
-
},
|
|
1848
|
-
messageId: "orphan",
|
|
1849
|
-
node
|
|
1850
|
-
});
|
|
1851
|
-
}
|
|
1852
|
-
}
|
|
1853
|
-
} };
|
|
1854
|
-
},
|
|
1855
|
-
meta: {
|
|
1856
|
-
docs: RULE_DOCS["c11-contract-data-pairing"],
|
|
1857
|
-
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)." },
|
|
1858
|
-
type: "problem"
|
|
1859
|
-
}
|
|
1860
|
-
};
|
|
1861
|
-
//#endregion
|
|
1862
|
-
//#region src/lint/rules/c13-underscored-ground.ts
|
|
1863
|
-
const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
|
|
1864
|
-
/**
|
|
1865
|
-
* CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
|
|
1866
|
-
* directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
|
|
1867
|
-
* `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
|
|
1868
|
-
* `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
|
|
1869
|
-
* fallback.
|
|
1870
|
-
*
|
|
1871
|
-
* The catch this rule exists for: a consumer that upgrades and keeps the old
|
|
1872
|
-
* names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
|
|
1873
|
-
* `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
|
|
1874
|
-
* and the tree becomes invisible rather than wrong. The failure that follows is
|
|
1875
|
-
* "fixture does not exist" on every spec at once, one step removed from the
|
|
1876
|
-
* cause; this rule states the cause and the rename.
|
|
1877
|
-
*
|
|
1878
|
-
* Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
|
|
1879
|
-
* spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
|
|
1880
|
-
* nearest specs root. A DOMAIN folder named after the ground it holds
|
|
1881
|
-
* (`specs/api/requests/_requests/`) is never a probe target — only what sits
|
|
1882
|
-
* beside a spec is.
|
|
1883
|
-
*/
|
|
1884
|
-
const c13UnderscoredGround = {
|
|
1885
|
-
create(context) {
|
|
1886
|
-
const file = context.physicalFilename;
|
|
1887
|
-
const anchor = specsAnchor(file);
|
|
1888
|
-
if (!TEST_FILE$11.test(file) || anchor === void 0) return {};
|
|
1889
|
-
return { Program(node) {
|
|
1890
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1891
|
-
const probe = (parent, legacy) => {
|
|
1892
|
-
const path = (0, node_path.join)(parent, legacy);
|
|
1893
|
-
if (seen.has(path) || !isDirectory(path)) return;
|
|
1894
|
-
seen.add(path);
|
|
1895
|
-
context.report({
|
|
1896
|
-
data: {
|
|
1897
|
-
ground: RENAMED_GROUND_DIRS[legacy],
|
|
1898
|
-
legacy
|
|
1899
|
-
},
|
|
1900
|
-
messageId: "notUnderscored",
|
|
1901
|
-
node
|
|
1902
|
-
});
|
|
1903
|
-
};
|
|
1904
|
-
for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe((0, node_path.dirname)(file), legacy);
|
|
1905
|
-
probe(anchor.directory, "fixtures");
|
|
1906
|
-
} };
|
|
1907
|
-
},
|
|
1908
|
-
meta: {
|
|
1909
|
-
docs: RULE_DOCS["c13-underscored-ground"],
|
|
1910
|
-
messages: { notUnderscored: "The directory \"{{legacy}}/\" is a pre-14 ground name — rename it to \"{{ground}}/\" (`git mv {{legacy}} {{ground}}`). What a spec stands on carries the underscore; a spec's own folder never does, and no resolver reads the un-underscored name (C13 — see docs/10-linting.md)." },
|
|
1911
|
-
type: "problem"
|
|
1912
|
-
}
|
|
1913
|
-
};
|
|
1914
|
-
//#endregion
|
|
1915
|
-
//#region src/lint/rules/c2-http-only-requests.ts
|
|
1916
|
-
const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
|
|
1917
|
-
/**
|
|
1918
|
-
* CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
|
|
1919
|
-
* a COMPLETE request: method, path, headers, body). Anchored on the feature's
|
|
1920
|
-
* visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
|
|
1921
|
-
* `_requests/` directory is probed (cached readdir) and any non-`.http` entry
|
|
1922
|
-
* is reported on the test file.
|
|
1923
|
-
*/
|
|
1924
|
-
const c2HttpOnlyRequests = {
|
|
1925
|
-
create(context) {
|
|
1926
|
-
const file = context.physicalFilename;
|
|
1927
|
-
if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
|
|
1928
|
-
return { Program(node) {
|
|
1929
|
-
const requestsDir = (0, node_path.join)((0, node_path.dirname)(file), GROUND_REQUESTS);
|
|
1930
|
-
for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
|
|
1931
|
-
data: { entry },
|
|
1932
|
-
messageId: "notHttp",
|
|
1933
|
-
node
|
|
1934
|
-
});
|
|
1935
|
-
} };
|
|
1936
|
-
},
|
|
1937
|
-
meta: {
|
|
1938
|
-
docs: RULE_DOCS["c2-http-only-requests"],
|
|
1939
|
-
messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/10-linting.md)." },
|
|
1940
|
-
type: "problem"
|
|
1941
|
-
}
|
|
1942
|
-
};
|
|
1943
|
-
//#endregion
|
|
1944
|
-
//#region src/lint/rules/c4-contract-shape.ts
|
|
1945
|
-
/** The only directories a `contracts/` root may hold — the provider carriers. */
|
|
1946
|
-
const PROVIDERS = /* @__PURE__ */ new Set([
|
|
1947
|
-
"anthropic",
|
|
1948
|
-
"http",
|
|
1949
|
-
"openai"
|
|
1950
|
-
]);
|
|
1951
|
-
const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
|
|
1952
|
-
/** A file oxlint itself visits — the AST half of the rule reports on those. */
|
|
1953
|
-
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
1954
|
-
/** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
|
|
1955
|
-
const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
|
|
1956
|
-
/** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
|
|
1957
|
-
const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
|
|
1958
|
-
/** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
|
|
1959
|
-
const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
|
|
1960
|
-
/** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
|
|
1961
|
-
const RESPONSE_DATA_FILE = /\.response\.json$/;
|
|
1962
|
-
/** The name a specifier/declaration exports under, when it is `default`. */
|
|
1963
|
-
function exportsDefault(node) {
|
|
1964
|
-
return (node.specifiers ?? []).some((specifier) => {
|
|
1965
|
-
const exported = specifier.exported;
|
|
1966
|
-
return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
|
|
1967
|
-
});
|
|
1968
|
-
}
|
|
1969
|
-
/** Is this node a `defineContract(...)` call? */
|
|
1970
|
-
function isDefineContract(node) {
|
|
1971
|
-
const callee = node.callee;
|
|
1972
|
-
return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
|
|
1973
|
-
}
|
|
1974
|
-
/** A default export that may legitimately produce a contract (value or factory). */
|
|
1975
|
-
function producesContract(declaration) {
|
|
1976
|
-
if (declaration === void 0) return false;
|
|
1977
|
-
return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
|
|
1978
|
-
}
|
|
1979
|
-
/**
|
|
1980
|
-
* The provider a `request:` value implies, when the builder is written as
|
|
1981
|
-
* `<provider>.<verb>(…)` — the only statically decidable form.
|
|
1820
|
+
* The provider a `request:` value implies, when the builder is written as
|
|
1821
|
+
* `<provider>.<verb>(…)` — the only statically decidable form.
|
|
1982
1822
|
*/
|
|
1983
1823
|
function requestProvider(request) {
|
|
1984
1824
|
if (request?.type !== "CallExpression") return;
|
|
@@ -2006,7 +1846,7 @@ function checkLayout(context, node, contractsDir) {
|
|
|
2006
1846
|
});
|
|
2007
1847
|
continue;
|
|
2008
1848
|
}
|
|
2009
|
-
if (!PROVIDERS.has(entry)) {
|
|
1849
|
+
if (!PROVIDERS$1.has(entry)) {
|
|
2010
1850
|
context.report({
|
|
2011
1851
|
data: { entry },
|
|
2012
1852
|
messageId: "badProviderDir",
|
|
@@ -2022,7 +1862,7 @@ function checkLayout(context, node, contractsDir) {
|
|
|
2022
1862
|
messageId: "providerSubfolder",
|
|
2023
1863
|
node
|
|
2024
1864
|
});
|
|
2025
|
-
else if (!/\.[cm]?ts$/u.test(child) && !
|
|
1865
|
+
else if (!/\.[cm]?ts$/u.test(child) && !child.endsWith(".response.json")) context.report({
|
|
2026
1866
|
data: {
|
|
2027
1867
|
child,
|
|
2028
1868
|
provider: entry
|
|
@@ -2091,7 +1931,7 @@ function checkUnit(context, program, base, provider) {
|
|
|
2091
1931
|
if (isDefineContract(node)) {
|
|
2092
1932
|
const argument = node.arguments?.[0];
|
|
2093
1933
|
const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
|
|
2094
|
-
if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
|
|
1934
|
+
if (declared !== void 0 && PROVIDERS$1.has(declared) && declared !== provider) context.report({
|
|
2095
1935
|
data: {
|
|
2096
1936
|
declared,
|
|
2097
1937
|
provider
|
|
@@ -2133,7 +1973,7 @@ const c4ContractShape = {
|
|
|
2133
1973
|
if (!parts.includes("specs")) return {};
|
|
2134
1974
|
const contractsIndex = parts.lastIndexOf("contracts");
|
|
2135
1975
|
if (contractsIndex === -1) {
|
|
2136
|
-
if (!TEST_FILE$
|
|
1976
|
+
if (!TEST_FILE$11.test(file)) return {};
|
|
2137
1977
|
return { Program(node) {
|
|
2138
1978
|
checkLayout(context, node, (0, node_path.join)((0, node_path.dirname)(file), "contracts"));
|
|
2139
1979
|
} };
|
|
@@ -2144,8 +1984,8 @@ const c4ContractShape = {
|
|
|
2144
1984
|
checkComposite(context, node, base);
|
|
2145
1985
|
} };
|
|
2146
1986
|
if (depth === 2) {
|
|
2147
|
-
const provider = parts[contractsIndex + 1];
|
|
2148
|
-
return PROVIDERS.has(provider) ? { Program(node) {
|
|
1987
|
+
const provider = parts[contractsIndex + 1] ?? "";
|
|
1988
|
+
return PROVIDERS$1.has(provider) ? { Program(node) {
|
|
2149
1989
|
checkUnit(context, node, base, provider);
|
|
2150
1990
|
} } : {};
|
|
2151
1991
|
}
|
|
@@ -2160,25 +2000,25 @@ const c4ContractShape = {
|
|
|
2160
2000
|
meta: {
|
|
2161
2001
|
docs: RULE_DOCS["c4-contract-shape"],
|
|
2162
2002
|
messages: {
|
|
2163
|
-
badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts — the folder already carries the provider (C4 — see docs/
|
|
2164
|
-
badProviderDir: "contracts/{{entry}}/ is not a provider directory — a contracts/ root holds only http/, openai/, anthropic/ and *.contracts.ts files (C4 — see docs/
|
|
2165
|
-
missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/
|
|
2166
|
-
missingDefault: "Contract unit \"{{base}}\" has no default export — a unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/
|
|
2167
|
-
notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/
|
|
2168
|
-
notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/
|
|
2169
|
-
providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/
|
|
2170
|
-
providerMismatch: "This contract lives in contracts/{{provider}}/ but its request is a `{{declared}}.*` builder — the folder carries the provider (C4 — see docs/
|
|
2171
|
-
providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/
|
|
2172
|
-
rootEntry: "contracts/{{entry}} is not a *.contracts.ts facade — data files live in a provider folder next to their contract (C4 — see docs/
|
|
2173
|
-
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/
|
|
2174
|
-
tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/
|
|
2003
|
+
badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts — the folder already carries the provider (C4 — see docs/13-linting.md).",
|
|
2004
|
+
badProviderDir: "contracts/{{entry}}/ is not a provider directory — a contracts/ root holds only http/, openai/, anthropic/ and *.contracts.ts files (C4 — see docs/13-linting.md).",
|
|
2005
|
+
missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/13-linting.md).",
|
|
2006
|
+
missingDefault: "Contract unit \"{{base}}\" has no default export — a unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/13-linting.md).",
|
|
2007
|
+
notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/13-linting.md).",
|
|
2008
|
+
notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/13-linting.md).",
|
|
2009
|
+
providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/13-linting.md).",
|
|
2010
|
+
providerMismatch: "This contract lives in contracts/{{provider}}/ but its request is a `{{declared}}.*` builder — the folder carries the provider (C4 — see docs/13-linting.md).",
|
|
2011
|
+
providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/13-linting.md).",
|
|
2012
|
+
rootEntry: "contracts/{{entry}} is not a *.contracts.ts facade — data files live in a provider folder next to their contract (C4 — see docs/13-linting.md).",
|
|
2013
|
+
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/13-linting.md).",
|
|
2014
|
+
tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/13-linting.md)."
|
|
2175
2015
|
},
|
|
2176
2016
|
type: "problem"
|
|
2177
2017
|
}
|
|
2178
2018
|
};
|
|
2179
2019
|
//#endregion
|
|
2180
2020
|
//#region src/lint/rules/c6-tomatch-extension.ts
|
|
2181
|
-
const TEST_FILE$
|
|
2021
|
+
const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2182
2022
|
/** Does the expect(…) subject chain contain a `.directory(…)` call? */
|
|
2183
2023
|
function subjectIsDirectory(toMatchCallee) {
|
|
2184
2024
|
const expectCall = toMatchCallee.object;
|
|
@@ -2203,7 +2043,7 @@ function subjectIsDirectory(toMatchCallee) {
|
|
|
2203
2043
|
const c6ToMatchExtension = {
|
|
2204
2044
|
create(context) {
|
|
2205
2045
|
const file = context.physicalFilename;
|
|
2206
|
-
if (!TEST_FILE$
|
|
2046
|
+
if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
|
|
2207
2047
|
return { CallExpression(node) {
|
|
2208
2048
|
const callee = node.callee;
|
|
2209
2049
|
if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
|
|
@@ -2222,13 +2062,13 @@ const c6ToMatchExtension = {
|
|
|
2222
2062
|
},
|
|
2223
2063
|
meta: {
|
|
2224
2064
|
docs: RULE_DOCS["c6-tomatch-extension"],
|
|
2225
|
-
messages: { missingExtension: "toMatch(\"{{name}}\") is missing its extension — the extension is part of the fixture name (\"help.txt\", never \"help\"); only directory-tree snapshots (_expected/<name>/) omit it (C6 — see docs/
|
|
2065
|
+
messages: { missingExtension: "toMatch(\"{{name}}\") is missing its extension — the extension is part of the fixture name (\"help.txt\", never \"help\"); only directory-tree snapshots (_expected/<name>/) omit it (C6 — see docs/13-linting.md)." },
|
|
2226
2066
|
type: "problem"
|
|
2227
2067
|
}
|
|
2228
2068
|
};
|
|
2229
2069
|
//#endregion
|
|
2230
2070
|
//#region src/lint/rules/c7-seeds-sql-only.ts
|
|
2231
|
-
const TEST_FILE$
|
|
2071
|
+
const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2232
2072
|
/**
|
|
2233
2073
|
* CONVENTIONS C7 — `.seed()` carries DATABASE state only: `_seeds/` contains
|
|
2234
2074
|
* nothing but `*.sql` fragments. File state goes through `.fixture()`; there
|
|
@@ -2238,7 +2078,7 @@ const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
|
|
|
2238
2078
|
const c7SeedsSqlOnly = {
|
|
2239
2079
|
create(context) {
|
|
2240
2080
|
const file = context.physicalFilename;
|
|
2241
|
-
if (!TEST_FILE$
|
|
2081
|
+
if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
|
|
2242
2082
|
return { Program(node) {
|
|
2243
2083
|
const seedsDir = (0, node_path.join)((0, node_path.dirname)(file), GROUND_SEEDS);
|
|
2244
2084
|
for (const entry of listDirectory(seedsDir) ?? []) if (!entry.endsWith(".sql")) context.report({
|
|
@@ -2250,13 +2090,13 @@ const c7SeedsSqlOnly = {
|
|
|
2250
2090
|
},
|
|
2251
2091
|
meta: {
|
|
2252
2092
|
docs: RULE_DOCS["c7-seeds-sql-only"],
|
|
2253
|
-
messages: { notSql: "_seeds/{{entry}} is not a .sql file — _seeds/ carries database state only; file state goes through .fixture() (C7 — see docs/
|
|
2093
|
+
messages: { notSql: "_seeds/{{entry}} is not a .sql file — _seeds/ carries database state only; file state goes through .fixture() (C7 — see docs/13-linting.md)." },
|
|
2254
2094
|
type: "problem"
|
|
2255
2095
|
}
|
|
2256
2096
|
};
|
|
2257
2097
|
//#endregion
|
|
2258
2098
|
//#region src/lint/rules/c8-referenced-fixture-exists.ts
|
|
2259
|
-
const TEST_FILE$
|
|
2099
|
+
const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2260
2100
|
/** The ground directory each fixture-referencing verb reads from. */
|
|
2261
2101
|
const VERB_ROOTS = {
|
|
2262
2102
|
request: GROUND_REQUESTS,
|
|
@@ -2289,7 +2129,7 @@ const c8ReferencedFixtureExists = {
|
|
|
2289
2129
|
create(context) {
|
|
2290
2130
|
const file = context.physicalFilename;
|
|
2291
2131
|
const anchor = specsAnchor(file);
|
|
2292
|
-
if (!TEST_FILE$
|
|
2132
|
+
if (!TEST_FILE$8.test(file) || anchor === void 0) return {};
|
|
2293
2133
|
const featureDir = (0, node_path.dirname)(file);
|
|
2294
2134
|
return { CallExpression(node) {
|
|
2295
2135
|
const callee = node.callee;
|
|
@@ -2299,7 +2139,7 @@ const c8ReferencedFixtureExists = {
|
|
|
2299
2139
|
if (verb === "fixture") {
|
|
2300
2140
|
const value = stringValue(args[0]);
|
|
2301
2141
|
if (value === void 0 || !looksLikePath(value)) return;
|
|
2302
|
-
const target = resolveFixture(value.replace(
|
|
2142
|
+
const target = resolveFixture(value.replace(/\/+$/u, ""), featureDir, anchor.directory);
|
|
2303
2143
|
if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
|
|
2304
2144
|
data: {
|
|
2305
2145
|
path: value,
|
|
@@ -2314,7 +2154,7 @@ const c8ReferencedFixtureExists = {
|
|
|
2314
2154
|
if (root === void 0) return;
|
|
2315
2155
|
const value = stringValue(args[0]);
|
|
2316
2156
|
if (value === void 0 || !looksLikePath(value)) return;
|
|
2317
|
-
const target = (0, node_path.join)(featureDir, root, value.replace(
|
|
2157
|
+
const target = (0, node_path.join)(featureDir, root, value.replace(/\/+$/u, ""));
|
|
2318
2158
|
if (!(verb === "toMatch" ? isFile(target) || isDirectory(target) : isFile(target))) context.report({
|
|
2319
2159
|
data: {
|
|
2320
2160
|
path: `${root}/${value}`,
|
|
@@ -2327,236 +2167,147 @@ const c8ReferencedFixtureExists = {
|
|
|
2327
2167
|
},
|
|
2328
2168
|
meta: {
|
|
2329
2169
|
docs: RULE_DOCS["c8-referenced-fixture-exists"],
|
|
2330
|
-
messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its ground {{root}}/ root (C8 — see docs/
|
|
2170
|
+
messages: { missing: "Referenced fixture \"{{path}}\" does not exist on disk under its ground {{root}}/ root (C8 — see docs/13-linting.md). Create it or fix the reference." },
|
|
2331
2171
|
type: "problem"
|
|
2332
2172
|
}
|
|
2333
2173
|
};
|
|
2334
2174
|
//#endregion
|
|
2335
|
-
//#region src/lint/rules/
|
|
2336
|
-
/** A
|
|
2337
|
-
const
|
|
2338
|
-
/** Is `node` the `x.response.body` member access itself? */
|
|
2339
|
-
function isResponseBody(node) {
|
|
2340
|
-
if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
|
|
2341
|
-
const object = node.object;
|
|
2342
|
-
return object !== void 0 && memberPropertyName(object) === "response";
|
|
2343
|
-
}
|
|
2344
|
-
/** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
|
|
2345
|
-
function unwrap(node) {
|
|
2346
|
-
let current = node ?? void 0;
|
|
2347
|
-
while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
|
|
2348
|
-
else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
|
|
2349
|
-
else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
|
|
2350
|
-
else return current;
|
|
2351
|
-
}
|
|
2175
|
+
//#region src/lint/rules/c10-contracts-boundary.ts
|
|
2176
|
+
/** A specifier reaching into a provider folder — the internal half of contracts/. */
|
|
2177
|
+
const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
|
|
2352
2178
|
/**
|
|
2353
|
-
* CONVENTIONS
|
|
2354
|
-
*
|
|
2355
|
-
*
|
|
2356
|
-
* `
|
|
2357
|
-
*
|
|
2358
|
-
*
|
|
2359
|
-
*
|
|
2360
|
-
* A probe is a body-rooted read: a direct `.response.body` use, or a field
|
|
2361
|
-
* access on the local binding a `const body = result.response.body as {…}`
|
|
2362
|
-
* introduces. The count is the same threshold the golden transformation applies.
|
|
2179
|
+
* CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
|
|
2180
|
+
* a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
|
|
2181
|
+
* named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
|
|
2182
|
+
* and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
|
|
2183
|
+
* importing a unit is an error — a test routes through the facade, so a
|
|
2184
|
+
* scenario is named once, next to the world it derives from.
|
|
2363
2185
|
*/
|
|
2364
|
-
const
|
|
2186
|
+
const c10ContractsBoundary = {
|
|
2365
2187
|
create(context) {
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
let responseBodyNodes = 0;
|
|
2374
|
-
walk(callback, (inner) => {
|
|
2375
|
-
if (isResponseBody(inner)) responseBodyNodes++;
|
|
2376
|
-
if (inner.type === "VariableDeclarator") {
|
|
2377
|
-
const init = unwrap(inner.init);
|
|
2378
|
-
const id = inner.id;
|
|
2379
|
-
if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
|
|
2380
|
-
}
|
|
2381
|
-
});
|
|
2382
|
-
let aliasReads = 0;
|
|
2383
|
-
walk(callback, (inner) => {
|
|
2384
|
-
if (inner.type !== "MemberExpression") return;
|
|
2385
|
-
const object = inner.object;
|
|
2386
|
-
if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
|
|
2387
|
-
});
|
|
2388
|
-
const probes = responseBodyNodes - aliases.size + aliasReads;
|
|
2389
|
-
if (probes >= threshold) context.report({
|
|
2390
|
-
data: { count: String(probes) },
|
|
2391
|
-
messageId: "bodyProbeCluster",
|
|
2188
|
+
const parts = segments(context.physicalFilename);
|
|
2189
|
+
if (!parts.includes("specs")) return {};
|
|
2190
|
+
if (parts.slice(0, -1).includes("contracts")) return {};
|
|
2191
|
+
return importSourceVisitor(({ node, source }) => {
|
|
2192
|
+
if (INTERNAL_UNIT.test(source)) context.report({
|
|
2193
|
+
data: { source },
|
|
2194
|
+
messageId: "internal",
|
|
2392
2195
|
node
|
|
2393
2196
|
});
|
|
2394
|
-
}
|
|
2197
|
+
});
|
|
2395
2198
|
},
|
|
2396
2199
|
meta: {
|
|
2397
|
-
docs: RULE_DOCS["
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
schema: [{
|
|
2401
|
-
additionalProperties: false,
|
|
2402
|
-
properties: { threshold: {
|
|
2403
|
-
minimum: 1,
|
|
2404
|
-
type: "integer"
|
|
2405
|
-
} },
|
|
2406
|
-
type: "object"
|
|
2407
|
-
}],
|
|
2408
|
-
type: "suggestion"
|
|
2200
|
+
docs: RULE_DOCS["c10-contracts-boundary"],
|
|
2201
|
+
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/13-linting.md)." },
|
|
2202
|
+
type: "problem"
|
|
2409
2203
|
}
|
|
2410
2204
|
};
|
|
2411
2205
|
//#endregion
|
|
2412
|
-
//#region src/lint/rules/
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2206
|
+
//#region src/lint/rules/c11-contract-data-pairing.ts
|
|
2207
|
+
const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2208
|
+
/** The provider folders that may carry data files. */
|
|
2209
|
+
const PROVIDERS = [
|
|
2210
|
+
"anthropic",
|
|
2211
|
+
"http",
|
|
2212
|
+
"openai"
|
|
2213
|
+
];
|
|
2214
|
+
/** Data files: served payloads and matched inputs, both owned by a contract. */
|
|
2215
|
+
const DATA_SUFFIXES = [".request.ts", ".response.json"];
|
|
2216
|
+
/** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
|
|
2217
|
+
function ownerOf(entry) {
|
|
2218
|
+
return `${entry.slice(0, entry.indexOf("."))}.ts`;
|
|
2422
2219
|
}
|
|
2423
2220
|
/**
|
|
2424
|
-
*
|
|
2425
|
-
*
|
|
2426
|
-
*
|
|
2427
|
-
*
|
|
2428
|
-
*
|
|
2429
|
-
*
|
|
2430
|
-
* a `toMatch` merely written inside `inUpdateMode(() => …)` is NOT flagged.
|
|
2221
|
+
* CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
|
|
2222
|
+
* `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
|
|
2223
|
+
* pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
|
|
2224
|
+
* up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
|
|
2225
|
+
* file with no owner is dead weight no test can reach — reported on the feature's
|
|
2226
|
+
* test file, since oxlint never visits a `.json`.
|
|
2431
2227
|
*/
|
|
2432
|
-
const
|
|
2433
|
-
"ArrowFunctionExpression",
|
|
2434
|
-
"AwaitExpression",
|
|
2435
|
-
"ChainExpression",
|
|
2436
|
-
"MemberExpression",
|
|
2437
|
-
"ParenthesizedExpression",
|
|
2438
|
-
"TSAsExpression",
|
|
2439
|
-
"TSNonNullExpression"
|
|
2440
|
-
]);
|
|
2441
|
-
function isWrappedInExpect(node) {
|
|
2442
|
-
let current = node.parent;
|
|
2443
|
-
while (current !== void 0) {
|
|
2444
|
-
if (current.type === "CallExpression") {
|
|
2445
|
-
const callee = current.callee;
|
|
2446
|
-
return callee?.type === "Identifier" && callee.name === "expect";
|
|
2447
|
-
}
|
|
2448
|
-
if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
|
|
2449
|
-
current = current.parent;
|
|
2450
|
-
}
|
|
2451
|
-
return false;
|
|
2452
|
-
}
|
|
2453
|
-
/**
|
|
2454
|
-
* CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
|
|
2455
|
-
* test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
|
|
2456
|
-
* asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
|
|
2457
|
-
* unfrozen fixture is silently rewritten with the actual output — the matcher
|
|
2458
|
-
* writes instead of throwing — which destroys the negative case (the assertion
|
|
2459
|
-
* then no longer throws). Pass `{ frozen: true }` so the fixture is never
|
|
2460
|
-
* rewritten and the mismatch/error still throws in update mode.
|
|
2461
|
-
*
|
|
2462
|
-
* The structural signal is precise for the wrapped forms; a `toMatch` routed
|
|
2463
|
-
* through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
|
|
2464
|
-
* is out of static reach — see the D13 process note for that residue.
|
|
2465
|
-
*/
|
|
2466
|
-
const d13wUnfrozenNegativeFixture = {
|
|
2228
|
+
const c11ContractDataPairing = {
|
|
2467
2229
|
create(context) {
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2230
|
+
const file = context.physicalFilename;
|
|
2231
|
+
if (!TEST_FILE$7.test(file) || !segments(file).includes("specs")) return {};
|
|
2232
|
+
return { Program(node) {
|
|
2233
|
+
const contractsDir = (0, node_path.join)((0, node_path.dirname)(file), "contracts");
|
|
2234
|
+
for (const provider of PROVIDERS) {
|
|
2235
|
+
const entries = listDirectory((0, node_path.join)(contractsDir, provider));
|
|
2236
|
+
if (entries === null) continue;
|
|
2237
|
+
const present = new Set(entries);
|
|
2238
|
+
for (const entry of entries) {
|
|
2239
|
+
if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
|
|
2240
|
+
const owner = ownerOf(entry);
|
|
2241
|
+
if (!present.has(owner)) context.report({
|
|
2242
|
+
data: {
|
|
2243
|
+
entry,
|
|
2244
|
+
owner,
|
|
2245
|
+
provider
|
|
2246
|
+
},
|
|
2247
|
+
messageId: "orphan",
|
|
2248
|
+
node
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2478
2252
|
} };
|
|
2479
2253
|
},
|
|
2480
2254
|
meta: {
|
|
2481
|
-
docs: RULE_DOCS["
|
|
2482
|
-
messages: {
|
|
2483
|
-
type: "
|
|
2255
|
+
docs: RULE_DOCS["c11-contract-data-pairing"],
|
|
2256
|
+
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/13-linting.md)." },
|
|
2257
|
+
type: "problem"
|
|
2484
2258
|
}
|
|
2485
2259
|
};
|
|
2486
2260
|
//#endregion
|
|
2487
|
-
//#region src/lint/rules/
|
|
2488
|
-
|
|
2489
|
-
const MIN_STATUS = 100;
|
|
2490
|
-
const MAX_STATUS = 599;
|
|
2491
|
-
/** Matchers that pin a single value — the shape of a status probe. */
|
|
2492
|
-
const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
|
|
2493
|
-
/** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
|
|
2494
|
-
function isExpectCall(node) {
|
|
2495
|
-
if (node?.type !== "CallExpression") return false;
|
|
2496
|
-
const callee = node.callee;
|
|
2497
|
-
return callee?.type === "Identifier" && callee.name === "expect";
|
|
2498
|
-
}
|
|
2499
|
-
/** Is `node` a numeric literal in the HTTP status range (100–599)? */
|
|
2500
|
-
function isStatusLiteral(node) {
|
|
2501
|
-
if (node?.type !== "Literal" || typeof node.value !== "number") return false;
|
|
2502
|
-
const value = node.value;
|
|
2503
|
-
return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
|
|
2504
|
-
}
|
|
2505
|
-
/**
|
|
2506
|
-
* Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
|
|
2507
|
-
* `X.status` is any `.status` member access and `N` is a numeric literal in the
|
|
2508
|
-
* HTTP status range? The numeric-literal gate keeps container-status STRINGS
|
|
2509
|
-
* (`expect(neo.status).toBe('running')`) out.
|
|
2510
|
-
*/
|
|
2511
|
-
function isStatusProbe(node) {
|
|
2512
|
-
if (node.type !== "CallExpression") return false;
|
|
2513
|
-
const callee = node.callee;
|
|
2514
|
-
if (callee?.type !== "MemberExpression") return false;
|
|
2515
|
-
const matcher = memberPropertyName(callee);
|
|
2516
|
-
if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
|
|
2517
|
-
const expectCall = callee.object;
|
|
2518
|
-
if (!isExpectCall(expectCall)) return false;
|
|
2519
|
-
const subject = (expectCall?.arguments ?? [])[0];
|
|
2520
|
-
if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
|
|
2521
|
-
const expected = (node.arguments ?? [])[0];
|
|
2522
|
-
return isStatusLiteral(expected);
|
|
2523
|
-
}
|
|
2261
|
+
//#region src/lint/rules/c13-underscored-ground.ts
|
|
2262
|
+
const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2524
2263
|
/**
|
|
2525
|
-
* CONVENTIONS
|
|
2526
|
-
*
|
|
2527
|
-
*
|
|
2528
|
-
* `
|
|
2264
|
+
* CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
|
|
2265
|
+
* directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
|
|
2266
|
+
* `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
|
|
2267
|
+
* `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
|
|
2268
|
+
* fallback.
|
|
2529
2269
|
*
|
|
2530
|
-
*
|
|
2531
|
-
*
|
|
2532
|
-
*
|
|
2533
|
-
*
|
|
2534
|
-
*
|
|
2535
|
-
* the
|
|
2270
|
+
* The catch this rule exists for: a consumer that upgrades and keeps the old
|
|
2271
|
+
* names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
|
|
2272
|
+
* `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
|
|
2273
|
+
* and the tree becomes invisible rather than wrong. The failure that follows is
|
|
2274
|
+
* "fixture does not exist" on every spec at once, one step removed from the
|
|
2275
|
+
* cause; this rule states the cause and the rename.
|
|
2276
|
+
*
|
|
2277
|
+
* Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
|
|
2278
|
+
* spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
|
|
2279
|
+
* nearest specs root. A DOMAIN folder named after the ground it holds
|
|
2280
|
+
* (`specs/api/requests/_requests/`) is never a probe target — only what sits
|
|
2281
|
+
* beside a spec is.
|
|
2536
2282
|
*/
|
|
2537
|
-
const
|
|
2283
|
+
const c13UnderscoredGround = {
|
|
2538
2284
|
create(context) {
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2285
|
+
const file = context.physicalFilename;
|
|
2286
|
+
const anchor = specsAnchor(file);
|
|
2287
|
+
if (!TEST_FILE$6.test(file) || anchor === void 0) return {};
|
|
2288
|
+
return { Program(node) {
|
|
2289
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2290
|
+
const probe = (parent, legacy) => {
|
|
2291
|
+
const path = (0, node_path.join)(parent, legacy);
|
|
2292
|
+
if (seen.has(path) || !isDirectory(path)) return;
|
|
2293
|
+
seen.add(path);
|
|
2294
|
+
context.report({
|
|
2295
|
+
data: {
|
|
2296
|
+
ground: RENAMED_GROUND_DIRS[legacy] ?? legacy,
|
|
2297
|
+
legacy
|
|
2298
|
+
},
|
|
2299
|
+
messageId: "notUnderscored",
|
|
2300
|
+
node
|
|
2301
|
+
});
|
|
2302
|
+
};
|
|
2303
|
+
for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe((0, node_path.dirname)(file), legacy);
|
|
2304
|
+
probe(anchor.directory, "fixtures");
|
|
2554
2305
|
} };
|
|
2555
2306
|
},
|
|
2556
2307
|
meta: {
|
|
2557
|
-
docs: RULE_DOCS["
|
|
2558
|
-
messages: {
|
|
2559
|
-
type: "
|
|
2308
|
+
docs: RULE_DOCS["c13-underscored-ground"],
|
|
2309
|
+
messages: { notUnderscored: "The directory \"{{legacy}}/\" is a pre-14 ground name — rename it to \"{{ground}}/\" (`git mv {{legacy}} {{ground}}`). What a spec stands on carries the underscore; a spec's own folder never does, and no resolver reads the un-underscored name (C13 — see docs/13-linting.md)." },
|
|
2310
|
+
type: "problem"
|
|
2560
2311
|
}
|
|
2561
2312
|
};
|
|
2562
2313
|
//#endregion
|
|
@@ -2610,7 +2361,7 @@ const d2AwaitIoMatcher = {
|
|
|
2610
2361
|
},
|
|
2611
2362
|
meta: {
|
|
2612
2363
|
docs: RULE_DOCS["d2-await-io-matcher"],
|
|
2613
|
-
messages: { mustAwait: "expect(…).{{matcher}}(…) is IO-backed and returns a promise — it must be awaited or returned, else the assertion never runs (D2 — see docs/
|
|
2364
|
+
messages: { mustAwait: "expect(…).{{matcher}}(…) is IO-backed and returns a promise — it must be awaited or returned, else the assertion never runs (D2 — see docs/13-linting.md)." },
|
|
2614
2365
|
type: "problem"
|
|
2615
2366
|
}
|
|
2616
2367
|
};
|
|
@@ -2664,7 +2415,7 @@ const d2wAwaitSyncMatcher = {
|
|
|
2664
2415
|
},
|
|
2665
2416
|
meta: {
|
|
2666
2417
|
docs: RULE_DOCS["d2w-await-sync-matcher"],
|
|
2667
|
-
messages: { redundantAwait: "await on expect(…).{{matcher}}(…) is redundant — that matcher is synchronous; only IO-backed matchers (toMatchRows/toBeEmpty/toBeRunning) are awaited (D2 — see docs/
|
|
2418
|
+
messages: { redundantAwait: "await on expect(…).{{matcher}}(…) is redundant — that matcher is synchronous; only IO-backed matchers (toMatchRows/toBeEmpty/toBeRunning) are awaited (D2 — see docs/13-linting.md)." },
|
|
2668
2419
|
type: "suggestion"
|
|
2669
2420
|
}
|
|
2670
2421
|
};
|
|
@@ -2692,7 +2443,7 @@ const KNOWN_TOKEN_LITERAL = new RegExp(`^\\{\\{(?:${[
|
|
|
2692
2443
|
"url",
|
|
2693
2444
|
"uuid",
|
|
2694
2445
|
"workdir"
|
|
2695
|
-
].join("|")})(?:#[\\w.-]+)?\\}\\}
|
|
2446
|
+
].join("|")})(?:#[\\w.-]+)?\\}\\}$`, "u");
|
|
2696
2447
|
/**
|
|
2697
2448
|
* Is this function a pure chain of `.replace(…, '{{token}}')` normalisations?
|
|
2698
2449
|
* Accepts an arrow with an expression body or a single-return body whose
|
|
@@ -2704,8 +2455,9 @@ function isTokenEquivalentTransform(fn) {
|
|
|
2704
2455
|
let expression = fn.body;
|
|
2705
2456
|
if (expression?.type === "BlockStatement") {
|
|
2706
2457
|
const statements = expression.body ?? [];
|
|
2707
|
-
|
|
2708
|
-
|
|
2458
|
+
const [only] = statements;
|
|
2459
|
+
if (statements.length !== 1 || only?.type !== "ReturnStatement") return false;
|
|
2460
|
+
expression = only.argument;
|
|
2709
2461
|
}
|
|
2710
2462
|
let sawReplace = false;
|
|
2711
2463
|
while (expression?.type === "CallExpression") {
|
|
@@ -2741,124 +2493,352 @@ const d6wTransformTokenEquivalent = {
|
|
|
2741
2493
|
},
|
|
2742
2494
|
meta: {
|
|
2743
2495
|
docs: RULE_DOCS["d6w-transform-token-equivalent"],
|
|
2744
|
-
messages: { tokenEquivalent: "This transform only rewrites output into known token literals — write the tokens in the _expected/ fixture instead; transform is an escape hatch for uncovered noise (D6 — see docs/
|
|
2496
|
+
messages: { tokenEquivalent: "This transform only rewrites output into known token literals — write the tokens in the _expected/ fixture instead; transform is an escape hatch for uncovered noise (D6 — see docs/13-linting.md)." },
|
|
2745
2497
|
type: "suggestion"
|
|
2746
2498
|
}
|
|
2747
|
-
};
|
|
2748
|
-
//#endregion
|
|
2749
|
-
//#region src/lint/rules/d8w-text-bypass.ts
|
|
2750
|
-
/** Text-oriented matchers that tempt a raw `.text` bypass (D8). */
|
|
2751
|
-
const TEXT_MATCHERS = /* @__PURE__ */ new Set(["toContain", "toMatch"]);
|
|
2499
|
+
};
|
|
2500
|
+
//#endregion
|
|
2501
|
+
//#region src/lint/rules/d8w-text-bypass.ts
|
|
2502
|
+
/** Text-oriented matchers that tempt a raw `.text` bypass (D8). */
|
|
2503
|
+
const TEXT_MATCHERS = /* @__PURE__ */ new Set(["toContain", "toMatch"]);
|
|
2504
|
+
/**
|
|
2505
|
+
* CONVENTIONS D8 (warning) — `.text` is the raw stream escape hatch. Asserting
|
|
2506
|
+
* `expect(result.text).toContain(…)` throws away the typed subject (its token
|
|
2507
|
+
* grammar, its `toMatch('file.txt')` fixture resolution) for a substring check.
|
|
2508
|
+
* Prefer asserting on the accessor subject itself.
|
|
2509
|
+
*/
|
|
2510
|
+
const d8wTextBypass = {
|
|
2511
|
+
create(context) {
|
|
2512
|
+
return { CallExpression(node) {
|
|
2513
|
+
const callee = node.callee;
|
|
2514
|
+
const matcher = callee === void 0 ? void 0 : memberPropertyName(callee);
|
|
2515
|
+
if (matcher === void 0 || !TEXT_MATCHERS.has(matcher)) return;
|
|
2516
|
+
const expectCall = callee?.object;
|
|
2517
|
+
if (expectCall?.type !== "CallExpression") return;
|
|
2518
|
+
const expectCallee = expectCall.callee;
|
|
2519
|
+
if (expectCallee?.type !== "Identifier" || expectCallee.name !== "expect") return;
|
|
2520
|
+
const subject = expectCall.arguments?.[0];
|
|
2521
|
+
if (subject !== void 0 && memberPropertyName(subject) === "text") context.report({
|
|
2522
|
+
data: { matcher },
|
|
2523
|
+
messageId: "textBypass",
|
|
2524
|
+
node
|
|
2525
|
+
});
|
|
2526
|
+
} };
|
|
2527
|
+
},
|
|
2528
|
+
meta: {
|
|
2529
|
+
docs: RULE_DOCS["d8w-text-bypass"],
|
|
2530
|
+
messages: { textBypass: "expect(x.text).{{matcher}}(…) asserts on the raw stream — prefer the typed accessor subject (expect(x)) so the token grammar and fixture resolution apply (D8 — see docs/13-linting.md)." },
|
|
2531
|
+
type: "suggestion"
|
|
2532
|
+
}
|
|
2533
|
+
};
|
|
2534
|
+
//#endregion
|
|
2535
|
+
//#region src/lint/rules/d9w-single-use-ref.ts
|
|
2536
|
+
const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2537
|
+
/** A captured-ref token: `{{kind#ref}}`. Captures the ref name. */
|
|
2538
|
+
const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/gu;
|
|
2539
|
+
/** Is this call `match.ref(...)`? */
|
|
2540
|
+
function isMatchRef(node) {
|
|
2541
|
+
const callee = node.callee;
|
|
2542
|
+
if (callee?.type !== "MemberExpression" || memberPropertyName(callee) !== "ref") return false;
|
|
2543
|
+
const object = callee.object;
|
|
2544
|
+
return object?.type === "Identifier" && object.name === "match";
|
|
2545
|
+
}
|
|
2546
|
+
/** Ref names captured in a fixture text (or a directory-tree snapshot's files). */
|
|
2547
|
+
function refsInFixture(target) {
|
|
2548
|
+
const refs = [];
|
|
2549
|
+
const collect = (text) => {
|
|
2550
|
+
for (const match of text.matchAll(REF_TOKEN)) refs.push(match.groups?.ref ?? "");
|
|
2551
|
+
};
|
|
2552
|
+
if (isFile(target)) {
|
|
2553
|
+
const text = readFileCached(target);
|
|
2554
|
+
if (text !== null) collect(text);
|
|
2555
|
+
return refs;
|
|
2556
|
+
}
|
|
2557
|
+
if (isDirectory(target)) for (const entry of listDirectory(target) ?? []) refs.push(...refsInFixture((0, node_path.join)(target, entry)));
|
|
2558
|
+
return refs;
|
|
2559
|
+
}
|
|
2560
|
+
/**
|
|
2561
|
+
* CONVENTIONS D9 (warning) — a capture ref (`match.ref('order')`,
|
|
2562
|
+
* `{{uuid#order}}`) earns its keep only when it is asserted more than once (the
|
|
2563
|
+
* point of a capture is the equality check on later occurrences). Aggregating
|
|
2564
|
+
* per file — code `match.ref` literals plus the `{{kind#ref}}` tokens of the
|
|
2565
|
+
* `_expected/` fixtures the file references — a ref that appears exactly once
|
|
2566
|
+
* everywhere is a plain matcher wearing a name.
|
|
2567
|
+
*/
|
|
2568
|
+
const d9wSingleUseRef = {
|
|
2569
|
+
create(context) {
|
|
2570
|
+
const file = context.physicalFilename;
|
|
2571
|
+
if (!TEST_FILE$5.test(file) || !segments(file).includes("specs")) return {};
|
|
2572
|
+
const featureDir = (0, node_path.dirname)(file);
|
|
2573
|
+
return { Program(program) {
|
|
2574
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2575
|
+
const codeNodes = /* @__PURE__ */ new Map();
|
|
2576
|
+
const fixtureNodes = /* @__PURE__ */ new Map();
|
|
2577
|
+
const bump = (ref) => {
|
|
2578
|
+
counts.set(ref, (counts.get(ref) ?? 0) + 1);
|
|
2579
|
+
};
|
|
2580
|
+
walk(program, (node) => {
|
|
2581
|
+
if (node.type !== "CallExpression") return;
|
|
2582
|
+
if (isMatchRef(node)) {
|
|
2583
|
+
const ref = stringValue(node.arguments?.[0]);
|
|
2584
|
+
if (ref !== void 0) {
|
|
2585
|
+
bump(ref);
|
|
2586
|
+
if (!codeNodes.has(ref)) codeNodes.set(ref, node);
|
|
2587
|
+
}
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
if (memberPropertyName(node.callee) === "toMatch") {
|
|
2591
|
+
const name = stringValue(node.arguments?.[0]);
|
|
2592
|
+
if (name !== void 0) {
|
|
2593
|
+
const target = (0, node_path.join)(featureDir, GROUND_EXPECTED, name.replace(/\/+$/u, ""));
|
|
2594
|
+
for (const ref of refsInFixture(target)) {
|
|
2595
|
+
bump(ref);
|
|
2596
|
+
if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
});
|
|
2601
|
+
for (const [ref, count] of counts) {
|
|
2602
|
+
const node = codeNodes.get(ref) ?? fixtureNodes.get(ref);
|
|
2603
|
+
if (count === 1 && node !== void 0) context.report({
|
|
2604
|
+
data: { ref },
|
|
2605
|
+
messageId: "singleUse",
|
|
2606
|
+
node
|
|
2607
|
+
});
|
|
2608
|
+
}
|
|
2609
|
+
} };
|
|
2610
|
+
},
|
|
2611
|
+
meta: {
|
|
2612
|
+
docs: RULE_DOCS["d9w-single-use-ref"],
|
|
2613
|
+
messages: { singleUse: "Capture ref \"{{ref}}\" is used only once in this spec — a ref earns its name by asserting equality across occurrences; use a plain matcher instead (D9 — see docs/13-linting.md)." },
|
|
2614
|
+
type: "suggestion"
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2617
|
+
//#endregion
|
|
2618
|
+
//#region src/lint/rules/d12w-response-body-probe.ts
|
|
2619
|
+
/** A test reading the response body this many times (or more) is a cluster. */
|
|
2620
|
+
const DEFAULT_THRESHOLD = 3;
|
|
2621
|
+
/** Is `node` the `x.response.body` member access itself? */
|
|
2622
|
+
function isResponseBody(node) {
|
|
2623
|
+
if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
|
|
2624
|
+
const object = node.object;
|
|
2625
|
+
return object !== void 0 && memberPropertyName(object) === "response";
|
|
2626
|
+
}
|
|
2627
|
+
/** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
|
|
2628
|
+
function unwrap(node) {
|
|
2629
|
+
let current = node ?? void 0;
|
|
2630
|
+
while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
|
|
2631
|
+
else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
|
|
2632
|
+
else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
|
|
2633
|
+
else return current;
|
|
2634
|
+
}
|
|
2635
|
+
/**
|
|
2636
|
+
* CONVENTIONS D12 (warning) — a full-response golden is the norm for API
|
|
2637
|
+
* assertions (the D11 boundary, mechanized). A test that accumulates a CLUSTER
|
|
2638
|
+
* of raw body probes — reading `.response.body` (or a variable cast from it)
|
|
2639
|
+
* `threshold`+ times in one test callback — should collapse into one golden:
|
|
2640
|
+
* `expect(result.response).toMatch('case.http')`. One or two probes stay silent
|
|
2641
|
+
* (a legitimate scalpel: absence, a single-field delta, third-party output).
|
|
2642
|
+
*
|
|
2643
|
+
* A probe is a body-rooted read: a direct `.response.body` use, or a field
|
|
2644
|
+
* access on the local binding a `const body = result.response.body as {…}`
|
|
2645
|
+
* introduces. The count is the same threshold the golden transformation applies.
|
|
2646
|
+
*/
|
|
2647
|
+
const d12wResponseBodyProbe = {
|
|
2648
|
+
create(context) {
|
|
2649
|
+
if (!segments(context.filename).includes("specs")) return {};
|
|
2650
|
+
const threshold = context.options[0]?.threshold ?? DEFAULT_THRESHOLD;
|
|
2651
|
+
return { CallExpression(node) {
|
|
2652
|
+
if (!isTestCallee(node.callee)) return;
|
|
2653
|
+
const callback = findTestCallback(node.arguments ?? []);
|
|
2654
|
+
if (callback === void 0) return;
|
|
2655
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
2656
|
+
let responseBodyNodes = 0;
|
|
2657
|
+
walk(callback, (inner) => {
|
|
2658
|
+
if (isResponseBody(inner)) responseBodyNodes++;
|
|
2659
|
+
if (inner.type === "VariableDeclarator") {
|
|
2660
|
+
const init = unwrap(inner.init);
|
|
2661
|
+
const id = inner.id;
|
|
2662
|
+
if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
|
|
2663
|
+
}
|
|
2664
|
+
});
|
|
2665
|
+
let aliasReads = 0;
|
|
2666
|
+
walk(callback, (inner) => {
|
|
2667
|
+
if (inner.type !== "MemberExpression") return;
|
|
2668
|
+
const object = inner.object;
|
|
2669
|
+
if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
|
|
2670
|
+
});
|
|
2671
|
+
const probes = responseBodyNodes - aliases.size + aliasReads;
|
|
2672
|
+
if (probes >= threshold) context.report({
|
|
2673
|
+
data: { count: String(probes) },
|
|
2674
|
+
messageId: "bodyProbeCluster",
|
|
2675
|
+
node
|
|
2676
|
+
});
|
|
2677
|
+
} };
|
|
2678
|
+
},
|
|
2679
|
+
meta: {
|
|
2680
|
+
docs: RULE_DOCS["d12w-response-body-probe"],
|
|
2681
|
+
defaultOptions: [{ threshold: DEFAULT_THRESHOLD }],
|
|
2682
|
+
messages: { bodyProbeCluster: "{{count}} body probes in one test — this wants a full golden: expect(result.response).toMatch('x.http') (D12 — see docs/13-linting.md)." },
|
|
2683
|
+
schema: [{
|
|
2684
|
+
additionalProperties: false,
|
|
2685
|
+
properties: { threshold: {
|
|
2686
|
+
minimum: 1,
|
|
2687
|
+
type: "integer"
|
|
2688
|
+
} },
|
|
2689
|
+
type: "object"
|
|
2690
|
+
}],
|
|
2691
|
+
type: "suggestion"
|
|
2692
|
+
}
|
|
2693
|
+
};
|
|
2694
|
+
//#endregion
|
|
2695
|
+
//#region src/lint/rules/d13w-unfrozen-negative-fixture.ts
|
|
2696
|
+
/**
|
|
2697
|
+
* Does `arguments[1]` of a `toMatch(name, options)` call carry `{ frozen: true }`?
|
|
2698
|
+
* A frozen fixture opts out of update-mode rewriting, so it is exempt.
|
|
2699
|
+
*/
|
|
2700
|
+
function hasFrozenOption(args) {
|
|
2701
|
+
const options = args[1];
|
|
2702
|
+
if (options === void 0 || options.type !== "ObjectExpression") return false;
|
|
2703
|
+
const value = findProperty(options, "frozen")?.value;
|
|
2704
|
+
return value?.type === "Literal" && value.value === true;
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Is this `toMatch(…)` call nested inside a WRAPPING `expect(…)` — the shape of a
|
|
2708
|
+
* negative assertion (`expect(() => …toMatch(…)).toThrow()` or
|
|
2709
|
+
* `expect(…toMatch(…)).rejects.toThrow()`)? The subject's own `expect(x)` is the
|
|
2710
|
+
* member OBJECT of `.toMatch` (a descendant), never an ancestor, so a positive
|
|
2711
|
+
* `expect(x).toMatch('f')` never trips this. The walk stays inside the current
|
|
2712
|
+
* expression: it stops at the first statement boundary or non-`expect` call, so
|
|
2713
|
+
* a `toMatch` merely written inside `inUpdateMode(() => …)` is NOT flagged.
|
|
2714
|
+
*/
|
|
2715
|
+
const TRANSPARENT_ANCESTORS = /* @__PURE__ */ new Set([
|
|
2716
|
+
"ArrowFunctionExpression",
|
|
2717
|
+
"AwaitExpression",
|
|
2718
|
+
"ChainExpression",
|
|
2719
|
+
"MemberExpression",
|
|
2720
|
+
"ParenthesizedExpression",
|
|
2721
|
+
"TSAsExpression",
|
|
2722
|
+
"TSNonNullExpression"
|
|
2723
|
+
]);
|
|
2724
|
+
function isWrappedInExpect(node) {
|
|
2725
|
+
let current = node.parent;
|
|
2726
|
+
while (current !== void 0) {
|
|
2727
|
+
if (current.type === "CallExpression") {
|
|
2728
|
+
const callee = current.callee;
|
|
2729
|
+
return callee?.type === "Identifier" && callee.name === "expect";
|
|
2730
|
+
}
|
|
2731
|
+
if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
|
|
2732
|
+
current = current.parent;
|
|
2733
|
+
}
|
|
2734
|
+
return false;
|
|
2735
|
+
}
|
|
2752
2736
|
/**
|
|
2753
|
-
* CONVENTIONS
|
|
2754
|
-
* `expect(
|
|
2755
|
-
*
|
|
2756
|
-
*
|
|
2737
|
+
* CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
|
|
2738
|
+
* test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
|
|
2739
|
+
* asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
|
|
2740
|
+
* unfrozen fixture is silently rewritten with the actual output — the matcher
|
|
2741
|
+
* writes instead of throwing — which destroys the negative case (the assertion
|
|
2742
|
+
* then no longer throws). Pass `{ frozen: true }` so the fixture is never
|
|
2743
|
+
* rewritten and the mismatch/error still throws in update mode.
|
|
2744
|
+
*
|
|
2745
|
+
* The structural signal is precise for the wrapped forms; a `toMatch` routed
|
|
2746
|
+
* through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
|
|
2747
|
+
* is out of static reach — see the D13 process note for that residue.
|
|
2757
2748
|
*/
|
|
2758
|
-
const
|
|
2749
|
+
const d13wUnfrozenNegativeFixture = {
|
|
2759
2750
|
create(context) {
|
|
2751
|
+
if (!segments(context.filename).includes("specs")) return {};
|
|
2760
2752
|
return { CallExpression(node) {
|
|
2761
2753
|
const callee = node.callee;
|
|
2762
|
-
|
|
2763
|
-
if (
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
if (expectCallee?.type !== "Identifier" || expectCallee.name !== "expect") return;
|
|
2768
|
-
const subject = expectCall.arguments?.[0];
|
|
2769
|
-
if (subject !== void 0 && memberPropertyName(subject) === "text") context.report({
|
|
2770
|
-
data: { matcher },
|
|
2771
|
-
messageId: "textBypass",
|
|
2754
|
+
if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
|
|
2755
|
+
if (!isWrappedInExpect(node)) return;
|
|
2756
|
+
if (hasFrozenOption(node.arguments ?? [])) return;
|
|
2757
|
+
context.report({
|
|
2758
|
+
messageId: "unfrozenNegativeFixture",
|
|
2772
2759
|
node
|
|
2773
2760
|
});
|
|
2774
2761
|
} };
|
|
2775
2762
|
},
|
|
2776
2763
|
meta: {
|
|
2777
|
-
docs: RULE_DOCS["
|
|
2778
|
-
messages: {
|
|
2764
|
+
docs: RULE_DOCS["d13w-unfrozen-negative-fixture"],
|
|
2765
|
+
messages: { unfrozenNegativeFixture: "This toMatch asserts a mismatch (wrapped in expect(…).toThrow/.rejects) — pass { frozen: true } so TEST_UPDATE=1 never overwrites the deliberately-wrong fixture (D13 — see docs/13-linting.md)." },
|
|
2779
2766
|
type: "suggestion"
|
|
2780
2767
|
}
|
|
2781
2768
|
};
|
|
2782
2769
|
//#endregion
|
|
2783
|
-
//#region src/lint/rules/
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
const
|
|
2787
|
-
/**
|
|
2788
|
-
|
|
2770
|
+
//#region src/lint/rules/d15w-status-only-probe.ts
|
|
2771
|
+
/** An HTTP status code lives in this range — the numeric-literal gate. */
|
|
2772
|
+
const MIN_STATUS = 100;
|
|
2773
|
+
const MAX_STATUS = 599;
|
|
2774
|
+
/** Matchers that pin a single value — the shape of a status probe. */
|
|
2775
|
+
const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
|
|
2776
|
+
/** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
|
|
2777
|
+
function isExpectCall(node) {
|
|
2778
|
+
if (node?.type !== "CallExpression") return false;
|
|
2789
2779
|
const callee = node.callee;
|
|
2790
|
-
|
|
2791
|
-
const object = callee.object;
|
|
2792
|
-
return object?.type === "Identifier" && object.name === "match";
|
|
2780
|
+
return callee?.type === "Identifier" && callee.name === "expect";
|
|
2793
2781
|
}
|
|
2794
|
-
/**
|
|
2795
|
-
function
|
|
2796
|
-
|
|
2797
|
-
const
|
|
2798
|
-
|
|
2799
|
-
};
|
|
2800
|
-
if (isFile(target)) {
|
|
2801
|
-
const text = readFileCached(target);
|
|
2802
|
-
if (text !== null) collect(text);
|
|
2803
|
-
return refs;
|
|
2804
|
-
}
|
|
2805
|
-
if (isDirectory(target)) for (const entry of listDirectory(target) ?? []) refs.push(...refsInFixture((0, node_path.join)(target, entry)));
|
|
2806
|
-
return refs;
|
|
2782
|
+
/** Is `node` a numeric literal in the HTTP status range (100–599)? */
|
|
2783
|
+
function isStatusLiteral(node) {
|
|
2784
|
+
if (node?.type !== "Literal" || typeof node.value !== "number") return false;
|
|
2785
|
+
const { value } = node;
|
|
2786
|
+
return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
|
|
2807
2787
|
}
|
|
2808
2788
|
/**
|
|
2809
|
-
*
|
|
2810
|
-
* `
|
|
2811
|
-
*
|
|
2812
|
-
*
|
|
2813
|
-
* `_expected/` fixtures the file references — a ref that appears exactly once
|
|
2814
|
-
* everywhere is a plain matcher wearing a name.
|
|
2789
|
+
* Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
|
|
2790
|
+
* `X.status` is any `.status` member access and `N` is a numeric literal in the
|
|
2791
|
+
* HTTP status range? The numeric-literal gate keeps container-status STRINGS
|
|
2792
|
+
* (`expect(neo.status).toBe('running')`) out.
|
|
2815
2793
|
*/
|
|
2816
|
-
|
|
2794
|
+
function isStatusProbe(node) {
|
|
2795
|
+
if (node.type !== "CallExpression") return false;
|
|
2796
|
+
const callee = node.callee;
|
|
2797
|
+
if (callee?.type !== "MemberExpression") return false;
|
|
2798
|
+
const matcher = memberPropertyName(callee);
|
|
2799
|
+
if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
|
|
2800
|
+
const expectCall = callee.object;
|
|
2801
|
+
if (!isExpectCall(expectCall)) return false;
|
|
2802
|
+
const subject = (expectCall?.arguments ?? [])[0];
|
|
2803
|
+
if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
|
|
2804
|
+
const expected = (node.arguments ?? [])[0];
|
|
2805
|
+
return isStatusLiteral(expected);
|
|
2806
|
+
}
|
|
2807
|
+
/**
|
|
2808
|
+
* CONVENTIONS D11/D15 (warning) — a spec test whose ONLY assertions are HTTP
|
|
2809
|
+
* status probes (`expect(X.status).toBe(N)` / `.toEqual(N)`, N a numeric literal
|
|
2810
|
+
* 100–599) throws away the full-response golden. Pin the whole response instead:
|
|
2811
|
+
* `expect(result.response).toMatch('case.http')`.
|
|
2812
|
+
*
|
|
2813
|
+
* This complements d12w (which needs a CLUSTER of ≥3 body probes): the lone
|
|
2814
|
+
* `expect(result.status).toBe(422)` as a test's sole assertion falls below that
|
|
2815
|
+
* threshold yet still hides everything the response carries. The rule fires only
|
|
2816
|
+
* when EVERY `expect(…)` in the callback is a status probe — a status probe
|
|
2817
|
+
* sitting NEXT TO a real assertion (a golden, `toMatchRows`, `toContain`, …) is
|
|
2818
|
+
* the legitimate scalpel and stays silent.
|
|
2819
|
+
*/
|
|
2820
|
+
const d15wStatusOnlyProbe = {
|
|
2817
2821
|
create(context) {
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
if (ref !== void 0) {
|
|
2833
|
-
bump(ref);
|
|
2834
|
-
if (!codeNodes.has(ref)) codeNodes.set(ref, node);
|
|
2835
|
-
}
|
|
2836
|
-
return;
|
|
2837
|
-
}
|
|
2838
|
-
if (memberPropertyName(node.callee) === "toMatch") {
|
|
2839
|
-
const name = stringValue(node.arguments?.[0]);
|
|
2840
|
-
if (name !== void 0) {
|
|
2841
|
-
const target = (0, node_path.join)(featureDir, GROUND_EXPECTED, name.replace(/\/+$/, ""));
|
|
2842
|
-
for (const ref of refsInFixture(target)) {
|
|
2843
|
-
bump(ref);
|
|
2844
|
-
if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
|
|
2845
|
-
}
|
|
2846
|
-
}
|
|
2847
|
-
}
|
|
2822
|
+
if (!segments(context.filename).includes("specs")) return {};
|
|
2823
|
+
return { CallExpression(node) {
|
|
2824
|
+
if (!isTestCallee(node.callee)) return;
|
|
2825
|
+
const callback = findTestCallback(node.arguments ?? []);
|
|
2826
|
+
if (callback === void 0) return;
|
|
2827
|
+
let expectCalls = 0;
|
|
2828
|
+
let statusProbes = 0;
|
|
2829
|
+
walk(callback, (inner) => {
|
|
2830
|
+
if (isExpectCall(inner)) expectCalls++;
|
|
2831
|
+
if (isStatusProbe(inner)) statusProbes++;
|
|
2832
|
+
});
|
|
2833
|
+
if (expectCalls >= 1 && statusProbes === expectCalls) context.report({
|
|
2834
|
+
messageId: "statusOnlyProbe",
|
|
2835
|
+
node
|
|
2848
2836
|
});
|
|
2849
|
-
for (const [ref, count] of counts) {
|
|
2850
|
-
const node = codeNodes.get(ref) ?? fixtureNodes.get(ref);
|
|
2851
|
-
if (count === 1 && node !== void 0) context.report({
|
|
2852
|
-
data: { ref },
|
|
2853
|
-
messageId: "singleUse",
|
|
2854
|
-
node
|
|
2855
|
-
});
|
|
2856
|
-
}
|
|
2857
2837
|
} };
|
|
2858
2838
|
},
|
|
2859
2839
|
meta: {
|
|
2860
|
-
docs: RULE_DOCS["
|
|
2861
|
-
messages: {
|
|
2840
|
+
docs: RULE_DOCS["d15w-status-only-probe"],
|
|
2841
|
+
messages: { statusOnlyProbe: "The test's only assertion(s) are status probes — pin the full response: expect(result.response).toMatch('case.http') (D11 — see docs/13-linting.md)." },
|
|
2862
2842
|
type: "suggestion"
|
|
2863
2843
|
}
|
|
2864
2844
|
};
|
|
@@ -2916,7 +2896,7 @@ function findOwnManifest(startDir) {
|
|
|
2916
2896
|
*/
|
|
2917
2897
|
function declaredSubpaths(startDir) {
|
|
2918
2898
|
if (startDir === void 0 && cached !== null) return cached;
|
|
2919
|
-
const exports = findOwnManifest(startDir ??
|
|
2899
|
+
const exports = findOwnManifest(startDir ?? __dirname)?.exports;
|
|
2920
2900
|
const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
|
|
2921
2901
|
if (startDir === void 0) cached = subpaths;
|
|
2922
2902
|
return subpaths;
|
|
@@ -2952,15 +2932,15 @@ const f1NoSubpathImport = {
|
|
|
2952
2932
|
},
|
|
2953
2933
|
meta: {
|
|
2954
2934
|
docs: RULE_DOCS["f1-no-subpath-import"],
|
|
2955
|
-
messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/
|
|
2935
|
+
messages: { subpath: "Import from \"@jterrazz/test\", not \"{{source}}\" — internal subpaths do not exist (F1 — see docs/13-linting.md). Published subpaths, exempt everywhere: {{published}}." },
|
|
2956
2936
|
type: "problem"
|
|
2957
2937
|
}
|
|
2958
2938
|
};
|
|
2959
2939
|
//#endregion
|
|
2960
2940
|
//#region src/lint/rules/f2-no-test-imports-in-prod.ts
|
|
2961
|
-
const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx
|
|
2962
|
-
const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s
|
|
2963
|
-
const TEST_IMPORT$1 = /\.(?:test|fixtures)(?:\.[cm]?[jt]sx?)
|
|
2941
|
+
const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx?$/u;
|
|
2942
|
+
const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s$/u;
|
|
2943
|
+
const TEST_IMPORT$1 = /\.(?:test|fixtures)(?:\.[cm]?[jt]sx?)?$/u;
|
|
2964
2944
|
const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
|
|
2965
2945
|
/**
|
|
2966
2946
|
* CONVENTIONS F2 — production code never imports a test artefact. In a
|
|
@@ -3000,7 +2980,7 @@ const f2NoTestImportsInProd = {
|
|
|
3000
2980
|
},
|
|
3001
2981
|
meta: {
|
|
3002
2982
|
docs: RULE_DOCS["f2-no-test-imports-in-prod"],
|
|
3003
|
-
messages: { testImport: "Production code must not import the test artefact \"{{source}}\" — test imports are only legal from specs/, *.test.ts, *.fixtures.ts or *.specification.ts (F2 — see docs/
|
|
2983
|
+
messages: { testImport: "Production code must not import the test artefact \"{{source}}\" — test imports are only legal from specs/, *.test.ts, *.fixtures.ts or *.specification.ts (F2 — see docs/13-linting.md)." },
|
|
3004
2984
|
type: "problem"
|
|
3005
2985
|
}
|
|
3006
2986
|
};
|
|
@@ -3017,15 +2997,15 @@ const TOOL_SUBPATH = "@jterrazz/test/oxlint";
|
|
|
3017
2997
|
* `server: () => createApp()` importing `../../src/app.js`).
|
|
3018
2998
|
*/
|
|
3019
2999
|
const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
|
|
3020
|
-
"core",
|
|
3021
3000
|
"integrations",
|
|
3022
3001
|
"lint",
|
|
3002
|
+
"specification",
|
|
3023
3003
|
"vitest"
|
|
3024
3004
|
]);
|
|
3025
3005
|
/**
|
|
3026
3006
|
* CONVENTIONS F3 — from `specs/`, deep-importing the FRAMEWORK's internals is
|
|
3027
3007
|
* forbidden: a relative path resolving inside the framework repo's
|
|
3028
|
-
* `src/{
|
|
3008
|
+
* `src/{specification,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
|
|
3029
3009
|
* other than the sanctioned tool-facing `@jterrazz/test/oxlint`. A consumer's
|
|
3030
3010
|
* imports of its OWN app source are always allowed — that is the pattern.
|
|
3031
3011
|
*
|
|
@@ -3051,7 +3031,7 @@ const f3SpecsPublicEntry = {
|
|
|
3051
3031
|
}
|
|
3052
3032
|
const marker = source.indexOf("/src/");
|
|
3053
3033
|
if (marker === -1 && !source.startsWith("src/")) return;
|
|
3054
|
-
const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0];
|
|
3034
|
+
const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0] ?? "";
|
|
3055
3035
|
if (!FRAMEWORK_LAYERS.has(layer)) return;
|
|
3056
3036
|
if (underIntegrations && layer === "integrations") return;
|
|
3057
3037
|
context.report({
|
|
@@ -3063,14 +3043,14 @@ const f3SpecsPublicEntry = {
|
|
|
3063
3043
|
},
|
|
3064
3044
|
meta: {
|
|
3065
3045
|
docs: RULE_DOCS["f3-specs-public-entry"],
|
|
3066
|
-
messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/
|
|
3046
|
+
messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/13-linting.md; only @jterrazz/test/oxlint, and src/integrations/** from specs/integrations/, are exempt)." },
|
|
3067
3047
|
type: "problem"
|
|
3068
3048
|
}
|
|
3069
3049
|
};
|
|
3070
3050
|
//#endregion
|
|
3071
3051
|
//#region src/lint/rules/f4-no-test-to-test-import.ts
|
|
3072
|
-
const TEST_FILE$3 = /\.test\.[cm]?[jt]sx
|
|
3073
|
-
const TEST_IMPORT = /\.test(?:\.[cm]?[jt]sx?)
|
|
3052
|
+
const TEST_FILE$3 = /\.test\.[cm]?[jt]sx?$/u;
|
|
3053
|
+
const TEST_IMPORT = /\.test(?:\.[cm]?[jt]sx?)?$/u;
|
|
3074
3054
|
/**
|
|
3075
3055
|
* CONVENTIONS F4 — a `*.test.ts` never imports another `*.test.ts`. Shared
|
|
3076
3056
|
* test data belongs in a `*.fixtures.ts` neighbour (F5), shared runners in a
|
|
@@ -3089,14 +3069,14 @@ const f4NoTestToTestImport = {
|
|
|
3089
3069
|
},
|
|
3090
3070
|
meta: {
|
|
3091
3071
|
docs: RULE_DOCS["f4-no-test-to-test-import"],
|
|
3092
|
-
messages: { testToTest: "A test file must not import another test file (\"{{source}}\") — share data via a *.fixtures.ts neighbour or a *.specification.ts runner (F4 — see docs/
|
|
3072
|
+
messages: { testToTest: "A test file must not import another test file (\"{{source}}\") — share data via a *.fixtures.ts neighbour or a *.specification.ts runner (F4 — see docs/13-linting.md)." },
|
|
3093
3073
|
type: "problem"
|
|
3094
3074
|
}
|
|
3095
3075
|
};
|
|
3096
3076
|
//#endregion
|
|
3097
3077
|
//#region src/lint/rules/f5-fixtures-only-from-tests.ts
|
|
3098
|
-
const TEST_FILE$2 = /\.test\.[cm]?[jt]sx
|
|
3099
|
-
const FIXTURES_IMPORT = /\.fixtures(?:\.[cm]?[jt]sx?)
|
|
3078
|
+
const TEST_FILE$2 = /\.test\.[cm]?[jt]sx?$/u;
|
|
3079
|
+
const FIXTURES_IMPORT = /\.fixtures(?:\.[cm]?[jt]sx?)?$/u;
|
|
3100
3080
|
/**
|
|
3101
3081
|
* CONVENTIONS F5 — a `*.fixtures.ts` module is only importable from
|
|
3102
3082
|
* `*.test.ts` files. Any other importer (prod code, a specification file,
|
|
@@ -3115,13 +3095,13 @@ const f5FixturesOnlyFromTests = {
|
|
|
3115
3095
|
},
|
|
3116
3096
|
meta: {
|
|
3117
3097
|
docs: RULE_DOCS["f5-fixtures-only-from-tests"],
|
|
3118
|
-
messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (F5 — see docs/
|
|
3098
|
+
messages: { fixturesImport: "A *.fixtures.ts module (\"{{source}}\") is only importable from *.test.ts files (F5 — see docs/13-linting.md)." },
|
|
3119
3099
|
type: "problem"
|
|
3120
3100
|
}
|
|
3121
3101
|
};
|
|
3122
3102
|
//#endregion
|
|
3123
3103
|
//#region src/lint/rules/i1-layer-boundaries.ts
|
|
3124
|
-
const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx
|
|
3104
|
+
const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/u;
|
|
3125
3105
|
function matchesPackage(source, packages) {
|
|
3126
3106
|
return packages.some((name) => source === name || source.startsWith(`${name}/`));
|
|
3127
3107
|
}
|
|
@@ -3133,7 +3113,7 @@ function pathInsideSrc(path) {
|
|
|
3133
3113
|
}
|
|
3134
3114
|
/** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
|
|
3135
3115
|
function withoutExtension(path) {
|
|
3136
|
-
return path.replace(/\.[cm]?[jt]sx
|
|
3116
|
+
return path.replace(/\.[cm]?[jt]sx?$/u, "");
|
|
3137
3117
|
}
|
|
3138
3118
|
/** Does `target` match one of the declared paths (prefix, or exact module)? */
|
|
3139
3119
|
function matchesPath(target, patterns) {
|
|
@@ -3207,8 +3187,8 @@ const i1LayerBoundaries = {
|
|
|
3207
3187
|
defaultOptions: [{ layers: {} }],
|
|
3208
3188
|
docs: RULE_DOCS["i1-layer-boundaries"],
|
|
3209
3189
|
messages: {
|
|
3210
|
-
crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/
|
|
3211
|
-
foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/
|
|
3190
|
+
crossLayer: "Layer \"{{layer}}\" must not import \"{{source}}\" — outside the edges its layer map declares (I1 — see docs/13-linting.md).",
|
|
3191
|
+
foreignDependency: "\"{{source}}\" is not a dependency layer \"{{layer}}\" declares — one layer states the packages it may import (I1 — see docs/13-linting.md)."
|
|
3212
3192
|
},
|
|
3213
3193
|
schema: [{
|
|
3214
3194
|
additionalProperties: false,
|
|
@@ -3250,7 +3230,7 @@ const i1LayerBoundaries = {
|
|
|
3250
3230
|
};
|
|
3251
3231
|
//#endregion
|
|
3252
3232
|
//#region src/lint/rules/i2-sibling-test-naming.ts
|
|
3253
|
-
const TEST_FILE$1 = /\.test\.[cm]?[jt]sx
|
|
3233
|
+
const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/u;
|
|
3254
3234
|
/**
|
|
3255
3235
|
* CONVENTIONS I2 — module tests are NEIGHBOURS: the test of `<file>.ts` is
|
|
3256
3236
|
* `<file>.test.ts` next to it (Go's `foo_test.go` parity). Flags, on visited
|
|
@@ -3278,7 +3258,7 @@ const i2SiblingTestNaming = {
|
|
|
3278
3258
|
".js"
|
|
3279
3259
|
].map((extension) => file.replace(TEST_FILE$1, extension));
|
|
3280
3260
|
if (!neighbours.some(isFile)) context.report({
|
|
3281
|
-
data: { expected: neighbours[0]
|
|
3261
|
+
data: { expected: neighbours[0]?.split(/[/\\]/u).at(-1) ?? "" },
|
|
3282
3262
|
messageId: "orphanTest",
|
|
3283
3263
|
node
|
|
3284
3264
|
});
|
|
@@ -3296,16 +3276,16 @@ const i2SiblingTestNaming = {
|
|
|
3296
3276
|
meta: {
|
|
3297
3277
|
docs: RULE_DOCS["i2-sibling-test-naming"],
|
|
3298
3278
|
messages: {
|
|
3299
|
-
orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (I2 — see docs/
|
|
3300
|
-
rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (I2 — see docs/
|
|
3301
|
-
testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/
|
|
3279
|
+
orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (I2 — see docs/13-linting.md). A test needing more than its module is a specification: move it to specs/.",
|
|
3280
|
+
rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (I2 — see docs/13-linting.md).",
|
|
3281
|
+
testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/13-linting.md)."
|
|
3302
3282
|
},
|
|
3303
3283
|
type: "problem"
|
|
3304
3284
|
}
|
|
3305
3285
|
};
|
|
3306
3286
|
//#endregion
|
|
3307
3287
|
//#region src/lint/rules/i4-no-vi-mock-in-src.ts
|
|
3308
|
-
const TEST_FILE = /\.test\.[cm]?[jt]sx
|
|
3288
|
+
const TEST_FILE = /\.test\.[cm]?[jt]sx?$/u;
|
|
3309
3289
|
const DATA_ASSET = new RegExp(`\\.(?:${[
|
|
3310
3290
|
"avif",
|
|
3311
3291
|
"bin",
|
|
@@ -3343,10 +3323,10 @@ const DATA_ASSET = new RegExp(`\\.(?:${[
|
|
|
3343
3323
|
"yaml",
|
|
3344
3324
|
"yml",
|
|
3345
3325
|
"zip"
|
|
3346
|
-
].join("|")})$`, "
|
|
3326
|
+
].join("|")})$`, "iu");
|
|
3347
3327
|
/** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
|
|
3348
3328
|
function withoutSuffix(source) {
|
|
3349
|
-
return source.replace(/[?#]
|
|
3329
|
+
return source.replace(/[?#].*$/u, "");
|
|
3350
3330
|
}
|
|
3351
3331
|
/**
|
|
3352
3332
|
* CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
|
|
@@ -3398,57 +3378,14 @@ const i4NoViMockInSrc = {
|
|
|
3398
3378
|
meta: {
|
|
3399
3379
|
docs: RULE_DOCS["i4-no-vi-mock-in-src"],
|
|
3400
3380
|
messages: {
|
|
3401
|
-
assetImport: "A src/ module test must not import the data asset \"{{source}}\" — inline it as code or move the test to specs/ (I4 — see docs/
|
|
3402
|
-
bannedDir: "`{{dir}}/` directories are banned under src/ — mocks and data are code: mockOf/mockOfDate inline, payloads in a *.fixtures.ts neighbour (I4 — see docs/
|
|
3403
|
-
viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (I4 — see docs/
|
|
3381
|
+
assetImport: "A src/ module test must not import the data asset \"{{source}}\" — inline it as code or move the test to specs/ (I4 — see docs/13-linting.md).",
|
|
3382
|
+
bannedDir: "`{{dir}}/` directories are banned under src/ — mocks and data are code: mockOf/mockOfDate inline, payloads in a *.fixtures.ts neighbour (I4 — see docs/13-linting.md).",
|
|
3383
|
+
viMock: "`vi.mock` is banned under src/ — use mockOf/mockOfDate (I4 — see docs/13-linting.md)."
|
|
3404
3384
|
},
|
|
3405
3385
|
type: "problem"
|
|
3406
3386
|
}
|
|
3407
3387
|
};
|
|
3408
3388
|
//#endregion
|
|
3409
|
-
//#region src/lint/rules/j1-no-only-skip.ts
|
|
3410
|
-
/** Test runners whose `.only` / `.skip` modifiers must never be committed. */
|
|
3411
|
-
const RUNNERS = /* @__PURE__ */ new Set([
|
|
3412
|
-
"describe",
|
|
3413
|
-
"it",
|
|
3414
|
-
"test"
|
|
3415
|
-
]);
|
|
3416
|
-
/** The focus/skip modifiers CONVENTIONS J1 forbids. */
|
|
3417
|
-
const FORBIDDEN = /* @__PURE__ */ new Set(["only", "skip"]);
|
|
3418
|
-
/**
|
|
3419
|
-
* CONVENTIONS J1 — no committed `.only` / `.skip`.
|
|
3420
|
-
*
|
|
3421
|
-
* Flags the member form used across the specs: `describe.only`, `test.only`,
|
|
3422
|
-
* `it.only` and their `.skip` counterparts. Modifiers that legitimately gate a
|
|
3423
|
-
* test (`test.skipIf`, `test.runIf`) are untouched — only the bare `only`/`skip`
|
|
3424
|
-
* that pin or disable a test in a commit are reported.
|
|
3425
|
-
*/
|
|
3426
|
-
const j1NoOnlySkip = {
|
|
3427
|
-
create(context) {
|
|
3428
|
-
return { MemberExpression(node) {
|
|
3429
|
-
if (node.computed === true) return;
|
|
3430
|
-
const object = node.object;
|
|
3431
|
-
const property = node.property;
|
|
3432
|
-
if (object?.type !== "Identifier" || property?.type !== "Identifier") return;
|
|
3433
|
-
const runner = object.name;
|
|
3434
|
-
const modifier = property.name;
|
|
3435
|
-
if (RUNNERS.has(runner) && FORBIDDEN.has(modifier)) context.report({
|
|
3436
|
-
data: {
|
|
3437
|
-
modifier,
|
|
3438
|
-
runner
|
|
3439
|
-
},
|
|
3440
|
-
messageId: "forbidden",
|
|
3441
|
-
node
|
|
3442
|
-
});
|
|
3443
|
-
} };
|
|
3444
|
-
},
|
|
3445
|
-
meta: {
|
|
3446
|
-
docs: RULE_DOCS["j1-no-only-skip"],
|
|
3447
|
-
messages: { forbidden: "Committed `{{runner}}.{{modifier}}` is not allowed (J1 — see docs/10-linting.md). Remove it before committing." },
|
|
3448
|
-
type: "problem"
|
|
3449
|
-
}
|
|
3450
|
-
};
|
|
3451
|
-
//#endregion
|
|
3452
3389
|
//#region src/lint/rules/j2-no-sleep-in-specs.ts
|
|
3453
3390
|
/**
|
|
3454
3391
|
* CONVENTIONS J2 — spec files (`specs/**`) contain no arbitrary sleeps:
|
|
@@ -3487,138 +3424,13 @@ const j2NoSleepInSpecs = {
|
|
|
3487
3424
|
meta: {
|
|
3488
3425
|
docs: RULE_DOCS["j2-no-sleep-in-specs"],
|
|
3489
3426
|
messages: {
|
|
3490
|
-
sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/
|
|
3491
|
-
timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/
|
|
3427
|
+
sleep: "No arbitrary sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/13-linting.md).",
|
|
3428
|
+
timersImport: "No timer-based sleeps in specs — synchronise via `waitFor` or the framework (J2 — see docs/13-linting.md)."
|
|
3492
3429
|
},
|
|
3493
3430
|
type: "problem"
|
|
3494
3431
|
}
|
|
3495
3432
|
};
|
|
3496
3433
|
//#endregion
|
|
3497
|
-
//#region src/lint/rules/j3-no-expectless-test.ts
|
|
3498
|
-
/** Does `callback` contain at least one `expect(…)` call? */
|
|
3499
|
-
function hasExpect(callback) {
|
|
3500
|
-
let found = false;
|
|
3501
|
-
walk(callback, (node) => {
|
|
3502
|
-
if (found || node.type !== "CallExpression") return;
|
|
3503
|
-
const callee = node.callee;
|
|
3504
|
-
if (callee?.type === "Identifier" && callee.name === "expect") found = true;
|
|
3505
|
-
});
|
|
3506
|
-
return found;
|
|
3507
|
-
}
|
|
3508
|
-
/**
|
|
3509
|
-
* CONVENTIONS J3 — a test asserts something. A `test('…', () => { … })` whose
|
|
3510
|
-
* body contains no `expect(…)` is either dead or a silent no-op.
|
|
3511
|
-
*
|
|
3512
|
-
* Reuses the shared B4 test-shape helpers. `test.todo(...)` (and any other
|
|
3513
|
-
* callback-less form) is skipped — there is nothing to assert yet.
|
|
3514
|
-
*/
|
|
3515
|
-
const j3NoExpectlessTest = {
|
|
3516
|
-
create(context) {
|
|
3517
|
-
return { CallExpression(node) {
|
|
3518
|
-
const callee = node.callee;
|
|
3519
|
-
if (!isTestCallee(callee)) return;
|
|
3520
|
-
if (testCalleeHasModifier(callee, "todo")) return;
|
|
3521
|
-
const callback = findTestCallback(node.arguments ?? []);
|
|
3522
|
-
if (callback === void 0) return;
|
|
3523
|
-
if (!hasExpect(callback)) context.report({
|
|
3524
|
-
messageId: "noExpect",
|
|
3525
|
-
node
|
|
3526
|
-
});
|
|
3527
|
-
} };
|
|
3528
|
-
},
|
|
3529
|
-
meta: {
|
|
3530
|
-
docs: RULE_DOCS["j3-no-expectless-test"],
|
|
3531
|
-
messages: { noExpect: "Test has no `expect(…)` — a test must assert something (J3 — see docs/10-linting.md). Use `test.todo` for a pending test." },
|
|
3532
|
-
type: "problem"
|
|
3533
|
-
}
|
|
3534
|
-
};
|
|
3535
|
-
//#endregion
|
|
3536
|
-
//#region src/lint/rules/j4-unique-test-names.ts
|
|
3537
|
-
/**
|
|
3538
|
-
* CONVENTIONS J4 — test names are the sole description of behaviour (B3), so two
|
|
3539
|
-
* tests sharing a literal name in one file are a copy-paste smell: one shadows
|
|
3540
|
-
* the other in the reporter. Flags the second (and later) occurrence.
|
|
3541
|
-
*
|
|
3542
|
-
* `.each` / parametrized tests are skipped — their name is a template expanded
|
|
3543
|
-
* per row, collisions there are by construction.
|
|
3544
|
-
*/
|
|
3545
|
-
const j4UniqueTestNames = {
|
|
3546
|
-
create(context) {
|
|
3547
|
-
const seen = /* @__PURE__ */ new Set();
|
|
3548
|
-
return { CallExpression(node) {
|
|
3549
|
-
const callee = node.callee;
|
|
3550
|
-
if (!isTestCallee(callee)) return;
|
|
3551
|
-
if (testCalleeHasModifier(callee, "each")) return;
|
|
3552
|
-
const name = testName(node);
|
|
3553
|
-
if (name === void 0) return;
|
|
3554
|
-
if (seen.has(name)) {
|
|
3555
|
-
context.report({
|
|
3556
|
-
data: { name },
|
|
3557
|
-
messageId: "duplicate",
|
|
3558
|
-
node
|
|
3559
|
-
});
|
|
3560
|
-
return;
|
|
3561
|
-
}
|
|
3562
|
-
seen.add(name);
|
|
3563
|
-
} };
|
|
3564
|
-
},
|
|
3565
|
-
meta: {
|
|
3566
|
-
docs: RULE_DOCS["j4-unique-test-names"],
|
|
3567
|
-
messages: { duplicate: "Duplicate test name \"{{name}}\" in this file — the test name is the sole description of behaviour, so it must be unique (J4 — see docs/10-linting.md)." },
|
|
3568
|
-
type: "problem"
|
|
3569
|
-
}
|
|
3570
|
-
};
|
|
3571
|
-
//#endregion
|
|
3572
|
-
//#region src/lint/rules/j5-lowercase-title.ts
|
|
3573
|
-
/** The block/test introducers whose title literal C1' constrains. */
|
|
3574
|
-
const TITLED = /* @__PURE__ */ new Set([
|
|
3575
|
-
"describe",
|
|
3576
|
-
"it",
|
|
3577
|
-
"test"
|
|
3578
|
-
]);
|
|
3579
|
-
/** An identifier-shaped, all-caps/underscored first word: `VALID_CATEGORIES`,
|
|
3580
|
-
* `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
|
|
3581
|
-
* misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
|
|
3582
|
-
const ALL_CAPS_IDENTIFIER = /^[A-Z][A-Z0-9_]*$/u;
|
|
3583
|
-
/**
|
|
3584
|
-
* CONVENTIONS J5 — the first character of a `test('…')` / `describe('…')` title
|
|
3585
|
-
* literal must be lowercase.
|
|
3586
|
-
*
|
|
3587
|
-
* The test name is the sole description of behaviour (B3): a sentence fragment,
|
|
3588
|
-
* lowercase-led like prose (`'rejects an unknown job'`). Exempt are titles
|
|
3589
|
-
* whose first WORD is an identifier-shaped all-caps/underscored token
|
|
3590
|
-
* (`'VALID_CATEGORIES is rejected'`, `'HTTP is upgraded'`, `'DI wires …'`) —
|
|
3591
|
-
* lowercasing a named symbol would misspell it — and titles opening on a
|
|
3592
|
-
* non-letter (a `$`, a `{`, a `%s` placeholder). Only lowercase-able prose
|
|
3593
|
-
* first words are enforced. Covers the modifier and parametrized forms
|
|
3594
|
-
* (`test.only`, `describe.each(...)(...)`) via the chain root.
|
|
3595
|
-
*/
|
|
3596
|
-
const j5LowercaseTitle = {
|
|
3597
|
-
create(context) {
|
|
3598
|
-
return { CallExpression(node) {
|
|
3599
|
-
const callee = node.callee;
|
|
3600
|
-
const root = chainRootName(callee);
|
|
3601
|
-
if (root === void 0 || !TITLED.has(root)) return;
|
|
3602
|
-
const title = stringValue(node.arguments?.[0]);
|
|
3603
|
-
if (title === void 0) return;
|
|
3604
|
-
const first = [...title][0];
|
|
3605
|
-
if (first === void 0 || !/\p{L}/u.test(first)) return;
|
|
3606
|
-
const firstWord = title.split(/\s+/u)[0];
|
|
3607
|
-
if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
|
|
3608
|
-
if (first !== first.toLocaleLowerCase()) context.report({
|
|
3609
|
-
data: { runner: root },
|
|
3610
|
-
messageId: "uppercase",
|
|
3611
|
-
node
|
|
3612
|
-
});
|
|
3613
|
-
} };
|
|
3614
|
-
},
|
|
3615
|
-
meta: {
|
|
3616
|
-
docs: RULE_DOCS["j5-lowercase-title"],
|
|
3617
|
-
messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
|
|
3618
|
-
type: "problem"
|
|
3619
|
-
}
|
|
3620
|
-
};
|
|
3621
|
-
//#endregion
|
|
3622
3434
|
//#region src/lint/rules/w1-scenario-pure.ts
|
|
3623
3435
|
/** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
|
|
3624
3436
|
const SCENARIO_ACTIONS$1 = /* @__PURE__ */ new Set(["open", "visit"]);
|
|
@@ -3648,7 +3460,7 @@ const w1ScenarioPure = {
|
|
|
3648
3460
|
},
|
|
3649
3461
|
meta: {
|
|
3650
3462
|
docs: RULE_DOCS["w1-scenario-pure"],
|
|
3651
|
-
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/
|
|
3463
|
+
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/13-linting.md)." },
|
|
3652
3464
|
type: "problem"
|
|
3653
3465
|
}
|
|
3654
3466
|
};
|
|
@@ -3664,7 +3476,7 @@ const SCENARIO_ACTIONS = /* @__PURE__ */ new Set(["open", "visit"]);
|
|
|
3664
3476
|
*
|
|
3665
3477
|
* Registered in a consumer's (or this repo's own) `oxlint.config.ts` via
|
|
3666
3478
|
* `jsPlugins: ['@jterrazz/test/oxlint']` and referenced as `jterrazz/<rule>` in
|
|
3667
|
-
* the `rules` map, e.g. `'jterrazz/
|
|
3479
|
+
* the `rules` map, e.g. `'jterrazz/j2-no-sleep-in-specs': 'error'` — or enabled
|
|
3668
3480
|
* wholesale by spreading {@link recommendedRules}.
|
|
3669
3481
|
*
|
|
3670
3482
|
* This entry is bundled by tsdown (`dist/oxlint.js`); rules import nothing from
|
|
@@ -3714,11 +3526,7 @@ const plugin = {
|
|
|
3714
3526
|
"i1-layer-boundaries": i1LayerBoundaries,
|
|
3715
3527
|
"i2-sibling-test-naming": i2SiblingTestNaming,
|
|
3716
3528
|
"i4-no-vi-mock-in-src": i4NoViMockInSrc,
|
|
3717
|
-
"j1-no-only-skip": j1NoOnlySkip,
|
|
3718
3529
|
"j2-no-sleep-in-specs": j2NoSleepInSpecs,
|
|
3719
|
-
"j3-no-expectless-test": j3NoExpectlessTest,
|
|
3720
|
-
"j4-unique-test-names": j4UniqueTestNames,
|
|
3721
|
-
"j5-lowercase-title": j5LowercaseTitle,
|
|
3722
3530
|
"w1-scenario-pure": w1ScenarioPure,
|
|
3723
3531
|
"w2w-user-facing-elements": {
|
|
3724
3532
|
create(context) {
|
|
@@ -3740,7 +3548,7 @@ const plugin = {
|
|
|
3740
3548
|
},
|
|
3741
3549
|
meta: {
|
|
3742
3550
|
docs: RULE_DOCS["w2w-user-facing-elements"],
|
|
3743
|
-
messages: { testIdElement: "Prefer a user-facing element (button, link, field, heading, content) over testId() — the escape hatch hides what the user actually sees (W2 — see docs/
|
|
3551
|
+
messages: { testIdElement: "Prefer a user-facing element (button, link, field, heading, content) over testId() — the escape hatch hides what the user actually sees (W2 — see docs/13-linting.md)." },
|
|
3744
3552
|
type: "suggestion"
|
|
3745
3553
|
}
|
|
3746
3554
|
}
|
|
@@ -3758,7 +3566,7 @@ const plugin = {
|
|
|
3758
3566
|
*
|
|
3759
3567
|
* 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }]
|
|
3760
3568
|
*/
|
|
3761
|
-
const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w
|
|
3569
|
+
const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w-/u.test(rule) ? "warn" : "error"]));
|
|
3762
3570
|
/**
|
|
3763
3571
|
* The composable testing fragment — wire the plugin, enable the whole
|
|
3764
3572
|
* catalogue, and ship the one override every strict consumer needs. Designed
|