@jterrazz/test 16.1.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.
@@ -1,4 +1,4 @@
1
- import { a as formatElement, r as AmbiguousElementError, t as warnSubstringOnly } from "./substring-warning.js";
1
+ import { i as AmbiguousElementError, n as warnSubstringOnly, o as formatElement } from "./substring-warning.js";
2
2
  import { mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
@@ -430,7 +430,7 @@ var AppiumAdapter = class {
430
430
  const first = matches[0];
431
431
  if (first === void 0 || await matches.length !== 1) return null;
432
432
  const evidence = await captureMatch(first);
433
- warnSubstringOnly(level, this.options.udid, evidence.label);
433
+ await warnSubstringOnly(level, this.options.udid, evidence.label);
434
434
  return loose;
435
435
  }
436
436
  /** Enumerate the candidates' evidence, truncated. */
@@ -1358,6 +1358,45 @@ function describeAmbiguity(options) {
1358
1358
  ].join("\n");
1359
1359
  }
1360
1360
  //#endregion
1361
+ //#region src/model/elements/aria-name.ts
1362
+ /**
1363
+ * The accessible name an ARIA snapshot states.
1364
+ *
1365
+ * A descriptor matches the name the BROWSER computes, and that name is not the
1366
+ * element's text: `<button><span>Experiments</span><span>9</span></button>`
1367
+ * reads as `Experiments9` through `textContent` and is named "Experiments 9",
1368
+ * a field's name comes from its label and is nowhere in its text at all, and a
1369
+ * name cut at eighty characters resolves to nothing. Reading the name off the
1370
+ * snapshot is what makes the spelling the window prints a spelling that can be
1371
+ * written back into a spec.
1372
+ *
1373
+ * The dialect is the one both surfaces already speak — playwright's, on the
1374
+ * page directly and through `server.commands` for a mounted component — so the
1375
+ * projection is stated once, here, and neither adapter computes a name itself.
1376
+ */
1377
+ /**
1378
+ * The name of the FIRST node a snapshot describes: `- button "Experiments 9"`,
1379
+ * optionally followed by `:` and its children.
1380
+ *
1381
+ * A quote must follow the role across a SPACE. `- paragraph: "…"` is a node
1382
+ * with text and no name, and reading that text as a name would hand the author
1383
+ * a spelling no role descriptor matches on.
1384
+ */
1385
+ const NAMED = /^-\s+[\w-]+\s+"(?<name>(?:[^"\\]|\\.)*)"/u;
1386
+ /** Undo the escaping the snapshot applies to a quote or a backslash inside a name. */
1387
+ function unescaped(name) {
1388
+ return name.replaceAll(/\\(?<char>.)/gu, "$<char>");
1389
+ }
1390
+ /**
1391
+ * The accessible name the snapshot gives the node it describes, or `undefined`
1392
+ * when that node has none — an unnamed landmark, a paragraph of text.
1393
+ */
1394
+ function accessibleNameIn(snapshot) {
1395
+ const [first] = snapshot.split("\n");
1396
+ const name = (first === void 0 ? null : NAMED.exec(first.trim()))?.groups?.name;
1397
+ return name === void 0 ? void 0 : unescaped(name);
1398
+ }
1399
+ //#endregion
1361
1400
  //#region src/model/elements/substring-warning.ts
1362
1401
  /**
1363
1402
  * The transitional warning for a name that matches only as a SUBSTRING.
@@ -1379,6 +1418,14 @@ function describeAmbiguity(options) {
1379
1418
  */
1380
1419
  /** How long the window lasts, stated in the message itself. */
1381
1420
  const WINDOW = "through 16.x";
1421
+ /** The node side's printer — the default every adapter but the page's uses. */
1422
+ function toStandardError(line) {
1423
+ if (typeof process === "undefined") {
1424
+ console.warn(line);
1425
+ return;
1426
+ }
1427
+ process.stderr.write(`${line}\n`);
1428
+ }
1382
1429
  /** Descriptors already reported in this process. */
1383
1430
  const reported = /* @__PURE__ */ new Set();
1384
1431
  /** The identity a descriptor is deduplicated by — kind, name, and its scope chain. */
@@ -1391,15 +1438,26 @@ function identityOf(element) {
1391
1438
  * designated something as a substring. Returns whether a line was printed,
1392
1439
  * which is what the adapters' own tests assert on.
1393
1440
  */
1394
- function warnSubstringOnly(element, where, found) {
1441
+ async function warnSubstringOnly(element, where, found, print = toStandardError) {
1395
1442
  const identity = identityOf(element);
1396
1443
  if (reported.has(identity)) return false;
1397
1444
  reported.add(identity);
1398
1445
  const spelling = found === void 0 ? "" : ` The name to write is '${found.replaceAll(/\s+/gu, " ").trim()}'.`;
1399
- console.warn(`@jterrazz/test: ${element.kind}('${element.name ?? ""}') matched nothing as a whole accessible name, and matched only as a SUBSTRING of a longer one (${where}).${spelling} Names are exact from 16.0: write the name in full, scope it with \`within(…)\`, or state \`{ exact: false }\` to keep the old behaviour. The substring is still resolved, with this warning, ${WINDOW}; gone in 17.0, where the same descriptor is simply not found.`);
1446
+ await print(`@jterrazz/test: ${element.kind}('${element.name ?? ""}') matched nothing as a whole accessible name, and matched only as a SUBSTRING of a longer one (${where}).${spelling} Names are exact from 16.0: write the name in full, scope it with \`within(…)\`, or state \`{ exact: false }\` to keep the old behaviour. The substring is still resolved, with this warning, ${WINDOW}; gone in 17.0, where the same descriptor is simply not found.`);
1400
1447
  return true;
1401
1448
  }
1402
1449
  /**
1450
+ * How long a descriptor is given to ARRIVE before the window concludes it is
1451
+ * absent — the budget `settles` spends and no more.
1452
+ *
1453
+ * A count is one observation, and a verb called right after a navigating
1454
+ * `click()` takes it on the page being LEFT. The window may not decide
1455
+ * "nothing matches this whole name" on that reading: the destination has not
1456
+ * been parsed yet. Long enough for a navigation to commit, far short of the
1457
+ * verb's own 30 s, and paid only when there is a substring to widen to.
1458
+ */
1459
+ const WINDOW_SETTLE_MS = 5e3;
1460
+ /**
1403
1461
  * The descriptor to retry with, LEVEL by level, or `undefined` when nothing
1404
1462
  * would change.
1405
1463
  *
@@ -1416,8 +1474,8 @@ function warnSubstringOnly(element, where, found) {
1416
1474
  *
1417
1475
  * Asked BEFORE the action, not after it fails: a verb waits its whole
1418
1476
  * actionability budget, which is the test's own budget, so a retry afterwards
1419
- * never runs — the test is already over. The probes are counts, which wait for
1420
- * nothing, and the common case (the name matches whole) costs one per level.
1477
+ * never runs — the test is already over. The common case (the name matches
1478
+ * whole) costs one count per level, which waits for nothing.
1421
1479
  */
1422
1480
  async function widenedForWindow(element, where, probe) {
1423
1481
  const chain = [];
@@ -1437,8 +1495,16 @@ async function widenedForWindow(element, where, probe) {
1437
1495
  }
1438
1496
  /**
1439
1497
  * The one level, widened and warned about, or `undefined` when it designates
1440
- * something already, states its own `exact`, carries no name, or would
1441
- * designate nothing either way.
1498
+ * something already, states its own `exact`, carries no name, would designate
1499
+ * nothing either way, or turns out to be exact on the surface that ARRIVES.
1500
+ *
1501
+ * The last is the navigation case, and it is the reason the verdict is not two
1502
+ * counts: a `see()` fired right after a navigating `click()` counts the page
1503
+ * being left, so a descriptor that is perfectly exact on the DESTINATION read
1504
+ * as substring-only — non-deterministically, since which page each count
1505
+ * landed on was a race. A widening is therefore only decided once the exact
1506
+ * descriptor has been given the settle budget and still designates nothing.
1507
+ * The tell of the old fault was the printed name equalling the name written.
1442
1508
  */
1443
1509
  async function widenedLevel(candidate, where, probe) {
1444
1510
  if (candidate.exact !== void 0 || candidate.name === void 0) return;
@@ -1449,12 +1515,23 @@ async function widenedLevel(candidate, where, probe) {
1449
1515
  exact: false
1450
1516
  };
1451
1517
  if (await probe.count(loose) !== 1) return;
1452
- warnSubstringOnly(candidate, where, await probe.nameOf(loose));
1518
+ if (!alreadyJudged(candidate) && await probe.settles(candidate)) return;
1519
+ await warnSubstringOnly(candidate, where, await probe.nameOf(loose), probe.print);
1453
1520
  return loose;
1454
1521
  } catch {
1455
1522
  return;
1456
1523
  }
1457
1524
  }
1525
+ /**
1526
+ * Has this descriptor already been judged substring-only in this run?
1527
+ *
1528
+ * The verdict is made once: a scenario that clicks the same link in ten tests
1529
+ * pays the settle budget on the first of them and is widened straight away on
1530
+ * the nine after it.
1531
+ */
1532
+ function alreadyJudged(element) {
1533
+ return reported.has(identityOf(element));
1534
+ }
1458
1535
  //#endregion
1459
1536
  //#region src/seams/vitest-browser/vitest-browser.adapter.ts
1460
1537
  /**
@@ -1549,13 +1626,49 @@ function ambiguousLevel(element) {
1549
1626
  for (const level of chain.slice(0, -1)) if (locate(page, level).elements().length > 1) return level;
1550
1627
  return element;
1551
1628
  }
1629
+ /**
1630
+ * The name to WRITE for this descriptor — the whole one, never truncated.
1631
+ *
1632
+ * A role descriptor matches the accessible name the browser computed, and a
1633
+ * page cannot compute one: the snapshot comes back from the node side, of the
1634
+ * one node the locator's own selector names. Text content would be the wrong
1635
+ * answer — it glues inline children with no separator and carries nothing of a
1636
+ * field's label — except for a `text` descriptor, which matches text.
1637
+ */
1638
+ async function wholeNameOf(element) {
1639
+ const locator = locate(page, element);
1640
+ if (element.kind !== "text") try {
1641
+ const named = accessibleNameIn(await commands.ariaNode(locator.selector));
1642
+ if (named !== void 0) return named;
1643
+ } catch {}
1644
+ const collapsed = (locator.query()?.textContent ?? "").replaceAll(/\s+/gu, " ").trim();
1645
+ return collapsed === "" ? void 0 : collapsed;
1646
+ }
1647
+ /**
1648
+ * Does the descriptor arrive if the subject is given the settle budget?
1649
+ *
1650
+ * A mounted component never navigates, but it does re-render: a subject whose
1651
+ * name arrives with the state after a click is the same question a page asks
1652
+ * across a navigation, and one window answers both. The poll is vitest's own,
1653
+ * with the window's budget rather than the test's — what is being waited for
1654
+ * here is a surface settling, not an assertion coming true.
1655
+ */
1656
+ async function settles(element) {
1657
+ try {
1658
+ await expect.poll(() => locate(page, element).elements().length, { timeout: WINDOW_SETTLE_MS }).toBeGreaterThan(0);
1659
+ return true;
1660
+ } catch {
1661
+ return false;
1662
+ }
1663
+ }
1552
1664
  /** What the transitional window may ask of the mounted page. */
1553
1665
  const PROBE = {
1554
1666
  count: async (element) => await Promise.resolve(locate(page, element).elements().length),
1555
- nameOf: async (element) => {
1556
- const [match] = candidates(locate(page, element));
1557
- return await Promise.resolve(match?.accessibleName ?? match?.text);
1558
- }
1667
+ nameOf: async (element) => await wholeNameOf(element),
1668
+ print: async (line) => {
1669
+ await commands.notify(line);
1670
+ },
1671
+ settles: async (element) => await settles(element)
1559
1672
  };
1560
1673
  /**
1561
1674
  * Run one visitor action, turning "more than one match" into the W3 refusal.
@@ -2849,7 +2962,7 @@ const specification = {
2849
2962
  mobile: () => refuse("specification.mobile()"),
2850
2963
  website: () => refuse("specification.website()")
2851
2964
  };
2852
- const process = () => refuse("process()");
2965
+ const process$1 = () => refuse("process()");
2853
2966
  const ProcessHandle = nodeOnlyClass("ProcessHandle");
2854
2967
  const postgres = () => refuse("postgres()");
2855
2968
  const redis = () => refuse("redis()");
@@ -2864,4 +2977,4 @@ function text(value) {
2864
2977
  return new TextAccessor(value, "text", "");
2865
2978
  }
2866
2979
  //#endregion
2867
- export { Matcher, ProcessHandle, anthropic, banner, button, clock, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, focused, form, heading, http, intercept, link, listitem, main, match, mockOf, navigation, openai, option, postgres, process, redis, region, required, row, search, selected, specification, sqlite, status, table, testId, text, valued, waitUntil, within };
2980
+ export { Matcher, ProcessHandle, anthropic, banner, button, clock, complementary, component, content, contentinfo, defineContract, defineContracts, dialog, disabled, enabled, field, focused, form, heading, http, intercept, link, listitem, main, match, mockOf, navigation, openai, option, postgres, process$1 as process, redis, region, required, row, search, selected, specification, sqlite, status, table, testId, text, valued, waitUntil, within };
package/dist/checker.js CHANGED
@@ -1370,6 +1370,40 @@ const JEST_CONFIG = /^jest\.config\.[cm]?[jt]s$/u;
1370
1370
  const JEST_REACH = /\b(?:roots|testMatch|testPathDirs|testPathIgnorePatterns)\s*:\s*\[(?<paths>[^\]]*)\]/gu;
1371
1371
  /** One quoted entry of such a list. */
1372
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
+ }
1373
1407
  /** Every `jest.config.*` under `rootDir`, by absolute path — read once per root. */
1374
1408
  const jestConfigs = /* @__PURE__ */ new Map();
1375
1409
  /** Find them, bounded by the same skips and depth the member walk uses. */
@@ -1550,8 +1584,8 @@ function simulatedDom(config, rootDir) {
1550
1584
  * test can see, while dropping a retired seam means writing its facet.
1551
1585
  */
1552
1586
  function seamDependencies(member) {
1553
- const { label, manifest, memberDir, rootDir, subject } = member;
1554
- return declaredSeams(manifest, declaresJest(manifest) || jestReachesMember(memberDir, rootDir)).map((seam) => ({
1587
+ const { label, manifest, memberDir, subject } = member;
1588
+ return declaredSeams(manifest, declaresJest(manifest) || jestReachesMember(memberDir, workspaceRootOf(memberDir))).map((seam) => ({
1555
1589
  file: join(label, "package.json"),
1556
1590
  line: lineOfKey(memberDir, seam.name),
1557
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)`,
@@ -1590,7 +1624,6 @@ function checkMember(memberDir, rootDir) {
1590
1624
  label,
1591
1625
  manifest,
1592
1626
  memberDir,
1593
- rootDir,
1594
1627
  subject
1595
1628
  }),
1596
1629
  ...anchoredToRoot(checkSpecOutsideSpecs(memberDir), label)
package/dist/index.d.ts CHANGED
@@ -1730,9 +1730,11 @@ type DomMount = (container: HTMLElement) => (() => void) | void;
1730
1730
  */
1731
1731
  declare module 'vitest/browser' {
1732
1732
  interface BrowserCommands {
1733
+ ariaNode: (selector: string) => Promise<string>;
1733
1734
  ariaTree: () => Promise<string>;
1734
1735
  goldenRead: (name: string) => Promise<null | string>;
1735
1736
  goldenWrite: (name: string, content: string) => Promise<string>;
1737
+ notify: (line: string) => Promise<void>;
1736
1738
  }
1737
1739
  }
1738
1740
  //#endregion
package/dist/oxlint.cjs CHANGED
@@ -271,7 +271,7 @@ const RULE_DOCS = {
271
271
  },
272
272
  "c1-domain-structure": {
273
273
  channel: "statique",
274
- convention: "A specs tree DECLARES its shape through the `depth` option: `facet-domain` (the default — a test at facet/domain depth, a `*.specification.ts` at the facet root), `facet` (a test at the facet root OR one domain folder down, never deeper), `mirror` (a test at any depth ≥ 1, named after its directory), `off`. In every mode, `off` included: a directory with a leading underscore is GROUND, never a domain — no spec lives inside one. One exception, and only one: ground may be CODE, and a `<module>.test.ts` sitting BESIDE the `<module>.ts` it tests is legal there (that is I2). A test with no neighbouring module, or a `*.specification.ts`, is still a violation. A `.test.tsx` is out of reach: a rendered unit is specified beside its component, never in a facet/domain tree — I2 holds its neighbourhood.",
274
+ convention: "A specs tree DECLARES its shape through the `depth` option: `facet-domain` (the default — a test at facet/domain depth, a `*.specification.ts` at the facet root), `facet` (a test at the facet root OR one domain folder down, never deeper), `mirror` (a test at any depth ≥ 1, named after its directory), `off`. A first-level folder that is no facet — a repository suite, `surface/` or `lint/` — is judged by the same depth as a facet is. In every mode, `off` included: a directory with a leading underscore is GROUND, never a domain — no spec lives inside one. One exception, and only one: ground may be CODE, and a `<module>.test.ts` sitting BESIDE the `<module>.ts` it tests is legal there (that is I2). A test with no neighbouring module, or a `*.specification.ts`, is still a violation. A `.test.tsx` is out of reach: a rendered unit is specified beside its component, never in a facet/domain tree — I2 holds its neighbourhood.",
275
275
  facet: "model",
276
276
  family: "C",
277
277
  fix: "Move the file to the depth the tree declares, or declare the depth the tree actually has.",
@@ -661,7 +661,7 @@ const RULE_DOCS = {
661
661
  },
662
662
  "j2-no-sleep": {
663
663
  channel: "statique",
664
- convention: "No arbitrary sleep (`setTimeout`/`setInterval`/`Atomics.wait`, or a `node:timers/promises` import) in any test file — synchronisation is `see()`/`gone()` inside a scenario and `waitUntil(predicate)` everywhere else. A timer inside a test DOUBLE's implementation (`vi.fn(…)`, `mockImplementation(…)`, a `mockOf` handler) is out of reach: the double is staging the world, not the test waiting on it.",
664
+ convention: "No arbitrary sleep (`setTimeout`/`setInterval`/`Atomics.wait`, or a `node:timers/promises` import) in any test file — synchronisation is `see()`/`gone()` inside a scenario and `waitUntil(predicate)` everywhere else. A timer inside a test DOUBLE's implementation is out of reach: the double is staging the world, not the test waiting on it. A double is a factory call (`vi.fn(…)`, `mockImplementation(…)`, a `mockOf` handler) or a function-valued PROPERTY of an object literal, which is the plainest double there is — `const git: GitGateway = { cloneRepository: async () => … }`, where the object itself is the double.",
665
665
  facet: "model",
666
666
  family: "J",
667
667
  fix: "Wait for the condition, not for a duration.",
@@ -5639,6 +5639,20 @@ function isDoubleImplementation(node) {
5639
5639
  return name !== void 0 && DOUBLE_FACTORIES.has(name);
5640
5640
  }
5641
5641
  /**
5642
+ * The FUNCTION a property of an object literal holds, or `undefined`.
5643
+ *
5644
+ * A double is not always built by a factory: the plainest one there is, in a
5645
+ * test file, is an object literal whose properties are the port's methods —
5646
+ * `const git: GitGateway = { cloneRepository: async () => … }`. The object IS
5647
+ * the double, so a timer in one of its methods stages the world exactly as
5648
+ * `vi.fn(() => setTimeout(…))` does, and judging it as the test sleeping asked
5649
+ * an author to remove the very thing under test.
5650
+ */
5651
+ function implementationOf(node) {
5652
+ const value = child(node, "value");
5653
+ return value?.type === "ArrowFunctionExpression" || value?.type === "FunctionExpression" ? value : void 0;
5654
+ }
5655
+ /**
5642
5656
  * CONVENTIONS J2 — a test contains no arbitrary sleep, wherever it sits:
5643
5657
  * synchronisation is `see()`/`gone()` inside a scenario and `waitUntil()`
5644
5658
  * everywhere else. Flags `setTimeout(…)` calls (bare or as a member, e.g.
@@ -5654,7 +5668,8 @@ function isDoubleImplementation(node) {
5654
5668
  * about the ORDER results come back in, which no predicate can state: a
5655
5669
  * `waitUntil` waits for something to become true, while what this test needs is
5656
5670
  * for something to happen late. The test is not the one waiting, so the rule is
5657
- * not about it.
5671
+ * not about it. A double written as a plain object literal is the same thing
5672
+ * said without a factory, and it counts too.
5658
5673
  */
5659
5674
  const j2NoSleep = {
5660
5675
  create(context) {
@@ -5662,6 +5677,13 @@ const j2NoSleep = {
5662
5677
  const doubles = [];
5663
5678
  const insideADouble = (node) => doubles.some((span) => nodeStart(node) >= span.start && nodeEnd(node) <= span.end);
5664
5679
  return {
5680
+ Property(node) {
5681
+ const implementation = implementationOf(node);
5682
+ if (implementation !== void 0) doubles.push({
5683
+ end: nodeEnd(implementation),
5684
+ start: nodeStart(implementation)
5685
+ });
5686
+ },
5665
5687
  CallExpression(node) {
5666
5688
  if (isDoubleImplementation(node)) {
5667
5689
  doubles.push({
package/dist/oxlint.js CHANGED
@@ -267,7 +267,7 @@ const RULE_DOCS = {
267
267
  },
268
268
  "c1-domain-structure": {
269
269
  channel: "statique",
270
- convention: "A specs tree DECLARES its shape through the `depth` option: `facet-domain` (the default — a test at facet/domain depth, a `*.specification.ts` at the facet root), `facet` (a test at the facet root OR one domain folder down, never deeper), `mirror` (a test at any depth ≥ 1, named after its directory), `off`. In every mode, `off` included: a directory with a leading underscore is GROUND, never a domain — no spec lives inside one. One exception, and only one: ground may be CODE, and a `<module>.test.ts` sitting BESIDE the `<module>.ts` it tests is legal there (that is I2). A test with no neighbouring module, or a `*.specification.ts`, is still a violation. A `.test.tsx` is out of reach: a rendered unit is specified beside its component, never in a facet/domain tree — I2 holds its neighbourhood.",
270
+ convention: "A specs tree DECLARES its shape through the `depth` option: `facet-domain` (the default — a test at facet/domain depth, a `*.specification.ts` at the facet root), `facet` (a test at the facet root OR one domain folder down, never deeper), `mirror` (a test at any depth ≥ 1, named after its directory), `off`. A first-level folder that is no facet — a repository suite, `surface/` or `lint/` — is judged by the same depth as a facet is. In every mode, `off` included: a directory with a leading underscore is GROUND, never a domain — no spec lives inside one. One exception, and only one: ground may be CODE, and a `<module>.test.ts` sitting BESIDE the `<module>.ts` it tests is legal there (that is I2). A test with no neighbouring module, or a `*.specification.ts`, is still a violation. A `.test.tsx` is out of reach: a rendered unit is specified beside its component, never in a facet/domain tree — I2 holds its neighbourhood.",
271
271
  facet: "model",
272
272
  family: "C",
273
273
  fix: "Move the file to the depth the tree declares, or declare the depth the tree actually has.",
@@ -657,7 +657,7 @@ const RULE_DOCS = {
657
657
  },
658
658
  "j2-no-sleep": {
659
659
  channel: "statique",
660
- convention: "No arbitrary sleep (`setTimeout`/`setInterval`/`Atomics.wait`, or a `node:timers/promises` import) in any test file — synchronisation is `see()`/`gone()` inside a scenario and `waitUntil(predicate)` everywhere else. A timer inside a test DOUBLE's implementation (`vi.fn(…)`, `mockImplementation(…)`, a `mockOf` handler) is out of reach: the double is staging the world, not the test waiting on it.",
660
+ convention: "No arbitrary sleep (`setTimeout`/`setInterval`/`Atomics.wait`, or a `node:timers/promises` import) in any test file — synchronisation is `see()`/`gone()` inside a scenario and `waitUntil(predicate)` everywhere else. A timer inside a test DOUBLE's implementation is out of reach: the double is staging the world, not the test waiting on it. A double is a factory call (`vi.fn(…)`, `mockImplementation(…)`, a `mockOf` handler) or a function-valued PROPERTY of an object literal, which is the plainest double there is — `const git: GitGateway = { cloneRepository: async () => … }`, where the object itself is the double.",
661
661
  facet: "model",
662
662
  family: "J",
663
663
  fix: "Wait for the condition, not for a duration.",
@@ -5635,6 +5635,20 @@ function isDoubleImplementation(node) {
5635
5635
  return name !== void 0 && DOUBLE_FACTORIES.has(name);
5636
5636
  }
5637
5637
  /**
5638
+ * The FUNCTION a property of an object literal holds, or `undefined`.
5639
+ *
5640
+ * A double is not always built by a factory: the plainest one there is, in a
5641
+ * test file, is an object literal whose properties are the port's methods —
5642
+ * `const git: GitGateway = { cloneRepository: async () => … }`. The object IS
5643
+ * the double, so a timer in one of its methods stages the world exactly as
5644
+ * `vi.fn(() => setTimeout(…))` does, and judging it as the test sleeping asked
5645
+ * an author to remove the very thing under test.
5646
+ */
5647
+ function implementationOf(node) {
5648
+ const value = child(node, "value");
5649
+ return value?.type === "ArrowFunctionExpression" || value?.type === "FunctionExpression" ? value : void 0;
5650
+ }
5651
+ /**
5638
5652
  * CONVENTIONS J2 — a test contains no arbitrary sleep, wherever it sits:
5639
5653
  * synchronisation is `see()`/`gone()` inside a scenario and `waitUntil()`
5640
5654
  * everywhere else. Flags `setTimeout(…)` calls (bare or as a member, e.g.
@@ -5650,7 +5664,8 @@ function isDoubleImplementation(node) {
5650
5664
  * about the ORDER results come back in, which no predicate can state: a
5651
5665
  * `waitUntil` waits for something to become true, while what this test needs is
5652
5666
  * for something to happen late. The test is not the one waiting, so the rule is
5653
- * not about it.
5667
+ * not about it. A double written as a plain object literal is the same thing
5668
+ * said without a factory, and it counts too.
5654
5669
  */
5655
5670
  const j2NoSleep = {
5656
5671
  create(context) {
@@ -5658,6 +5673,13 @@ const j2NoSleep = {
5658
5673
  const doubles = [];
5659
5674
  const insideADouble = (node) => doubles.some((span) => nodeStart(node) >= span.start && nodeEnd(node) <= span.end);
5660
5675
  return {
5676
+ Property(node) {
5677
+ const implementation = implementationOf(node);
5678
+ if (implementation !== void 0) doubles.push({
5679
+ end: nodeEnd(implementation),
5680
+ start: nodeStart(implementation)
5681
+ });
5682
+ },
5661
5683
  CallExpression(node) {
5662
5684
  if (isDoubleImplementation(node)) {
5663
5685
  doubles.push({
@@ -1,8 +1,47 @@
1
- import { a as formatElement, i as describeAmbiguity, n as widenedForWindow, r as AmbiguousElementError } from "./substring-warning.js";
1
+ import { a as describeAmbiguity, i as AmbiguousElementError, o as formatElement, r as widenedForWindow, t as WINDOW_SETTLE_MS } from "./substring-warning.js";
2
2
  import { mkdtempSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { setTimeout } from "node:timers/promises";
6
+ //#region src/model/elements/aria-name.ts
7
+ /**
8
+ * The accessible name an ARIA snapshot states.
9
+ *
10
+ * A descriptor matches the name the BROWSER computes, and that name is not the
11
+ * element's text: `<button><span>Experiments</span><span>9</span></button>`
12
+ * reads as `Experiments9` through `textContent` and is named "Experiments 9",
13
+ * a field's name comes from its label and is nowhere in its text at all, and a
14
+ * name cut at eighty characters resolves to nothing. Reading the name off the
15
+ * snapshot is what makes the spelling the window prints a spelling that can be
16
+ * written back into a spec.
17
+ *
18
+ * The dialect is the one both surfaces already speak — playwright's, on the
19
+ * page directly and through `server.commands` for a mounted component — so the
20
+ * projection is stated once, here, and neither adapter computes a name itself.
21
+ */
22
+ /**
23
+ * The name of the FIRST node a snapshot describes: `- button "Experiments 9"`,
24
+ * optionally followed by `:` and its children.
25
+ *
26
+ * A quote must follow the role across a SPACE. `- paragraph: "…"` is a node
27
+ * with text and no name, and reading that text as a name would hand the author
28
+ * a spelling no role descriptor matches on.
29
+ */
30
+ const NAMED = /^-\s+[\w-]+\s+"(?<name>(?:[^"\\]|\\.)*)"/u;
31
+ /** Undo the escaping the snapshot applies to a quote or a backslash inside a name. */
32
+ function unescaped(name) {
33
+ return name.replaceAll(/\\(?<char>.)/gu, "$<char>");
34
+ }
35
+ /**
36
+ * The accessible name the snapshot gives the node it describes, or `undefined`
37
+ * when that node has none — an unnamed landmark, a paragraph of text.
38
+ */
39
+ function accessibleNameIn(snapshot) {
40
+ const [first] = snapshot.split("\n");
41
+ const name = (first === void 0 ? null : NAMED.exec(first.trim()))?.groups?.name;
42
+ return name === void 0 ? void 0 : unescaped(name);
43
+ }
44
+ //#endregion
6
45
  //#region src/seams/playwright/playwright.adapter.ts
7
46
  /** How many candidates the ambiguity error enumerates before truncating. */
8
47
  const MAX_REPORTED_MATCHES = 10;
@@ -107,12 +146,62 @@ async function act(page, element, action) {
107
146
  function probeOf(page) {
108
147
  return {
109
148
  count: async (element) => await locate(page, element).count(),
110
- nameOf: async (element) => {
111
- const [match] = await captureMatches(locate(page, element));
112
- return match?.accessibleName ?? match?.text;
113
- }
149
+ nameOf: async (element) => await wholeNameOf(page, element),
150
+ settles: async (element) => await settles(page, element)
114
151
  };
115
152
  }
153
+ /**
154
+ * Does the descriptor arrive if the page is given the settle budget?
155
+ *
156
+ * A poll rather than `waitFor`, for the reason the rest of this adapter has no
157
+ * `.first()`: waiting on the locator itself would have to narrow a strict
158
+ * locator to one element, and a count answers the same question without
159
+ * touching the mechanism W3 rests on. The locator is rebuilt every round, so a
160
+ * navigation that replaced the document is read on the new one.
161
+ */
162
+ async function settles(page, element) {
163
+ const deadline = Date.now() + WINDOW_SETTLE_MS;
164
+ for (;;) {
165
+ if (await countOrZero(page, element) > 0) return true;
166
+ if (Date.now() >= deadline) return false;
167
+ await setTimeout(VALUE_POLL_MS);
168
+ }
169
+ }
170
+ /** A count taken mid-navigation throws; the page is simply not answering yet. */
171
+ async function countOrZero(page, element) {
172
+ try {
173
+ return await locate(page, element).count();
174
+ } catch {
175
+ return 0;
176
+ }
177
+ }
178
+ /**
179
+ * The name to WRITE for this descriptor — the whole one, never truncated.
180
+ *
181
+ * A role descriptor matches the accessible name the browser computed, so the
182
+ * name is read off the ARIA snapshot, which is that same computation: the text
183
+ * content glues inline children with no separator (`Experiments9` for a button
184
+ * named "Experiments 9") and a field's name is its label, which its text never
185
+ * carries at all. A `text` descriptor matches text, so text is what it is told
186
+ * to write.
187
+ */
188
+ async function wholeNameOf(page, element) {
189
+ const locator = locate(page, element);
190
+ if (element.kind !== "text") try {
191
+ const named = accessibleNameIn(await locator.ariaSnapshot());
192
+ if (named !== void 0) return named;
193
+ } catch {}
194
+ return await wholeTextOf(locator);
195
+ }
196
+ /** The element's text, whitespace-collapsed and WHOLE — a name cut mid-word resolves to nothing. */
197
+ async function wholeTextOf(locator) {
198
+ try {
199
+ const collapsed = (await locator.evaluate((node) => node.textContent ?? "")).replaceAll(/\s+/gu, " ").trim();
200
+ return collapsed === "" ? void 0 : collapsed;
201
+ } catch {
202
+ return;
203
+ }
204
+ }
116
205
  /** Walk the scope chain outside-in; the outermost ambiguous level is the fault. */
117
206
  async function findAmbiguousLevel(page, element) {
118
207
  const chain = [];
@@ -164,6 +164,14 @@ function describeAmbiguity(options) {
164
164
  */
165
165
  /** How long the window lasts, stated in the message itself. */
166
166
  const WINDOW = "through 16.x";
167
+ /** The node side's printer — the default every adapter but the page's uses. */
168
+ function toStandardError(line) {
169
+ if (typeof process === "undefined") {
170
+ console.warn(line);
171
+ return;
172
+ }
173
+ process.stderr.write(`${line}\n`);
174
+ }
167
175
  /** Descriptors already reported in this process. */
168
176
  const reported = /* @__PURE__ */ new Set();
169
177
  /** The identity a descriptor is deduplicated by — kind, name, and its scope chain. */
@@ -176,15 +184,26 @@ function identityOf(element) {
176
184
  * designated something as a substring. Returns whether a line was printed,
177
185
  * which is what the adapters' own tests assert on.
178
186
  */
179
- function warnSubstringOnly(element, where, found) {
187
+ async function warnSubstringOnly(element, where, found, print = toStandardError) {
180
188
  const identity = identityOf(element);
181
189
  if (reported.has(identity)) return false;
182
190
  reported.add(identity);
183
191
  const spelling = found === void 0 ? "" : ` The name to write is '${found.replaceAll(/\s+/gu, " ").trim()}'.`;
184
- console.warn(`@jterrazz/test: ${element.kind}('${element.name ?? ""}') matched nothing as a whole accessible name, and matched only as a SUBSTRING of a longer one (${where}).${spelling} Names are exact from 16.0: write the name in full, scope it with \`within(…)\`, or state \`{ exact: false }\` to keep the old behaviour. The substring is still resolved, with this warning, ${WINDOW}; gone in 17.0, where the same descriptor is simply not found.`);
192
+ await print(`@jterrazz/test: ${element.kind}('${element.name ?? ""}') matched nothing as a whole accessible name, and matched only as a SUBSTRING of a longer one (${where}).${spelling} Names are exact from 16.0: write the name in full, scope it with \`within(…)\`, or state \`{ exact: false }\` to keep the old behaviour. The substring is still resolved, with this warning, ${WINDOW}; gone in 17.0, where the same descriptor is simply not found.`);
185
193
  return true;
186
194
  }
187
195
  /**
196
+ * How long a descriptor is given to ARRIVE before the window concludes it is
197
+ * absent — the budget `settles` spends and no more.
198
+ *
199
+ * A count is one observation, and a verb called right after a navigating
200
+ * `click()` takes it on the page being LEFT. The window may not decide
201
+ * "nothing matches this whole name" on that reading: the destination has not
202
+ * been parsed yet. Long enough for a navigation to commit, far short of the
203
+ * verb's own 30 s, and paid only when there is a substring to widen to.
204
+ */
205
+ const WINDOW_SETTLE_MS = 5e3;
206
+ /**
188
207
  * The descriptor to retry with, LEVEL by level, or `undefined` when nothing
189
208
  * would change.
190
209
  *
@@ -201,8 +220,8 @@ function warnSubstringOnly(element, where, found) {
201
220
  *
202
221
  * Asked BEFORE the action, not after it fails: a verb waits its whole
203
222
  * actionability budget, which is the test's own budget, so a retry afterwards
204
- * never runs — the test is already over. The probes are counts, which wait for
205
- * nothing, and the common case (the name matches whole) costs one per level.
223
+ * never runs — the test is already over. The common case (the name matches
224
+ * whole) costs one count per level, which waits for nothing.
206
225
  */
207
226
  async function widenedForWindow(element, where, probe) {
208
227
  const chain = [];
@@ -222,8 +241,16 @@ async function widenedForWindow(element, where, probe) {
222
241
  }
223
242
  /**
224
243
  * The one level, widened and warned about, or `undefined` when it designates
225
- * something already, states its own `exact`, carries no name, or would
226
- * designate nothing either way.
244
+ * something already, states its own `exact`, carries no name, would designate
245
+ * nothing either way, or turns out to be exact on the surface that ARRIVES.
246
+ *
247
+ * The last is the navigation case, and it is the reason the verdict is not two
248
+ * counts: a `see()` fired right after a navigating `click()` counts the page
249
+ * being left, so a descriptor that is perfectly exact on the DESTINATION read
250
+ * as substring-only — non-deterministically, since which page each count
251
+ * landed on was a race. A widening is therefore only decided once the exact
252
+ * descriptor has been given the settle budget and still designates nothing.
253
+ * The tell of the old fault was the printed name equalling the name written.
227
254
  */
228
255
  async function widenedLevel(candidate, where, probe) {
229
256
  if (candidate.exact !== void 0 || candidate.name === void 0) return;
@@ -234,11 +261,22 @@ async function widenedLevel(candidate, where, probe) {
234
261
  exact: false
235
262
  };
236
263
  if (await probe.count(loose) !== 1) return;
237
- warnSubstringOnly(candidate, where, await probe.nameOf(loose));
264
+ if (!alreadyJudged(candidate) && await probe.settles(candidate)) return;
265
+ await warnSubstringOnly(candidate, where, await probe.nameOf(loose), probe.print);
238
266
  return loose;
239
267
  } catch {
240
268
  return;
241
269
  }
242
270
  }
271
+ /**
272
+ * Has this descriptor already been judged substring-only in this run?
273
+ *
274
+ * The verdict is made once: a scenario that clicks the same link in ten tests
275
+ * pays the settle budget on the first of them and is widened straight away on
276
+ * the nine after it.
277
+ */
278
+ function alreadyJudged(element) {
279
+ return reported.has(identityOf(element));
280
+ }
243
281
  //#endregion
244
- export { formatElement as a, describeAmbiguity as i, widenedForWindow as n, AmbiguousElementError as r, warnSubstringOnly as t };
282
+ export { describeAmbiguity as a, AmbiguousElementError as i, warnSubstringOnly as n, formatElement as o, widenedForWindow as r, WINDOW_SETTLE_MS as t };
package/dist/vitest.js CHANGED
@@ -350,6 +350,23 @@ function goldenPath(testPath, name) {
350
350
  const ariaTree = async (context) => {
351
351
  return await (await context.frame()).locator("body").ariaSnapshot();
352
352
  };
353
+ /**
354
+ * The ARIA snapshot of ONE node, named by the selector the page's locator
355
+ * carries — the accessible name, as the browser computed it, for a page that
356
+ * cannot compute one itself.
357
+ */
358
+ const ariaNode = async (context, selector) => {
359
+ return await (await context.frame()).locator(selector).ariaSnapshot();
360
+ };
361
+ /**
362
+ * A line the page has for whoever is running the suite, written on the node
363
+ * side's stderr — the one channel vitest's default reporter passes through.
364
+ * The console is captured and dropped, so a page that printed to it printed to
365
+ * nobody.
366
+ */
367
+ const notify = (_context, line) => {
368
+ process.stderr.write(`${line}\n`);
369
+ };
353
370
  /** The golden's current content, or `null` when it does not exist yet. */
354
371
  const goldenRead = (context, name) => {
355
372
  const path = goldenPath(context.testPath, name);
@@ -367,9 +384,11 @@ const goldenWrite = (context, name, content) => {
367
384
  * page calls and the names the project registers cannot drift.
368
385
  */
369
386
  const COMPONENT_COMMANDS = {
387
+ ariaNode,
370
388
  ariaTree,
371
389
  goldenRead,
372
- goldenWrite
390
+ goldenWrite,
391
+ notify
373
392
  };
374
393
  //#endregion
375
394
  //#region src/facets/component/component.project.ts
@@ -108,9 +108,9 @@ The package proves itself in five layers, and they are meant to be read from the
108
108
 
109
109
  | Layer | Where | What it judges | Files |
110
110
  | ----------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ | ----- |
111
- | Module tests | `src/**/*.test.ts` beside the module | one module, through its own exports, with nothing started | 52 |
111
+ | Module tests | `src/**/*.test.ts` beside the module | one module, through its own exports, with nothing started | 54 |
112
112
  | Rule tests | `src/lint/rules/<facet>/<rule>.test.ts` beside the rule | one rule: what it flags, what it leaves alone, and the message it prints | 56 |
113
- | The package’s own specs | `specs/<facet>/` | the framework's own facets, each met through its constructor | 65 |
113
+ | The package’s own specs | `specs/<facet>/` | the framework's own facets, each met through its constructor | 67 |
114
114
  | The lint suite | `specs/lint/**` | the built binary end to end, over fixture projects | 88 |
115
115
  | Meta-tests | `src/lint/*.test.ts` | the corpus itself: the catalogue, the matrix, the cards, the floor | 18 |
116
116
 
@@ -185,7 +185,7 @@ suite on the day it lands.
185
185
 
186
186
  <!-- GENERATED:siblings — do not edit by hand; run `npm run docs`. Source: src/lint/siblings.ts -->
187
187
 
188
- Today **81 modules** have no sibling test, and every one of them is claimed:
188
+ Today **80 modules** have no sibling test, and every one of them is claimed:
189
189
 
190
190
  | Kind | Proven instead by |
191
191
  | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -197,7 +197,7 @@ Today **81 modules** have no sibling test, and every one of them is claimed:
197
197
  | a mobile module that needs a device (2) | the mobile facet has no tree under `specs/` and cannot have one here (M1); what a device is NOT needed for — resolving the simulator, projecting the page source, wording the ambiguity — has its own sibling test, and these two are the rest |
198
198
  | a facet module (8) | the facet is proven end to end by its own tree under `specs/<facet>/`, which is what the constructor exists to make possible |
199
199
  | a result accessor (7) | an accessor is what a terminal action hands back: it is exercised by every spec that asserts on a result, and `result.test.ts` holds the base |
200
- | a seam adapter (13) | a seam is proven through the facet that drives it — a probe that could only reach it directly is an integration spec under `specs/integration/<seam>/` (chapter 03) |
200
+ | a seam adapter (12) | a seam is proven through the facet that drives it — a probe that could only reach it directly is an integration spec under `specs/integration/<seam>/` (chapter 03) |
201
201
  | a bundled CLI entry (3) | it is argument parsing over a module that has its own tests, and `specs/lint/` runs the built binary end to end |
202
202
  | a lint-layer module (4) | the lint layer is proven by the rule tests beside each rule and by `specs/lint/`, which runs the real oxlint binary and the real checker over fixture projects |
203
203
  | a chain or runner internal (7) | it is reached through the chain, so its proof is the facet specs that drive the chain — a direct test would assert on a seam nothing else speaks to |
@@ -275,18 +275,18 @@ What the framework can do, and how many of this package’s own test FILES carry
275
275
  | `.call()` | — | — | — | 6 | — | — | — | — |
276
276
  | `.exec()` | — | — | 7 | — | — | — | — | — |
277
277
  | `.run()` | — | — | 2 | — | — | — | — | — |
278
- | `.visit()` | — | — | — | — | 10 | — | — | — |
278
+ | `.visit()` | — | — | — | — | 11 | — | — | — |
279
279
  | `.fetch()` | — | — | — | — | 2 | — | — | — |
280
280
  | `.open()` | — | — | — | — | — | 0 | — | — |
281
- | `.render()` | — | — | — | — | — | — | 14 | — |
281
+ | `.render()` | — | — | — | — | — | — | 15 | — |
282
282
 
283
283
  ### Verb
284
284
 
285
285
  | Capability | api | jobs | cli | integration | website | mobile | component | module |
286
286
  | ---------- | --- | ---- | --- | ----------- | ------- | ------ | --------- | ------ |
287
- | `see` | — | — | — | — | 5 | 0 | 14 | — |
287
+ | `see` | — | — | — | — | 6 | 0 | 15 | — |
288
288
  | `fill` | — | — | — | — | 3 | 0 | 2 | — |
289
- | `click` | — | — | — | — | 3 | — | 4 | — |
289
+ | `click` | — | — | — | — | 4 | — | 5 | — |
290
290
  | `gone` | — | — | — | — | 2 | — | 4 | — |
291
291
  | `press` | — | — | — | — | 0 | — | 2 | — |
292
292
  | `hover` | — | — | — | — | 0 | — | 1 | — |
@@ -301,14 +301,14 @@ What the framework can do, and how many of this package’s own test FILES carry
301
301
 
302
302
  | Capability | api | jobs | cli | integration | website | mobile | component | module |
303
303
  | --------------- | --- | ---- | --- | ----------- | ------- | ------ | --------- | ------ |
304
- | `button` | — | — | — | — | 3 | 0 | 4 | — |
305
- | `field` | — | — | — | — | 3 | 0 | 3 | — |
304
+ | `button` | — | — | — | — | 4 | 0 | 5 | — |
305
+ | `field` | — | — | — | — | 4 | 0 | 3 | — |
306
306
  | `content` | — | — | — | — | 3 | 0 | 10 | — |
307
307
  | `testId` | — | — | — | — | 0 | 0 | 1 | — |
308
- | `heading` | — | — | — | — | 0 | — | 1 | — |
309
- | `link` | — | — | — | — | 2 | — | 2 | — |
308
+ | `heading` | — | — | — | — | 1 | — | 1 | — |
309
+ | `link` | — | — | — | — | 3 | — | 2 | — |
310
310
  | `dialog` | — | — | — | — | 0 | — | 1 | — |
311
- | `status` | — | — | — | — | 0 | — | 2 | — |
311
+ | `status` | — | — | — | — | 0 | — | 3 | — |
312
312
  | `table` | — | — | — | — | 0 | — | 1 | — |
313
313
  | `row` | — | — | — | — | 0 | — | 1 | — |
314
314
  | `listitem` | — | — | — | — | 0 | — | 1 | — |
@@ -345,8 +345,8 @@ What the framework can do, and how many of this package’s own test FILES carry
345
345
  | `.containerIds` | — | — | 1 | — | — | — | — | — |
346
346
  | `.value` | — | — | — | 6 | — | — | — | — |
347
347
  | `.error` | — | — | — | 5 | — | — | — | — |
348
- | `.tree` | — | — | — | — | 2 | — | 3 | — |
349
- | `.content` | — | — | — | — | 4 | 0 | 11 | — |
348
+ | `.tree` | — | — | — | — | 2 | — | 4 | — |
349
+ | `.content` | — | — | — | — | 4 | 0 | 12 | — |
350
350
  | `.console` | — | — | — | — | 1 | — | 3 | — |
351
351
  | `.errors` | — | — | — | — | 4 | — | 5 | — |
352
352
  | `.html` | — | — | — | — | 0 | — | 5 | — |
@@ -414,7 +414,7 @@ What the framework can do, and how many of this package’s own test FILES carry
414
414
  ### Exemptions
415
415
 
416
416
  - no mobile tree: an iOS simulator is not something CI provisions (M1) — `services`·mobile, `root`·mobile, `backend`·mobile, `device`·mobile, `app`·mobile, `timeouts`·mobile, `.intercept()`·mobile, `.open()`·mobile, `see`·mobile, `fill`·mobile, `tap`·mobile, `button`·mobile, `field`·mobile, `content`·mobile, `testId`·mobile, `.content`·mobile, `.screen`·mobile
417
- - the surface ships and this package does not specify it here yet — `root`·cli, `root`·website, `root`·component, `backend`·website, `external`·website, `clock`·component, `locale`·component, `timezone`·component, `viewport`·component, `.clock()`·jobs, `.intercept()`·website, `press`·website, `hover`·website, `check`·website, `goto`·website, `testId`·website, `heading`·website, `dialog`·website, `status`·website, `table`·website, `row`·website, `listitem`·website, `banner`·website, `banner`·component, `complementary`·website, `complementary`·component, `contentinfo`·component, `form`·website, `form`·component, `search`·website, `search`·component, `.file()`·api, `.file()`·integration, `.directory()`·api, `.directory()`·integration, `.html`·website, `.title`·website, `.links`·website, `toMatchRows()`·component, `.json body`·api
417
+ - the surface ships and this package does not specify it here yet — `root`·cli, `root`·website, `root`·component, `backend`·website, `external`·website, `clock`·component, `locale`·component, `timezone`·component, `viewport`·component, `.clock()`·jobs, `.intercept()`·website, `press`·website, `hover`·website, `check`·website, `goto`·website, `testId`·website, `dialog`·website, `status`·website, `table`·website, `row`·website, `listitem`·website, `banner`·website, `banner`·component, `complementary`·website, `complementary`·component, `contentinfo`·component, `form`·website, `form`·component, `search`·website, `search`·component, `.file()`·api, `.file()`·integration, `.directory()`·api, `.directory()`·integration, `.html`·website, `.title`·website, `.links`·website, `toMatchRows()`·component, `.json body`·api
418
418
  - the package serves its own fixture site, so `server` is what it proves; `url` targets an already-running deployment, which is a consumer shape (A11 states the XOR) — `url`·website
419
419
  - a job's oracle is the table it wrote (`toMatchRows`): its result carries no file and no directory — `toMatch('<name>')`·jobs
420
420
  - the engine owns the family (proven in the module column); a facet uses the ones its goldens need — `{{any}}`·api, `{{any}}`·integration, `{{any}}`·website, `{{base64}}`·api, `{{base64}}`·cli, `{{base64}}`·integration, `{{base64}}`·website, `{{date}}`·api, `{{date}}`·cli, `{{date}}`·integration, `{{date}}`·website, `{{duration}}`·api, `{{duration}}`·integration, `{{duration}}`·website, `{{email}}`·api, `{{email}}`·cli, `{{email}}`·integration, `{{email}}`·website, `{{float}}`·api, `{{float}}`·cli, `{{float}}`·integration, `{{float}}`·website, `{{hex}}`·api, `{{hex}}`·cli, `{{hex}}`·integration, `{{hex}}`·website, `{{int}}`·api, `{{int}}`·cli, `{{int}}`·integration, `{{int}}`·website, `{{ip}}`·api, `{{ip}}`·cli, `{{ip}}`·integration, `{{ip}}`·website, `{{iso8601}}`·api, `{{iso8601}}`·cli, `{{iso8601}}`·integration, `{{iso8601}}`·website, `{{number}}`·api, `{{number}}`·cli, `{{number}}`·integration, `{{number}}`·website, `{{path}}`·api, `{{path}}`·cli, `{{path}}`·integration, `{{path}}`·website, `{{port}}`·api, `{{port}}`·cli, `{{port}}`·integration, `{{port}}`·website, `{{semver}}`·api, `{{semver}}`·integration, `{{semver}}`·website, `{{sha}}`·api, `{{sha}}`·cli, `{{sha}}`·integration, `{{sha}}`·website, `{{string}}`·api, `{{string}}`·integration, `{{string}}`·website, `{{time}}`·api, `{{time}}`·cli, `{{time}}`·integration, `{{time}}`·website, `{{ulid}}`·api, `{{ulid}}`·cli, `{{ulid}}`·integration, `{{ulid}}`·website, `{{url}}`·api, `{{url}}`·integration, `{{url}}`·website, `{{uuid}}`·api, `{{uuid}}`·cli, `{{uuid}}`·integration, `{{uuid}}`·website, `{{workdir}}`·api, `{{workdir}}`·integration, `{{workdir}}`·website
@@ -436,7 +436,7 @@ The table is GENERATED from the files, because the channel grows faster than a h
436
436
  | `src/lint/ast.test.ts` | specsAnchor | 7 |
437
437
  | `src/lint/cards.test.ts` | signature cards — generation freshness (meta-test) · signature cards — completeness against the facet declaration (meta-test) · the skill routes to the cards (meta-test) · signature cards — what a card says | 15 |
438
438
  | `src/lint/checker-crossfile.test.ts` | checker suppression — suppressedLines (checker-disable comments) | 9 |
439
- | `src/lint/checker-member.test.ts` | the member pass — what a workspace member owes | 17 |
439
+ | `src/lint/checker-member.test.ts` | the member pass — what a workspace member owes | 18 |
440
440
  | `src/lint/checker-spec.test.ts` | fixSpecDocument — key order and block scalars | 5 |
441
441
  | `src/lint/checker.test.ts` | conventions checker — findUnknownTokens (D4) · conventions checker — findKnownTokens (D10) · conventions checker — checkConventionFiles (D4) | 8 |
442
442
  | `src/lint/coverage.test.ts` | the coverage ratchet | 8 |
@@ -214,8 +214,14 @@ The window reaches every LEVEL of a chain, not the target alone: `within(button(
214
214
 
215
215
  The window is asked BEFORE the verb acts, not after it fails: a verb waits its whole actionability budget, which is the test's own budget, so a retry afterwards never runs — the test is already over. What it costs on the ordinary path is one count per level of the chain, which waits for nothing.
216
216
 
217
+ **A widening is decided on a SETTLED surface.** A count is one observation, and a `see()` fired right after a navigating `click()` takes it on the page being left: a descriptor that is perfectly exact on the destination read as substring-only, non-deterministically, and the tell was the printed name coming back equal to the name already written. So a level that designates nothing and has exactly one substring match is given five seconds to arrive before anything is printed, and the verdict is reached once per descriptor per run — a scenario that clicks the same link in ten tests waits on the first of them. Nothing else waits: a descriptor with no substring match at all, which is every ordinary `gone()`, is still two counts and no pause.
218
+
217
219
  It widens on exactly one match, never on several: a name two elements carry in part is the ambiguity the old default hid, and the window may not resolve it by guessing which one the author meant. That descriptor designates nothing, as W3 says, and the refusal is the one to read.
218
220
 
221
+ **The name it prints is the ACCESSIBLE name, and the whole of it.** It is read off the element's ARIA snapshot — the browser's own name computation, which is what the descriptor matched on — and never off the text content: a button holding two inline children reads as `Experiments9` through its text and is named "Experiments 9", and a field's name is its label, which its text carries nothing of. Nor is it truncated: a name cut at eighty characters, mid-word, designates nothing when it is written back. The one descriptor whose printed name is TEXT is `content()`, because text is what it matches. On a device the name is what the device reports as the element's label.
222
+
223
+ **The line goes to stderr**, and never to the console. Vitest CAPTURES the console and its default reporter prints nothing of what it captured, so a warning written with `console.warn` is invisible in the very run that fired it — `npm test` showed zero lines on a run that had one, and a deadline nobody can see is a deadline nobody meets. The node-side adapters write the line to the runner's own stderr; a mounted component, which runs IN the page and has no stderr, hands it back to the node side through `server.commands`. Nothing but a plain `vitest --run` is needed to read it.
224
+
219
225
  ## `testId()` — the one escape hatch
220
226
 
221
227
  `testId(id)` reads `data-testid` on the web and the accessibility identifier on a device. It is the ONLY descriptor that names something a user cannot perceive, and rule W2 is an error on it: a `testId()` carries a same-line comment naming what the element lacks.
@@ -46,6 +46,8 @@ covers (`<file>.test.ts`, I2), and the ASSEMBLED product sits under
46
46
  C12 and C18). A few specifications have neither address, and they are not
47
47
  mistakes.
48
48
 
49
+ ### What has no address of its own
50
+
49
51
  What they specify is a REPOSITORY's own surface: the subpaths its
50
52
  `package.json` publishes, a `.d.ts` beside every `.js` it ships, the lint guards
51
53
  a tree owes, the files a build is expected to write. There is no one module to
@@ -55,14 +57,39 @@ runner"); put beside a module under `src/`, I2 refuses them ("no neighbour
55
57
  `exports.ts` found"). Both refusals are right, and a legitimate file shape with
56
58
  no green position is a hole in the conventions, not a defect in the file.
57
59
 
60
+ ### The door, and the depth it is judged by
61
+
58
62
  **The third door is a FIRST-LEVEL folder under `specs/` that is not a facet
59
63
  name.** It is a repository suite: it covers a TREE rather than a product served
60
64
  through an entry. C12's rename clause does not reach it, C18 and C20 do not
61
- reach it, and C1's declared depth is what judges its shape. This package's own
62
- `specs/lint/` is one; a package whose product IS its published tree writes the
63
- same shape — a suite folder named for what it covers, holding one test that
64
- resolves every subpath the manifest publishes and one that holds each `.d.ts`
65
- to the `.js` beside it.
65
+ reach it, and C1's declared depth is what judges its shape.
66
+
67
+ **C1 judges a suite exactly as it judges a facet.** The suite folder is the
68
+ first level and the tests sit one level down, in a folder named for what they
69
+ cover: at the default depth, `specs/<suite>/<domain>/<aspect>.test.ts`. A test
70
+ written straight into the suite folder is refused for the same reason one at a
71
+ facet root is — the level that says what a file covers would be missing. This
72
+ package's own `specs/lint/` is two levels deep for that reason.
73
+
74
+ ### The two names the estate shares
75
+
76
+ Two suites carry names the whole estate shares, because two repositories
77
+ inventing two names for one idea is the drift a vocabulary exists to stop: a
78
+ package's own published SURFACE is `surface/`, and a repository's own lint
79
+ guards are `lint/`. Every other suite is named for what it covers.
80
+
81
+ ```
82
+ specs/
83
+ ├── surface/ # what the package publishes
84
+ │ └── package/
85
+ │ ├── exports.test.ts # every subpath the manifest resolves
86
+ │ └── types.test.ts # a `.d.ts` beside each `.js` it ships
87
+ └── lint/ # the guards the repository's own tree owes
88
+ └── <family>/
89
+ └── <rule>.test.ts
90
+ ```
91
+
92
+ ### The suffix, and the one thing the door does not license
66
93
 
67
94
  The files there carry `.test.ts` — the UNIT's word — because what they prove is
68
95
  read without assembling anything, and `unit()` is what collects them (`roots`,
@@ -141,26 +141,26 @@ Every rule the framework enforces, across its enforcement channels — **statiqu
141
141
 
142
142
  ### C — Files & folders
143
143
 
144
- | Code | Implementation | Channel | Reach | Convention | Fix | Rationale |
145
- | ---- | ----------------------------------------------------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
146
- | C1 | <a id="c1-domain-structure"></a>`c1-domain-structure` | statique | specs | A specs tree DECLARES its shape through the `depth` option: `facet-domain` (the default — a test at facet/domain depth, a `*.specification.ts` at the facet root), `facet` (a test at the facet root OR one domain folder down, never deeper), `mirror` (a test at any depth ≥ 1, named after its directory), `off`. In every mode, `off` included: a directory with a leading underscore is GROUND, never a domain — no spec lives inside one. One exception, and only one: ground may be CODE, and a `<module>.test.ts` sitting BESIDE the `<module>.ts` it tests is legal there (that is I2). A test with no neighbouring module, or a `*.specification.ts`, is still a violation. A `.test.tsx` is out of reach: a rendered unit is specified beside its component, never in a facet/domain tree — I2 holds its neighbourhood. | Move the file to the depth the tree declares, or declare the depth the tree actually has. | A fixed depth makes every file's place predictable and statically checkable. |
147
- | C4 | <a id="c4-contract-shape"></a>`c4-contract-shape` | statique | contract | Under `specs/**/contracts/`: the ROOT carries only `*.contracts.ts` facades (a default export built by `defineContracts(...)` or a composition re-export; named exports are scenarios) and the provider folders `http` \| `openai` \| `anthropic`. A unit contract is `<provider>/<kebab-name>.ts` with an `export default defineContract(...)` (or a factory returning one), and its `request` builder must name the provider of its own folder. A provider folder is flat and holds only `*.ts` and `*.response.json`. | Move the file to the half it belongs to: a facade at the root, a unit under its provider. | A fixed tree separates the public facade from the internal units and makes the FOLDER carry the provider — filenames go back to being domain words, and bulky data sits beside the contract that serves it. |
148
- | C6 | <a id="c6-tomatch-extension"></a>`c6-tomatch-extension` | statique | tests | `toMatch`'s argument carries its extension (`'help.txt'`, `'<case>.aria.yaml'` for a tree golden), except for a directory snapshot; a file subject with no extension is an error. | Write the fixture's full name, extension included. | The extension is part of the expected file's name — dropping it breaks `_expected/` resolution. |
149
- | C7 | <a id="c7-seeds-sql-only"></a>`c7-seeds-sql-only` | statique | ground | `_seeds/` holds `*.sql` files only; any other file is an error. | Write the seed as SQL, or move the file to `_fixtures/` as the material it is. | `.seed()` carries database state only — no seed handlers, no prefix dispatch. |
150
- | C8 | <a id="c8-referenced-fixture-exists"></a>`c8-referenced-fixture-exists` | statique | tests | A literal passed to `.request`/`.seed`/`.fixture`/`toMatch` must exist on disk under its conventional root; a missing path is an error. | Create the file, or fix the name — the message prints the path it looked for. | Catching a typo statically avoids a failure that would only surface at run time. |
151
- | C10 | <a id="c10-contracts-boundary"></a>`c10-contracts-boundary` | statique | tests | Outside `specs/**/contracts/`, an import resolving into a provider folder (`contracts/{http,openai,anthropic}/…`) is an error: a test imports only the `*.contracts.ts` facades. | Add a named scenario export to the facade and import that instead. | Unit contracts are composition details; going through the facade keeps each scenario named in the same place as the world it derives from. |
152
- | C11 | <a id="c11-contract-data-pairing"></a>`c11-contract-data-pairing` | statique | contract | Inside a provider folder, every `*.response.json` and every `*.request.ts` has a `<stem>.ts` sibling — the stem is the name up to the FIRST dot (`events.fr.response.json` → `events.ts`). Orphan data is an error. | Write the contract that serves the payload, or delete the payload. | Data exists only as served by a contract — a file with no owner is dead weight no test ever loads. |
153
- | C13 | <a id="c13-underscored-ground"></a>`c13-underscored-ground` | statique | specs | Under `specs/`, a spec's ground carries a leading underscore: `_fixtures/`, `_expected/`, `_requests/`, `_seeds/`. A `fixtures/`, `expected/`, `requests/` or `seeds/` directory is a pre-14 name — an error naming the rename. | Rename the directory with the leading underscore the resolvers read. | No resolver reads the underscore-less name any more: left as is, the tree goes invisible rather than wrong, and the failure lands one step past its cause. |
154
- | C9 | <a id="c9-dead-fixtures"></a>`c9-dead-fixtures` | checker | ground | No dead fixtures: every file under `_seeds/`/`_requests/`/`_fixtures/` and every top-level entry of `_expected/` must be referenced; a feature directory with no test file and no `*.spec.yaml` is an orphan (a warning when the argument is not a literal). A `<case>.spec.yaml` references the fixtures its `fixture:` entries name. | Reference it from the spec it stands under, or delete it. | The C8 mirror — a fixture nothing references is dead weight that misleads the reader. |
155
- | C12 | <a id="c12-spec-file-name"></a>`c12-spec-file-name` | checker | specs | The suffix says the kind, and the tree agrees with it. A `.spec.ts` — the ASSEMBLED product — lives under `specs/<facet>/`; anywhere else is an error. A `.test.ts` — the UNIT — lives beside its module; under a facet folder (`api`, `cli`, `integration`, `jobs`, `mobile`, `website`) it is an error, fixable by `--fix` (a `git mv`). A `.test.ts` that reaches no runner is C18’s, whose fix is the opposite move, and the mover leaves it alone. A first level that is NOT a facet is a repository suite: it covers a tree, keeps `.test.ts`, and C1’s declared depth is what judges it — unless the tree carries its `*.specification.ts` at its own ROOT, which makes the whole tree one facet’s and every `.test.ts` below it a spec. A document is named `<case>.spec.yaml`: `<case>` in kebab-case, without the words `test`/`spec`/`cli` the suffix already carries, and never the bare name of its directory. | Move the `.spec.ts` under `specs/<facet>/` (or rename it `.test.ts` beside its module); rename the facet `.test.ts` to `.spec.ts` — `jterrazz-test-check <root> --fix` does it with `git mv`, and names every `include`/`exclude` entry of the member the rename broke. | With no agreement between the suffix and the tree, a reader cannot say what a test proves without opening it; and the filename is the first sentence they read — `rm/rm.spec.yaml` says nothing twice. |
156
- | C14 | <a id="c14-pool-fixture-shared"></a>`c14-pool-fixture-shared` | checker | ground | A pool fixture is SHARED, or it is local: a directory of `<specs>/_fixtures/` referenced (`$FIXTURES/<name>`) from a single spec directory — two documents of one leaf count as one — must live beside that leaf, at `<leaf>/_fixtures/<name>/`, referenced by the relative form. Zero references is the dead-fixture error (C9). Fixed by `--fix`: the directory is moved and the literals rewritten. | Run `jterrazz-test-check <root> --fix`; it moves the directory and rewrites what named it. | A single scenario's fixture parked in the pool reads as shared ground: everyone assumes someone else depends on it and nobody dares touch it. |
157
- | C15 | <a id="c15-local-fixture-reach"></a>`c15-local-fixture-reach` | checker | ground | A local fixture is read only from its own directory: a `fixture:`/`.fixture()` path containing `..`, or whose target leaves the `_fixtures/` of the spec naming it, is an error — ground shared by several leaves goes in the `$FIXTURES` pool. | Copy it under this leaf's own `_fixtures/`, or promote it to the pool. | A leaf reaching into a neighbour's fixtures is the pool through another door, without the pool's visibility: the neighbour breaks a spec two folders away without knowing. |
158
- | C18 | <a id="c18-module-test-under-facet"></a>`c18-module-test-under-facet` | checker | specs | A test under `specs/<facet>/`, where the facet root holds a `*.specification.ts(x)`, imports that module or constructs a runner itself. Ground is out of reach — nothing under a leading-underscore folder is a spec, a `.test.ts` under `_expected/` being the GOLDEN a fixer writes. So is any first-level folder that is not a facet: a specification with no module to sit beside and no runner to reach lives in a repository suite there. | Import `{ <facet> }` from `<facet>.specification.js`, move a module test beside its module, or move a repository suite to a first-level folder of its own under `specs/`. | A test reaching no runner under a facet is a module test paying the facet's budget and services to prove something that belongs beside its module. |
159
- | C20 | <a id="c20-facet-folder"></a>`c20-facet-folder` | checker | specs | A first-level folder named for one of the six facets holds a `*.specification.ts(x)` calling `specification.<facet>(`. Positive-only: any other first-level name is a repository suite, judged by C1's declared depth alone. | Create the specification the folder promises, or rename the folder. | Folder = constructor is the law the fork rests on; a facet folder with no runner tells the reader something false before they open a file. |
160
- | C21 | <a id="c21w-ground-owned-by-one"></a>`c21w-ground-owned-by-one` | checker | ground | Ground in a leaf holding ≥ 2 node tests (`.test.ts`, `.spec.ts`) that a single one of them reads is a warning. A `.test.tsx` tree is out of reach, as it is for C1. | Give that spec its own domain folder, with the ground beside it. | Ground one spec reads, parked where several sit, reads as shared: everyone assumes someone else depends on it and nobody dares touch it. |
161
- | C23 | <a id="c23-seed-no-clock-read"></a>`c23-seed-no-clock-read` | checker | ground | A `_seeds/**/*.sql` states absolute timestamps: `CURRENT_TIMESTAMP`/`CURRENT_DATE`/`CURRENT_TIME`, `NOW()` and the quoted `'now'` SQLite's date functions take (`datetime('now')`, `strftime(…, 'now')`, `date('now', '-1 day')`) are refused. A comment is not state, so a clock read inside one is not a finding. | Write the instant the case is about, and pin the run's clock with `.clock()` on the chain. | A seed that reads the clock makes the row's date the date the suite happened to run: a case about "published yesterday" proves something different every day, and under SQLite `datetime('now')` writes a space-separated text where an ORM writes ISO `T`, so the two misorder across a calendar day. The R8 dry run counts 36, every one of them signews-api's. |
162
- | C5 | <a id="c5-slash-is-a-subfolder"></a>`c5-slash-is-a-subfolder` | runtime | ground | A slash in a fixture name is a SUBFOLDER of `_expected/`: `toMatch('build/verbose.txt')` resolves to `_expected/build/verbose.txt`. | Name the subfolder in the fixture name; the resolver creates it under `_expected/`. | A flat `_expected/` stops being readable at a dozen files, and the alternative — a second option naming a directory — would be a second way to say where a golden lives. |
163
- | C1 | <a id="c1-asset-grouping"></a>`c1-asset-grouping` | process | specs | The folder follows the assets: a test with asset directories of its own gets its own domain; tests with no local assets group as sibling `<aspect>` files. The static rule only checks the depth. | Read what the assets say: a test that stands on its own ground earns a folder. | It is the assets that settle the grouping — a criterion no channel can decide alone. |
144
+ | Code | Implementation | Channel | Reach | Convention | Fix | Rationale |
145
+ | ---- | ----------------------------------------------------------------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
146
+ | C1 | <a id="c1-domain-structure"></a>`c1-domain-structure` | statique | specs | A specs tree DECLARES its shape through the `depth` option: `facet-domain` (the default — a test at facet/domain depth, a `*.specification.ts` at the facet root), `facet` (a test at the facet root OR one domain folder down, never deeper), `mirror` (a test at any depth ≥ 1, named after its directory), `off`. A first-level folder that is no facet — a repository suite, `surface/` or `lint/` — is judged by the same depth as a facet is. In every mode, `off` included: a directory with a leading underscore is GROUND, never a domain — no spec lives inside one. One exception, and only one: ground may be CODE, and a `<module>.test.ts` sitting BESIDE the `<module>.ts` it tests is legal there (that is I2). A test with no neighbouring module, or a `*.specification.ts`, is still a violation. A `.test.tsx` is out of reach: a rendered unit is specified beside its component, never in a facet/domain tree — I2 holds its neighbourhood. | Move the file to the depth the tree declares, or declare the depth the tree actually has. | A fixed depth makes every file's place predictable and statically checkable. |
147
+ | C4 | <a id="c4-contract-shape"></a>`c4-contract-shape` | statique | contract | Under `specs/**/contracts/`: the ROOT carries only `*.contracts.ts` facades (a default export built by `defineContracts(...)` or a composition re-export; named exports are scenarios) and the provider folders `http` \| `openai` \| `anthropic`. A unit contract is `<provider>/<kebab-name>.ts` with an `export default defineContract(...)` (or a factory returning one), and its `request` builder must name the provider of its own folder. A provider folder is flat and holds only `*.ts` and `*.response.json`. | Move the file to the half it belongs to: a facade at the root, a unit under its provider. | A fixed tree separates the public facade from the internal units and makes the FOLDER carry the provider — filenames go back to being domain words, and bulky data sits beside the contract that serves it. |
148
+ | C6 | <a id="c6-tomatch-extension"></a>`c6-tomatch-extension` | statique | tests | `toMatch`'s argument carries its extension (`'help.txt'`, `'<case>.aria.yaml'` for a tree golden), except for a directory snapshot; a file subject with no extension is an error. | Write the fixture's full name, extension included. | The extension is part of the expected file's name — dropping it breaks `_expected/` resolution. |
149
+ | C7 | <a id="c7-seeds-sql-only"></a>`c7-seeds-sql-only` | statique | ground | `_seeds/` holds `*.sql` files only; any other file is an error. | Write the seed as SQL, or move the file to `_fixtures/` as the material it is. | `.seed()` carries database state only — no seed handlers, no prefix dispatch. |
150
+ | C8 | <a id="c8-referenced-fixture-exists"></a>`c8-referenced-fixture-exists` | statique | tests | A literal passed to `.request`/`.seed`/`.fixture`/`toMatch` must exist on disk under its conventional root; a missing path is an error. | Create the file, or fix the name — the message prints the path it looked for. | Catching a typo statically avoids a failure that would only surface at run time. |
151
+ | C10 | <a id="c10-contracts-boundary"></a>`c10-contracts-boundary` | statique | tests | Outside `specs/**/contracts/`, an import resolving into a provider folder (`contracts/{http,openai,anthropic}/…`) is an error: a test imports only the `*.contracts.ts` facades. | Add a named scenario export to the facade and import that instead. | Unit contracts are composition details; going through the facade keeps each scenario named in the same place as the world it derives from. |
152
+ | C11 | <a id="c11-contract-data-pairing"></a>`c11-contract-data-pairing` | statique | contract | Inside a provider folder, every `*.response.json` and every `*.request.ts` has a `<stem>.ts` sibling — the stem is the name up to the FIRST dot (`events.fr.response.json` → `events.ts`). Orphan data is an error. | Write the contract that serves the payload, or delete the payload. | Data exists only as served by a contract — a file with no owner is dead weight no test ever loads. |
153
+ | C13 | <a id="c13-underscored-ground"></a>`c13-underscored-ground` | statique | specs | Under `specs/`, a spec's ground carries a leading underscore: `_fixtures/`, `_expected/`, `_requests/`, `_seeds/`. A `fixtures/`, `expected/`, `requests/` or `seeds/` directory is a pre-14 name — an error naming the rename. | Rename the directory with the leading underscore the resolvers read. | No resolver reads the underscore-less name any more: left as is, the tree goes invisible rather than wrong, and the failure lands one step past its cause. |
154
+ | C9 | <a id="c9-dead-fixtures"></a>`c9-dead-fixtures` | checker | ground | No dead fixtures: every file under `_seeds/`/`_requests/`/`_fixtures/` and every top-level entry of `_expected/` must be referenced; a feature directory with no test file and no `*.spec.yaml` is an orphan (a warning when the argument is not a literal). A `<case>.spec.yaml` references the fixtures its `fixture:` entries name. | Reference it from the spec it stands under, or delete it. | The C8 mirror — a fixture nothing references is dead weight that misleads the reader. |
155
+ | C12 | <a id="c12-spec-file-name"></a>`c12-spec-file-name` | checker | specs | The suffix says the kind, and the tree agrees with it. A `.spec.ts` — the ASSEMBLED product — lives under `specs/<facet>/`; anywhere else is an error. A `.test.ts` — the UNIT — lives beside its module; under a facet folder (`api`, `cli`, `integration`, `jobs`, `mobile`, `website`) it is an error, fixable by `--fix` (a `git mv`). A `.test.ts` that reaches no runner is C18’s, whose fix is the opposite move, and the mover leaves it alone. A first level that is NOT a facet is a repository suite: it covers a tree, keeps `.test.ts`, and C1’s declared depth is what judges it — unless the tree carries its `*.specification.ts` at its own ROOT, which makes the whole tree one facet’s and every `.test.ts` below it a spec. A document is named `<case>.spec.yaml`: `<case>` in kebab-case, without the words `test`/`spec`/`cli` the suffix already carries, and never the bare name of its directory. | Move the `.spec.ts` under `specs/<facet>/` (or rename it `.test.ts` beside its module); rename the facet `.test.ts` to `.spec.ts` — `jterrazz-test-check <root> --fix` does it with `git mv`, and names every `include`/`exclude` entry of the member the rename broke. | With no agreement between the suffix and the tree, a reader cannot say what a test proves without opening it; and the filename is the first sentence they read — `rm/rm.spec.yaml` says nothing twice. |
156
+ | C14 | <a id="c14-pool-fixture-shared"></a>`c14-pool-fixture-shared` | checker | ground | A pool fixture is SHARED, or it is local: a directory of `<specs>/_fixtures/` referenced (`$FIXTURES/<name>`) from a single spec directory — two documents of one leaf count as one — must live beside that leaf, at `<leaf>/_fixtures/<name>/`, referenced by the relative form. Zero references is the dead-fixture error (C9). Fixed by `--fix`: the directory is moved and the literals rewritten. | Run `jterrazz-test-check <root> --fix`; it moves the directory and rewrites what named it. | A single scenario's fixture parked in the pool reads as shared ground: everyone assumes someone else depends on it and nobody dares touch it. |
157
+ | C15 | <a id="c15-local-fixture-reach"></a>`c15-local-fixture-reach` | checker | ground | A local fixture is read only from its own directory: a `fixture:`/`.fixture()` path containing `..`, or whose target leaves the `_fixtures/` of the spec naming it, is an error — ground shared by several leaves goes in the `$FIXTURES` pool. | Copy it under this leaf's own `_fixtures/`, or promote it to the pool. | A leaf reaching into a neighbour's fixtures is the pool through another door, without the pool's visibility: the neighbour breaks a spec two folders away without knowing. |
158
+ | C18 | <a id="c18-module-test-under-facet"></a>`c18-module-test-under-facet` | checker | specs | A test under `specs/<facet>/`, where the facet root holds a `*.specification.ts(x)`, imports that module or constructs a runner itself. Ground is out of reach — nothing under a leading-underscore folder is a spec, a `.test.ts` under `_expected/` being the GOLDEN a fixer writes. So is any first-level folder that is not a facet: a specification with no module to sit beside and no runner to reach lives in a repository suite there. | Import `{ <facet> }` from `<facet>.specification.js`, move a module test beside its module, or move a repository suite to a first-level folder of its own under `specs/`. | A test reaching no runner under a facet is a module test paying the facet's budget and services to prove something that belongs beside its module. |
159
+ | C20 | <a id="c20-facet-folder"></a>`c20-facet-folder` | checker | specs | A first-level folder named for one of the six facets holds a `*.specification.ts(x)` calling `specification.<facet>(`. Positive-only: any other first-level name is a repository suite, judged by C1's declared depth alone. | Create the specification the folder promises, or rename the folder. | Folder = constructor is the law the fork rests on; a facet folder with no runner tells the reader something false before they open a file. |
160
+ | C21 | <a id="c21w-ground-owned-by-one"></a>`c21w-ground-owned-by-one` | checker | ground | Ground in a leaf holding ≥ 2 node tests (`.test.ts`, `.spec.ts`) that a single one of them reads is a warning. A `.test.tsx` tree is out of reach, as it is for C1. | Give that spec its own domain folder, with the ground beside it. | Ground one spec reads, parked where several sit, reads as shared: everyone assumes someone else depends on it and nobody dares touch it. |
161
+ | C23 | <a id="c23-seed-no-clock-read"></a>`c23-seed-no-clock-read` | checker | ground | A `_seeds/**/*.sql` states absolute timestamps: `CURRENT_TIMESTAMP`/`CURRENT_DATE`/`CURRENT_TIME`, `NOW()` and the quoted `'now'` SQLite's date functions take (`datetime('now')`, `strftime(…, 'now')`, `date('now', '-1 day')`) are refused. A comment is not state, so a clock read inside one is not a finding. | Write the instant the case is about, and pin the run's clock with `.clock()` on the chain. | A seed that reads the clock makes the row's date the date the suite happened to run: a case about "published yesterday" proves something different every day, and under SQLite `datetime('now')` writes a space-separated text where an ORM writes ISO `T`, so the two misorder across a calendar day. The R8 dry run counts 36, every one of them signews-api's. |
162
+ | C5 | <a id="c5-slash-is-a-subfolder"></a>`c5-slash-is-a-subfolder` | runtime | ground | A slash in a fixture name is a SUBFOLDER of `_expected/`: `toMatch('build/verbose.txt')` resolves to `_expected/build/verbose.txt`. | Name the subfolder in the fixture name; the resolver creates it under `_expected/`. | A flat `_expected/` stops being readable at a dozen files, and the alternative — a second option naming a directory — would be a second way to say where a golden lives. |
163
+ | C1 | <a id="c1-asset-grouping"></a>`c1-asset-grouping` | process | specs | The folder follows the assets: a test with asset directories of its own gets its own domain; tests with no local assets group as sibling `<aspect>` files. The static rule only checks the depth. | Read what the assets say: a test that stands on its own ground earns a folder. | It is the assets that settle the grouping — a criterion no channel can decide alone. |
164
164
 
165
165
  ### D — Assertions
166
166
 
@@ -226,12 +226,12 @@ Every rule the framework enforces, across its enforcement channels — **statiqu
226
226
 
227
227
  ### J — Hygiene
228
228
 
229
- | Code | Implementation | Channel | Reach | Convention | Fix | Rationale |
230
- | ---- | ------------------------------------------------------------------------- | -------- | ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
231
- | J2 | <a id="j2-no-sleep"></a>`j2-no-sleep` | statique | tests | No arbitrary sleep (`setTimeout`/`setInterval`/`Atomics.wait`, or a `node:timers/promises` import) in any test file — synchronisation is `see()`/`gone()` inside a scenario and `waitUntil(predicate)` everywhere else. A timer inside a test DOUBLE's implementation (`vi.fn(…)`, `mockImplementation(…)`, a `mockOf` handler) is out of reach: the double is staging the world, not the test waiting on it. | Wait for the condition, not for a duration. | A fixed sleep makes tests slow and flaky; waiting on a condition is deterministic. |
232
- | J6 | <a id="j6w-given-in-the-test"></a>`j6w-given-in-the-test` | statique | tests | A `beforeEach`/`beforeAll` in a test file is a warning, and so is an `afterEach`/`afterAll` whose body does more than restore (`cleanup`, `vi.useRealTimers`, `vi.restoreAllMocks`, `vi.resetAllMocks`, `vi.unstubAllGlobals`, `vi.unstubAllEnvs`) — handed over by name (`afterEach(vi.restoreAllMocks)`) or called in the body. `afterAll(cleanup)` in a `*.specification.ts` is A4's own idiom and out of reach by role. | Call a function from each test instead of a hook; `clock.at()` and `intercept()` restore themselves. | A hook moves the setup out of the only place a reader looks, shares it with every test in the file, and half of them come to depend on it quietly. |
233
- | J10 | <a id="j10-one-describe-level"></a>`j10-one-describe-level` | upstream | tests | At most one level of `describe` — `vitest/max-nested-describe` with `{ max: 1 }`, set by the `testing` fragment. | Flatten the nesting: the test name is the sentence, and one level groups the file. | A tree of contexts is state a reader has to hold in their head to know what a test is about, and the name of the test stops being the sentence it was. |
234
- | J9 | <a id="j9-checker-suppression-reason"></a>`j9-checker-suppression-reason` | checker | specs | Every `checker-disable-next-line` / `checker-disable-line` directive carries ` -- <reason>`, wherever a checker pass can be silenced: a test, a document, a fixture under ground. The word inside a string is prose, not a directive. | Write `checker-disable-next-line <id> -- <reason>`. | A suppression with no reason is a rule turned off by someone no longer here: the next reader cannot tell whether the pass was wrong or simply in the way, so the line survives every review. The toolchain's own gate reads `oxlint-disable*` and nothing else. |
229
+ | Code | Implementation | Channel | Reach | Convention | Fix | Rationale |
230
+ | ---- | ------------------------------------------------------------------------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
231
+ | J2 | <a id="j2-no-sleep"></a>`j2-no-sleep` | statique | tests | No arbitrary sleep (`setTimeout`/`setInterval`/`Atomics.wait`, or a `node:timers/promises` import) in any test file — synchronisation is `see()`/`gone()` inside a scenario and `waitUntil(predicate)` everywhere else. A timer inside a test DOUBLE's implementation is out of reach: the double is staging the world, not the test waiting on it. A double is a factory call (`vi.fn(…)`, `mockImplementation(…)`, a `mockOf` handler) or a function-valued PROPERTY of an object literal, which is the plainest double there is — `const git: GitGateway = { cloneRepository: async () => … }`, where the object itself is the double. | Wait for the condition, not for a duration. | A fixed sleep makes tests slow and flaky; waiting on a condition is deterministic. |
232
+ | J6 | <a id="j6w-given-in-the-test"></a>`j6w-given-in-the-test` | statique | tests | A `beforeEach`/`beforeAll` in a test file is a warning, and so is an `afterEach`/`afterAll` whose body does more than restore (`cleanup`, `vi.useRealTimers`, `vi.restoreAllMocks`, `vi.resetAllMocks`, `vi.unstubAllGlobals`, `vi.unstubAllEnvs`) — handed over by name (`afterEach(vi.restoreAllMocks)`) or called in the body. `afterAll(cleanup)` in a `*.specification.ts` is A4's own idiom and out of reach by role. | Call a function from each test instead of a hook; `clock.at()` and `intercept()` restore themselves. | A hook moves the setup out of the only place a reader looks, shares it with every test in the file, and half of them come to depend on it quietly. |
233
+ | J10 | <a id="j10-one-describe-level"></a>`j10-one-describe-level` | upstream | tests | At most one level of `describe` — `vitest/max-nested-describe` with `{ max: 1 }`, set by the `testing` fragment. | Flatten the nesting: the test name is the sentence, and one level groups the file. | A tree of contexts is state a reader has to hold in their head to know what a test is about, and the name of the test stops being the sentence it was. |
234
+ | J9 | <a id="j9-checker-suppression-reason"></a>`j9-checker-suppression-reason` | checker | specs | Every `checker-disable-next-line` / `checker-disable-line` directive carries ` -- <reason>`, wherever a checker pass can be silenced: a test, a document, a fixture under ground. The word inside a string is prose, not a directive. | Write `checker-disable-next-line <id> -- <reason>`. | A suppression with no reason is a rule turned off by someone no longer here: the next reader cannot tell whether the pass was wrong or simply in the way, so the line survives every review. The toolchain's own gate reads `oxlint-disable*` and nothing else. |
235
235
 
236
236
  ### K — Retro-propagation
237
237
 
@@ -442,6 +442,8 @@ F8 says which of the two it found: a carried dependency is "a transitive of `@jt
442
442
 
443
443
  **The jest allowance follows the tests, not the manifest.** `@testing-library/react-native` stands where jest runs the member's tests, and jest is not always the member's own: a library co-locating its render tests in `src/` while the app beside it owns the only working React Native toolchain runs them from THAT app's `jest.config.*`. So the pass reads two sources — the member's own declaration (a `jest`/`jest-expo` dependency, a `jest` field, a `test` script calling it), and a `jest.config.*` anywhere in the workspace whose `roots`/`testMatch` resolve INTO the member (`<rootDir>` is jest's own token for the config's directory). A config that reaches another member clears nothing here: the allowance is about the files jest collects.
444
444
 
445
+ The workspace it searches is walked UP to, from the member, and never taken from the anchor the run was started at: the app that runs the member's tests sits BESIDE it, which is above the member and never below it. `cd packages/native && jterrazz-test-check` — the run that member's own gate makes — otherwise found no jest at all and reported F8 while the root-anchored run cleared the same member. The walk stops at the first directory declaring a workspace (`workspaces` in its manifest, or a `pnpm-workspace.yaml`) and at the checkout's own boundary, since a `.git` is where this repository ends.
446
+
445
447
  `jterrazz-test-check --member <dir>` runs them over one member. `jterrazz-test-check` with NO path runs both halves over the whole project: every `specs/` root discovered from the root manifest's `workspaces`, and every member. That is the run the toolchain's ratchet rests on, so the contract is exactly "a path-less run reports what the per-root and per-member runs report" — and the discovery is the toolchain's, to the segment: a `workspaces` glob expands with `*` for one segment and `**` for any depth (bounded at six), and each member is walked for the spec roots it owns, a nested `web/specs` included. A shape `@jterrazz/typescript` reaches and this run did not would be a finding the ratchet could neither record nor clear.
446
448
 
447
449
  `--format json` prints `[{ code, file, line, severity, message }]` on stdout and nothing else. `code` is `jterrazz-check(<id>)` — `jterrazz-check(d4)`, `jterrazz-check(f8)` — which is the namespace `@jterrazz/typescript`'s ratchet records the counts under, flat, in `oxlint.baseline.json` beside the oxlint ones. Severity travels but is never read by the ratchet: a finding a release ships at `warn` is exactly the debt a ratchet exists to hold down.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "16.1.0",
3
+ "version": "16.1.1",
4
4
  "description": "Declarative testing framework for HTTP APIs, CLIs, background jobs, websites, mobile apps and rendered components — one entry point, real infrastructure and a real browser, golden-file assertions, plus an oxlint convention plugin.",
5
5
  "keywords": [
6
6
  "api-testing",
@@ -77,7 +77,7 @@
77
77
  },
78
78
  "devDependencies": {
79
79
  "@hono/node-server": "^2.0.8",
80
- "@jterrazz/typescript": "^10.2.1",
80
+ "@jterrazz/typescript": "^10.3.1",
81
81
  "@types/better-sqlite3": "^7.6.13",
82
82
  "@types/node": "^26.1.1",
83
83
  "@types/pg": "^8.20.0",