@jterrazz/test 15.0.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 +1 -1
- package/dist/ambiguity.js +1 -1
- package/dist/appium.adapter.js +33 -24
- package/dist/artifacts.js +503 -525
- package/dist/checker.js +60 -59
- package/dist/index.d.ts +587 -660
- package/dist/index.js +191 -188
- package/dist/match.js +6 -16
- package/dist/oxlint.cjs +513 -514
- package/dist/oxlint.d.cts +7064 -15
- package/dist/oxlint.d.ts +7064 -15
- package/dist/oxlint.js +513 -514
- 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 -7
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. */
|
|
@@ -479,7 +479,7 @@ const RULE_DOCS = {
|
|
|
479
479
|
},
|
|
480
480
|
"f3-specs-public-entry": {
|
|
481
481
|
channel: "statique",
|
|
482
|
-
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/**`.",
|
|
483
483
|
family: "F",
|
|
484
484
|
id: "F3",
|
|
485
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."
|
|
@@ -889,83 +889,6 @@ const a1SpecificationFile = {
|
|
|
889
889
|
}
|
|
890
890
|
};
|
|
891
891
|
//#endregion
|
|
892
|
-
//#region src/core/specification/shared/binding.ts
|
|
893
|
-
/**
|
|
894
|
-
* Compose-binding resolution and the case conversions it shares with the
|
|
895
|
-
* automatic env injection (CONVENTIONS A6 / B6).
|
|
896
|
-
*
|
|
897
|
-
* A services-record key is written in the natural TypeScript style
|
|
898
|
-
* (`analyticsDb`), while a compose service name is kebab-case
|
|
899
|
-
* (`analytics-db`). These helpers bridge the two vocabularies deterministically
|
|
900
|
-
* so the record key stays the single source of truth and `composeService`
|
|
901
|
-
* remains a rare escape hatch.
|
|
902
|
-
*/
|
|
903
|
-
/**
|
|
904
|
-
* Convert a record key to its kebab-case form: camelCase boundaries become
|
|
905
|
-
* `-`, everything is lowercased. `analyticsDb` → `analytics-db`, `db` → `db`.
|
|
906
|
-
*/
|
|
907
|
-
function toKebabCase(key) {
|
|
908
|
-
return key.replace(/(?<lower>[a-z0-9])(?<upper>[A-Z])/g, "$<lower>-$<upper>").toLowerCase();
|
|
909
|
-
}
|
|
910
|
-
//#endregion
|
|
911
|
-
//#region src/lint/rules/a10-duplicate-binding.ts
|
|
912
|
-
/** The compose service an entry binds to: explicit `composeService`, else the key's kebab form. */
|
|
913
|
-
function canonicalBinding(entry, key) {
|
|
914
|
-
const factory = entry.value;
|
|
915
|
-
if (factory?.type === "CallExpression") {
|
|
916
|
-
const options = factory.arguments?.[0];
|
|
917
|
-
if (options?.type === "ObjectExpression") {
|
|
918
|
-
const explicit = stringValue(findProperty(options, "composeService")?.value);
|
|
919
|
-
if (explicit !== void 0) return explicit;
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
return toKebabCase(key);
|
|
923
|
-
}
|
|
924
|
-
/**
|
|
925
|
-
* CONVENTIONS A10 — within one `services` record, two keys that derive (or
|
|
926
|
-
* pin via `composeService`) the SAME compose service name are a silent
|
|
927
|
-
* collision: the record is a map, and the second binding would shadow the
|
|
928
|
-
* first. Detected on the same kebab derivation the runtime binding uses
|
|
929
|
-
* (`analyticsDb` and `analytics-db` both → `analytics-db`), and across explicit
|
|
930
|
-
* `composeService` values.
|
|
931
|
-
*/
|
|
932
|
-
const a10DuplicateBinding = {
|
|
933
|
-
create(context) {
|
|
934
|
-
return { CallExpression(node) {
|
|
935
|
-
if (specificationMember(node) === void 0) return;
|
|
936
|
-
for (const argument of node.arguments ?? []) {
|
|
937
|
-
const record = findProperty(argument, "services")?.value;
|
|
938
|
-
if (record?.type !== "ObjectExpression") continue;
|
|
939
|
-
const seen = /* @__PURE__ */ new Map();
|
|
940
|
-
for (const entry of record.properties ?? []) {
|
|
941
|
-
const key = propertyKeyName(entry);
|
|
942
|
-
if (key === void 0) continue;
|
|
943
|
-
const binding = canonicalBinding(entry, key);
|
|
944
|
-
const first = seen.get(binding);
|
|
945
|
-
if (first !== void 0) {
|
|
946
|
-
context.report({
|
|
947
|
-
data: {
|
|
948
|
-
binding,
|
|
949
|
-
first,
|
|
950
|
-
key
|
|
951
|
-
},
|
|
952
|
-
messageId: "duplicate",
|
|
953
|
-
node: entry
|
|
954
|
-
});
|
|
955
|
-
continue;
|
|
956
|
-
}
|
|
957
|
-
seen.set(binding, key);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
} };
|
|
961
|
-
},
|
|
962
|
-
meta: {
|
|
963
|
-
docs: RULE_DOCS["a10-duplicate-binding"],
|
|
964
|
-
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)." },
|
|
965
|
-
type: "problem"
|
|
966
|
-
}
|
|
967
|
-
};
|
|
968
|
-
//#endregion
|
|
969
892
|
//#region src/lint/rules/a2-known-constructors.ts
|
|
970
893
|
/** The five constructors, and only five (A2 — see docs/13-linting.md). */
|
|
971
894
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
@@ -1128,6 +1051,25 @@ const a5ModeWithServer = {
|
|
|
1128
1051
|
}
|
|
1129
1052
|
};
|
|
1130
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
|
|
1131
1073
|
//#region src/lint/rules/a6w-redundant-compose-service.ts
|
|
1132
1074
|
/**
|
|
1133
1075
|
* CONVENTIONS A6 (warning) — `composeService:` is the escape hatch for
|
|
@@ -1169,7 +1111,7 @@ const a6wRedundantComposeService = {
|
|
|
1169
1111
|
}
|
|
1170
1112
|
};
|
|
1171
1113
|
//#endregion
|
|
1172
|
-
//#region src/
|
|
1114
|
+
//#region src/specification/facets/_common/resolve.ts
|
|
1173
1115
|
/** What makes a directory a project root — a manifest, or a test stack. */
|
|
1174
1116
|
const ROOT_MARKERS = ["package.json", "docker/compose.test.yaml"];
|
|
1175
1117
|
/**
|
|
@@ -1236,7 +1178,65 @@ const a9wRedundantRoot = {
|
|
|
1236
1178
|
}
|
|
1237
1179
|
};
|
|
1238
1180
|
//#endregion
|
|
1239
|
-
//#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
|
|
1240
1240
|
/**
|
|
1241
1241
|
* The GROUND a row of specs stands on — the naming law of a spec tree.
|
|
1242
1242
|
*
|
|
@@ -1277,7 +1277,7 @@ const RENAMED_GROUND_DIRS = {
|
|
|
1277
1277
|
seeds: GROUND_SEEDS
|
|
1278
1278
|
};
|
|
1279
1279
|
//#endregion
|
|
1280
|
-
//#region src/
|
|
1280
|
+
//#region src/specification/facets/_common/fixtures.ts
|
|
1281
1281
|
/**
|
|
1282
1282
|
* Fixture path resolution + copy semantics for the `cli` facet's `.fixture()`.
|
|
1283
1283
|
*
|
|
@@ -1324,7 +1324,7 @@ const b2KnownFixtureMarker = {
|
|
|
1324
1324
|
const args = node.arguments ?? [];
|
|
1325
1325
|
const path = leadingText(args[0]);
|
|
1326
1326
|
if (path === void 0 || !path.startsWith("$")) return;
|
|
1327
|
-
const marker = path.replace(
|
|
1327
|
+
const marker = path.replace(/\/+$/u, "").split("/")[0] ?? "";
|
|
1328
1328
|
if (!KNOWN.has(marker)) context.report({
|
|
1329
1329
|
data: {
|
|
1330
1330
|
known: [...KNOWN_FIXTURE_MARKERS].join(", "),
|
|
@@ -1458,7 +1458,7 @@ const b5AwaitUsing = {
|
|
|
1458
1458
|
};
|
|
1459
1459
|
//#endregion
|
|
1460
1460
|
//#region src/lint/rules/b6w-redundant-env-url.ts
|
|
1461
|
-
const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL
|
|
1461
|
+
const URL_KEY = /^[A-Z0-9]+(?:_[A-Z0-9]+)*_URL$/u;
|
|
1462
1462
|
/**
|
|
1463
1463
|
* CONVENTIONS B6 (warning) — in `cli` mode with services, the framework
|
|
1464
1464
|
* already injects `<KEY>_URL` for every service (plus the unambiguous
|
|
@@ -1494,7 +1494,7 @@ const b6wRedundantEnvUrl = {
|
|
|
1494
1494
|
//#endregion
|
|
1495
1495
|
//#region src/lint/rules/b8-kebab-trigger.ts
|
|
1496
1496
|
/** Stable kebab-case job identifier: `nightly-report`, `send-welcome-emails`. */
|
|
1497
|
-
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)
|
|
1497
|
+
const KEBAB_CASE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
|
|
1498
1498
|
/**
|
|
1499
1499
|
* CONVENTIONS B8 — a job `name` passed to `.trigger()` is a stable kebab-case
|
|
1500
1500
|
* identifier; it is a contract between the app and its tests. Only string
|
|
@@ -1750,212 +1750,71 @@ const c1DomainStructure = {
|
|
|
1750
1750
|
}
|
|
1751
1751
|
};
|
|
1752
1752
|
//#endregion
|
|
1753
|
-
//#region src/lint/rules/
|
|
1754
|
-
|
|
1755
|
-
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;
|
|
1756
1755
|
/**
|
|
1757
|
-
* CONVENTIONS
|
|
1758
|
-
* a
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
* 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.
|
|
1763
1761
|
*/
|
|
1764
|
-
const
|
|
1762
|
+
const c2HttpOnlyRequests = {
|
|
1765
1763
|
create(context) {
|
|
1766
|
-
const
|
|
1767
|
-
if (!
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
if (
|
|
1771
|
-
data: {
|
|
1772
|
-
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",
|
|
1773
1771
|
node
|
|
1774
1772
|
});
|
|
1775
|
-
}
|
|
1773
|
+
} };
|
|
1776
1774
|
},
|
|
1777
1775
|
meta: {
|
|
1778
|
-
docs: RULE_DOCS["
|
|
1779
|
-
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)." },
|
|
1780
1778
|
type: "problem"
|
|
1781
1779
|
}
|
|
1782
1780
|
};
|
|
1783
1781
|
//#endregion
|
|
1784
|
-
//#region src/lint/rules/
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
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([
|
|
1788
1785
|
"anthropic",
|
|
1789
1786
|
"http",
|
|
1790
1787
|
"openai"
|
|
1791
|
-
];
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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";
|
|
1797
1814
|
}
|
|
1798
1815
|
/**
|
|
1799
|
-
*
|
|
1800
|
-
*
|
|
1801
|
-
* pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
|
|
1802
|
-
* up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
|
|
1803
|
-
* file with no owner is dead weight no test can reach — reported on the feature's
|
|
1804
|
-
* test file, since oxlint never visits a `.json`.
|
|
1805
|
-
*/
|
|
1806
|
-
const c11ContractDataPairing = {
|
|
1807
|
-
create(context) {
|
|
1808
|
-
const file = context.physicalFilename;
|
|
1809
|
-
if (!TEST_FILE$12.test(file) || !segments(file).includes("specs")) return {};
|
|
1810
|
-
return { Program(node) {
|
|
1811
|
-
const contractsDir = join(dirname(file), "contracts");
|
|
1812
|
-
for (const provider of PROVIDERS$1) {
|
|
1813
|
-
const entries = listDirectory(join(contractsDir, provider));
|
|
1814
|
-
if (entries === null) continue;
|
|
1815
|
-
const present = new Set(entries);
|
|
1816
|
-
for (const entry of entries) {
|
|
1817
|
-
if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
|
|
1818
|
-
const owner = ownerOf(entry);
|
|
1819
|
-
if (!present.has(owner)) context.report({
|
|
1820
|
-
data: {
|
|
1821
|
-
entry,
|
|
1822
|
-
owner,
|
|
1823
|
-
provider
|
|
1824
|
-
},
|
|
1825
|
-
messageId: "orphan",
|
|
1826
|
-
node
|
|
1827
|
-
});
|
|
1828
|
-
}
|
|
1829
|
-
}
|
|
1830
|
-
} };
|
|
1831
|
-
},
|
|
1832
|
-
meta: {
|
|
1833
|
-
docs: RULE_DOCS["c11-contract-data-pairing"],
|
|
1834
|
-
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)." },
|
|
1835
|
-
type: "problem"
|
|
1836
|
-
}
|
|
1837
|
-
};
|
|
1838
|
-
//#endregion
|
|
1839
|
-
//#region src/lint/rules/c13-underscored-ground.ts
|
|
1840
|
-
const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
|
|
1841
|
-
/**
|
|
1842
|
-
* CONVENTIONS C13 — the ground of a spec carries a leading underscore. A
|
|
1843
|
-
* directory named `fixtures/`, `expected/`, `requests/` or `seeds/` under
|
|
1844
|
-
* `specs/` is a pre-14 name: since `@jterrazz/test` 14.0.0 the four are
|
|
1845
|
-
* `_fixtures/`, `_expected/`, `_requests/` and `_seeds/`, with no compatibility
|
|
1846
|
-
* fallback.
|
|
1847
|
-
*
|
|
1848
|
-
* The catch this rule exists for: a consumer that upgrades and keeps the old
|
|
1849
|
-
* names loses nothing loudly. Every resolver — `.fixture()`, `.seed()`,
|
|
1850
|
-
* `.request()`, `toMatch()` — looks under the underscored name, finds nothing,
|
|
1851
|
-
* and the tree becomes invisible rather than wrong. The failure that follows is
|
|
1852
|
-
* "fixture does not exist" on every spec at once, one step removed from the
|
|
1853
|
-
* cause; this rule states the cause and the rename.
|
|
1854
|
-
*
|
|
1855
|
-
* Anchored on the feature's visited test file, exactly as C2/C7/C11 are: the
|
|
1856
|
-
* spec's own sibling directories are probed, plus the `$FIXTURES` pool at the
|
|
1857
|
-
* nearest specs root. A DOMAIN folder named after the ground it holds
|
|
1858
|
-
* (`specs/api/requests/_requests/`) is never a probe target — only what sits
|
|
1859
|
-
* beside a spec is.
|
|
1860
|
-
*/
|
|
1861
|
-
const c13UnderscoredGround = {
|
|
1862
|
-
create(context) {
|
|
1863
|
-
const file = context.physicalFilename;
|
|
1864
|
-
const anchor = specsAnchor(file);
|
|
1865
|
-
if (!TEST_FILE$11.test(file) || anchor === void 0) return {};
|
|
1866
|
-
return { Program(node) {
|
|
1867
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1868
|
-
const probe = (parent, legacy) => {
|
|
1869
|
-
const path = join(parent, legacy);
|
|
1870
|
-
if (seen.has(path) || !isDirectory(path)) return;
|
|
1871
|
-
seen.add(path);
|
|
1872
|
-
context.report({
|
|
1873
|
-
data: {
|
|
1874
|
-
ground: RENAMED_GROUND_DIRS[legacy],
|
|
1875
|
-
legacy
|
|
1876
|
-
},
|
|
1877
|
-
messageId: "notUnderscored",
|
|
1878
|
-
node
|
|
1879
|
-
});
|
|
1880
|
-
};
|
|
1881
|
-
for (const legacy of Object.keys(RENAMED_GROUND_DIRS)) probe(dirname(file), legacy);
|
|
1882
|
-
probe(anchor.directory, "fixtures");
|
|
1883
|
-
} };
|
|
1884
|
-
},
|
|
1885
|
-
meta: {
|
|
1886
|
-
docs: RULE_DOCS["c13-underscored-ground"],
|
|
1887
|
-
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)." },
|
|
1888
|
-
type: "problem"
|
|
1889
|
-
}
|
|
1890
|
-
};
|
|
1891
|
-
//#endregion
|
|
1892
|
-
//#region src/lint/rules/c2-http-only-requests.ts
|
|
1893
|
-
const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
|
|
1894
|
-
/**
|
|
1895
|
-
* CONVENTIONS C2 — `_requests/` contains only `.http` files (a request file is
|
|
1896
|
-
* a COMPLETE request: method, path, headers, body). Anchored on the feature's
|
|
1897
|
-
* visited test file: when `<feature>/<feature>.test.ts` is linted, its sibling
|
|
1898
|
-
* `_requests/` directory is probed (cached readdir) and any non-`.http` entry
|
|
1899
|
-
* is reported on the test file.
|
|
1900
|
-
*/
|
|
1901
|
-
const c2HttpOnlyRequests = {
|
|
1902
|
-
create(context) {
|
|
1903
|
-
const file = context.physicalFilename;
|
|
1904
|
-
if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
|
|
1905
|
-
return { Program(node) {
|
|
1906
|
-
const requestsDir = join(dirname(file), GROUND_REQUESTS);
|
|
1907
|
-
for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
|
|
1908
|
-
data: { entry },
|
|
1909
|
-
messageId: "notHttp",
|
|
1910
|
-
node
|
|
1911
|
-
});
|
|
1912
|
-
} };
|
|
1913
|
-
},
|
|
1914
|
-
meta: {
|
|
1915
|
-
docs: RULE_DOCS["c2-http-only-requests"],
|
|
1916
|
-
messages: { notHttp: "_requests/{{entry}} is not a .http file — _requests/ contains only complete .http request files (C2 — see docs/13-linting.md)." },
|
|
1917
|
-
type: "problem"
|
|
1918
|
-
}
|
|
1919
|
-
};
|
|
1920
|
-
//#endregion
|
|
1921
|
-
//#region src/lint/rules/c4-contract-shape.ts
|
|
1922
|
-
/** The only directories a `contracts/` root may hold — the provider carriers. */
|
|
1923
|
-
const PROVIDERS = /* @__PURE__ */ new Set([
|
|
1924
|
-
"anthropic",
|
|
1925
|
-
"http",
|
|
1926
|
-
"openai"
|
|
1927
|
-
]);
|
|
1928
|
-
const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
|
|
1929
|
-
/** A file oxlint itself visits — the AST half of the rule reports on those. */
|
|
1930
|
-
const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
|
|
1931
|
-
/** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
|
|
1932
|
-
const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
|
|
1933
|
-
/** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
|
|
1934
|
-
const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
|
|
1935
|
-
/** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
|
|
1936
|
-
const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
|
|
1937
|
-
/** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
|
|
1938
|
-
const RESPONSE_DATA_FILE = /\.response\.json$/;
|
|
1939
|
-
/** The name a specifier/declaration exports under, when it is `default`. */
|
|
1940
|
-
function exportsDefault(node) {
|
|
1941
|
-
return (node.specifiers ?? []).some((specifier) => {
|
|
1942
|
-
const exported = specifier.exported;
|
|
1943
|
-
return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
|
|
1944
|
-
});
|
|
1945
|
-
}
|
|
1946
|
-
/** Is this node a `defineContract(...)` call? */
|
|
1947
|
-
function isDefineContract(node) {
|
|
1948
|
-
const callee = node.callee;
|
|
1949
|
-
return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
|
|
1950
|
-
}
|
|
1951
|
-
/** A default export that may legitimately produce a contract (value or factory). */
|
|
1952
|
-
function producesContract(declaration) {
|
|
1953
|
-
if (declaration === void 0) return false;
|
|
1954
|
-
return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
|
|
1955
|
-
}
|
|
1956
|
-
/**
|
|
1957
|
-
* The provider a `request:` value implies, when the builder is written as
|
|
1958
|
-
* `<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.
|
|
1959
1818
|
*/
|
|
1960
1819
|
function requestProvider(request) {
|
|
1961
1820
|
if (request?.type !== "CallExpression") return;
|
|
@@ -1983,7 +1842,7 @@ function checkLayout(context, node, contractsDir) {
|
|
|
1983
1842
|
});
|
|
1984
1843
|
continue;
|
|
1985
1844
|
}
|
|
1986
|
-
if (!PROVIDERS.has(entry)) {
|
|
1845
|
+
if (!PROVIDERS$1.has(entry)) {
|
|
1987
1846
|
context.report({
|
|
1988
1847
|
data: { entry },
|
|
1989
1848
|
messageId: "badProviderDir",
|
|
@@ -1999,7 +1858,7 @@ function checkLayout(context, node, contractsDir) {
|
|
|
1999
1858
|
messageId: "providerSubfolder",
|
|
2000
1859
|
node
|
|
2001
1860
|
});
|
|
2002
|
-
else if (!/\.[cm]?ts$/u.test(child) && !
|
|
1861
|
+
else if (!/\.[cm]?ts$/u.test(child) && !child.endsWith(".response.json")) context.report({
|
|
2003
1862
|
data: {
|
|
2004
1863
|
child,
|
|
2005
1864
|
provider: entry
|
|
@@ -2068,7 +1927,7 @@ function checkUnit(context, program, base, provider) {
|
|
|
2068
1927
|
if (isDefineContract(node)) {
|
|
2069
1928
|
const argument = node.arguments?.[0];
|
|
2070
1929
|
const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
|
|
2071
|
-
if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
|
|
1930
|
+
if (declared !== void 0 && PROVIDERS$1.has(declared) && declared !== provider) context.report({
|
|
2072
1931
|
data: {
|
|
2073
1932
|
declared,
|
|
2074
1933
|
provider
|
|
@@ -2110,7 +1969,7 @@ const c4ContractShape = {
|
|
|
2110
1969
|
if (!parts.includes("specs")) return {};
|
|
2111
1970
|
const contractsIndex = parts.lastIndexOf("contracts");
|
|
2112
1971
|
if (contractsIndex === -1) {
|
|
2113
|
-
if (!TEST_FILE$
|
|
1972
|
+
if (!TEST_FILE$11.test(file)) return {};
|
|
2114
1973
|
return { Program(node) {
|
|
2115
1974
|
checkLayout(context, node, join(dirname(file), "contracts"));
|
|
2116
1975
|
} };
|
|
@@ -2121,8 +1980,8 @@ const c4ContractShape = {
|
|
|
2121
1980
|
checkComposite(context, node, base);
|
|
2122
1981
|
} };
|
|
2123
1982
|
if (depth === 2) {
|
|
2124
|
-
const provider = parts[contractsIndex + 1];
|
|
2125
|
-
return PROVIDERS.has(provider) ? { Program(node) {
|
|
1983
|
+
const provider = parts[contractsIndex + 1] ?? "";
|
|
1984
|
+
return PROVIDERS$1.has(provider) ? { Program(node) {
|
|
2126
1985
|
checkUnit(context, node, base, provider);
|
|
2127
1986
|
} } : {};
|
|
2128
1987
|
}
|
|
@@ -2155,7 +2014,7 @@ const c4ContractShape = {
|
|
|
2155
2014
|
};
|
|
2156
2015
|
//#endregion
|
|
2157
2016
|
//#region src/lint/rules/c6-tomatch-extension.ts
|
|
2158
|
-
const TEST_FILE$
|
|
2017
|
+
const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2159
2018
|
/** Does the expect(…) subject chain contain a `.directory(…)` call? */
|
|
2160
2019
|
function subjectIsDirectory(toMatchCallee) {
|
|
2161
2020
|
const expectCall = toMatchCallee.object;
|
|
@@ -2180,7 +2039,7 @@ function subjectIsDirectory(toMatchCallee) {
|
|
|
2180
2039
|
const c6ToMatchExtension = {
|
|
2181
2040
|
create(context) {
|
|
2182
2041
|
const file = context.physicalFilename;
|
|
2183
|
-
if (!TEST_FILE$
|
|
2042
|
+
if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
|
|
2184
2043
|
return { CallExpression(node) {
|
|
2185
2044
|
const callee = node.callee;
|
|
2186
2045
|
if (callee === void 0 || memberPropertyName(callee) !== "toMatch") return;
|
|
@@ -2205,7 +2064,7 @@ const c6ToMatchExtension = {
|
|
|
2205
2064
|
};
|
|
2206
2065
|
//#endregion
|
|
2207
2066
|
//#region src/lint/rules/c7-seeds-sql-only.ts
|
|
2208
|
-
const TEST_FILE$
|
|
2067
|
+
const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2209
2068
|
/**
|
|
2210
2069
|
* CONVENTIONS C7 — `.seed()` carries DATABASE state only: `_seeds/` contains
|
|
2211
2070
|
* nothing but `*.sql` fragments. File state goes through `.fixture()`; there
|
|
@@ -2215,7 +2074,7 @@ const TEST_FILE$7 = /\.test\.[cm]?[jt]sx?$/;
|
|
|
2215
2074
|
const c7SeedsSqlOnly = {
|
|
2216
2075
|
create(context) {
|
|
2217
2076
|
const file = context.physicalFilename;
|
|
2218
|
-
if (!TEST_FILE$
|
|
2077
|
+
if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
|
|
2219
2078
|
return { Program(node) {
|
|
2220
2079
|
const seedsDir = join(dirname(file), GROUND_SEEDS);
|
|
2221
2080
|
for (const entry of listDirectory(seedsDir) ?? []) if (!entry.endsWith(".sql")) context.report({
|
|
@@ -2233,7 +2092,7 @@ const c7SeedsSqlOnly = {
|
|
|
2233
2092
|
};
|
|
2234
2093
|
//#endregion
|
|
2235
2094
|
//#region src/lint/rules/c8-referenced-fixture-exists.ts
|
|
2236
|
-
const TEST_FILE$
|
|
2095
|
+
const TEST_FILE$8 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2237
2096
|
/** The ground directory each fixture-referencing verb reads from. */
|
|
2238
2097
|
const VERB_ROOTS = {
|
|
2239
2098
|
request: GROUND_REQUESTS,
|
|
@@ -2266,7 +2125,7 @@ const c8ReferencedFixtureExists = {
|
|
|
2266
2125
|
create(context) {
|
|
2267
2126
|
const file = context.physicalFilename;
|
|
2268
2127
|
const anchor = specsAnchor(file);
|
|
2269
|
-
if (!TEST_FILE$
|
|
2128
|
+
if (!TEST_FILE$8.test(file) || anchor === void 0) return {};
|
|
2270
2129
|
const featureDir = dirname(file);
|
|
2271
2130
|
return { CallExpression(node) {
|
|
2272
2131
|
const callee = node.callee;
|
|
@@ -2276,7 +2135,7 @@ const c8ReferencedFixtureExists = {
|
|
|
2276
2135
|
if (verb === "fixture") {
|
|
2277
2136
|
const value = stringValue(args[0]);
|
|
2278
2137
|
if (value === void 0 || !looksLikePath(value)) return;
|
|
2279
|
-
const target = resolveFixture(value.replace(
|
|
2138
|
+
const target = resolveFixture(value.replace(/\/+$/u, ""), featureDir, anchor.directory);
|
|
2280
2139
|
if (target !== void 0 && !isFile(target) && !isDirectory(target)) context.report({
|
|
2281
2140
|
data: {
|
|
2282
2141
|
path: value,
|
|
@@ -2291,7 +2150,7 @@ const c8ReferencedFixtureExists = {
|
|
|
2291
2150
|
if (root === void 0) return;
|
|
2292
2151
|
const value = stringValue(args[0]);
|
|
2293
2152
|
if (value === void 0 || !looksLikePath(value)) return;
|
|
2294
|
-
const target = join(featureDir, root, value.replace(
|
|
2153
|
+
const target = join(featureDir, root, value.replace(/\/+$/u, ""));
|
|
2295
2154
|
if (!(verb === "toMatch" ? isFile(target) || isDirectory(target) : isFile(target))) context.report({
|
|
2296
2155
|
data: {
|
|
2297
2156
|
path: `${root}/${value}`,
|
|
@@ -2309,231 +2168,142 @@ const c8ReferencedFixtureExists = {
|
|
|
2309
2168
|
}
|
|
2310
2169
|
};
|
|
2311
2170
|
//#endregion
|
|
2312
|
-
//#region src/lint/rules/
|
|
2313
|
-
/** A
|
|
2314
|
-
const
|
|
2315
|
-
/** Is `node` the `x.response.body` member access itself? */
|
|
2316
|
-
function isResponseBody(node) {
|
|
2317
|
-
if (node.type !== "MemberExpression" || memberPropertyName(node) !== "body") return false;
|
|
2318
|
-
const object = node.object;
|
|
2319
|
-
return object !== void 0 && memberPropertyName(object) === "response";
|
|
2320
|
-
}
|
|
2321
|
-
/** Peel `await`, `as T`, and `!` off an initializer to reach its core expression. */
|
|
2322
|
-
function unwrap(node) {
|
|
2323
|
-
let current = node ?? void 0;
|
|
2324
|
-
while (current != null) if (current.type === "AwaitExpression") current = current.argument ?? void 0;
|
|
2325
|
-
else if (current.type === "TSAsExpression" || current.type === "TSNonNullExpression") current = current.expression ?? void 0;
|
|
2326
|
-
else if (current.type === "ParenthesizedExpression") current = current.expression ?? void 0;
|
|
2327
|
-
else return current;
|
|
2328
|
-
}
|
|
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;
|
|
2329
2174
|
/**
|
|
2330
|
-
* CONVENTIONS
|
|
2331
|
-
*
|
|
2332
|
-
*
|
|
2333
|
-
* `
|
|
2334
|
-
*
|
|
2335
|
-
*
|
|
2336
|
-
*
|
|
2337
|
-
* A probe is a body-rooted read: a direct `.response.body` use, or a field
|
|
2338
|
-
* access on the local binding a `const body = result.response.body as {…}`
|
|
2339
|
-
* 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.
|
|
2340
2181
|
*/
|
|
2341
|
-
const
|
|
2182
|
+
const c10ContractsBoundary = {
|
|
2342
2183
|
create(context) {
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
let responseBodyNodes = 0;
|
|
2351
|
-
walk(callback, (inner) => {
|
|
2352
|
-
if (isResponseBody(inner)) responseBodyNodes++;
|
|
2353
|
-
if (inner.type === "VariableDeclarator") {
|
|
2354
|
-
const init = unwrap(inner.init);
|
|
2355
|
-
const id = inner.id;
|
|
2356
|
-
if (init !== void 0 && isResponseBody(init) && id?.type === "Identifier") aliases.add(id.name);
|
|
2357
|
-
}
|
|
2358
|
-
});
|
|
2359
|
-
let aliasReads = 0;
|
|
2360
|
-
walk(callback, (inner) => {
|
|
2361
|
-
if (inner.type !== "MemberExpression") return;
|
|
2362
|
-
const object = inner.object;
|
|
2363
|
-
if (object?.type === "Identifier" && aliases.has(object.name)) aliasReads++;
|
|
2364
|
-
});
|
|
2365
|
-
const probes = responseBodyNodes - aliases.size + aliasReads;
|
|
2366
|
-
if (probes >= threshold) context.report({
|
|
2367
|
-
data: { count: String(probes) },
|
|
2368
|
-
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",
|
|
2369
2191
|
node
|
|
2370
2192
|
});
|
|
2371
|
-
}
|
|
2193
|
+
});
|
|
2372
2194
|
},
|
|
2373
2195
|
meta: {
|
|
2374
|
-
docs: RULE_DOCS["
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
schema: [{
|
|
2378
|
-
additionalProperties: false,
|
|
2379
|
-
properties: { threshold: {
|
|
2380
|
-
minimum: 1,
|
|
2381
|
-
type: "integer"
|
|
2382
|
-
} },
|
|
2383
|
-
type: "object"
|
|
2384
|
-
}],
|
|
2385
|
-
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"
|
|
2386
2199
|
}
|
|
2387
2200
|
};
|
|
2388
2201
|
//#endregion
|
|
2389
|
-
//#region src/lint/rules/
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
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`;
|
|
2399
2215
|
}
|
|
2400
2216
|
/**
|
|
2401
|
-
*
|
|
2402
|
-
*
|
|
2403
|
-
*
|
|
2404
|
-
*
|
|
2405
|
-
*
|
|
2406
|
-
*
|
|
2407
|
-
* 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`.
|
|
2408
2223
|
*/
|
|
2409
|
-
const
|
|
2410
|
-
"ArrowFunctionExpression",
|
|
2411
|
-
"AwaitExpression",
|
|
2412
|
-
"ChainExpression",
|
|
2413
|
-
"MemberExpression",
|
|
2414
|
-
"ParenthesizedExpression",
|
|
2415
|
-
"TSAsExpression",
|
|
2416
|
-
"TSNonNullExpression"
|
|
2417
|
-
]);
|
|
2418
|
-
function isWrappedInExpect(node) {
|
|
2419
|
-
let current = node.parent;
|
|
2420
|
-
while (current !== void 0) {
|
|
2421
|
-
if (current.type === "CallExpression") {
|
|
2422
|
-
const callee = current.callee;
|
|
2423
|
-
return callee?.type === "Identifier" && callee.name === "expect";
|
|
2424
|
-
}
|
|
2425
|
-
if (!TRANSPARENT_ANCESTORS.has(current.type)) return false;
|
|
2426
|
-
current = current.parent;
|
|
2427
|
-
}
|
|
2428
|
-
return false;
|
|
2429
|
-
}
|
|
2430
|
-
/**
|
|
2431
|
-
* CONVENTIONS D13 (warning) — a `toMatch` whose failure is the behaviour under
|
|
2432
|
-
* test (wrapped in `expect(() => …).toThrow()` or `expect(…).rejects.toThrow()`)
|
|
2433
|
-
* asserts a DELIBERATELY-WRONG or MISSING fixture. Under `TEST_UPDATE=1` an
|
|
2434
|
-
* unfrozen fixture is silently rewritten with the actual output — the matcher
|
|
2435
|
-
* writes instead of throwing — which destroys the negative case (the assertion
|
|
2436
|
-
* then no longer throws). Pass `{ frozen: true }` so the fixture is never
|
|
2437
|
-
* rewritten and the mismatch/error still throws in update mode.
|
|
2438
|
-
*
|
|
2439
|
-
* The structural signal is precise for the wrapped forms; a `toMatch` routed
|
|
2440
|
-
* through a helper that owns the try/catch (`catchMessage(() => …toMatch(…))`)
|
|
2441
|
-
* is out of static reach — see the D13 process note for that residue.
|
|
2442
|
-
*/
|
|
2443
|
-
const d13wUnfrozenNegativeFixture = {
|
|
2224
|
+
const c11ContractDataPairing = {
|
|
2444
2225
|
create(context) {
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
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
|
+
}
|
|
2455
2248
|
} };
|
|
2456
2249
|
},
|
|
2457
2250
|
meta: {
|
|
2458
|
-
docs: RULE_DOCS["
|
|
2459
|
-
messages: {
|
|
2460
|
-
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"
|
|
2461
2254
|
}
|
|
2462
2255
|
};
|
|
2463
2256
|
//#endregion
|
|
2464
|
-
//#region src/lint/rules/
|
|
2465
|
-
|
|
2466
|
-
const MIN_STATUS = 100;
|
|
2467
|
-
const MAX_STATUS = 599;
|
|
2468
|
-
/** Matchers that pin a single value — the shape of a status probe. */
|
|
2469
|
-
const EQUALITY_MATCHERS = /* @__PURE__ */ new Set(["toBe", "toEqual"]);
|
|
2470
|
-
/** Is `node` an `expect(…)` call (bare `expect` identifier callee)? */
|
|
2471
|
-
function isExpectCall(node) {
|
|
2472
|
-
if (node?.type !== "CallExpression") return false;
|
|
2473
|
-
const callee = node.callee;
|
|
2474
|
-
return callee?.type === "Identifier" && callee.name === "expect";
|
|
2475
|
-
}
|
|
2476
|
-
/** Is `node` a numeric literal in the HTTP status range (100–599)? */
|
|
2477
|
-
function isStatusLiteral(node) {
|
|
2478
|
-
if (node?.type !== "Literal" || typeof node.value !== "number") return false;
|
|
2479
|
-
const value = node.value;
|
|
2480
|
-
return Number.isInteger(value) && value >= MIN_STATUS && value <= MAX_STATUS;
|
|
2481
|
-
}
|
|
2482
|
-
/**
|
|
2483
|
-
* Is `node` a status probe — `expect(X.status).toBe(N)` / `.toEqual(N)` where
|
|
2484
|
-
* `X.status` is any `.status` member access and `N` is a numeric literal in the
|
|
2485
|
-
* HTTP status range? The numeric-literal gate keeps container-status STRINGS
|
|
2486
|
-
* (`expect(neo.status).toBe('running')`) out.
|
|
2487
|
-
*/
|
|
2488
|
-
function isStatusProbe(node) {
|
|
2489
|
-
if (node.type !== "CallExpression") return false;
|
|
2490
|
-
const callee = node.callee;
|
|
2491
|
-
if (callee?.type !== "MemberExpression") return false;
|
|
2492
|
-
const matcher = memberPropertyName(callee);
|
|
2493
|
-
if (matcher === void 0 || !EQUALITY_MATCHERS.has(matcher)) return false;
|
|
2494
|
-
const expectCall = callee.object;
|
|
2495
|
-
if (!isExpectCall(expectCall)) return false;
|
|
2496
|
-
const subject = (expectCall?.arguments ?? [])[0];
|
|
2497
|
-
if (subject?.type !== "MemberExpression" || memberPropertyName(subject) !== "status") return false;
|
|
2498
|
-
const expected = (node.arguments ?? [])[0];
|
|
2499
|
-
return isStatusLiteral(expected);
|
|
2500
|
-
}
|
|
2257
|
+
//#region src/lint/rules/c13-underscored-ground.ts
|
|
2258
|
+
const TEST_FILE$6 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2501
2259
|
/**
|
|
2502
|
-
* CONVENTIONS
|
|
2503
|
-
*
|
|
2504
|
-
*
|
|
2505
|
-
* `
|
|
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.
|
|
2506
2265
|
*
|
|
2507
|
-
*
|
|
2508
|
-
*
|
|
2509
|
-
*
|
|
2510
|
-
*
|
|
2511
|
-
*
|
|
2512
|
-
* 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.
|
|
2513
2278
|
*/
|
|
2514
|
-
const
|
|
2279
|
+
const c13UnderscoredGround = {
|
|
2515
2280
|
create(context) {
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
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");
|
|
2531
2301
|
} };
|
|
2532
2302
|
},
|
|
2533
2303
|
meta: {
|
|
2534
|
-
docs: RULE_DOCS["
|
|
2535
|
-
messages: {
|
|
2536
|
-
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"
|
|
2537
2307
|
}
|
|
2538
2308
|
};
|
|
2539
2309
|
//#endregion
|
|
@@ -2669,7 +2439,7 @@ const KNOWN_TOKEN_LITERAL = new RegExp(`^\\{\\{(?:${[
|
|
|
2669
2439
|
"url",
|
|
2670
2440
|
"uuid",
|
|
2671
2441
|
"workdir"
|
|
2672
|
-
].join("|")})(?:#[\\w.-]+)?\\}\\}
|
|
2442
|
+
].join("|")})(?:#[\\w.-]+)?\\}\\}$`, "u");
|
|
2673
2443
|
/**
|
|
2674
2444
|
* Is this function a pure chain of `.replace(…, '{{token}}')` normalisations?
|
|
2675
2445
|
* Accepts an arrow with an expression body or a single-return body whose
|
|
@@ -2681,8 +2451,9 @@ function isTokenEquivalentTransform(fn) {
|
|
|
2681
2451
|
let expression = fn.body;
|
|
2682
2452
|
if (expression?.type === "BlockStatement") {
|
|
2683
2453
|
const statements = expression.body ?? [];
|
|
2684
|
-
|
|
2685
|
-
|
|
2454
|
+
const [only] = statements;
|
|
2455
|
+
if (statements.length !== 1 || only?.type !== "ReturnStatement") return false;
|
|
2456
|
+
expression = only.argument;
|
|
2686
2457
|
}
|
|
2687
2458
|
let sawReplace = false;
|
|
2688
2459
|
while (expression?.type === "CallExpression") {
|
|
@@ -2758,9 +2529,9 @@ const d8wTextBypass = {
|
|
|
2758
2529
|
};
|
|
2759
2530
|
//#endregion
|
|
2760
2531
|
//#region src/lint/rules/d9w-single-use-ref.ts
|
|
2761
|
-
const TEST_FILE$5 = /\.test\.[cm]?[jt]sx
|
|
2532
|
+
const TEST_FILE$5 = /\.test\.[cm]?[jt]sx?$/u;
|
|
2762
2533
|
/** A captured-ref token: `{{kind#ref}}`. Captures the ref name. */
|
|
2763
|
-
const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/
|
|
2534
|
+
const REF_TOKEN = /\{\{[A-Za-z][A-Za-z0-9]*#(?<ref>[\w.-]+)\}\}/gu;
|
|
2764
2535
|
/** Is this call `match.ref(...)`? */
|
|
2765
2536
|
function isMatchRef(node) {
|
|
2766
2537
|
const callee = node.callee;
|
|
@@ -2815,7 +2586,7 @@ const d9wSingleUseRef = {
|
|
|
2815
2586
|
if (memberPropertyName(node.callee) === "toMatch") {
|
|
2816
2587
|
const name = stringValue(node.arguments?.[0]);
|
|
2817
2588
|
if (name !== void 0) {
|
|
2818
|
-
const target = join(featureDir, GROUND_EXPECTED, name.replace(
|
|
2589
|
+
const target = join(featureDir, GROUND_EXPECTED, name.replace(/\/+$/u, ""));
|
|
2819
2590
|
for (const ref of refsInFixture(target)) {
|
|
2820
2591
|
bump(ref);
|
|
2821
2592
|
if (!fixtureNodes.has(ref)) fixtureNodes.set(ref, node);
|
|
@@ -2840,6 +2611,234 @@ const d9wSingleUseRef = {
|
|
|
2840
2611
|
}
|
|
2841
2612
|
};
|
|
2842
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
|
+
}
|
|
2732
|
+
/**
|
|
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.
|
|
2744
|
+
*/
|
|
2745
|
+
const d13wUnfrozenNegativeFixture = {
|
|
2746
|
+
create(context) {
|
|
2747
|
+
if (!segments(context.filename).includes("specs")) return {};
|
|
2748
|
+
return { CallExpression(node) {
|
|
2749
|
+
const callee = node.callee;
|
|
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",
|
|
2755
|
+
node
|
|
2756
|
+
});
|
|
2757
|
+
} };
|
|
2758
|
+
},
|
|
2759
|
+
meta: {
|
|
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)." },
|
|
2762
|
+
type: "suggestion"
|
|
2763
|
+
}
|
|
2764
|
+
};
|
|
2765
|
+
//#endregion
|
|
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;
|
|
2775
|
+
const callee = node.callee;
|
|
2776
|
+
return callee?.type === "Identifier" && callee.name === "expect";
|
|
2777
|
+
}
|
|
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;
|
|
2783
|
+
}
|
|
2784
|
+
/**
|
|
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.
|
|
2789
|
+
*/
|
|
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 = {
|
|
2817
|
+
create(context) {
|
|
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
|
|
2832
|
+
});
|
|
2833
|
+
} };
|
|
2834
|
+
},
|
|
2835
|
+
meta: {
|
|
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)." },
|
|
2838
|
+
type: "suggestion"
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
//#endregion
|
|
2843
2842
|
//#region src/lint/package-exports.ts
|
|
2844
2843
|
/**
|
|
2845
2844
|
* The subpaths this package actually publishes, read from its OWN
|
|
@@ -2893,7 +2892,7 @@ function findOwnManifest(startDir) {
|
|
|
2893
2892
|
*/
|
|
2894
2893
|
function declaredSubpaths(startDir) {
|
|
2895
2894
|
if (startDir === void 0 && cached !== null) return cached;
|
|
2896
|
-
const exports = findOwnManifest(startDir ??
|
|
2895
|
+
const exports = findOwnManifest(startDir ?? import.meta.dirname)?.exports;
|
|
2897
2896
|
const subpaths = exports !== null && typeof exports === "object" ? Object.keys(exports).filter((key) => key.startsWith("./") && key !== ".").map((key) => `${PACKAGE$2}/${key.slice(2)}`).sort() : [];
|
|
2898
2897
|
if (startDir === void 0) cached = subpaths;
|
|
2899
2898
|
return subpaths;
|
|
@@ -2935,9 +2934,9 @@ const f1NoSubpathImport = {
|
|
|
2935
2934
|
};
|
|
2936
2935
|
//#endregion
|
|
2937
2936
|
//#region src/lint/rules/f2-no-test-imports-in-prod.ts
|
|
2938
|
-
const TEST_FILE$4 = /\.(?:test|fixtures|specification)\.[cm]?[jt]sx
|
|
2939
|
-
const CONFIG_FILE = /(?:^|[/\\])[\w.-]*\.config\.[cm]?[jt]s
|
|
2940
|
-
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;
|
|
2941
2940
|
const TOOL_SUBPATH$1 = "@jterrazz/test/oxlint";
|
|
2942
2941
|
/**
|
|
2943
2942
|
* CONVENTIONS F2 — production code never imports a test artefact. In a
|
|
@@ -2994,15 +2993,15 @@ const TOOL_SUBPATH = "@jterrazz/test/oxlint";
|
|
|
2994
2993
|
* `server: () => createApp()` importing `../../src/app.js`).
|
|
2995
2994
|
*/
|
|
2996
2995
|
const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
|
|
2997
|
-
"core",
|
|
2998
2996
|
"integrations",
|
|
2999
2997
|
"lint",
|
|
2998
|
+
"specification",
|
|
3000
2999
|
"vitest"
|
|
3001
3000
|
]);
|
|
3002
3001
|
/**
|
|
3003
3002
|
* CONVENTIONS F3 — from `specs/`, deep-importing the FRAMEWORK's internals is
|
|
3004
3003
|
* forbidden: a relative path resolving inside the framework repo's
|
|
3005
|
-
* `src/{
|
|
3004
|
+
* `src/{specification,integrations,vitest,lint}/`, or any `@jterrazz/test/<subpath>`
|
|
3006
3005
|
* other than the sanctioned tool-facing `@jterrazz/test/oxlint`. A consumer's
|
|
3007
3006
|
* imports of its OWN app source are always allowed — that is the pattern.
|
|
3008
3007
|
*
|
|
@@ -3028,7 +3027,7 @@ const f3SpecsPublicEntry = {
|
|
|
3028
3027
|
}
|
|
3029
3028
|
const marker = source.indexOf("/src/");
|
|
3030
3029
|
if (marker === -1 && !source.startsWith("src/")) return;
|
|
3031
|
-
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] ?? "";
|
|
3032
3031
|
if (!FRAMEWORK_LAYERS.has(layer)) return;
|
|
3033
3032
|
if (underIntegrations && layer === "integrations") return;
|
|
3034
3033
|
context.report({
|
|
@@ -3046,8 +3045,8 @@ const f3SpecsPublicEntry = {
|
|
|
3046
3045
|
};
|
|
3047
3046
|
//#endregion
|
|
3048
3047
|
//#region src/lint/rules/f4-no-test-to-test-import.ts
|
|
3049
|
-
const TEST_FILE$3 = /\.test\.[cm]?[jt]sx
|
|
3050
|
-
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;
|
|
3051
3050
|
/**
|
|
3052
3051
|
* CONVENTIONS F4 — a `*.test.ts` never imports another `*.test.ts`. Shared
|
|
3053
3052
|
* test data belongs in a `*.fixtures.ts` neighbour (F5), shared runners in a
|
|
@@ -3072,8 +3071,8 @@ const f4NoTestToTestImport = {
|
|
|
3072
3071
|
};
|
|
3073
3072
|
//#endregion
|
|
3074
3073
|
//#region src/lint/rules/f5-fixtures-only-from-tests.ts
|
|
3075
|
-
const TEST_FILE$2 = /\.test\.[cm]?[jt]sx
|
|
3076
|
-
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;
|
|
3077
3076
|
/**
|
|
3078
3077
|
* CONVENTIONS F5 — a `*.fixtures.ts` module is only importable from
|
|
3079
3078
|
* `*.test.ts` files. Any other importer (prod code, a specification file,
|
|
@@ -3098,7 +3097,7 @@ const f5FixturesOnlyFromTests = {
|
|
|
3098
3097
|
};
|
|
3099
3098
|
//#endregion
|
|
3100
3099
|
//#region src/lint/rules/i1-layer-boundaries.ts
|
|
3101
|
-
const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx
|
|
3100
|
+
const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/u;
|
|
3102
3101
|
function matchesPackage(source, packages) {
|
|
3103
3102
|
return packages.some((name) => source === name || source.startsWith(`${name}/`));
|
|
3104
3103
|
}
|
|
@@ -3110,7 +3109,7 @@ function pathInsideSrc(path) {
|
|
|
3110
3109
|
}
|
|
3111
3110
|
/** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
|
|
3112
3111
|
function withoutExtension(path) {
|
|
3113
|
-
return path.replace(/\.[cm]?[jt]sx
|
|
3112
|
+
return path.replace(/\.[cm]?[jt]sx?$/u, "");
|
|
3114
3113
|
}
|
|
3115
3114
|
/** Does `target` match one of the declared paths (prefix, or exact module)? */
|
|
3116
3115
|
function matchesPath(target, patterns) {
|
|
@@ -3227,7 +3226,7 @@ const i1LayerBoundaries = {
|
|
|
3227
3226
|
};
|
|
3228
3227
|
//#endregion
|
|
3229
3228
|
//#region src/lint/rules/i2-sibling-test-naming.ts
|
|
3230
|
-
const TEST_FILE$1 = /\.test\.[cm]?[jt]sx
|
|
3229
|
+
const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/u;
|
|
3231
3230
|
/**
|
|
3232
3231
|
* CONVENTIONS I2 — module tests are NEIGHBOURS: the test of `<file>.ts` is
|
|
3233
3232
|
* `<file>.test.ts` next to it (Go's `foo_test.go` parity). Flags, on visited
|
|
@@ -3255,7 +3254,7 @@ const i2SiblingTestNaming = {
|
|
|
3255
3254
|
".js"
|
|
3256
3255
|
].map((extension) => file.replace(TEST_FILE$1, extension));
|
|
3257
3256
|
if (!neighbours.some(isFile)) context.report({
|
|
3258
|
-
data: { expected: neighbours[0]
|
|
3257
|
+
data: { expected: neighbours[0]?.split(/[/\\]/u).at(-1) ?? "" },
|
|
3259
3258
|
messageId: "orphanTest",
|
|
3260
3259
|
node
|
|
3261
3260
|
});
|
|
@@ -3282,7 +3281,7 @@ const i2SiblingTestNaming = {
|
|
|
3282
3281
|
};
|
|
3283
3282
|
//#endregion
|
|
3284
3283
|
//#region src/lint/rules/i4-no-vi-mock-in-src.ts
|
|
3285
|
-
const TEST_FILE = /\.test\.[cm]?[jt]sx
|
|
3284
|
+
const TEST_FILE = /\.test\.[cm]?[jt]sx?$/u;
|
|
3286
3285
|
const DATA_ASSET = new RegExp(`\\.(?:${[
|
|
3287
3286
|
"avif",
|
|
3288
3287
|
"bin",
|
|
@@ -3320,10 +3319,10 @@ const DATA_ASSET = new RegExp(`\\.(?:${[
|
|
|
3320
3319
|
"yaml",
|
|
3321
3320
|
"yml",
|
|
3322
3321
|
"zip"
|
|
3323
|
-
].join("|")})$`, "
|
|
3322
|
+
].join("|")})$`, "iu");
|
|
3324
3323
|
/** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
|
|
3325
3324
|
function withoutSuffix(source) {
|
|
3326
|
-
return source.replace(/[?#]
|
|
3325
|
+
return source.replace(/[?#].*$/u, "");
|
|
3327
3326
|
}
|
|
3328
3327
|
/**
|
|
3329
3328
|
* CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
|
|
@@ -3563,7 +3562,7 @@ const plugin = {
|
|
|
3563
3562
|
*
|
|
3564
3563
|
* 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }]
|
|
3565
3564
|
*/
|
|
3566
|
-
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"]));
|
|
3567
3566
|
/**
|
|
3568
3567
|
* The composable testing fragment — wire the plugin, enable the whole
|
|
3569
3568
|
* catalogue, and ship the one override every strict consumer needs. Designed
|