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