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.
- package/CONTRIBUTING.md +12 -0
- package/INSTALL.md +8 -0
- package/bin/onboard.mjs +109 -13
- package/bin/start.mjs +1 -1
- package/build-info.json +2 -2
- package/demo/MANIFEST.json +27 -0
- package/docs/INTAKE.md +8 -0
- package/docs/architecture/04-configuration-reference.md +29 -11
- package/driver/CHANGELOG.md +49 -0
- package/driver/citation-census.json +3 -3
- package/driver/clearance-variants-record.mjs +12 -1
- package/driver/common-law-coverage-status.mjs +113 -0
- package/driver/config-inventory.mjs +1 -1
- package/driver/contract-audit.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +37 -37
- package/driver/contract-vocabulary.mjs +8 -8
- package/driver/coverage-form-io.mjs +3 -1
- package/driver/coverage-form.mjs +38 -11
- package/driver/coverage-ledger.mjs +37 -7
- package/driver/coverage-union.mjs +2 -2
- package/driver/crowd-context.mjs +19 -6
- package/driver/dev-portal.mjs +3 -3
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +80 -9
- package/driver/engine/CONTRACT.md +3 -2
- package/driver/engine/anthropic-agent.mjs +34 -7
- package/driver/engine/mcp/clarivate-server.mjs +4 -2
- package/driver/engine/mcp/corsearch-server.mjs +3 -1
- package/driver/engine/mcp/coverage-server.mjs +1 -1
- package/driver/engine/mcp/dispositions-server.mjs +47 -5
- package/driver/engine/mcp/euipo-server.mjs +2 -0
- package/driver/engine/mcp/free-tier-server.mjs +2 -0
- package/driver/engine/mcp/gather-config.mjs +8 -2
- package/driver/engine/mcp/probe-server.mjs +37 -0
- package/driver/engine/mcp/proposal-fields.mjs +45 -0
- package/driver/engine/mcp/recording-server.mjs +30 -0
- package/driver/engine/mcp/signa-server.mjs +2 -0
- package/driver/engine/mcp/supplemental.mjs +89 -12
- package/driver/engine/mcp/unit-note-server.mjs +50 -0
- package/driver/engine/mcp/uspto-local-server.mjs +2 -0
- package/driver/engine/openai-agent.mjs +7 -0
- package/driver/engine/probe.mjs +67 -14
- package/driver/engine/tool-refusal.mjs +16 -0
- package/driver/enqueue-schema.mjs +2 -2
- package/driver/envelope-settle.mjs +82 -13
- package/driver/findings-model.mjs +4 -4
- package/driver/gateway.mjs +18 -2
- package/driver/manager-groups-verdict.mjs +1 -1
- package/driver/matter-frame-record.mjs +24 -7
- package/driver/named-band.mjs +1 -1
- package/driver/package.json +1 -1
- package/driver/partial-payload-baseline.json +12 -3
- package/driver/pipeline-knockout.mjs +3 -3
- package/driver/pipeline.mjs +154 -50
- package/driver/plan-run-agreement-verdict.mjs +49 -0
- package/driver/portal-service.mjs +8 -4
- package/driver/progress.mjs +14 -3
- package/driver/publish/index.mjs +41 -26
- package/driver/publish/report-data.mjs +4 -3
- package/driver/publish/xlsx.mjs +26 -4
- package/driver/queue-markers.mjs +44 -0
- package/driver/queue-watch-verdict.mjs +2 -2
- package/driver/reference-score.mjs +10 -2
- package/driver/register-availability.mjs +2 -2
- package/driver/register-plan.mjs +313 -21
- package/driver/roster-verdict.mjs +1 -1
- package/driver/runner.mjs +26 -2
- package/driver/settle-stamp.mjs +10 -3
- package/driver/skills/clearance-common-law/SKILL.md +2 -0
- package/driver/skills/clearance-register/SKILL.md +44 -3
- package/driver/skills/clearance-register/digest.md +5 -5
- package/driver/skills/clearance-register/providers/clarivate.md +1 -1
- package/driver/skills/clearance-register/unit.md +39 -0
- package/driver/skills/clearance-variants/SKILL.md +1 -1
- package/driver/skills/matter-frame/SKILL.md +4 -2
- package/driver/stages.mjs +12 -5
- package/driver/status-snapshot.mjs +2 -2
- package/driver/suite-census.json +293 -29
- package/driver/synthesis-record.mjs +80 -2
- package/driver/unit-file-drift.mjs +3 -3
- package/driver/unit-inventory.mjs +2 -2
- package/driver/unit-state-verdict.mjs +1 -1
- package/driver/updater-identity.mjs +2 -3
- package/driver/variant-manifest-model.mjs +11 -1
- package/driver/verify.mjs +5 -5
- package/driver/withheld-families.mjs +104 -0
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/lib/brief.mjs +16 -12
- package/mcp-server/lib/runs.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +3 -2
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-DMthc7PQ.js → index-GBbbyQxc.js} +22 -4
- package/portal-ui/dist/index.html +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/_shared/count.mjs +2 -2
- package/providers/_shared/enumerate.mjs +15 -2
- package/providers/_shared/execute-plan.mjs +19 -1
- package/providers/_shared/plan-guards.mjs +40 -0
- package/providers/clarivate/src/capabilities.js +15 -5
- package/providers/clarivate/src/core.js +41 -5
- package/providers/corsearch/src/capabilities.js +4 -0
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/capabilities.js +22 -8
- package/providers/signa/src/core.js +12 -1
- package/scripts/demo-evidence.mjs +114 -0
- package/scripts/e2e.mjs +1 -1
- package/scripts/engine-probe.mjs +6 -5
- package/scripts/env-audit.mjs +1 -1
- package/scripts/freeze-example-run.mjs +3 -3
- package/scripts/live-surface-check.mjs +32 -33
- package/scripts/mint-suite-census.mjs +66 -0
- package/scripts/package-size-budget.mjs +117 -0
- package/scripts/register-plan-shape.mjs +259 -0
- package/scripts/release-note-required.mjs +38 -1
- package/scripts/repo-writes.mjs +1 -1
- package/scripts/report-sections-render-check.mjs +7 -3
- package/scripts/score.mjs +7 -1
- package/scripts/settings-render-check.mjs +36 -0
- package/scripts/travelling-predicates.mjs +1 -1
- package/shared/identifier-scan.mjs +22 -5
- 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.
|
package/scripts/repo-writes.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
229
|
-
|
|
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 (!
|
|
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)
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
}
|