@jterrazz/test 15.0.0 → 15.1.1
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 +538 -525
- package/dist/oxlint.d.cts +7064 -15
- package/dist/oxlint.d.ts +7064 -15
- package/dist/oxlint.js +538 -525
- 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>` que la map `exports` du paquet ne publie pas — les subpaths publiés sont exemptés, LUS du manifeste comme en F1, qui en tient la liste. 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
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
* negative assertion (`expect(() => …toMatch(…)).toThrow()` or
|
|
2403
|
-
* `expect(…toMatch(…)).rejects.toThrow()`)? The subject's own `expect(x)` is the
|
|
2404
|
-
* member OBJECT of `.toMatch` (a descendant), never an ancestor, so a positive
|
|
2405
|
-
* `expect(x).toMatch('f')` never trips this. The walk stays inside the current
|
|
2406
|
-
* expression: it stops at the first statement boundary or non-`expect` call, so
|
|
2407
|
-
* a `toMatch` merely written inside `inUpdateMode(() => …)` is NOT flagged.
|
|
2408
|
-
*/
|
|
2409
|
-
const TRANSPARENT_ANCESTORS = /* @__PURE__ */ new Set([
|
|
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;
|
|
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`;
|
|
2429
2215
|
}
|
|
2430
2216
|
/**
|
|
2431
|
-
* CONVENTIONS
|
|
2432
|
-
*
|
|
2433
|
-
*
|
|
2434
|
-
*
|
|
2435
|
-
*
|
|
2436
|
-
*
|
|
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.
|
|
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`.
|
|
2442
2223
|
*/
|
|
2443
|
-
const
|
|
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,10 +2934,10 @@ 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?)
|
|
2941
|
-
const TOOL_SUBPATH
|
|
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;
|
|
2940
|
+
const TOOL_SUBPATH = "@jterrazz/test/oxlint";
|
|
2942
2941
|
/**
|
|
2943
2942
|
* CONVENTIONS F2 — production code never imports a test artefact. In a
|
|
2944
2943
|
* non-test file (not `*.test.ts` / `*.fixtures.ts` / `*.specification.ts`,
|
|
@@ -2957,7 +2956,7 @@ const f2NoTestImportsInProd = {
|
|
|
2957
2956
|
const file = context.filename;
|
|
2958
2957
|
if (TEST_FILE$4.test(file) || CONFIG_FILE.test(file) || isUnderSpecs(file)) return {};
|
|
2959
2958
|
return { ...importSourceVisitor(({ node, source }) => {
|
|
2960
|
-
if (source === TOOL_SUBPATH
|
|
2959
|
+
if (source === TOOL_SUBPATH) return;
|
|
2961
2960
|
if (source === "vitest" || source.startsWith("vitest/")) context.report({
|
|
2962
2961
|
data: { source },
|
|
2963
2962
|
messageId: "testImport",
|
|
@@ -2984,7 +2983,6 @@ const f2NoTestImportsInProd = {
|
|
|
2984
2983
|
//#endregion
|
|
2985
2984
|
//#region src/lint/rules/f3-specs-public-entry.ts
|
|
2986
2985
|
const PACKAGE = "@jterrazz/test";
|
|
2987
|
-
const TOOL_SUBPATH = "@jterrazz/test/oxlint";
|
|
2988
2986
|
/**
|
|
2989
2987
|
* The framework's own top-level `src/` layers. Deep-importing any of these from
|
|
2990
2988
|
* a spec couples the spec to internals the public entry exists to hide. These
|
|
@@ -2994,20 +2992,27 @@ const TOOL_SUBPATH = "@jterrazz/test/oxlint";
|
|
|
2994
2992
|
* `server: () => createApp()` importing `../../src/app.js`).
|
|
2995
2993
|
*/
|
|
2996
2994
|
const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
|
|
2997
|
-
"core",
|
|
2998
2995
|
"integrations",
|
|
2999
2996
|
"lint",
|
|
2997
|
+
"specification",
|
|
3000
2998
|
"vitest"
|
|
3001
2999
|
]);
|
|
3002
3000
|
/**
|
|
3003
3001
|
* CONVENTIONS F3 — from `specs/`, deep-importing the FRAMEWORK's internals is
|
|
3004
3002
|
* forbidden: a relative path resolving inside the framework repo's
|
|
3005
|
-
* `src/{
|
|
3006
|
-
*
|
|
3007
|
-
*
|
|
3003
|
+
* `src/{specification,integrations,vitest,lint}/`, or a `@jterrazz/test/<subpath>`
|
|
3004
|
+
* the package does not publish. A consumer's imports of its OWN app source are
|
|
3005
|
+
* always allowed — that is the pattern.
|
|
3006
|
+
*
|
|
3007
|
+
* The exempt subpaths are not listed here: they are read from the package's own
|
|
3008
|
+
* `exports` map, the same source F1 uses. A published subpath IS the public
|
|
3009
|
+
* surface — reaching `@jterrazz/test/vitest` from a specs-tree `vitest.config.ts`
|
|
3010
|
+
* is the prescribed gesture, not a leak — so what a spec must not reach is what
|
|
3011
|
+
* the manifest never published. The root entry (`.`) is not a subpath and never
|
|
3012
|
+
* concerned this branch: `@jterrazz/test` is the public entry F3 points at.
|
|
3008
3013
|
*
|
|
3009
|
-
* Distinct from F1 (which
|
|
3010
|
-
*
|
|
3014
|
+
* Distinct from F1 (which judges the same specifiers from any file): F3 is the
|
|
3015
|
+
* specs-specific guard that also reaches relative framework-internal paths.
|
|
3011
3016
|
* Documented exception: specs under `specs/integrations/` may deep-import the
|
|
3012
3017
|
* integration module they cover (`src/integrations/**`).
|
|
3013
3018
|
*/
|
|
@@ -3015,12 +3020,17 @@ const f3SpecsPublicEntry = {
|
|
|
3015
3020
|
create(context) {
|
|
3016
3021
|
const anchor = specsAnchor(context.filename);
|
|
3017
3022
|
if (anchor === void 0) return {};
|
|
3023
|
+
const published = new Set(declaredSubpaths());
|
|
3024
|
+
const exempt = published.size === 0 ? "none" : [...published].join(", ");
|
|
3018
3025
|
const underIntegrations = anchor.relative[0] === "integrations";
|
|
3019
3026
|
return { ...importSourceVisitor(({ node, source }) => {
|
|
3020
3027
|
if (source.startsWith(`${PACKAGE}/`)) {
|
|
3021
|
-
if (source
|
|
3028
|
+
if (published.has(source)) return;
|
|
3022
3029
|
context.report({
|
|
3023
|
-
data: {
|
|
3030
|
+
data: {
|
|
3031
|
+
published: exempt,
|
|
3032
|
+
source
|
|
3033
|
+
},
|
|
3024
3034
|
messageId: "deepImport",
|
|
3025
3035
|
node
|
|
3026
3036
|
});
|
|
@@ -3028,11 +3038,14 @@ const f3SpecsPublicEntry = {
|
|
|
3028
3038
|
}
|
|
3029
3039
|
const marker = source.indexOf("/src/");
|
|
3030
3040
|
if (marker === -1 && !source.startsWith("src/")) return;
|
|
3031
|
-
const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0];
|
|
3041
|
+
const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0] ?? "";
|
|
3032
3042
|
if (!FRAMEWORK_LAYERS.has(layer)) return;
|
|
3033
3043
|
if (underIntegrations && layer === "integrations") return;
|
|
3034
3044
|
context.report({
|
|
3035
|
-
data: {
|
|
3045
|
+
data: {
|
|
3046
|
+
published: exempt,
|
|
3047
|
+
source
|
|
3048
|
+
},
|
|
3036
3049
|
messageId: "deepImport",
|
|
3037
3050
|
node
|
|
3038
3051
|
});
|
|
@@ -3040,14 +3053,14 @@ const f3SpecsPublicEntry = {
|
|
|
3040
3053
|
},
|
|
3041
3054
|
meta: {
|
|
3042
3055
|
docs: RULE_DOCS["f3-specs-public-entry"],
|
|
3043
|
-
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
|
|
3056
|
+
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). Exempt: the published subpaths {{published}}, and src/integrations/** from specs/integrations/." },
|
|
3044
3057
|
type: "problem"
|
|
3045
3058
|
}
|
|
3046
3059
|
};
|
|
3047
3060
|
//#endregion
|
|
3048
3061
|
//#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?)
|
|
3062
|
+
const TEST_FILE$3 = /\.test\.[cm]?[jt]sx?$/u;
|
|
3063
|
+
const TEST_IMPORT = /\.test(?:\.[cm]?[jt]sx?)?$/u;
|
|
3051
3064
|
/**
|
|
3052
3065
|
* CONVENTIONS F4 — a `*.test.ts` never imports another `*.test.ts`. Shared
|
|
3053
3066
|
* test data belongs in a `*.fixtures.ts` neighbour (F5), shared runners in a
|
|
@@ -3072,8 +3085,8 @@ const f4NoTestToTestImport = {
|
|
|
3072
3085
|
};
|
|
3073
3086
|
//#endregion
|
|
3074
3087
|
//#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?)
|
|
3088
|
+
const TEST_FILE$2 = /\.test\.[cm]?[jt]sx?$/u;
|
|
3089
|
+
const FIXTURES_IMPORT = /\.fixtures(?:\.[cm]?[jt]sx?)?$/u;
|
|
3077
3090
|
/**
|
|
3078
3091
|
* CONVENTIONS F5 — a `*.fixtures.ts` module is only importable from
|
|
3079
3092
|
* `*.test.ts` files. Any other importer (prod code, a specification file,
|
|
@@ -3098,7 +3111,7 @@ const f5FixturesOnlyFromTests = {
|
|
|
3098
3111
|
};
|
|
3099
3112
|
//#endregion
|
|
3100
3113
|
//#region src/lint/rules/i1-layer-boundaries.ts
|
|
3101
|
-
const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx
|
|
3114
|
+
const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/u;
|
|
3102
3115
|
function matchesPackage(source, packages) {
|
|
3103
3116
|
return packages.some((name) => source === name || source.startsWith(`${name}/`));
|
|
3104
3117
|
}
|
|
@@ -3110,7 +3123,7 @@ function pathInsideSrc(path) {
|
|
|
3110
3123
|
}
|
|
3111
3124
|
/** Strip a `.js`/`.ts`-style extension for exact-path comparison. */
|
|
3112
3125
|
function withoutExtension(path) {
|
|
3113
|
-
return path.replace(/\.[cm]?[jt]sx
|
|
3126
|
+
return path.replace(/\.[cm]?[jt]sx?$/u, "");
|
|
3114
3127
|
}
|
|
3115
3128
|
/** Does `target` match one of the declared paths (prefix, or exact module)? */
|
|
3116
3129
|
function matchesPath(target, patterns) {
|
|
@@ -3227,7 +3240,7 @@ const i1LayerBoundaries = {
|
|
|
3227
3240
|
};
|
|
3228
3241
|
//#endregion
|
|
3229
3242
|
//#region src/lint/rules/i2-sibling-test-naming.ts
|
|
3230
|
-
const TEST_FILE$1 = /\.test\.[cm]?[jt]sx
|
|
3243
|
+
const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/u;
|
|
3231
3244
|
/**
|
|
3232
3245
|
* CONVENTIONS I2 — module tests are NEIGHBOURS: the test of `<file>.ts` is
|
|
3233
3246
|
* `<file>.test.ts` next to it (Go's `foo_test.go` parity). Flags, on visited
|
|
@@ -3255,7 +3268,7 @@ const i2SiblingTestNaming = {
|
|
|
3255
3268
|
".js"
|
|
3256
3269
|
].map((extension) => file.replace(TEST_FILE$1, extension));
|
|
3257
3270
|
if (!neighbours.some(isFile)) context.report({
|
|
3258
|
-
data: { expected: neighbours[0]
|
|
3271
|
+
data: { expected: neighbours[0]?.split(/[/\\]/u).at(-1) ?? "" },
|
|
3259
3272
|
messageId: "orphanTest",
|
|
3260
3273
|
node
|
|
3261
3274
|
});
|
|
@@ -3282,7 +3295,7 @@ const i2SiblingTestNaming = {
|
|
|
3282
3295
|
};
|
|
3283
3296
|
//#endregion
|
|
3284
3297
|
//#region src/lint/rules/i4-no-vi-mock-in-src.ts
|
|
3285
|
-
const TEST_FILE = /\.test\.[cm]?[jt]sx
|
|
3298
|
+
const TEST_FILE = /\.test\.[cm]?[jt]sx?$/u;
|
|
3286
3299
|
const DATA_ASSET = new RegExp(`\\.(?:${[
|
|
3287
3300
|
"avif",
|
|
3288
3301
|
"bin",
|
|
@@ -3320,10 +3333,10 @@ const DATA_ASSET = new RegExp(`\\.(?:${[
|
|
|
3320
3333
|
"yaml",
|
|
3321
3334
|
"yml",
|
|
3322
3335
|
"zip"
|
|
3323
|
-
].join("|")})$`, "
|
|
3336
|
+
].join("|")})$`, "iu");
|
|
3324
3337
|
/** A bundler suffix (`./payload.json?raw`, `./doc.md#frag`) is not part of the extension. */
|
|
3325
3338
|
function withoutSuffix(source) {
|
|
3326
|
-
return source.replace(/[?#]
|
|
3339
|
+
return source.replace(/[?#].*$/u, "");
|
|
3327
3340
|
}
|
|
3328
3341
|
/**
|
|
3329
3342
|
* CONVENTIONS I4 — in module tests under `src/`, mocks and data are CODE:
|
|
@@ -3563,7 +3576,7 @@ const plugin = {
|
|
|
3563
3576
|
*
|
|
3564
3577
|
* 'jterrazz/b5-await-using': ['error', { runners: ['dockerCli'] }]
|
|
3565
3578
|
*/
|
|
3566
|
-
const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w
|
|
3579
|
+
const recommendedRules = Object.fromEntries(Object.keys(plugin.rules).map((rule) => [`jterrazz/${rule}`, /^\w+w-/u.test(rule) ? "warn" : "error"]));
|
|
3567
3580
|
/**
|
|
3568
3581
|
* The composable testing fragment — wire the plugin, enable the whole
|
|
3569
3582
|
* catalogue, and ship the one override every strict consumer needs. Designed
|