@jterrazz/test 10.1.0 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/intercept.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { t as ContractQueue } from "./queue.js";
1
2
  //#region src/integrations/msw/intercept.ts
2
3
  let mswModule = null;
3
4
  let mswHttp = null;
@@ -15,100 +16,54 @@ let serverInstance = null;
15
16
  /**
16
17
  * Start the MSW server (once per process).
17
18
  */
18
- async function ensureInterceptServer() {
19
+ async function ensureContractServer() {
19
20
  if (serverInstance) return;
20
21
  const { node } = await loadMsw();
21
22
  serverInstance = node.setupServer();
22
23
  serverInstance.listen({ onUnhandledRequest: "bypass" });
23
24
  }
24
- function describeTrigger(entry) {
25
- return `${entry.trigger.method === "*" ? "ANY" : entry.trigger.method} ${String(entry.trigger.url)}`;
26
- }
27
- /**
28
- * Pure FIFO queue over the chain's intercept entries. Each observed request
29
- * consumes the first unconsumed entry whose URL, method, and optional body
30
- * matcher accept it. Exported for unit tests — MSW never reaches this class.
31
- */
32
- var InterceptQueue = class {
33
- consumed;
34
- entries;
35
- constructor(entries) {
36
- this.entries = entries;
37
- this.consumed = entries.map(() => false);
38
- }
39
- /**
40
- * Unique trigger URLs, preserving RegExp/string identity for MSW routing.
41
- * Deduped by string form — two RegExp objects with the same source are
42
- * one trigger (and get one handler).
43
- */
44
- get urls() {
45
- const urls = /* @__PURE__ */ new Map();
46
- for (const entry of this.entries) {
47
- const key = String(entry.trigger.url);
48
- if (!urls.has(key)) urls.set(key, entry.trigger.url);
49
- }
50
- return [...urls.values()];
51
- }
52
- /** Methods declared for a given trigger URL. */
53
- methodsFor(url) {
54
- const methods = /* @__PURE__ */ new Set();
55
- for (const entry of this.entries) if (sameUrl(entry.trigger.url, url)) methods.add(entry.trigger.method);
56
- return [...methods];
57
- }
58
- /**
59
- * Consume and return the first unconsumed entry registered for
60
- * `handlerUrl`/`method` that accepts the request, or null when the queue
61
- * for that trigger is exhausted (or no matcher accepts).
62
- */
63
- take(handlerUrl, method, request) {
64
- for (let i = 0; i < this.entries.length; i++) {
65
- if (this.consumed[i]) continue;
66
- const entry = this.entries[i];
67
- if (!sameUrl(entry.trigger.url, handlerUrl)) continue;
68
- if (entry.trigger.method !== method && entry.trigger.method !== "*") continue;
69
- if (entry.trigger.match && !entry.trigger.match(request)) continue;
70
- this.consumed[i] = true;
71
- return entry;
72
- }
73
- return null;
74
- }
75
- /**
76
- * Build the strict-intercept failure for a request that matched no
77
- * registered intercept (CONVENTIONS D7): method + URL of the offending
78
- * request, plus every registered trigger and its consumption state.
79
- */
80
- unmatchedError(method, url) {
81
- const triggers = this.entries.length === 0 ? " (no intercepts registered)" : this.entries.map((entry, i) => ` - ${describeTrigger(entry)}${this.consumed[i] ? " (already consumed)" : ""}`).join("\n");
82
- return /* @__PURE__ */ new Error(`Unmatched outgoing HTTP request during spec: ${method} ${url}\nRegistered intercepts:\n${triggers}\nEvery outgoing request of a chain that declares intercepts must match one — add an .intercept() for it (or one more if its queue is exhausted).`);
83
- }
84
- };
85
- function sameUrl(a, b) {
86
- return a === b || String(a) === String(b);
25
+ /** Turn a contract response into the MSW reply, body kind by body kind. */
26
+ function toMswResponse(msw, response) {
27
+ const { body, headers, status = 200 } = response;
28
+ if (body === null || body === void 0) return new msw.HttpResponse(null, {
29
+ headers,
30
+ status
31
+ });
32
+ if (typeof body === "string") return new msw.HttpResponse(body, {
33
+ headers: {
34
+ "content-type": "text/plain; charset=utf-8",
35
+ ...headers
36
+ },
37
+ status
38
+ });
39
+ return msw.HttpResponse.json(body, {
40
+ headers,
41
+ status
42
+ });
87
43
  }
88
44
  /**
89
- * Register intercept entries as MSW handlers for one chain. A trailing
90
- * catch-all handler records any request that no entry accepted — the
91
- * builder rethrows it as the spec failure (rejecting the action promise,
92
- * never an unhandled rejection).
45
+ * Register a chain's contracts as MSW handlers. A trailing catch-all handler
46
+ * records any request no contract accepted — the builder rethrows it as the
47
+ * spec failure (rejecting the action promise, never an unhandled rejection).
93
48
  */
94
- async function registerIntercepts(entries) {
95
- if (entries.length === 0) return {
49
+ async function registerContracts(contracts) {
50
+ if (contracts.length === 0) return {
96
51
  cleanup: () => {},
97
52
  violation: () => null
98
53
  };
99
- await ensureInterceptServer();
54
+ await ensureContractServer();
100
55
  const { msw } = await loadMsw();
101
- const queue = new InterceptQueue(entries);
56
+ const queue = new ContractQueue(contracts);
102
57
  let violation = null;
103
58
  const recordViolation = (method, url) => {
104
59
  violation ??= queue.unmatchedError(method, url);
105
- return msw.HttpResponse.json({ error: `@jterrazz/test strict intercepts: unmatched request ${method} ${url}` }, { status: 501 });
60
+ return msw.HttpResponse.json({ error: `@jterrazz/test strict contracts: unmatched request ${method} ${url}` }, { status: 501 });
106
61
  };
107
62
  const handlers = [];
108
- for (const url of queue.urls) for (const method of queue.methodsFor(url)) {
63
+ for (const route of queue.routes) for (const method of route.methods) {
109
64
  const handlerFn = method === "*" ? msw.http.all : msw.http[method.toLowerCase()];
110
65
  if (!handlerFn) continue;
111
- const handler = handlerFn(url, async ({ request }) => {
66
+ const handler = handlerFn(route.url, async ({ request }) => {
112
67
  let body = null;
113
68
  const rawText = await request.clone().text();
114
69
  if (rawText) try {
@@ -123,16 +78,14 @@ async function registerIntercepts(entries) {
123
78
  const observed = {
124
79
  body,
125
80
  headers,
81
+ method: request.method.toUpperCase(),
126
82
  url: request.url
127
83
  };
128
- const entry = queue.take(url, request.method, observed);
129
- if (!entry) return recordViolation(request.method, request.url);
130
- const response = typeof entry.response === "function" ? entry.response(observed) : entry.response;
84
+ const contract = queue.take(observed);
85
+ if (!contract) return recordViolation(request.method, request.url);
86
+ const response = typeof contract.response === "function" ? contract.response(observed) : contract.response;
131
87
  if (response.delay) await new Promise((r) => setTimeout(r, response.delay));
132
- return msw.HttpResponse.json(response.body, {
133
- status: response.status ?? 200,
134
- headers: response.headers
135
- });
88
+ return toMswResponse(msw, response);
136
89
  });
137
90
  handlers.push(handler);
138
91
  }
@@ -142,8 +95,8 @@ async function registerIntercepts(entries) {
142
95
  cleanup: () => {
143
96
  serverInstance.resetHandlers();
144
97
  },
145
- violation: () => violation
98
+ violation: () => violation ?? queue.requiredError()
146
99
  };
147
100
  }
148
101
  //#endregion
149
- export { registerIntercepts };
102
+ export { registerContracts };
package/dist/match.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region src/core/matching/match.ts
2
- /** Every kind usable as a `{{token}}` in fixture files (all but ref/regex). */
2
+ /** Every kind usable as a `{{token}}` in fixture files (all but ref/regex/includes). */
3
3
  const TOKEN_KINDS = [
4
4
  "any",
5
5
  "base64",
@@ -28,6 +28,8 @@ const TOKEN_KINDS = [
28
28
  * constructed directly by user code.
29
29
  */
30
30
  var Matcher = class {
31
+ /** For kind 'includes': the substring the actual value must contain. */
32
+ includes;
31
33
  kind;
32
34
  /** For kind 'ref': the capture to assert inequality against. */
33
35
  notRef;
@@ -36,6 +38,7 @@ var Matcher = class {
36
38
  /** For kind 'regex': the pattern the actual value must match. */
37
39
  regex;
38
40
  constructor(kind, options = {}) {
41
+ this.includes = options.includes;
39
42
  this.kind = kind;
40
43
  this.notRef = options.notRef;
41
44
  this.refName = options.refName;
@@ -44,6 +47,7 @@ var Matcher = class {
44
47
  /** Placeholder-style rendering used in failure diffs and serialization. */
45
48
  toString() {
46
49
  switch (this.kind) {
50
+ case "includes": return `{{includes:${this.includes}}}`;
47
51
  case "ref": return this.notRef ? `{{ref#${this.refName}!${this.notRef}}}` : `{{ref#${this.refName}}}`;
48
52
  case "regex": return `{{regex:${this.regex?.source}}}`;
49
53
  default: return this.refName ? `{{${this.kind}#${this.refName}}}` : `{{${this.kind}}}`;
@@ -83,6 +87,13 @@ const match = {
83
87
  float: typed("float"),
84
88
  /** Matches a hexadecimal string. */
85
89
  hex: typed("hex"),
90
+ /**
91
+ * Matches a string CONTAINING the given substring. Code-only, like
92
+ * {@link match.regex} — the `{{token}}` fixture vocabulary does not grow.
93
+ * The explicit escape hatch now that contract string filters
94
+ * (`openai.chat({ user })`) mean exact equality.
95
+ */
96
+ includes: (substring) => new Matcher("includes", { includes: substring }),
86
97
  /** Matches an integer (or an integer string in text contexts). */
87
98
  int: typed("int"),
88
99
  /** Matches an IPv4 address. */
package/dist/oxlint.cjs CHANGED
@@ -255,6 +255,20 @@ const RULE_DOCS = {
255
255
  id: "C1",
256
256
  rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
257
257
  },
258
+ "c10-contracts-boundary": {
259
+ channel: "statique",
260
+ convention: "Hors de `specs/**/contracts/`, un import qui résout dans un dossier de provider (`contracts/{http,openai,anthropic}/…`) est une erreur : un test n’importe que les façades `*.contracts.ts`.",
261
+ family: "C",
262
+ id: "C10",
263
+ rationale: "Les contrats unitaires sont des détails de composition ; passer par la façade garde chaque scénario nommé au même endroit que le monde dont il dérive."
264
+ },
265
+ "c11-contract-data-pairing": {
266
+ channel: "statique",
267
+ convention: "Dans un dossier de provider, tout `*.response.json` et tout `*.request.ts` a un frère `<souche>.ts` — la souche est le nom jusqu’au PREMIER point (`events.fr.response.json` → `events.ts`) ; une donnée orpheline est une erreur.",
268
+ family: "C",
269
+ id: "C11",
270
+ rationale: "Une donnée n’existe que servie par un contrat — un fichier sans propriétaire est du poids mort qu’aucun test ne charge."
271
+ },
258
272
  "c2-http-only-requests": {
259
273
  channel: "statique",
260
274
  convention: "`requests/` ne contient que des fichiers `.http` ; toute autre extension est une erreur.",
@@ -264,10 +278,10 @@ const RULE_DOCS = {
264
278
  },
265
279
  "c4-contract-shape": {
266
280
  channel: "statique",
267
- convention: "Un fichier de `contracts/` respecte `<nom>.<provider>.ts` (provider openai|anthropic|http), à plat, `export default defineContract(...)`, imports depuis le point d’entrée public.",
281
+ convention: "Sous `specs/**/contracts/` : la RACINE ne porte que des façades `*.contracts.ts` (export default construit par `defineContracts(...)` ou une re-export de composition ; exports nommés = scénarios) et les dossiers de provider `http` | `openai` | `anthropic`. Un contrat unitaire est `<provider>/<nom-kebab>.ts` avec un `export default defineContract(...)` (ou une factory qui en retourne un), et son builder de `request` doit désigner le provider de son dossier. Un dossier de provider est plat et ne contient que des `*.ts` et des `*.response.json`.",
268
282
  family: "C",
269
283
  id: "C4",
270
- rationale: "Une forme figée rend les contrats découvrables et typables sans convention locale."
284
+ rationale: "Une arborescence figée sépare la façade publique des unités internes et fait porter le provider par le DOSSIER — les noms de fichiers redeviennent du métier, et la donnée volumineuse se range à côté du contrat qui la sert."
271
285
  },
272
286
  "c6-tomatch-extension": {
273
287
  channel: "statique",
@@ -494,7 +508,7 @@ const CHECKER_PASSES = [
494
508
  },
495
509
  {
496
510
  channel: "checker",
497
- convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`intercepts/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` est orphelin (warning si argument non littéral).",
511
+ convention: "Aucune fixture morte : tout fichier sous `seeds/`/`requests/`/`fixtures/` et toute entrée de premier niveau de `expected/` doit être référencée ; un dossier de feature sans `*.test.ts` est orphelin (warning si argument non littéral).",
498
512
  family: "C",
499
513
  id: "C9",
500
514
  name: "c9-dead-fixtures",
@@ -1436,8 +1450,94 @@ const c1DomainStructure = {
1436
1450
  }
1437
1451
  };
1438
1452
  //#endregion
1453
+ //#region src/lint/rules/c10-contracts-boundary.ts
1454
+ /** A specifier reaching into a provider folder — the internal half of contracts/. */
1455
+ const INTERNAL_UNIT = /(?:^|\/)contracts\/(?:anthropic|http|openai)\//u;
1456
+ /**
1457
+ * CONVENTIONS C10 — the contracts boundary. A feature's `contracts/` folder has
1458
+ * a PUBLIC half (`*.contracts.ts` facades: the default export is the world, the
1459
+ * named exports are its scenarios) and an INTERNAL half (the `http/`, `openai/`
1460
+ * and `anthropic/` unit contracts the facades compose). Outside `contracts/`,
1461
+ * importing a unit is an error — a test routes through the facade, so a
1462
+ * scenario is named once, next to the world it derives from.
1463
+ */
1464
+ const c10ContractsBoundary = {
1465
+ create(context) {
1466
+ const parts = segments(context.physicalFilename);
1467
+ if (!parts.includes("specs")) return {};
1468
+ if (parts.slice(0, -1).includes("contracts")) return {};
1469
+ return importSourceVisitor(({ node, source }) => {
1470
+ if (INTERNAL_UNIT.test(source)) context.report({
1471
+ data: { source },
1472
+ messageId: "internal",
1473
+ node
1474
+ });
1475
+ });
1476
+ },
1477
+ meta: {
1478
+ docs: RULE_DOCS["c10-contracts-boundary"],
1479
+ messages: { internal: "Import \"{{source}}\" reaches into a provider folder — only contracts/*.contracts.ts is importable from a test; add a named scenario export there instead (C10 — see docs/10-linting.md)." },
1480
+ type: "problem"
1481
+ }
1482
+ };
1483
+ //#endregion
1484
+ //#region src/lint/rules/c11-contract-data-pairing.ts
1485
+ const TEST_FILE$11 = /\.test\.[cm]?[jt]sx?$/;
1486
+ /** The provider folders that may carry data files. */
1487
+ const PROVIDERS$1 = [
1488
+ "anthropic",
1489
+ "http",
1490
+ "openai"
1491
+ ];
1492
+ /** Data files: served payloads and matched inputs, both owned by a contract. */
1493
+ const DATA_SUFFIXES = [".request.ts", ".response.json"];
1494
+ /** The owning contract of a data file: its name up to the FIRST dot, `.ts`. */
1495
+ function ownerOf(entry) {
1496
+ return `${entry.slice(0, entry.indexOf("."))}.ts`;
1497
+ }
1498
+ /**
1499
+ * CONVENTIONS C11 — data is owned by a contract. Inside a provider folder every
1500
+ * `*.response.json` (served payload) and `*.request.ts` (matched prompt/body)
1501
+ * pairs with the sibling `<stem>.ts` that serves it, where the stem is the name
1502
+ * up to the FIRST dot: `events.fr.response.json` belongs to `events.ts`. A data
1503
+ * file with no owner is dead weight no test can reach — reported on the feature's
1504
+ * test file, since oxlint never visits a `.json`.
1505
+ */
1506
+ const c11ContractDataPairing = {
1507
+ create(context) {
1508
+ const file = context.physicalFilename;
1509
+ if (!TEST_FILE$11.test(file) || !segments(file).includes("specs")) return {};
1510
+ return { Program(node) {
1511
+ const contractsDir = (0, node_path.join)((0, node_path.dirname)(file), "contracts");
1512
+ for (const provider of PROVIDERS$1) {
1513
+ const entries = listDirectory((0, node_path.join)(contractsDir, provider));
1514
+ if (entries === null) continue;
1515
+ const present = new Set(entries);
1516
+ for (const entry of entries) {
1517
+ if (!DATA_SUFFIXES.some((suffix) => entry.endsWith(suffix))) continue;
1518
+ const owner = ownerOf(entry);
1519
+ if (!present.has(owner)) context.report({
1520
+ data: {
1521
+ entry,
1522
+ owner,
1523
+ provider
1524
+ },
1525
+ messageId: "orphan",
1526
+ node
1527
+ });
1528
+ }
1529
+ }
1530
+ } };
1531
+ },
1532
+ meta: {
1533
+ docs: RULE_DOCS["c11-contract-data-pairing"],
1534
+ messages: { orphan: "contracts/{{provider}}/{{entry}} has no owning contract — data pairs with the sibling {{owner}} (stem = name up to the first dot) (C11 — see docs/10-linting.md)." },
1535
+ type: "problem"
1536
+ }
1537
+ };
1538
+ //#endregion
1439
1539
  //#region src/lint/rules/c2-http-only-requests.ts
1440
- const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1540
+ const TEST_FILE$10 = /\.test\.[cm]?[jt]sx?$/;
1441
1541
  /**
1442
1542
  * CONVENTIONS C2 — `requests/` contains only `.http` files (a request file is
1443
1543
  * a COMPLETE request: method, path, headers, body). Anchored on the feature's
@@ -1448,7 +1548,7 @@ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1448
1548
  const c2HttpOnlyRequests = {
1449
1549
  create(context) {
1450
1550
  const file = context.physicalFilename;
1451
- if (!TEST_FILE$9.test(file) || !segments(file).includes("specs")) return {};
1551
+ if (!TEST_FILE$10.test(file) || !segments(file).includes("specs")) return {};
1452
1552
  return { Program(node) {
1453
1553
  const requestsDir = (0, node_path.join)((0, node_path.dirname)(file), "requests");
1454
1554
  for (const entry of listDirectory(requestsDir) ?? []) if (!entry.endsWith(".http")) context.report({
@@ -1466,65 +1566,217 @@ const c2HttpOnlyRequests = {
1466
1566
  };
1467
1567
  //#endregion
1468
1568
  //#region src/lint/rules/c4-contract-shape.ts
1469
- /** Providers a contract file may declare (C4 see docs/10-linting.md). */
1569
+ /** The only directories a `contracts/` root may holdthe provider carriers. */
1470
1570
  const PROVIDERS = /* @__PURE__ */ new Set([
1471
1571
  "anthropic",
1472
1572
  "http",
1473
1573
  "openai"
1474
1574
  ]);
1475
- const CONTRACT_NAME = /^[A-Za-z0-9][\w-]*\.(?<provider>[a-z]+)\.[cm]?ts$/;
1476
- /** Is the import source the framework's public entry (`@jterrazz/test` / `src/index`)? */
1477
- function isPublicEntry(source) {
1478
- return source === "@jterrazz/test" || source.endsWith("/src/index.js") || source.endsWith("/src/index.ts");
1575
+ const TEST_FILE$9 = /\.test\.[cm]?[jt]sx?$/;
1576
+ /** A file oxlint itself visits — the AST half of the rule reports on those. */
1577
+ const SOURCE_FILE = /\.[cm]?[jt]sx?$/;
1578
+ /** The public facade of a feature: `contracts/<kebab>.contracts.ts`. */
1579
+ const COMPOSITE_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.contracts\.[cm]?ts$/u;
1580
+ /** An internal unit contract: `contracts/<provider>/<kebab>.ts`. */
1581
+ const UNIT_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.[cm]?ts$/u;
1582
+ /** Matched data next to its contract: `contracts/<provider>/<stem>.request.ts`. */
1583
+ const REQUEST_DATA_FILE = /^[a-z0-9]+(?:-[a-z0-9]+)*\.request\.[cm]?ts$/u;
1584
+ /** Served data next to its contract: `contracts/<provider>/<stem>[.<qualifier>].response.json`. */
1585
+ const RESPONSE_DATA_FILE = /\.response\.json$/;
1586
+ /** The name a specifier/declaration exports under, when it is `default`. */
1587
+ function exportsDefault(node) {
1588
+ return (node.specifiers ?? []).some((specifier) => {
1589
+ const exported = specifier.exported;
1590
+ return exported?.type === "Identifier" && exported.name === "default" || stringValue(exported) === "default";
1591
+ });
1592
+ }
1593
+ /** Is this node a `defineContract(...)` call? */
1594
+ function isDefineContract(node) {
1595
+ const callee = node.callee;
1596
+ return node.type === "CallExpression" && callee?.type === "Identifier" && callee.name === "defineContract";
1597
+ }
1598
+ /** A default export that may legitimately produce a contract (value or factory). */
1599
+ function producesContract(declaration) {
1600
+ if (declaration === void 0) return false;
1601
+ return isDefineContract(declaration) || declaration.type === "ArrowFunctionExpression" || declaration.type === "FunctionDeclaration" || declaration.type === "FunctionExpression" || declaration.type === "Identifier";
1479
1602
  }
1480
1603
  /**
1481
- * CONVENTIONS C4 a contract file (`contracts/<name>.<provider>.ts`) has a
1482
- * rigid shape: flat under `contracts/` (no subfolders), `provider { openai,
1483
- * anthropic, http }`, a single `export default defineContract(...)`, no named
1484
- * exports, and imports only from the public entry. The runtime channel (the
1485
- * loader) only catches a bad default export; this rule closes the rest.
1604
+ * The provider a `request:` value implies, when the builder is written as
1605
+ * `<provider>.<verb>(…)` the only statically decidable form.
1486
1606
  */
1487
- const c4ContractShape = {
1488
- create(context) {
1489
- const parts = segments(context.physicalFilename);
1490
- const contractsIndex = parts.lastIndexOf("contracts");
1491
- if (contractsIndex === -1 || contractsIndex >= parts.length - 1 || !parts.includes("specs")) return {};
1492
- const base = parts.at(-1) ?? "";
1493
- return { Program(node) {
1494
- if (contractsIndex !== parts.length - 2) context.report({
1495
- data: { base },
1496
- messageId: "subfolder",
1607
+ function requestProvider(request) {
1608
+ if (request?.type !== "CallExpression") return;
1609
+ const callee = request.callee;
1610
+ if (callee?.type !== "MemberExpression") return;
1611
+ const object = callee.object;
1612
+ return object?.type === "Identifier" ? object.name : void 0;
1613
+ }
1614
+ /**
1615
+ * The layout half — walks the feature's `contracts/` tree from the test file
1616
+ * that owns it. Covers what oxlint never visits: a stray `.json` at the root,
1617
+ * a directory that is not a provider, a nested folder or a foreign extension
1618
+ * inside a provider folder.
1619
+ */
1620
+ function checkLayout(context, node, contractsDir) {
1621
+ const entries = listDirectory(contractsDir);
1622
+ if (entries === null) return;
1623
+ for (const entry of entries) {
1624
+ const path = (0, node_path.join)(contractsDir, entry);
1625
+ if (!isDirectory(path)) {
1626
+ if (!SOURCE_FILE.test(entry)) context.report({
1627
+ data: { entry },
1628
+ messageId: "rootEntry",
1497
1629
  node
1498
1630
  });
1499
- const match = CONTRACT_NAME.exec(base);
1500
- if (match === null || !PROVIDERS.has(match.groups?.provider ?? "")) context.report({
1501
- data: { base },
1502
- messageId: "badName",
1631
+ continue;
1632
+ }
1633
+ if (!PROVIDERS.has(entry)) {
1634
+ context.report({
1635
+ data: { entry },
1636
+ messageId: "badProviderDir",
1503
1637
  node
1504
1638
  });
1505
- let hasDefault = false;
1506
- for (const statement of node.body ?? []) if (statement.type === "ImportDeclaration") {
1507
- const source = stringValue(statement.source);
1508
- if (source !== void 0 && !isPublicEntry(source)) context.report({
1509
- data: { source },
1510
- messageId: "foreignImport",
1511
- node: statement
1512
- });
1513
- } else if (statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") context.report({
1514
- messageId: "namedExport",
1515
- node: statement
1516
- });
1517
- else if (statement.type === "ExportDefaultDeclaration") {
1639
+ continue;
1640
+ }
1641
+ for (const child of listDirectory(path) ?? []) if (isDirectory((0, node_path.join)(path, child))) context.report({
1642
+ data: {
1643
+ child,
1644
+ provider: entry
1645
+ },
1646
+ messageId: "providerSubfolder",
1647
+ node
1648
+ });
1649
+ else if (!/\.[cm]?ts$/u.test(child) && !RESPONSE_DATA_FILE.test(child)) context.report({
1650
+ data: {
1651
+ child,
1652
+ provider: entry
1653
+ },
1654
+ messageId: "providerEntry",
1655
+ node
1656
+ });
1657
+ }
1658
+ }
1659
+ /** The facade: `<kebab>.contracts.ts`, default-exporting a composition. */
1660
+ function checkComposite(context, program, base) {
1661
+ if (!COMPOSITE_FILE.test(base)) {
1662
+ context.report({
1663
+ data: { base },
1664
+ messageId: "rootFile",
1665
+ node: program
1666
+ });
1667
+ return;
1668
+ }
1669
+ let hasDefault = false;
1670
+ let composed = false;
1671
+ walk(program, (node) => {
1672
+ if (node.type === "ExportDefaultDeclaration") hasDefault = true;
1673
+ else if (node.type === "ExportAllDeclaration" || node.type === "ExportNamedDeclaration") {
1674
+ if (exportsDefault(node)) {
1518
1675
  hasDefault = true;
1519
- const declaration = statement.declaration;
1520
- const callee = declaration?.type === "CallExpression" ? declaration.callee : void 0;
1521
- if (callee?.type !== "Identifier" || callee.name !== "defineContract") context.report({
1522
- messageId: "notDefineContract",
1523
- node: statement
1524
- });
1676
+ composed = stringValue(node.source) !== void 0 || composed;
1525
1677
  }
1526
- if (!hasDefault) context.report({
1527
- messageId: "missingDefault",
1678
+ } else if (node.type === "CallExpression") {
1679
+ const callee = node.callee;
1680
+ if (callee?.type === "Identifier" && callee.name === "defineContracts" || callee !== void 0 && memberPropertyName(callee) === "with") composed = true;
1681
+ }
1682
+ });
1683
+ if (!hasDefault) context.report({
1684
+ data: { base },
1685
+ messageId: "missingComposite",
1686
+ node: program
1687
+ });
1688
+ else if (!composed) context.report({
1689
+ data: { base },
1690
+ messageId: "notDefineContracts",
1691
+ node: program
1692
+ });
1693
+ }
1694
+ /** A unit contract under its provider folder. */
1695
+ function checkUnit(context, program, base, provider) {
1696
+ if (REQUEST_DATA_FILE.test(base)) return;
1697
+ if (!UNIT_FILE.test(base)) {
1698
+ context.report({
1699
+ data: { base },
1700
+ messageId: "badName",
1701
+ node: program
1702
+ });
1703
+ return;
1704
+ }
1705
+ let hasDefault = false;
1706
+ let wellFormed = false;
1707
+ walk(program, (node) => {
1708
+ if (node.type === "ExportDefaultDeclaration") {
1709
+ hasDefault = true;
1710
+ wellFormed = producesContract(node.declaration) || wellFormed;
1711
+ } else if (node.type === "ExportNamedDeclaration" && exportsDefault(node)) {
1712
+ hasDefault = true;
1713
+ wellFormed = true;
1714
+ }
1715
+ if (isDefineContract(node)) {
1716
+ const argument = node.arguments?.[0];
1717
+ const declared = requestProvider(argument === void 0 ? void 0 : findProperty(argument, "request")?.value);
1718
+ if (declared !== void 0 && PROVIDERS.has(declared) && declared !== provider) context.report({
1719
+ data: {
1720
+ declared,
1721
+ provider
1722
+ },
1723
+ messageId: "providerMismatch",
1724
+ node
1725
+ });
1726
+ }
1727
+ });
1728
+ if (!hasDefault) context.report({
1729
+ data: { base },
1730
+ messageId: "missingDefault",
1731
+ node: program
1732
+ });
1733
+ else if (!wellFormed) context.report({
1734
+ data: { base },
1735
+ messageId: "notDefineContract",
1736
+ node: program
1737
+ });
1738
+ }
1739
+ /**
1740
+ * CONVENTIONS C4 — the structure of a feature's `contracts/` tree. The ROOT
1741
+ * holds only `*.contracts.ts` facades (default export = a `defineContracts`
1742
+ * composition, named exports = scenario factories) and the provider
1743
+ * directories `http` / `openai` / `anthropic`. A provider directory holds
1744
+ * `<kebab>.ts` unit contracts (default export = `defineContract(...)` or a
1745
+ * factory returning one) plus their sibling data (`*.response.json`,
1746
+ * `*.request.ts`) — the FOLDER carries the provider, so a unit whose `request`
1747
+ * builder names another provider is an error.
1748
+ *
1749
+ * Two halves: the AST half reports on each contract file it visits, the layout
1750
+ * half walks the tree from the feature's test file (oxlint never visits a
1751
+ * `.json` fixture or an empty directory).
1752
+ */
1753
+ const c4ContractShape = {
1754
+ create(context) {
1755
+ const file = context.physicalFilename;
1756
+ const parts = segments(file);
1757
+ if (!parts.includes("specs")) return {};
1758
+ const contractsIndex = parts.lastIndexOf("contracts");
1759
+ if (contractsIndex === -1) {
1760
+ if (!TEST_FILE$9.test(file)) return {};
1761
+ return { Program(node) {
1762
+ checkLayout(context, node, (0, node_path.join)((0, node_path.dirname)(file), "contracts"));
1763
+ } };
1764
+ }
1765
+ const base = parts.at(-1) ?? "";
1766
+ const depth = parts.length - 1 - contractsIndex;
1767
+ if (depth === 1) return { Program(node) {
1768
+ checkComposite(context, node, base);
1769
+ } };
1770
+ if (depth === 2) {
1771
+ const provider = parts[contractsIndex + 1];
1772
+ return PROVIDERS.has(provider) ? { Program(node) {
1773
+ checkUnit(context, node, base, provider);
1774
+ } } : {};
1775
+ }
1776
+ return { Program(node) {
1777
+ context.report({
1778
+ data: { base },
1779
+ messageId: "tooDeep",
1528
1780
  node
1529
1781
  });
1530
1782
  } };
@@ -1532,12 +1784,18 @@ const c4ContractShape = {
1532
1784
  meta: {
1533
1785
  docs: RULE_DOCS["c4-contract-shape"],
1534
1786
  messages: {
1535
- badName: "Contract file \"{{base}}\" must be named <name>.<provider>.ts with provider openai | anthropic | http (C4 — see docs/10-linting.md).",
1536
- foreignImport: "Contract imports \"{{source}}\" — a contract imports only from the public entry (@jterrazz/test) (C4 — see docs/10-linting.md).",
1537
- missingDefault: "Contract file has no `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1538
- namedExport: "Contract files have no named exportsonly `export default defineContract(...)` (C4 — see docs/10-linting.md).",
1539
- notDefineContract: "The default export of a contract file must be `defineContract(...)` syntactically (C4 — see docs/10-linting.md).",
1540
- subfolder: "Contract file \"{{base}}\" is nested contracts/ is flat, no subfolders (C4 — see docs/10-linting.md)."
1787
+ badName: "Contract unit \"{{base}}\" must be named <kebab-name>.ts the folder already carries the provider (C4 — see docs/10-linting.md).",
1788
+ badProviderDir: "contracts/{{entry}}/ is not a provider directory — a contracts/ root holds only http/, openai/, anthropic/ and *.contracts.ts files (C4 — see docs/10-linting.md).",
1789
+ missingComposite: "Contract facade \"{{base}}\" has no default export — it must default-export the composed world (C4 — see docs/10-linting.md).",
1790
+ missingDefault: "Contract unit \"{{base}}\" has no default exporta unit default-exports `defineContract(...)` or a factory returning one (C4 — see docs/10-linting.md).",
1791
+ notDefineContract: "The default export of a contract unit must be `defineContract(...)` or a factory returning a contract (C4 — see docs/10-linting.md).",
1792
+ notDefineContracts: "The default export of \"{{base}}\" must be built from `defineContracts(...)` (or a composition re-export) (C4 — see docs/10-linting.md).",
1793
+ providerEntry: "contracts/{{provider}}/{{child}} is neither a *.ts contract nor a *.response.json payload (C4 — see docs/10-linting.md).",
1794
+ providerMismatch: "This contract lives in contracts/{{provider}}/ but its request is a `{{declared}}.*` builder — the folder carries the provider (C4 — see docs/10-linting.md).",
1795
+ providerSubfolder: "contracts/{{provider}}/{{child}}/ is nested — a provider folder is flat (C4 — see docs/10-linting.md).",
1796
+ rootEntry: "contracts/{{entry}} is not a *.contracts.ts facade — data files live in a provider folder next to their contract (C4 — see docs/10-linting.md).",
1797
+ rootFile: "Contract file \"{{base}}\" sits at the contracts/ root, which holds only *.contracts.ts facades — a unit contract belongs in http/, openai/ or anthropic/ (C4 — see docs/10-linting.md).",
1798
+ tooDeep: "Contract file \"{{base}}\" is nested deeper than contracts/<provider>/ (C4 — see docs/10-linting.md)."
1541
1799
  },
1542
1800
  type: "problem"
1543
1801
  }
@@ -2954,6 +3212,8 @@ const plugin = {
2954
3212
  "b8-kebab-trigger": b8KebabTrigger,
2955
3213
  "b9w-product-command": b9wProductCommand,
2956
3214
  "c1-domain-structure": c1DomainStructure,
3215
+ "c10-contracts-boundary": c10ContractsBoundary,
3216
+ "c11-contract-data-pairing": c11ContractDataPairing,
2957
3217
  "c2-http-only-requests": c2HttpOnlyRequests,
2958
3218
  "c4-contract-shape": c4ContractShape,
2959
3219
  "c6-tomatch-extension": c6ToMatchExtension,