@jterrazz/test 16.0.0 → 16.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.
Files changed (211) hide show
  1. package/dist/appium.adapter.js +59 -4
  2. package/dist/browser/index.js +203 -42
  3. package/dist/checker.js +305 -63
  4. package/dist/index.d.ts +35 -4
  5. package/dist/index.js +47 -24
  6. package/dist/oxlint.cjs +123 -28
  7. package/dist/oxlint.js +123 -28
  8. package/dist/playwright.adapter.js +88 -50
  9. package/dist/substring-warning.js +282 -0
  10. package/dist/vitest.js +91 -12
  11. package/docs/01-architecture.md +95 -0
  12. package/docs/02-developing.md +351 -0
  13. package/docs/03-testing.md +495 -0
  14. package/docs/04-operating.md +101 -0
  15. package/docs/05-module-tests.md +152 -0
  16. package/docs/06-integration.md +180 -0
  17. package/docs/07-component.md +246 -0
  18. package/docs/08-website.md +404 -0
  19. package/docs/09-mobile.md +270 -0
  20. package/docs/10-api.md +264 -0
  21. package/docs/11-jobs.md +157 -0
  22. package/docs/12-cli.md +677 -0
  23. package/docs/13-elements.md +248 -0
  24. package/docs/14-assertions.md +314 -0
  25. package/docs/15-tokens.md +253 -0
  26. package/docs/16-contracts.md +438 -0
  27. package/docs/17-services.md +188 -0
  28. package/docs/18-conventions.md +332 -0
  29. package/docs/19-linting.md +512 -0
  30. package/docs/README.md +36 -0
  31. package/docs/decisions/001-v9-conventions-on-three-channels.md +50 -0
  32. package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +49 -0
  33. package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +84 -0
  34. package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +81 -0
  35. package/docs/decisions/005-the-doubles-ladder-is-closed.md +86 -0
  36. package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +245 -0
  37. package/docs/decisions/007-compose-mode-leaves-the-package.md +53 -0
  38. package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +75 -0
  39. package/docs/decisions/_template.md +22 -0
  40. package/docs/reference/classes/Matcher.md +120 -0
  41. package/docs/reference/classes/ProcessHandle.md +368 -0
  42. package/docs/reference/functions/defineContract.md +39 -0
  43. package/docs/reference/functions/defineContracts.md +36 -0
  44. package/docs/reference/functions/disabled.md +28 -0
  45. package/docs/reference/functions/enabled.md +20 -0
  46. package/docs/reference/functions/focused.md +26 -0
  47. package/docs/reference/functions/mockOf.md +79 -0
  48. package/docs/reference/functions/postgres.md +31 -0
  49. package/docs/reference/functions/process.md +33 -0
  50. package/docs/reference/functions/redis.md +28 -0
  51. package/docs/reference/functions/required.md +41 -0
  52. package/docs/reference/functions/selected.md +26 -0
  53. package/docs/reference/functions/sqlite.md +33 -0
  54. package/docs/reference/functions/testId.md +20 -0
  55. package/docs/reference/functions/text.md +40 -0
  56. package/docs/reference/functions/valued.md +28 -0
  57. package/docs/reference/functions/waitUntil.md +46 -0
  58. package/docs/reference/functions/within.md +29 -0
  59. package/docs/reference/index.md +196 -0
  60. package/docs/reference/interfaces/BaseResult.md +87 -0
  61. package/docs/reference/interfaces/CallResult.md +160 -0
  62. package/docs/reference/interfaces/CaptureScope.md +82 -0
  63. package/docs/reference/interfaces/CliResult.md +253 -0
  64. package/docs/reference/interfaces/ContainerAccessor.md +166 -0
  65. package/docs/reference/interfaces/DirectoryAccessor.md +33 -0
  66. package/docs/reference/interfaces/FetchResult.md +180 -0
  67. package/docs/reference/interfaces/FilesystemAccessor.md +46 -0
  68. package/docs/reference/interfaces/HttpResult.md +126 -0
  69. package/docs/reference/interfaces/JsonAccessor.md +46 -0
  70. package/docs/reference/interfaces/PageResult.md +356 -0
  71. package/docs/reference/interfaces/RenderResult.md +72 -0
  72. package/docs/reference/interfaces/RenderedText.md +110 -0
  73. package/docs/reference/interfaces/ResponseAccessor.md +47 -0
  74. package/docs/reference/interfaces/ScreenResult.md +128 -0
  75. package/docs/reference/interfaces/TableAccessor.md +26 -0
  76. package/docs/reference/interfaces/TextAccessor.md +96 -0
  77. package/docs/reference/type-aliases/ApiHandle.md +68 -0
  78. package/docs/reference/type-aliases/ApiSpecification.md +213 -0
  79. package/docs/reference/type-aliases/ApiSpecificationOptions.md +68 -0
  80. package/docs/reference/type-aliases/BrowserConsoleMessage.md +30 -0
  81. package/docs/reference/type-aliases/BrowserLinkElement.md +50 -0
  82. package/docs/reference/type-aliases/BrowserMetaElement.md +40 -0
  83. package/docs/reference/type-aliases/BrowserOpenOptions.md +87 -0
  84. package/docs/reference/type-aliases/BrowserPage.md +132 -0
  85. package/docs/reference/type-aliases/BrowserPort.md +51 -0
  86. package/docs/reference/type-aliases/CliEnv.md +11 -0
  87. package/docs/reference/type-aliases/CliHandle.md +80 -0
  88. package/docs/reference/type-aliases/CliInput.md +36 -0
  89. package/docs/reference/type-aliases/CliOutput.md +46 -0
  90. package/docs/reference/type-aliases/CliPort.md +58 -0
  91. package/docs/reference/type-aliases/CliSpecification.md +144 -0
  92. package/docs/reference/type-aliases/CliSpecificationOptions.md +134 -0
  93. package/docs/reference/type-aliases/ComponentChain.md +156 -0
  94. package/docs/reference/type-aliases/ComponentScenario.md +20 -0
  95. package/docs/reference/type-aliases/ComponentUi.md +47 -0
  96. package/docs/reference/type-aliases/ComponentVisitor.md +10 -0
  97. package/docs/reference/type-aliases/ContainerPort.md +113 -0
  98. package/docs/reference/type-aliases/Contract.md +68 -0
  99. package/docs/reference/type-aliases/ContractInput.md +13 -0
  100. package/docs/reference/type-aliases/ContractRequest.md +95 -0
  101. package/docs/reference/type-aliases/ContractResponder.md +23 -0
  102. package/docs/reference/type-aliases/ContractResponse.md +78 -0
  103. package/docs/reference/type-aliases/ContractResponseValue.md +13 -0
  104. package/docs/reference/type-aliases/Contracts.md +48 -0
  105. package/docs/reference/type-aliases/DatabaseKeys.md +16 -0
  106. package/docs/reference/type-aliases/DatabasePort.md +72 -0
  107. package/docs/reference/type-aliases/DeviceOpenOptions.md +46 -0
  108. package/docs/reference/type-aliases/DevicePort.md +51 -0
  109. package/docs/reference/type-aliases/DeviceScreen.md +32 -0
  110. package/docs/reference/type-aliases/DeviceTimeouts.md +39 -0
  111. package/docs/reference/type-aliases/DockerSpecConfig.md +43 -0
  112. package/docs/reference/type-aliases/DomMount.md +21 -0
  113. package/docs/reference/type-aliases/ElementKind.md +27 -0
  114. package/docs/reference/type-aliases/ElementMatch.md +74 -0
  115. package/docs/reference/type-aliases/ElementOptions.md +29 -0
  116. package/docs/reference/type-aliases/ElementRef.md +128 -0
  117. package/docs/reference/type-aliases/ExecOptions.md +37 -0
  118. package/docs/reference/type-aliases/FileAccessor.md +54 -0
  119. package/docs/reference/type-aliases/HonoApp.md +31 -0
  120. package/docs/reference/type-aliases/HttpContractFilter.md +49 -0
  121. package/docs/reference/type-aliases/HttpResponseInit.md +46 -0
  122. package/docs/reference/type-aliases/HttpStreamInit.md +58 -0
  123. package/docs/reference/type-aliases/IntegrationHandle.md +46 -0
  124. package/docs/reference/type-aliases/IntegrationSpecification.md +111 -0
  125. package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +42 -0
  126. package/docs/reference/type-aliases/Intercept.md +19 -0
  127. package/docs/reference/type-aliases/InterceptOptions.md +28 -0
  128. package/docs/reference/type-aliases/InterceptScope.md +11 -0
  129. package/docs/reference/type-aliases/IsolationStrategy.md +75 -0
  130. package/docs/reference/type-aliases/JobHandle.md +34 -0
  131. package/docs/reference/type-aliases/JobsHandle.md +57 -0
  132. package/docs/reference/type-aliases/JobsResult.md +16 -0
  133. package/docs/reference/type-aliases/JobsSpecification.md +94 -0
  134. package/docs/reference/type-aliases/JobsSpecificationOptions.md +56 -0
  135. package/docs/reference/type-aliases/LandmarkKind.md +20 -0
  136. package/docs/reference/type-aliases/LiterateRunFlags.md +27 -0
  137. package/docs/reference/type-aliases/LiterateServeRegistration.md +70 -0
  138. package/docs/reference/type-aliases/MatchFixtureOptions.md +27 -0
  139. package/docs/reference/type-aliases/MatchableRequest.md +61 -0
  140. package/docs/reference/type-aliases/MatcherKind.md +34 -0
  141. package/docs/reference/type-aliases/MobileBackendOptions.md +28 -0
  142. package/docs/reference/type-aliases/MobileElementKind.md +14 -0
  143. package/docs/reference/type-aliases/MobileElementMatch.md +60 -0
  144. package/docs/reference/type-aliases/MobileElementRef.md +20 -0
  145. package/docs/reference/type-aliases/MobileHandle.md +65 -0
  146. package/docs/reference/type-aliases/MobileScenario.md +20 -0
  147. package/docs/reference/type-aliases/MobileSpecification.md +51 -0
  148. package/docs/reference/type-aliases/MobileSpecificationOptions.md +126 -0
  149. package/docs/reference/type-aliases/MobileVisitor.md +81 -0
  150. package/docs/reference/type-aliases/MockOfOptions.md +26 -0
  151. package/docs/reference/type-aliases/MockPort.md +10 -0
  152. package/docs/reference/type-aliases/PinnedClock.md +10 -0
  153. package/docs/reference/type-aliases/PostgresOptions.md +32 -0
  154. package/docs/reference/type-aliases/ProcessOptions.md +112 -0
  155. package/docs/reference/type-aliases/RedisOptions.md +20 -0
  156. package/docs/reference/type-aliases/RenderSubject.md +12 -0
  157. package/docs/reference/type-aliases/ScreenNode.md +67 -0
  158. package/docs/reference/type-aliases/ServerPort.md +36 -0
  159. package/docs/reference/type-aliases/ServerResponse.md +46 -0
  160. package/docs/reference/type-aliases/ServerSpec.md +21 -0
  161. package/docs/reference/type-aliases/ServiceHandle.md +216 -0
  162. package/docs/reference/type-aliases/ServiceRecord.md +12 -0
  163. package/docs/reference/type-aliases/SpecDocument.md +84 -0
  164. package/docs/reference/type-aliases/SpecEnvToken.md +21 -0
  165. package/docs/reference/type-aliases/SpecFileAssertion.md +27 -0
  166. package/docs/reference/type-aliases/SpecFixture.md +30 -0
  167. package/docs/reference/type-aliases/SpecKind.md +8 -0
  168. package/docs/reference/type-aliases/SpecRun.md +108 -0
  169. package/docs/reference/type-aliases/SpecServeEntry.md +40 -0
  170. package/docs/reference/type-aliases/SpecStream.md +32 -0
  171. package/docs/reference/type-aliases/SpecificationConfig.md +290 -0
  172. package/docs/reference/type-aliases/SqliteOptions.md +36 -0
  173. package/docs/reference/type-aliases/SseEvent.md +58 -0
  174. package/docs/reference/type-aliases/StreamBody.md +61 -0
  175. package/docs/reference/type-aliases/TextFilter.md +22 -0
  176. package/docs/reference/type-aliases/VisitScenario.md +20 -0
  177. package/docs/reference/type-aliases/Visitor.md +215 -0
  178. package/docs/reference/type-aliases/WaitUntilOptions.md +46 -0
  179. package/docs/reference/type-aliases/WebsiteBackendOptions.md +37 -0
  180. package/docs/reference/type-aliases/WebsiteHandle.md +51 -0
  181. package/docs/reference/type-aliases/WebsiteSpecification.md +118 -0
  182. package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +40 -0
  183. package/docs/reference/variables/anthropic.md +19 -0
  184. package/docs/reference/variables/banner.md +10 -0
  185. package/docs/reference/variables/button.md +10 -0
  186. package/docs/reference/variables/clock.md +16 -0
  187. package/docs/reference/variables/complementary.md +10 -0
  188. package/docs/reference/variables/component.md +10 -0
  189. package/docs/reference/variables/content.md +10 -0
  190. package/docs/reference/variables/contentinfo.md +10 -0
  191. package/docs/reference/variables/dialog.md +10 -0
  192. package/docs/reference/variables/field.md +10 -0
  193. package/docs/reference/variables/form.md +10 -0
  194. package/docs/reference/variables/heading.md +10 -0
  195. package/docs/reference/variables/http.md +40 -0
  196. package/docs/reference/variables/intercept.md +11 -0
  197. package/docs/reference/variables/link.md +10 -0
  198. package/docs/reference/variables/listitem.md +10 -0
  199. package/docs/reference/variables/main.md +10 -0
  200. package/docs/reference/variables/match.md +8 -0
  201. package/docs/reference/variables/navigation.md +10 -0
  202. package/docs/reference/variables/openai.md +21 -0
  203. package/docs/reference/variables/option.md +17 -0
  204. package/docs/reference/variables/region.md +10 -0
  205. package/docs/reference/variables/row.md +10 -0
  206. package/docs/reference/variables/search.md +10 -0
  207. package/docs/reference/variables/specification.md +8 -0
  208. package/docs/reference/variables/status.md +10 -0
  209. package/docs/reference/variables/table.md +10 -0
  210. package/package.json +3 -2
  211. package/dist/ambiguity.js +0 -146
package/dist/checker.js CHANGED
@@ -688,7 +688,7 @@ function checkPoolFixtures(rootDir) {
688
688
  violations.push({
689
689
  file: relEntry,
690
690
  line: 1,
691
- message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/19-linting.md)`,
691
+ message: `${relEntry}: dead pool fixture — no spec under ${relative(rootDir, specsRoot) || basename(specsRoot)} references $FIXTURES/${entry.name} (C9 — see docs/19-linting.md)`,
692
692
  rule: "c9",
693
693
  severity: "error"
694
694
  });
@@ -939,6 +939,13 @@ const SKIPPED$2 = /* @__PURE__ */ new Set([
939
939
  GROUND_FIXTURES,
940
940
  "node_modules"
941
941
  ]);
942
+ /**
943
+ * Does this facet-relative path cross ground? A LEADING underscore names it —
944
+ * the four ground directories and nothing else carry one (C13).
945
+ */
946
+ function isUnderGround(relativePath) {
947
+ return relativePath.split(/[/\\]/u).some((segment) => segment.startsWith("_"));
948
+ }
942
949
  /** `readdirSync` that answers `[]` for anything it cannot read. */
943
950
  function entriesOf(dir) {
944
951
  try {
@@ -1045,6 +1052,7 @@ function checkModuleTestUnderFacet(specsRoot) {
1045
1052
  if (specificationsIn(path).length === 0) continue;
1046
1053
  for (const file of walkFiles(path)) {
1047
1054
  if (!isTestFileName(file.slice(file.lastIndexOf("/") + 1))) continue;
1055
+ if (isUnderGround(relative(path, file))) continue;
1048
1056
  if (reachesRunner(textOf$1(file) ?? "")) continue;
1049
1057
  const rel = relative(specsRoot, file);
1050
1058
  violations.push({
@@ -1142,6 +1150,14 @@ function checkGroundOwnedByOne(specsRoot) {
1142
1150
  * (the package's own `specs/lint/`, a repository's consistency suites) is a
1143
1151
  * repository suite: it covers a tree rather than the assembled product, so
1144
1152
  * it keeps `.test.ts` and C1's declared depth is what judges its shape.
1153
+ *
1154
+ * A tree may also be ONE facet's whole, with no `specs/<facet>/` level: the
1155
+ * runner sits at the root of `specs/` and the folders below it are domains (the
1156
+ * `depth: 'mirror'` shape, where the tree follows a command tree instead of a
1157
+ * facet/domain one). Such a tree carries a `*.specification.ts` at its root,
1158
+ * and that is what says so — every `.test.ts` below it specifies the assembled
1159
+ * product, whatever folder it sits in. Without this clause the whole shape was
1160
+ * invisible to C12 and its specs had to be renamed by hand.
1145
1161
  */
1146
1162
  /** The six facet folders — a first-level name under `specs/` that IS a constructor. */
1147
1163
  const FACETS = /* @__PURE__ */ new Set([
@@ -1229,23 +1245,39 @@ function checkSpecOutsideSpecs(memberDir) {
1229
1245
  function checkTestUnderFacet(specsRoot) {
1230
1246
  return movesUnderFacet(specsRoot).map(({ from }) => {
1231
1247
  const rel = relative(specsRoot, from);
1248
+ const first = rel.split(/[/\\]/u)[0] ?? "";
1232
1249
  return {
1233
1250
  file: rel,
1234
1251
  line: 1,
1235
- message: `${rel}:1: a \`.test.ts\` under \`specs/${rel.split(/[/\\]/u)[0] ?? ""}/\` specifies the assembled product — rename it \`.spec.ts\` (fixable: \`jterrazz-test-check <root> --fix\`) (C12 — see docs/19-linting.md)`,
1252
+ message: `${rel}:1: a \`.test.ts\` under \`${FACETS.has(first) ? `specs/${first}/` : "specs/"}\` specifies the assembled product — rename it \`.spec.ts\` (fixable: \`jterrazz-test-check <root> --fix\`) (C12 — see docs/19-linting.md)`,
1236
1253
  rule: "c12-spec-file-name",
1237
1254
  severity: "error"
1238
1255
  };
1239
1256
  });
1240
1257
  }
1258
+ /**
1259
+ * Is this tree ONE facet's whole, rooted at `specs/`?
1260
+ *
1261
+ * A `*.specification.ts` at the ROOT of the tree is the statement: the runner
1262
+ * is the tree's, so every folder below it is a domain of that one facet and no
1263
+ * first level is a repository suite.
1264
+ */
1265
+ function isRootedFacetTree(specsRoot) {
1266
+ try {
1267
+ return readdirSync(specsRoot, { withFileTypes: true }).some((entry) => entry.isFile() && entry.name.includes(".specification."));
1268
+ } catch {
1269
+ return false;
1270
+ }
1271
+ }
1241
1272
  /** The renames clause two asks for, as absolute from/to pairs. */
1242
1273
  function movesUnderFacet(specsRoot) {
1243
1274
  const moves = [];
1275
+ const rooted = isRootedFacetTree(specsRoot);
1244
1276
  for (const path of walk(specsRoot)) {
1245
1277
  if (!path.endsWith(".test.ts")) continue;
1246
1278
  const parts = relative(specsRoot, path).split(/[/\\]/u);
1247
1279
  const facet = parts[0];
1248
- if (parts.length < 2 || facet === void 0 || !FACETS.has(facet)) continue;
1280
+ if (!rooted && (parts.length < 2 || facet === void 0 || !FACETS.has(facet))) continue;
1249
1281
  if (parts.some((part) => part.startsWith("_"))) continue;
1250
1282
  if (!reachesRunner(textOf(path))) continue;
1251
1283
  moves.push({
@@ -1273,6 +1305,16 @@ function movesUnderFacet(specsRoot) {
1273
1305
  * it for one member, and a path-less `--format json` run at the project root
1274
1306
  * runs it for every member the root manifest declares.
1275
1307
  */
1308
+ /** How deep a member may sit below the root — the toolchain's bound, to the segment. */
1309
+ const MEMBER_DEPTH = 6;
1310
+ /** Directories the member walk never enters. */
1311
+ const SKIPPED = /* @__PURE__ */ new Set([
1312
+ ".git",
1313
+ ".artifacts",
1314
+ "coverage",
1315
+ "dist",
1316
+ "node_modules"
1317
+ ]);
1276
1318
  /** The two simulated DOMs — the same pair the statique E5b refuses. */
1277
1319
  const SIMULATED_DOM = /\benvironment\s*:\s*['"](?<dom>happy-dom|jsdom)['"]/u;
1278
1320
  /**
@@ -1310,16 +1352,114 @@ const RETIRED_SCOPES = ["@testing-library/"];
1310
1352
  * React Native renders under jest until the react-native-web answer lands, and
1311
1353
  * where jest is the sanctioned runner `@testing-library/react-native` is the
1312
1354
  * only vocabulary there is: the rule would be asking for a facet that does not
1313
- * reach that runtime yet. The allowance is read from the member itself — a
1314
- * declared `jest`, a `jest` field, or a `test` script that calls it — so it
1315
- * lapses the day the member stops running jest.
1355
+ * reach that runtime yet. The allowance lapses the day jest stops running the
1356
+ * member's tests, and it has TWO sources, because jest is not always the
1357
+ * member's own: in a workspace where one app owns the only working RN
1358
+ * toolchain, the library declares the vocabulary and the app runs the tests
1359
+ * that use it.
1316
1360
  */
1317
1361
  const JEST_ONLY_SEAM = "@testing-library/react-native";
1318
- /** Does this member run jest? */
1319
- function runsJest(manifest) {
1362
+ /** Does the member's OWN manifest say jest runs here? */
1363
+ function declaresJest(manifest) {
1320
1364
  const declares = (name) => name in (manifest.dependencies ?? {}) || name in (manifest.devDependencies ?? {});
1321
1365
  return declares("jest") || declares("jest-expo") || manifest.jest !== void 0 || /(?:^|[\s/])jest(?:\s|$)/u.test(manifest.scripts?.test ?? "");
1322
1366
  }
1367
+ /** The config names jest answers to, whatever a project writes it in. */
1368
+ const JEST_CONFIG = /^jest\.config\.[cm]?[jt]s$/u;
1369
+ /** The keys of a jest config that name WHERE its tests are. */
1370
+ const JEST_REACH = /\b(?:roots|testMatch|testPathDirs|testPathIgnorePatterns)\s*:\s*\[(?<paths>[^\]]*)\]/gu;
1371
+ /** One quoted entry of such a list. */
1372
+ const JEST_PATH = /['"`](?<path>[^'"`\n]*)['"`]/gu;
1373
+ /** The file a pnpm workspace is declared in, whichever spelling the repository uses. */
1374
+ const PNPM_WORKSPACE = ["pnpm-workspace.yaml", "pnpm-workspace.yml"];
1375
+ /** Does this directory DECLARE a workspace — npm/bun `workspaces`, or pnpm's own file? */
1376
+ function declaresWorkspace(dir) {
1377
+ if (PNPM_WORKSPACE.some((name) => existsSync(join(dir, name)))) return true;
1378
+ const manifest = readManifest(dir);
1379
+ return manifest !== null && workspacePatterns(manifest).length > 0;
1380
+ }
1381
+ /**
1382
+ * The workspace a member belongs to, found by walking UP from the member.
1383
+ *
1384
+ * The jest config that collects a member's tests is not always below it: in the
1385
+ * layout the allowance was written for, the app owning the only working React
1386
+ * Native toolchain roots into the library BESIDE it. Read from the member's own
1387
+ * anchor, the walk below it found nothing, so `cd packages/native &&
1388
+ * jterrazz-test-check` — which is what that member's own gate runs — reported
1389
+ * F8 while the root-anchored run cleared it. Two directions, two verdicts, on
1390
+ * one member: the chapter's contract is that a path-less run reports what the
1391
+ * per-root and per-member runs report.
1392
+ *
1393
+ * The walk stops at the first directory that declares a workspace, and never
1394
+ * leaves the checkout: a `.git` is where this repository ends and somebody
1395
+ * else's tree begins — a directory in a clone, a FILE in a worktree, so its
1396
+ * kind is never asked.
1397
+ */
1398
+ function workspaceRootOf(memberDir) {
1399
+ let current = memberDir;
1400
+ for (;;) {
1401
+ if (declaresWorkspace(current) || existsSync(join(current, ".git"))) return current;
1402
+ const parent = dirname(current);
1403
+ if (parent === current) return memberDir;
1404
+ current = parent;
1405
+ }
1406
+ }
1407
+ /** Every `jest.config.*` under `rootDir`, by absolute path — read once per root. */
1408
+ const jestConfigs = /* @__PURE__ */ new Map();
1409
+ /** Find them, bounded by the same skips and depth the member walk uses. */
1410
+ function findJestConfigs(rootDir) {
1411
+ const cached = jestConfigs.get(rootDir);
1412
+ if (cached !== void 0) return cached;
1413
+ const found = [];
1414
+ const walk = (dir, depth) => {
1415
+ if (depth > MEMBER_DEPTH) return;
1416
+ let entries;
1417
+ try {
1418
+ entries = readdirSync(dir, { withFileTypes: true });
1419
+ } catch {
1420
+ return;
1421
+ }
1422
+ for (const entry of entries) {
1423
+ const path = join(dir, entry.name);
1424
+ if (entry.isDirectory()) {
1425
+ if (!entry.name.startsWith(".") && !SKIPPED.has(entry.name)) walk(path, depth + 1);
1426
+ } else if (JEST_CONFIG.test(entry.name)) found.push(path);
1427
+ }
1428
+ };
1429
+ walk(rootDir, 0);
1430
+ jestConfigs.set(rootDir, found);
1431
+ return found;
1432
+ }
1433
+ /**
1434
+ * Does a jest config ELSEWHERE in the workspace collect this member's tests?
1435
+ *
1436
+ * jterrazz-design is the shape: the render tests are co-located in
1437
+ * `packages/native/src`, and `apps/playground` — which owns the only working
1438
+ * React Native toolchain — is what runs them, rooting into the library from its
1439
+ * own config. The library's manifest says `vitest --run`, so reading the
1440
+ * allowance from the member alone answered "no jest here" and F8 errored on
1441
+ * exactly the layout the exemption was written for.
1442
+ *
1443
+ * `<rootDir>` is jest's own token for the directory its config sits in, so a
1444
+ * path is resolved against that, and the member is reached when the result
1445
+ * lands inside it.
1446
+ */
1447
+ function jestReachesMember(memberDir, rootDir) {
1448
+ return findJestConfigs(rootDir).some((config) => {
1449
+ const from = dirname(config);
1450
+ let text;
1451
+ try {
1452
+ text = readFileSync(config, "utf8");
1453
+ } catch {
1454
+ return false;
1455
+ }
1456
+ return [...text.matchAll(JEST_REACH)].some((list) => [...(list.groups?.paths ?? "").matchAll(JEST_PATH)].some((entry) => {
1457
+ const stated = (entry.groups?.path ?? "").replace("<rootDir>", from);
1458
+ const absolute = resolve(from, stated);
1459
+ return absolute === memberDir || absolute.startsWith(`${memberDir}/`);
1460
+ }));
1461
+ });
1462
+ }
1323
1463
  /** Where a member states what it collects, whatever extension it writes it in. */
1324
1464
  const CONFIG_NAMES$1 = [
1325
1465
  "vitest.config.ts",
@@ -1329,14 +1469,6 @@ const CONFIG_NAMES$1 = [
1329
1469
  "vitest.config.mjs",
1330
1470
  "vitest.config.cjs"
1331
1471
  ];
1332
- /** Directories the member walk never enters. */
1333
- const SKIPPED = /* @__PURE__ */ new Set([
1334
- ".git",
1335
- ".artifacts",
1336
- "coverage",
1337
- "dist",
1338
- "node_modules"
1339
- ]);
1340
1472
  /** A member's manifest, or null when it has none (it is then not a member). */
1341
1473
  function readManifest(dir) {
1342
1474
  const path = join(dir, "package.json");
@@ -1394,9 +1526,8 @@ function holdsTests(dir) {
1394
1526
  */
1395
1527
  const DELEGATING_TEST = /--workspaces|(?:^|\s)-r(?:\s|$)|--recursive|turbo\s+run|nx\s+run-many|lerna\s+run/u;
1396
1528
  /** Every seam name this member declares that it should not, and why it should not. */
1397
- function declaredSeams(manifest) {
1529
+ function declaredSeams(manifest, jestRuns) {
1398
1530
  if (manifest.name === FRAMEWORK) return [];
1399
- const jestRuns = runsJest(manifest);
1400
1531
  const isRetired = (name) => {
1401
1532
  if (name === JEST_ONLY_SEAM && jestRuns) return false;
1402
1533
  return RETIRED.has(name) || RETIRED_SCOPES.some((scope) => name.startsWith(scope));
@@ -1452,8 +1583,9 @@ function simulatedDom(config, rootDir) {
1452
1583
  * The two are not the same sentence: removing a transitive changes nothing a
1453
1584
  * test can see, while dropping a retired seam means writing its facet.
1454
1585
  */
1455
- function seamDependencies(manifest, memberDir, label, subject) {
1456
- return declaredSeams(manifest).map((seam) => ({
1586
+ function seamDependencies(member) {
1587
+ const { label, manifest, memberDir, subject } = member;
1588
+ return declaredSeams(manifest, declaresJest(manifest) || jestReachesMember(memberDir, workspaceRootOf(memberDir))).map((seam) => ({
1457
1589
  file: join(label, "package.json"),
1458
1590
  line: lineOfKey(memberDir, seam.name),
1459
1591
  message: seam.kind === "carried" ? `${subject}: \`${seam.name}\` is a transitive of \`@jterrazz/test\` — remove the declaration (F8 — see docs/19-linting.md#f8-no-seam-dependency)` : `${subject}: \`${seam.name}\` is a seam \`@jterrazz/test\` replaced — the facet is \`component()\`, \`website()\`, \`clock\` or \`intercept()\`; remove the declaration (F8 — see docs/19-linting.md#f8-no-seam-dependency)`,
@@ -1488,7 +1620,12 @@ function checkMember(memberDir, rootDir) {
1488
1620
  return [
1489
1621
  ...config === null ? configPresent(manifest, memberDir, label, subject) : [],
1490
1622
  ...config === null ? [] : simulatedDom(config, rootDir),
1491
- ...seamDependencies(manifest, memberDir, label, subject),
1623
+ ...seamDependencies({
1624
+ label,
1625
+ manifest,
1626
+ memberDir,
1627
+ subject
1628
+ }),
1492
1629
  ...anchoredToRoot(checkSpecOutsideSpecs(memberDir), label)
1493
1630
  ];
1494
1631
  }
@@ -1509,8 +1646,6 @@ function toPattern(glob) {
1509
1646
  const escaped = glob.replaceAll(/[.+^${}()|[\]\\]/gu, String.raw`\$&`).replaceAll("**", " ").replaceAll("*", "[^/]*").replaceAll(" ", ".*");
1510
1647
  return new RegExp(`^${escaped}$`, "u");
1511
1648
  }
1512
- /** How deep a member may sit below the root — the toolchain's bound, to the segment. */
1513
- const MEMBER_DEPTH = 6;
1514
1649
  /**
1515
1650
  * Every workspace member declared by the root manifest, plus the root itself —
1516
1651
  * a single-package repository IS its own member, and the pass has to reach it.
@@ -1573,7 +1708,8 @@ const SPECS_DEPTH = 6;
1573
1708
  */
1574
1709
  function discoverSpecRoots(rootDir) {
1575
1710
  const roots = /* @__PURE__ */ new Set();
1576
- for (const member of discoverMembers(rootDir)) {
1711
+ const members = discoverMembers(rootDir);
1712
+ for (const member of members.length === 0 ? [rootDir] : members) {
1577
1713
  if (basename(member) === "specs") roots.add(member);
1578
1714
  const walk = (dir, depth) => {
1579
1715
  if (depth > SPECS_DEPTH) return;
@@ -2219,7 +2355,7 @@ const CLOCK_READ = /'now'|\bCURRENT_(?:TIMESTAMP|DATE|TIME)\b|\bNOW\s*\(\s*\)/iu
2219
2355
  const SQL_LINE_COMMENT = /--[^\n]*/gu;
2220
2356
  const SQL_BLOCK_COMMENT = /\/\*[\s\S]*?\*\//gu;
2221
2357
  /** The file with its comments blanked out, line for line so a finding still points. */
2222
- function withoutComments(text) {
2358
+ function withoutComments$1(text) {
2223
2359
  return text.replaceAll(SQL_BLOCK_COMMENT, (block) => block.replaceAll(/[^\n]/gu, " ")).replaceAll(SQL_LINE_COMMENT, "");
2224
2360
  }
2225
2361
  /**
@@ -2394,7 +2530,7 @@ function checkSeedClockReads(path, rel, name) {
2394
2530
  const text = decodeText(path);
2395
2531
  if (text === null) return [];
2396
2532
  const violations = [];
2397
- for (const [index, line] of withoutComments(text).split("\n").entries()) {
2533
+ for (const [index, line] of withoutComments$1(text).split("\n").entries()) {
2398
2534
  const found = CLOCK_READ.exec(line);
2399
2535
  if (found === null) continue;
2400
2536
  violations.push({
@@ -2532,23 +2668,33 @@ function formatViolations(violations) {
2532
2668
  /**
2533
2669
  * CLI entry for the conventions checker (bundled as `dist/checker.js`).
2534
2670
  *
2535
- * node dist/checker.js [rootDir] [--fix] # a specs tree
2671
+ * node dist/checker.js [dir] [--fix] # a specs tree, or a project root
2536
2672
  * node dist/checker.js --format json # every root, every member
2537
2673
  * node dist/checker.js --member <dir> # one workspace member
2538
2674
  *
2539
- * Three runs, one reporter. With a PATH it walks that tree and runs every
2540
- * checker pass over it — the token/HTTP grammar (D4 / D4b / D10), the
2541
- * `<case>.spec.yaml` document conventions, and the cross-file passes (C9 dead
2675
+ * Three runs, one reporter. A PATH is read for what it IS. Name a specs tree
2676
+ * — a directory with a `specs` segment in its path — and it walks that tree
2677
+ * with every tree pass: the token/HTTP grammar (D4 / D4b / D10), the
2678
+ * `<case>.spec.yaml` document conventions, the cross-file passes (C9 dead
2542
2679
  * fixtures, C14/C15 fixture placement, B5 await-using inference, A7 database
2543
- * property). With `--member <dir>` it runs the member pass (E3 config-present,
2544
- * E5b no simulated DOM in a config, F8 no seam dependency) over one workspace
2545
- * member, whether or not that member has a `specs/` root.
2680
+ * property) and the facet passes (C12, C18, C20, C21w), which read the first
2681
+ * level of the tree and are the reason the anchor has to be right. Name
2682
+ * anything else and it is a PROJECT ROOT: the run is the path-less one,
2683
+ * anchored there. With `--member <dir>` it runs the member pass (E3
2684
+ * config-present, E5b no simulated DOM in a config, F8 no seam dependency)
2685
+ * over one workspace member, whether or not that member has a `specs/` root.
2546
2686
  *
2547
2687
  * With NO path it runs both over the whole project: every `specs/` root it
2548
2688
  * discovers from the root manifest's workspaces, and every member. That is the
2549
2689
  * run `@jterrazz/typescript`'s ratchet rests on, so the contract is exactly
2550
2690
  * "a path-less run reports what the per-root and per-member runs report".
2551
2691
  *
2692
+ * A NAMED root that turns out to hold no `specs/` tree at all is refused, loud
2693
+ * and non-zero. Pointed at a project root, the facet passes used to key on its
2694
+ * first level, find no facet folder there and report nothing — `<project>` and
2695
+ * `<project>/specs` disagreed on the same tree, and the run that reported
2696
+ * nothing was the one a CI line was likelier to be written with.
2697
+ *
2552
2698
  * `--format json` prints `[{ code, file, line, severity, message }]` on stdout
2553
2699
  * and nothing else, where `code` is `jterrazz-check(<id>)` — the namespace the
2554
2700
  * ratchet records under. Exit 1 on any ERROR-level violation either way;
@@ -2561,10 +2707,11 @@ function formatViolations(violations) {
2561
2707
  * fixes everything exits 0. With no path it applies them to every `specs/` root
2562
2708
  * it discovers: `--fix` means the same thing whichever run the operator typed.
2563
2709
  *
2564
- * After a C12 rename it NAMES every include glob of the owning member that
2565
- * still says `.test.ts`. A glob the rename left behind collects nothing, the
2566
- * run stays green with fewer files, and nothing else in the toolchain would
2567
- * have said a word.
2710
+ * After a C12 rename it NAMES the entries of the owning member's configs that
2711
+ * the rename BROKE — a glob whose prefix covers a file it moved, or a literal
2712
+ * naming one — and nothing else. An entry the rename left behind collects
2713
+ * nothing, the run stays green with fewer files, and nothing else in the
2714
+ * toolchain would have said a word.
2568
2715
  *
2569
2716
  * C12's rename goes through `git mv` where the tree is a git working tree, so
2570
2717
  * the history follows the file across a migration that touches hundreds of
@@ -2661,7 +2808,10 @@ function fixSpecSuffixes(root) {
2661
2808
  for (const { from, to } of movesUnderFacet(root)) {
2662
2809
  if (to === void 0 || existsSync(to)) continue;
2663
2810
  renameKeepingHistory(from, to);
2664
- renamed.push(`${relative(root, from)} → ${relative(root, to)}`);
2811
+ renamed.push({
2812
+ from,
2813
+ to
2814
+ });
2665
2815
  }
2666
2816
  return renamed;
2667
2817
  }
@@ -2674,53 +2824,145 @@ const CONFIG_NAMES = [
2674
2824
  "vitest.config.mjs",
2675
2825
  "vitest.config.cjs"
2676
2826
  ];
2677
- /** Any string in a config that ends in the suffix the mover just retired. */
2678
- const TEST_GLOB = /['"`](?<glob>[^'"`]*\*[^'"`]*\.test\.ts)['"`]/gu;
2827
+ /** What a config COLLECTS: the two keys whose entries name test files. */
2828
+ const COLLECTION = /\b(?:include|exclude)\s*:\s*\[(?<entries>[^\]]*)\]/gu;
2829
+ /** One quoted entry of such a list. */
2830
+ const ENTRY = /['"`](?<entry>[^'"`\n]*)['"`]/gu;
2831
+ /** The quotes a config's entries are written in. */
2832
+ const QUOTES = /* @__PURE__ */ new Set([
2833
+ "'",
2834
+ "\"",
2835
+ "`"
2836
+ ]);
2837
+ /** The index of the newline ending the `//` comment that starts at `index`. */
2838
+ function endOfLineComment(text, index) {
2839
+ const newline = text.indexOf("\n", index);
2840
+ return newline === -1 ? text.length : newline;
2841
+ }
2842
+ /** The index just past the block comment that starts at `index`. */
2843
+ function endOfBlockComment(text, index) {
2844
+ const close = text.indexOf("*/", index + 2);
2845
+ return close === -1 ? text.length : close + 2;
2846
+ }
2679
2847
  /**
2680
- * The include globs a rename leaves behind.
2848
+ * The config, with what is not code taken out.
2849
+ *
2850
+ * A notice that read a COMMENT as an include told signews-mobile to follow a
2851
+ * glob no project collected on. The scan is character by character because a
2852
+ * regex cannot tell the two apart: `specs/api/**` + `/*.test.ts` opens a block
2853
+ * comment to any pattern that does not know it is inside a string, and a `://`
2854
+ * opens a line comment to one that does not either.
2855
+ */
2856
+ function withoutComments(text) {
2857
+ let out = "";
2858
+ let quote = null;
2859
+ let index = 0;
2860
+ while (index < text.length) {
2861
+ const char = text[index] ?? "";
2862
+ const pair = text.slice(index, index + 2);
2863
+ if (quote !== null) {
2864
+ const escaped = char === "\\";
2865
+ out += escaped ? pair : char;
2866
+ quote = char === quote ? null : quote;
2867
+ index += escaped ? 2 : 1;
2868
+ } else if (pair === "//") {
2869
+ out += "\n";
2870
+ index = endOfLineComment(text, index);
2871
+ } else if (pair === "/*") index = endOfBlockComment(text, index);
2872
+ else {
2873
+ quote = QUOTES.has(char) ? char : null;
2874
+ out += char;
2875
+ index += 1;
2876
+ }
2877
+ }
2878
+ return out;
2879
+ }
2880
+ /**
2881
+ * Does this entry of a config sitting in `dir` reach `file`?
2882
+ *
2883
+ * A glob reaches what its literal PREFIX — everything before the first `*` —
2884
+ * contains; an entry with no `*` reaches the one file it names. Both are
2885
+ * resolved against the config's own directory, which is what vitest does.
2886
+ */
2887
+ function reaches(dir, entry, file) {
2888
+ const star = entry.indexOf("*");
2889
+ if (star === -1) return resolve(dir, entry) === file;
2890
+ const prefix = resolve(dir, entry.slice(0, star));
2891
+ return file === prefix || file.startsWith(`${prefix}/`);
2892
+ }
2893
+ /** Every entry of every `include`/`exclude` list a config states. */
2894
+ function collectedEntries(text) {
2895
+ const entries = [];
2896
+ for (const list of text.matchAll(COLLECTION)) for (const match of (list.groups?.entries ?? "").matchAll(ENTRY)) entries.push(match.groups?.entry ?? "");
2897
+ return entries;
2898
+ }
2899
+ /** What one broken entry says, glob or literal. */
2900
+ function noticeFor(where, entry) {
2901
+ return entry.includes("*") ? `${where}: ${entry} still names \`.test.ts\` — the files it collected are \`.spec.ts\` now, and the project collects nothing until the glob follows (C12)` : `${where}: ${entry} names a file this run renamed — it is \`${entry.slice(0, -8)}.spec.ts\` now, and the entry collects nothing until it follows (C12)`;
2902
+ }
2903
+ /** The entries of ONE config the renames broke. */
2904
+ function staleEntriesIn(path, owner, renamed) {
2905
+ const directory = dirname(path);
2906
+ return collectedEntries(withoutComments(readFileSync(path, "utf8"))).filter((entry) => entry.endsWith(".test.ts")).filter((entry) => renamed.some(({ from }) => reaches(directory, entry, from))).map((entry) => noticeFor(relative(owner, path), entry));
2907
+ }
2908
+ /**
2909
+ * The entries a rename leaves behind.
2681
2910
  *
2682
2911
  * A consumer that names its suffix in an `include` loses the whole suite the
2683
2912
  * moment the mover renames it: vitest collects fewer files, exits 0, and the
2684
2913
  * only trace is a number nobody compares. The mover owns that consequence —
2685
- * it cannot rewrite a config it does not parse, so it NAMES every glob that
2686
- * still says `.test.ts` in the configs of the member it just moved files
2687
- * under, and the author updates them in the same commit as the rename.
2688
- */
2689
- function staleIncludes(root) {
2914
+ * it cannot rewrite a config it does not parse, so it NAMES what it broke, and
2915
+ * the author updates it in the same commit as the rename.
2916
+ *
2917
+ * What it names is exactly what it broke: an entry of an `include`/`exclude`
2918
+ * list, never a comment, and only where the entry reaches a file this run
2919
+ * actually moved. A glob over `src/` collecting module tests the mover never
2920
+ * touched is not a stale glob, and four repositories of the wave were told it
2921
+ * was — one of them would have emptied its unit project by following the
2922
+ * notice.
2923
+ */
2924
+ function staleIncludes(root, renamed) {
2925
+ if (renamed.length === 0) return [];
2690
2926
  const owner = packageRootOf(root) ?? dirname(root);
2691
- const found = [];
2692
- for (const directory of /* @__PURE__ */ new Set([
2927
+ return [.../* @__PURE__ */ new Set([
2693
2928
  root,
2694
2929
  owner,
2695
2930
  dirname(root)
2696
- ])) for (const name of CONFIG_NAMES) {
2697
- const path = join(directory, name);
2698
- if (!existsSync(path)) continue;
2699
- const text = readFileSync(path, "utf8");
2700
- for (const match of text.matchAll(TEST_GLOB)) {
2701
- const glob = match.groups?.glob ?? "";
2702
- if (glob !== "") found.push(`${relative(owner, path)}: ${glob}`);
2703
- }
2704
- }
2705
- return found;
2931
+ ])].flatMap((directory) => CONFIG_NAMES.map((name) => join(directory, name)).filter((path) => existsSync(path))).flatMap((path) => staleEntriesIn(path, owner, renamed));
2706
2932
  }
2707
2933
  /** Apply every rewritable pass over one tree, printing what it did. */
2708
2934
  function applyFixes(root) {
2709
2935
  const renamed = fixSpecSuffixes(root);
2710
- for (const rename of renamed) console.log(`conventions checker: renamed ${rename} (C12)`);
2711
- for (const glob of renamed.length === 0 ? [] : staleIncludes(root)) console.log(`conventions checker: ${glob} still names \`.test.ts\` — the files it collected are \`.spec.ts\` now, and the project collects nothing until the glob follows (C12)`);
2936
+ for (const { from, to } of renamed) console.log(`conventions checker: renamed ${relative(root, from)} → ${relative(root, to)} (C12)`);
2937
+ for (const stale of staleIncludes(root, renamed)) console.log(`conventions checker: ${stale}`);
2712
2938
  const written = fixSpecFiles(root);
2713
2939
  if (written.length > 0) console.log(`conventions checker: rewrote ${written.length} spec document(s)`);
2714
2940
  for (const move of fixPoolFixtures(root)) console.log(`conventions checker: moved ${move} (C14) — stage the rename`);
2715
2941
  }
2716
- if (positional !== void 0) {
2942
+ /**
2943
+ * Is this directory a specs tree?
2944
+ *
2945
+ * The `specs` SEGMENT is the anchor every other pass reads (`specsAnchor`), so
2946
+ * the CLI reads it too: `specs`, `apps/web/specs` and `specs/api` are trees —
2947
+ * a subtree of one is still inside it — and everything else is a project root
2948
+ * holding zero or more of them.
2949
+ */
2950
+ function isSpecsTree(dir) {
2951
+ return dir.split(/[/\\]/u).includes("specs");
2952
+ }
2953
+ if (positional !== void 0 && isSpecsTree(requireDirectory(positional, "directory"))) {
2717
2954
  const root = requireDirectory(positional, "directory");
2718
2955
  if (fix) applyFixes(root);
2719
2956
  report(runAllChecks(root), `under ${root}`, TREE_PASSES);
2720
2957
  }
2721
- const root = resolve(".");
2958
+ const root = positional === void 0 ? resolve(".") : requireDirectory(positional, "directory");
2959
+ const specsRoots = discoverSpecRoots(root);
2960
+ if (positional !== void 0 && specsRoots.length === 0) {
2961
+ console.error(`conventions checker: no \`specs/\` tree under ${root} — name a specs tree, or a project root that holds one`);
2962
+ process.exit(1);
2963
+ }
2722
2964
  const found = [];
2723
- for (const specsRoot of discoverSpecRoots(root)) {
2965
+ for (const specsRoot of specsRoots) {
2724
2966
  const prefix = relative(root, specsRoot);
2725
2967
  if (fix) applyFixes(specsRoot);
2726
2968
  for (const violation of runAllChecks(specsRoot)) found.push({
package/dist/index.d.ts CHANGED
@@ -707,8 +707,25 @@ type CallResultOptions = BaseResultOptions & {
707
707
  * What `result.value` is, decided by what the call returned: a string reads as
708
708
  * a stream, anything else as JSON. `.call<T>()` already knows T, so the test
709
709
  * reads one field (`result.value.value.ok`) without narrowing a union first.
710
+ *
711
+ * Three answers, because a type has three ways of relating to `string`:
712
+ *
713
+ * - EXACTLY a string — a `TextAccessor`, and nothing to narrow;
714
+ * - it MAY be a string (`string | undefined`, `string | number`, `any`) — the
715
+ * UNION, which the test narrows. The type used to read the static type only
716
+ * while the accessor was built from the RUNTIME value, so a subject typed
717
+ * `string | undefined` was handed back as a `JsonAccessor`: `.value.value`
718
+ * compiled, the runtime had built a `TextAccessor`, and the read silently
719
+ * answered `undefined`;
720
+ * - never a string — a `JsonAccessor<Returned>`.
721
+ *
722
+ * `Returned` there is what the value CLAIMS, not what survived the crossing:
723
+ * the accessor is built from `JSON.stringify`, so a `Map`, a `Date` or a class
724
+ * instance arrives as the plain JSON it serialises to (`{}`, an ISO string, an
725
+ * object of its own fields). A subject whose answer is one of those is
726
+ * projected to plain data inside the call, where the test can see it happen.
710
727
  */
711
- type CallValue<Returned> = Returned extends string ? TextAccessor : JsonAccessor<Returned>;
728
+ type CallValue<Returned> = [Returned] extends [string] ? TextAccessor : [Extract<Returned, string>] extends [never] ? JsonAccessor<Returned> : JsonAccessor<Exclude<Returned, string>> | TextAccessor;
712
729
  /**
713
730
  * The result of an in-process call — the integration facet's whole surface.
714
731
  *
@@ -741,10 +758,14 @@ declare class CallResult<Returned = unknown> extends BaseResult {
741
758
  /**
742
759
  * What the call RETURNED. A string reads as a stream (`<name>.txt`),
743
760
  * anything else as JSON (`<name>.json`) — the same two subjects every
744
- * other facet's results are built from.
761
+ * other facet's results are built from, chosen on the value in hand and
762
+ * typed by {@link CallValue}, which answers for the three ways a type can
763
+ * relate to `string`.
745
764
  *
746
- * A call that threw returns the empty value: what it produced is
747
- * {@link CallResult.error}.
765
+ * A call that threw has no value to choose an accessor on, so `value` is
766
+ * the JSON reading of `null` whatever the declared type promises: what a
767
+ * refusal produced is {@link CallResult.error}, and that is where a spec
768
+ * reads it.
748
769
  */
749
770
  get value(): CallValue<Returned>;
750
771
  }
@@ -1709,9 +1730,11 @@ type DomMount = (container: HTMLElement) => (() => void) | void;
1709
1730
  */
1710
1731
  declare module 'vitest/browser' {
1711
1732
  interface BrowserCommands {
1733
+ ariaNode: (selector: string) => Promise<string>;
1712
1734
  ariaTree: () => Promise<string>;
1713
1735
  goldenRead: (name: string) => Promise<null | string>;
1714
1736
  goldenWrite: (name: string, content: string) => Promise<string>;
1737
+ notify: (line: string) => Promise<void>;
1715
1738
  }
1716
1739
  }
1717
1740
  //#endregion
@@ -2883,6 +2906,14 @@ type WebsiteSpecificationOptions<Services extends ServiceRecord = ServiceRecord>
2883
2906
  * API it was started beside.
2884
2907
  */
2885
2908
  services?: Services;
2909
+ /**
2910
+ * Escape hatch: normaliser applied to every compared reading of a result
2911
+ * — the head projection and the json-ld among them — before the
2912
+ * comparison (CONVENTIONS D6). Does NOT mutate the raw `.text` accessor.
2913
+ * Prefer `{{token}}` placeholders in fixtures; this is for what a token
2914
+ * cannot name, a meta a FRAMEWORK owns and the page never asked for.
2915
+ */
2916
+ transform?: ((text: string) => string) | undefined;
2886
2917
  } & ({
2887
2918
  /**
2888
2919
  * Declared stub backend: started BEFORE the server command, its