clearotron 0.3.2 → 0.3.3-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CONTRIBUTING.md +12 -0
  2. package/INSTALL.md +8 -0
  3. package/bin/onboard.mjs +109 -13
  4. package/bin/start.mjs +1 -1
  5. package/build-info.json +2 -2
  6. package/demo/MANIFEST.json +27 -0
  7. package/docs/INTAKE.md +8 -0
  8. package/docs/architecture/04-configuration-reference.md +29 -11
  9. package/driver/CHANGELOG.md +49 -0
  10. package/driver/citation-census.json +3 -3
  11. package/driver/clearance-variants-record.mjs +12 -1
  12. package/driver/common-law-coverage-status.mjs +113 -0
  13. package/driver/config-inventory.mjs +1 -1
  14. package/driver/contract-audit.mjs +1 -1
  15. package/driver/contract-e3-backlog.mjs +37 -37
  16. package/driver/contract-vocabulary.mjs +8 -8
  17. package/driver/coverage-form-io.mjs +3 -1
  18. package/driver/coverage-form.mjs +38 -11
  19. package/driver/coverage-ledger.mjs +37 -7
  20. package/driver/coverage-union.mjs +2 -2
  21. package/driver/crowd-context.mjs +19 -6
  22. package/driver/dev-portal.mjs +3 -3
  23. package/driver/drainer-identity.mjs +1 -1
  24. package/driver/driver.config.mjs +80 -9
  25. package/driver/engine/CONTRACT.md +3 -2
  26. package/driver/engine/anthropic-agent.mjs +34 -7
  27. package/driver/engine/mcp/clarivate-server.mjs +4 -2
  28. package/driver/engine/mcp/corsearch-server.mjs +3 -1
  29. package/driver/engine/mcp/coverage-server.mjs +1 -1
  30. package/driver/engine/mcp/dispositions-server.mjs +47 -5
  31. package/driver/engine/mcp/euipo-server.mjs +2 -0
  32. package/driver/engine/mcp/free-tier-server.mjs +2 -0
  33. package/driver/engine/mcp/gather-config.mjs +8 -2
  34. package/driver/engine/mcp/probe-server.mjs +37 -0
  35. package/driver/engine/mcp/proposal-fields.mjs +45 -0
  36. package/driver/engine/mcp/recording-server.mjs +30 -0
  37. package/driver/engine/mcp/signa-server.mjs +2 -0
  38. package/driver/engine/mcp/supplemental.mjs +89 -12
  39. package/driver/engine/mcp/unit-note-server.mjs +50 -0
  40. package/driver/engine/mcp/uspto-local-server.mjs +2 -0
  41. package/driver/engine/openai-agent.mjs +7 -0
  42. package/driver/engine/probe.mjs +67 -14
  43. package/driver/engine/tool-refusal.mjs +16 -0
  44. package/driver/enqueue-schema.mjs +2 -2
  45. package/driver/envelope-settle.mjs +82 -13
  46. package/driver/findings-model.mjs +4 -4
  47. package/driver/gateway.mjs +18 -2
  48. package/driver/manager-groups-verdict.mjs +1 -1
  49. package/driver/matter-frame-record.mjs +24 -7
  50. package/driver/named-band.mjs +1 -1
  51. package/driver/package.json +1 -1
  52. package/driver/partial-payload-baseline.json +12 -3
  53. package/driver/pipeline-knockout.mjs +3 -3
  54. package/driver/pipeline.mjs +154 -50
  55. package/driver/plan-run-agreement-verdict.mjs +49 -0
  56. package/driver/portal-service.mjs +8 -4
  57. package/driver/progress.mjs +14 -3
  58. package/driver/publish/index.mjs +41 -26
  59. package/driver/publish/report-data.mjs +4 -3
  60. package/driver/publish/xlsx.mjs +26 -4
  61. package/driver/queue-markers.mjs +44 -0
  62. package/driver/queue-watch-verdict.mjs +2 -2
  63. package/driver/reference-score.mjs +10 -2
  64. package/driver/register-availability.mjs +2 -2
  65. package/driver/register-plan.mjs +313 -21
  66. package/driver/roster-verdict.mjs +1 -1
  67. package/driver/runner.mjs +26 -2
  68. package/driver/settle-stamp.mjs +10 -3
  69. package/driver/skills/clearance-common-law/SKILL.md +2 -0
  70. package/driver/skills/clearance-register/SKILL.md +44 -3
  71. package/driver/skills/clearance-register/digest.md +5 -5
  72. package/driver/skills/clearance-register/providers/clarivate.md +1 -1
  73. package/driver/skills/clearance-register/unit.md +39 -0
  74. package/driver/skills/clearance-variants/SKILL.md +1 -1
  75. package/driver/skills/matter-frame/SKILL.md +4 -2
  76. package/driver/stages.mjs +12 -5
  77. package/driver/status-snapshot.mjs +2 -2
  78. package/driver/suite-census.json +293 -29
  79. package/driver/synthesis-record.mjs +80 -2
  80. package/driver/unit-file-drift.mjs +3 -3
  81. package/driver/unit-inventory.mjs +2 -2
  82. package/driver/unit-state-verdict.mjs +1 -1
  83. package/driver/updater-identity.mjs +2 -3
  84. package/driver/variant-manifest-model.mjs +11 -1
  85. package/driver/verify.mjs +5 -5
  86. package/driver/withheld-families.mjs +104 -0
  87. package/mcp-server/CHANGELOG.md +8 -0
  88. package/mcp-server/lib/brief.mjs +16 -12
  89. package/mcp-server/lib/runs.mjs +1 -1
  90. package/mcp-server/package.json +1 -1
  91. package/mcp-server/server.mjs +3 -2
  92. package/package.json +2 -2
  93. package/portal-ui/dist/assets/{index-DMthc7PQ.js → index-GBbbyQxc.js} +22 -4
  94. package/portal-ui/dist/index.html +1 -1
  95. package/portal-ui/package.json +1 -1
  96. package/providers/_shared/count.mjs +2 -2
  97. package/providers/_shared/enumerate.mjs +15 -2
  98. package/providers/_shared/execute-plan.mjs +19 -1
  99. package/providers/_shared/plan-guards.mjs +40 -0
  100. package/providers/clarivate/src/capabilities.js +15 -5
  101. package/providers/clarivate/src/core.js +41 -5
  102. package/providers/corsearch/src/capabilities.js +4 -0
  103. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  104. package/providers/oauth-mcp-bridge/package.json +1 -1
  105. package/providers/signa/src/capabilities.js +22 -8
  106. package/providers/signa/src/core.js +12 -1
  107. package/scripts/demo-evidence.mjs +114 -0
  108. package/scripts/e2e.mjs +1 -1
  109. package/scripts/engine-probe.mjs +6 -5
  110. package/scripts/env-audit.mjs +1 -1
  111. package/scripts/freeze-example-run.mjs +3 -3
  112. package/scripts/live-surface-check.mjs +32 -33
  113. package/scripts/mint-suite-census.mjs +66 -0
  114. package/scripts/package-size-budget.mjs +117 -0
  115. package/scripts/register-plan-shape.mjs +259 -0
  116. package/scripts/release-note-required.mjs +38 -1
  117. package/scripts/repo-writes.mjs +1 -1
  118. package/scripts/report-sections-render-check.mjs +7 -3
  119. package/scripts/score.mjs +7 -1
  120. package/scripts/settings-render-check.mjs +36 -0
  121. package/scripts/travelling-predicates.mjs +1 -1
  122. package/shared/identifier-scan.mjs +22 -5
  123. package/shared/scroll-settle.mjs +67 -0
@@ -53,6 +53,23 @@
53
53
  // reach a number, which is the failure this repository has already paid for once. The remaining half is
54
54
  // a person reading the diff and asking what a lawyer would want told.
55
55
  //
56
+ // ── ONLY A PULL REQUEST'S OWN COMMITS ARE READ, AND THAT IS THE ONLY READING THAT COUNTS ─────────────
57
+ //
58
+ // The per-commit question below does not survive a squash. A squash commit adds every note the branch
59
+ // carried, so it "carries its own note", and any prose release line it inherited from a folded commit is
60
+ // excused. Measured on a simulated squash of an integration branch whose one offending commit this guard
61
+ // refused commit by commit: over the squash it passed. So this guard is a gate only where it reads the
62
+ // commits a pull request brings, one at a time, before they are folded. That is how CI runs it, on the
63
+ // pull request's range; on a push to main the range is empty and it says so rather than passing quietly.
64
+ //
65
+ // AND POINTED AT MAIN'S OWN LINE IT SAYS SO, ON EVERY ANSWER. A range with a commit on `origin/main`'s
66
+ // first-parent history is reading the squashes themselves. That reading still catches a squash that
67
+ // shipped code with no note at all (an arm replays one), so it is not refused. It cannot catch a prose
68
+ // line a squash inherited, and its pass is therefore not the per-commit answer. So every answer over
69
+ // that line carries the sentence that says which of the two it can give. An audit of what shipped
70
+ // reads the pull requests that brought it, never the squashes they became. An arm pins
71
+ // both readings side by side, and that CI runs this on the pull request's range and nowhere else.
72
+ //
56
73
  // ── A DECLINATION ANSWERS FOR ITS OWN COMMIT, NOT FOR THE RANGE ──────────────────────────────────────
57
74
  //
58
75
  // This read one `Release-note: none` anywhere in the range as the answer for all of it, so a pack of
@@ -270,7 +287,7 @@ function main() {
270
287
  // have failed, and would have reported the acceptance met for the life of the branch.
271
288
  const head = argAfter("--head") || "HEAD";
272
289
  const git = (...a) => execFileSync("git", a, { encoding: "utf8", maxBuffer: 1 << 28 });
273
- let changed, commits, atHead;
290
+ let changed, commits, atHead, onMainLine = [];
274
291
  try {
275
292
  changed = git("diff", "--name-only", `${base}...${head}`).split("\n").filter(Boolean);
276
293
  // THE BRANCH'S OWN COMMITS. On a pull request the range can reach commits main already carries, from
@@ -282,6 +299,12 @@ function main() {
282
299
  catch { /* no origin/main in this clone: the range as given */ }
283
300
  }
284
301
  const shas = git("rev-list", "--no-merges", "--reverse", `${base}..${head}`, ...exclude).split("\n").filter(Boolean);
302
+ // MAIN'S OWN LINE IS NAMED (see the header): a squash there excuses the prose it inherited. Asked
303
+ // only where main is known; a clone without it keeps the range as given.
304
+ let mainKnown = false;
305
+ try { git("rev-parse", "--verify", "-q", "origin/main^{commit}"); mainKnown = true; } catch { /* not known here */ }
306
+ const mainLine = mainKnown ? new Set(git("rev-list", "--first-parent", "origin/main").split("\n").filter(Boolean)) : new Set();
307
+ onMainLine = shas.filter((sha) => mainLine.has(sha));
285
308
  commits = shas.map((sha) => ({
286
309
  sha,
287
310
  subject: git("log", "-1", "--format=%s", sha).trim(),
@@ -302,6 +325,14 @@ function main() {
302
325
  catch (e) { console.error(`release-note-required: cannot read the shipped file list: ${e.message}`); process.exit(2); }
303
326
  if (!files.length) { console.error("release-note-required: package.json names no shipped files, so this cannot look"); process.exit(2); }
304
327
 
328
+ if (!commits.length) {
329
+ // NOTHING TO READ IS SAID, NOT IMPLIED. On a push to main every commit is already on main, so the range
330
+ // is empty by construction: the pull request that brought them was read before they were squashed.
331
+ console.log(`release-note-required: no commit in ${base}..${head} that main does not already hold, so there `
332
+ + "is nothing here to read. This guard answers for a pull request's own commits, one by one, and main's "
333
+ + "history is never read.");
334
+ return;
335
+ }
305
336
  const { visible, notes, declined, withdrawals, owed } = commitVerdicts({ commits, files, atHead });
306
337
  console.log(`release-note-required: ${changed.length} changed file(s) against ${base}`
307
338
  + `${head === "HEAD" ? "" : ` (head ${head})`}; `
@@ -309,6 +340,12 @@ function main() {
309
340
  + `${withdrawals.length ? `; ${withdrawals.length} answered by a note the range withdrew` : ""}`);
310
341
  // PER COMMIT, SAID AS PER COMMIT. "no note, declared on purpose" read as a verdict on the range, and beside
311
342
  // a range that carries a note it told a reader skimming the output that none went out. Found in review.
343
+ if (onMainLine.length) {
344
+ console.log(` NOT THE GATE'S READING: ${onMainLine.length} commit(s) read here are on main's own line (first `
345
+ + `${onMainLine[0].slice(0, 7)}), so they are squashes. Over a squash this finds a change that shipped with no `
346
+ + "note at all; it cannot find a prose release line the squash inherited, so a pass here is not the "
347
+ + "per-commit answer. The gate is the pull request's own reading, before the squash.");
348
+ }
312
349
  for (const d of declined) console.log(` ${d.sha.slice(0, 7)} declines a note of its own, on purpose: ${d.reason}`);
313
350
  // AN EXCUSED COMMIT IS PRINTED, WITH THE SHA THAT EXCUSED IT. A pass this check reached by reading
314
351
  // another commit's decision is one a person may want to disagree with, and they cannot if it is silent.
@@ -11,7 +11,7 @@
11
11
  // attribute the red that produces: it surfaces in a file whose diff is empty, on another branch, in
12
12
  // another agent's session, and it is intermittent — the three properties that make a defect expensive.
13
13
  //
14
- // `doctor-refuses-what-cannot-run.test.mjs:70` already states the rule in prose: moving the real
14
+ // `doctor-refuses-what-cannot-run.test.mjs` already states the rule in prose: moving the real
15
15
  // `portal-ui/dist` aside "would have been shorter and is wrong". Prose is not an instrument. This is.
16
16
  //
17
17
  // THE RULE IS ABSOLUTE, AND IT IS NOT A QUESTION ABOUT `git`. Tracked, untracked and ignored are the
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url'
27
27
  import { reapOnExit } from '../shared/reap-on-exit.mjs'
28
28
  import { browserRun } from '../shared/browser-temp-root.mjs'
29
29
  import { prepareReportForEmbed } from '../driver/portal-report.mjs'
30
+ import { scrollAfterPress } from '../shared/scroll-settle.mjs'
30
31
 
31
32
  const HERE = dirname(fileURLToPath(import.meta.url))
32
33
  const ROOT = join(HERE, '..')
@@ -225,11 +226,14 @@ for (const width of [1440, 400]) {
225
226
  const target = 2
226
227
  const before = await value('window.scrollY')
227
228
  await value(`document.querySelectorAll('nav.report-sections .report-section')[${target}].click()`)
228
- let settled = before, last = -1
229
- for (let i = 0; i < 40 && settled !== last; i++) { last = settled; await wait(150); settled = await value('window.scrollY') }
229
+ // The jump is animated and starts on the browser's own schedule, so the page is given a deadline to
230
+ // START moving before it is read as still. Waiting only for the position to settle answers "it never
231
+ // moved" for a scroll that had not yet begun — see scroll-settle.mjs.
232
+ const { moved, y: settled, waitedMs } = await scrollAfterPress({ read: () => value('window.scrollY'), wait, from: before })
230
233
  const s = await strip()
231
234
  const foot = await atFoot()
232
- if (!(settled > before)) fail.push(`${at}: pressing "${SECTIONS[target]}" did not move the page`)
235
+ if (!moved) fail.push(`${at}: pressing "${SECTIONS[target]}" did not move the page in ${waitedMs}ms`)
236
+ else if (!(settled > before)) fail.push(`${at}: pressing "${SECTIONS[target]}" left the page at ${settled}, not below ${before}`)
233
237
  else if (filled(s) !== (foot ? SECTIONS.length : target + 1)) fail.push(`${at}: after the jump to "${SECTIONS[target]}" the strip reads "${s}"`)
234
238
  else ok.push(`${at}: a press jumps to "${SECTIONS[target]}" and marks it — ${s}`)
235
239
  }
package/scripts/score.mjs CHANGED
@@ -245,9 +245,15 @@ function scopeOf(runDir, ref) {
245
245
  const instructed = readJson(driverDir(runDir, "instructed-scope.json"));
246
246
  const cls = instructed?.classes ?? instructed?.nice_classes ?? null;
247
247
  const terr = instructed?.jurisdictions ?? instructed?.territories ?? null;
248
+ // A worldwide request is recorded as a MODE, not a list entry: intake takes the word off the territory
249
+ // list and stamps `geography.mode` (enqueue-schema.mjs, GEOGRAPHY_MODES). So a worldwide run carries no
250
+ // territories, and falling through to the reference's own scope would score the run against what the
251
+ // lawyer wrote there instead of what the run was asked. The run's record wins, stated as the mode.
252
+ const worldwide = instructed?.geography?.mode === "worldwide";
248
253
  return {
249
254
  classes: Array.isArray(cls) && cls.length ? cls.map(String) : (ref.scope?.classes ?? []).map(String),
250
- territories: Array.isArray(terr) && terr.length ? terr.map(String) : (ref.scope?.territories ?? []).map(String),
255
+ territories: Array.isArray(terr) && terr.length ? terr.map(String)
256
+ : worldwide ? ["worldwide"] : (ref.scope?.territories ?? []).map(String),
251
257
  };
252
258
  }
253
259
 
@@ -600,6 +600,27 @@ if (await open('/portal/people', "document.querySelector('table.data tbody tr')"
600
600
  };
601
601
  })()`
602
602
 
603
+ // The bar's reading, then the two ways a reader moves the table with it: the table moved by a finger
604
+ // (the thumb follows) and a press at the track's right end (the table follows). The table is put back
605
+ // at its start afterwards so the screenshots below show it as a reader first meets it.
606
+ const pinnedBarProbe = `(async () => {
607
+ window.scrollTo(0, 0); await new Promise((r) => setTimeout(r, 200));
608
+ const wrap = document.querySelector('.table-wrap'); const bar = document.querySelector('.pinned-bar');
609
+ const thumb = bar && bar.querySelector('.pinned-thumb'); const track = bar && bar.querySelector('.pinned-track');
610
+ if (!wrap || !bar || !thumb || !track) return { wrap: !!wrap, bar: !!bar, thumb: !!thumb, track: !!track };
611
+ const b = bar.getBoundingClientRect(), t0 = thumb.getBoundingClientRect();
612
+ const out = { bar: true, onScreen: b.top >= 0 && b.bottom <= innerHeight + 1, barH: Math.round(b.height),
613
+ thumbW: Math.round(t0.width), thumbH: Math.round(t0.height), nativeBarH: wrap.offsetHeight - wrap.clientHeight };
614
+ wrap.scrollLeft = 200; await new Promise((r) => setTimeout(r, 200));
615
+ out.thumbFollowed = Math.round(thumb.getBoundingClientRect().left - t0.left);
616
+ const tr = track.getBoundingClientRect();
617
+ track.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: tr.right - 2, clientY: tr.top + 4, pointerId: 1 }));
618
+ await new Promise((r) => setTimeout(r, 200));
619
+ out.tableAtEnd = Math.round(wrap.scrollWidth - wrap.clientWidth - wrap.scrollLeft);
620
+ wrap.scrollLeft = 0; await new Promise((r) => setTimeout(r, 200));
621
+ return out;
622
+ })()`
623
+
603
624
  for (const [label, width] of [['desktop', 1280], ['phone', 400]]) {
604
625
  await cmd('Emulation.setDeviceMetricsOverride', { width, height: 900, deviceScaleFactor: 1, mobile: width < 700 })
605
626
  await sleep(350)
@@ -615,6 +636,21 @@ if (await open('/portal/people', "document.querySelector('table.data tbody tr')"
615
636
  ok(p.pageScrollsSideways === false,
616
637
  `${label}: the page itself does not scroll sideways — the table's overflow stays in the table`)
617
638
  console.log(` ${label}: wrapper ${p.wrapW}px, table ${p.tableW}px, wrapper scrollable: ${p.scrollable}`)
639
+ // — A TABLE THAT CONTINUES SHOWS ITS SCROLLBAR, on this screen as on the Clearances list. The same
640
+ // pinned bar is wired on both; the Clearances check asserts it and this one did not, so the bar here
641
+ // was drawn and unmeasured. Where the table fits there is no bar: a bar with nothing to scroll is a
642
+ // control that does nothing.
643
+ const pin = (await evalIn(pinnedBarProbe)) ?? {}
644
+ console.log(` ${label}: pinned bar ${JSON.stringify(pin)}`)
645
+ if (p.scrollable) {
646
+ ok(pin.bar && pin.onScreen, `${label}: the table continues past its edge, and its sideways bar is on screen`)
647
+ ok(pin.nativeBarH === 0, `${label}: only the pinned bar is drawn, not the table's own beside it`)
648
+ ok(pin.barH >= 9 && pin.thumbH >= 8 && pin.thumbW >= 24, `${label}: the pinned bar is drawn at a size a reader can see (${pin.barH}px, thumb ${pin.thumbW}×${pin.thumbH})`)
649
+ ok(pin.thumbFollowed > 0, `${label}: the thumb follows the table`)
650
+ ok(pin.tableAtEnd <= 1, `${label}: pressing the end of the track moves the table to its end`)
651
+ } else {
652
+ ok(pin.bar === false, `${label}: the table fits, and no sideways bar is drawn`)
653
+ }
618
654
  await cmd('Emulation.clearDeviceMetricsOverride', {})
619
655
  for (const theme of ['light', 'dark']) {
620
656
  await setTheme(theme)
@@ -103,7 +103,7 @@ function isPlumbing(node, child) {
103
103
  //
104
104
  // const wrote = files.length ? files.some(…) : null; gateway.mjs (by name)
105
105
  // const inScope = scope.size ? tokens.some(…) : (…); reasoning-tripwires.mjs:82
106
- // const reached = b.layer === "national" ? (…) : regions.some(…); register-plan.mjs:340 resolveRegions
106
+ // const reached = b.layer === "national" ? (…) : regions.some(…); resolveRegions() in register-plan.mjs
107
107
  //
108
108
  // The climb stopped at the ternary and reported "unresolved", which reads as a limit of the pattern
109
109
  // and was a missing case. All three are `local` — the boolean is bound and read in the same function.
@@ -116,14 +116,31 @@ export const ALLOWED_CONTEXT =
116
116
  * Escapes become spaces before matching, which fixes the class rather than that one line: `\n`, `\t`,
117
117
  * `\r` and `\\` in any quoted source, HTML numeric and named entities, and `%20`-style percent escapes.
118
118
  * A boundary in the SOURCE is a boundary in the TEXT, whatever the encoding.
119
+ *
120
+ * READ AS A LEXER READS THEM, and two misreadings of Windows paths to Node's executable are why
121
+ * (2026-09-22). Both reported a three-letter mark, spelled by the letters after the path's last `\n`,
122
+ * that was not there:
123
+ *
124
+ * - `\\` is consumed as a pair, in the same left-to-right pass as the others. Replaced in a separate
125
+ * pass after `\n`, the second backslash of a pair was read as the start of `\n`.
126
+ * - A Windows path written raw — drive letter, colon, ONE backslash, as in a comment, a doc or a TOML
127
+ * literal — holds no escapes, so its backslashes are left as separators. A separator is already a
128
+ * boundary, and reading `\n` there also broke a lowercase name that starts with n, r, t, f or v.
129
+ * The escaped form (`C:\\`) is not a raw path, so an escape beside a path in a string is still read.
119
130
  */
120
- export const unescapeBoundaries = (line) =>
121
- line
122
- .replace(/\\[nrtfv0]/g, " ")
123
- .replace(/\\\\/g, " ")
124
- .replace(/\\u[0-9a-fA-F]{4}|\\x[0-9a-fA-F]{2}/g, " ")
131
+ const BACKSLASH_ESCAPE = /\\(?:[\\nrtfv0]|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2})/g;
132
+ const RAW_WINDOWS_PATH = /(?<![A-Za-z0-9])[A-Za-z]:\\(?!\\)[^"'`<>|?*\r\n]*/g;
133
+
134
+ export const unescapeBoundaries = (line) => {
135
+ let out = "", at = 0;
136
+ for (const m of line.matchAll(RAW_WINDOWS_PATH)) {
137
+ out += line.slice(at, m.index).replace(BACKSLASH_ESCAPE, " ") + m[0];
138
+ at = m.index + m[0].length;
139
+ }
140
+ return (out + line.slice(at).replace(BACKSLASH_ESCAPE, " "))
125
141
  .replace(/&(?:#\d+|#x[0-9a-fA-F]+|[a-zA-Z]+);/g, " ")
126
142
  .replace(/%[0-9a-fA-F]{2}/g, " ");
143
+ };
127
144
 
128
145
  /**
129
146
  * Does this entry fire on this line?
@@ -0,0 +1,67 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ // scroll-settle.mjs — after a press, wait for the page to START moving, then for it to STOP.
4
+ //
5
+ // ── WHY A SETTLE WAIT ALONE IS NOT ENOUGH, WHICH IS THE WHOLE POINT ──────────────────────────────
6
+ //
7
+ // A check that presses something and reads the scroll position wants the position the page came to
8
+ // rest at. The obvious shape reads twice and stops when two reads agree:
9
+ //
10
+ // let settled = before, last = -1
11
+ // for (let i = 0; i < 40 && settled !== last; i++) { last = settled; await wait(150); settled = await read() }
12
+ //
13
+ // That is a settle detector, and it cannot tell a page that has STOPPED from one that has not STARTED.
14
+ // A smooth scroll begins on the browser's own schedule; when it has not begun by the first read, the
15
+ // first two reads are both the position before the press, they agree, the loop ends, and the caller is
16
+ // told the page never moved. The page then moves, a moment after nobody is looking.
17
+ //
18
+ // This failed once in continuous integration on a branch whose range touched no part of that page, and
19
+ // passed on re-run with nothing changed — the signature of a measurement that races the thing it
20
+ // measures rather than a defect in the page.
21
+ //
22
+ // So the wait is in two parts, and only the first is new: hold until the position CHANGES, giving up at
23
+ // a deadline; then hold until it stops changing. A page that does not move is now told apart from one
24
+ // that has not moved yet by how long it was given — which is why the deadline is returned, for the
25
+ // caller to name in its failure. A caller that says only "it did not move" leaves the next reader
26
+ // unable to tell a real defect from this race.
27
+ //
28
+ // PURE, with the read and the clock injected, so a test drives every path without a browser: a wait
29
+ // that can only be exercised against a real renderer cannot be shown to fail.
30
+
31
+ // Long enough that a scroll which has not begun by then is not merely late. The press is animated, so
32
+ // this is a deadline for the FIRST movement, never for the whole journey — the settle below carries that.
33
+ export const MOVE_DEADLINE_MS = 4000;
34
+ const STEP_MS = 150;
35
+ const QUIET_STEPS = 40;
36
+
37
+ /**
38
+ * Wait for a press to move the page, then for the movement to stop.
39
+ *
40
+ * @param {object} o
41
+ * @param {() => Promise<number|null>} o.read reads the scroll position now
42
+ * @param {(ms: number) => Promise<void>} o.wait sleeps
43
+ * @param {number} o.from the position before the press
44
+ * @param {() => number} [o.now] the clock the deadline is measured on
45
+ * @returns {Promise<{moved: boolean, y: number|null, waitedMs: number}>}
46
+ * `moved` false means the position never changed within `waitedMs` — the page was given that long.
47
+ */
48
+ export async function scrollAfterPress({ read, wait, from, now = Date.now,
49
+ deadlineMs = MOVE_DEADLINE_MS, stepMs = STEP_MS, quietSteps = QUIET_STEPS }) {
50
+ const started = now();
51
+ let y = from;
52
+ // FIRST, THAT IT MOVED AT ALL. The deadline is what makes the answer below a finding rather than a race.
53
+ while (y === from) {
54
+ if (now() - started >= deadlineMs) return { moved: false, y, waitedMs: now() - started };
55
+ await wait(stepMs);
56
+ y = await read();
57
+ }
58
+ // THEN, WHERE IT CAME TO REST. `last` starts at a value no read returns, so the position is always
59
+ // read at least once more: the first changed position is mid-animation, not the answer.
60
+ let last = null;
61
+ for (let i = 0; i < quietSteps && y !== last; i++) {
62
+ last = y;
63
+ await wait(stepMs);
64
+ y = await read();
65
+ }
66
+ return { moved: true, y, waitedMs: now() - started };
67
+ }