bareloop 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ All notable changes to bareloop are documented here. Format:
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html). Pre-1.0: **minor** = a ladder rung or
6
6
  feature lands, **patch** = docs, fixes, scaffolding.
7
7
 
8
+ ## [0.46.0] - 2026-10-23
9
+
10
+ ### Added
11
+
12
+ - **Cross-run learnings, code only (`src/learn.js`, `src/learnstore.js`, `src/stagewords.js`; ships OFF).** At the end of every run ($0, no model call anywhere) the run's own spine and gate audit become cards: a check that went red and later passed in the same run (FIXED) or was still red at run end (NEVER FIXED), each field copied from the record (job, step, the check and its catalogue sentence, the arbiter's lines, what the passing try wrote and its `attempt-diff`). Two cards are one lesson when they share a job, a check, an error kind and 60% of their flagged-text words (code-less cards fold on the words alone when both sides have some; untested on real data). Lessons go into ONE ledger per user, `~/.config/bareloop/learn/ledger.json`, count starting at 1 (+1 per run per row, idempotent by run id), with per-model counts, the latest sample card, and climbing (a lesson in 3 jobs on one place, then 3 places, climbs; no check-wide set-aside, all-jobs hints are read last; the level is worked out when memory is read, from rows not older than 56 days only, so it can drop back, and the file stores none). The ledger keeps every row and deletes none; what the planner is handed is filtered at read time: a row last seen more than 56 days ago and a row of another signed job version are not shown (a new job version opens its own rows), and a lesson seen again after that counts +1 on its old row. No `expired` flag is stored. A rubric job's lessons are held until the human Accept at the review door (up to 60 days). With the new `learningHints` setting in `config.json` ON, the PLANNER (the draft and the first replan only, never a worker) is handed up to 2,000 characters: this job's lessons, then this place's, then all-jobs, highest count first; the exact text is recorded as a `learnings` spine record and shown read-only in the Audit tab. OFF still keeps the tally. A FIXED hint also shows the first 3 `+`/`-` lines of what the passing try changed (never on NEVER FIXED hints); "It said" shows the first real flagged line rather than a summary line, and a path already shown in the same hint is written by its basename. The check-catalogue sentences moved verbatim from `src/panel/server.js` to `src/stagewords.js`.
13
+
14
+ - **Each worker try records what it changed (`attempt-diff`).** After every step try and every close-fix try the spine gets one `attempt-diff` event: the unified diff (-U1) of each changed file inside the try's write fence, secrets masked, capped at 4 KB per file and 16 KB per event (cut at a line boundary, with the count of cut lines), binary files by path only. Taken with throwaway git trees, so the branch, HEAD, index and stash are untouched; a try that changed nothing records nothing; a git failure records a `gitError` field and never fails the run. Spine only: no prompt, panel or gate-audit change, no model call.
15
+
16
+ - **The Audit tab's "What the planner was told" is one fold box like the other Audit sections.** The header is a full-width toggle `[✓] learnings What the planner was told from earlier runs · 3 hints` (no arrow, open or closed; the count is the hints actually handed to the planner, and the `[✓] learnings` sign and word come from the same helper as the schedule and step headers). Opened, each hint is shown as rows in the Audit table look (the same label and value rows the schedule section uses): a bold title row (`1 - this job - seen 2 times - check - error code - fixed|never fixed`), then It said / Fix touched / What changed; no inner boxes, per-hint toggles or separate raw-text fold. A first-replan record shows under an "At the first replan" sub-heading in the same style. The fields come from the server (`parseHintBlock` in `src/learn.js`, the one reader of the block, tested against real `renderHints` output); the page never splits the prose. Read-only.
17
+
18
+ ### Fixed
19
+
20
+ - **A green worktree run no longer fails its final commit when the repo gitignores `.smoke`, `.litectx` or `gate-audit.jsonl`.** `git add` exits 1 when an exclude names a path that exists and is gitignored (pulselog's `.*` rule: run mvk2e4uy), so the work never landed on the branch and the folder was kept. The commit now excludes only the books git would otherwise stage (an ignored path is never staged anyway); a genuine git failure still throws. The `node_modules` glob exclude and the attempt-diff snapshot (`add -f`) were not affected.
21
+
22
+ - **A key right after a hyphen is now found and masked.** The secret-shape inventory (`SECRET_PATTERNS`) refused to match a key whose previous character was `-`, so `step-ghp_...` slipped past the scan and the redactor. The distinctive prefixes `ghp_`, `github_pat_`, `AKIA`, `AIza` and `xox[bap]-` now drop only the hyphen from their left bound (a word character or `_` in front still blocks the match, so `flask-sqlalchemy` and look-alike words stay clean). `sk-` keeps the full bound because `sk-` ends ordinary words (`task-`...); a key directly after a hyphen there (`step-sk-...`) is a documented known limit of the shape list. Only adds matches.
23
+
24
+ - **Quadratic and cubic regexes made linear (ReDoS audit; same matches, fuzz-proven).** A long run of one character in a pasted page, a model answer, a patient's source or a config value could make a backtracking regex rescan it from every start. Fixed in `labelscan`, `pagerules`/`pagedry` (the number before a label or unit is now a backward scan, one shared copy), `learn` (patient and worktree path prefixes, `PATH_RE`, the union-find in `climb()`), `text` (`extractArtifact` on an unclosed code fence), `ladder` (`normalizeGap`), `judged` (declaration and JSDoc finders), `authorreadout` (the miswrite sentence), `close-integrity` (the string-literal scan), the bundle import reader, and the trailing-slash, zero, dot and space trims in `config`, `providerrows`, `source`, `schedulewords`, `authorscout` and `modellist`. Each was checked against the old regex on 1M+ random and edge strings with 0 mismatches where a regex was replaced, and each has a regression test that fails on the old code. No change to any result, verdict or signed hash.
25
+
26
+ ### Changed
27
+
28
+ - **Dead exports removed.** `ruleQuestions` and `wholeSentences` (`src/inputlook.js`; no caller since the asks became code-forced from the page map) and `hintsMatchRecord` (`src/learn.js`; no caller) are deleted. None is reachable from the package root (`src/index.js`).
29
+
8
30
  ## [0.45.0] - 2026-10-22
9
31
 
10
32
  ### Added
@@ -2723,6 +2723,20 @@ is not a spend record and no spend-slicing instrument needs to account for it.
2723
2723
  prints the same line when handed the run's spine file as its optional third positional.
2724
2724
 
2725
2725
 
2726
+ #### `attempt-diff` — what each worker try changed (`src/attemptdiff.js`, `src/planrun.js`)
2727
+
2728
+ One spine record per step try and per close-fix try that changed something inside the try's write fence:
2729
+ `{phase, step, iteration, files: [{path, diff, truncated, cutLines}], binary: [path], truncated, gitError?}`
2730
+ (`phase` is `step:<id>` or `fix`; `step` is `null` on a fix try). `diff` is one file's unified diff (`-U1`),
2731
+ cut at a line boundary at 4,096 bytes per file and 16,384 per event (`cutLines` = lines left out, `truncated`
2732
+ is the event-level flag); a binary file is listed by path only; every string passes the one secret inventory.
2733
+ It is read off two throwaway git trees (a temporary index at try start and at try end, then `git diff`
2734
+ between them), so the work branch, HEAD, the real index and the stash list are never touched; only
2735
+ unreferenced loose objects are left. Gitignored files inside the fence are seen (`add -f`, so a rubric job's
2736
+ `output/` shows); `.git`, `node_modules`, `.smoke`, `.litectx` and `gate-audit.jsonl` never are. A try that
2737
+ changed nothing in scope emits nothing, and a failed capture emits `gitError` with empty lists: a record,
2738
+ never a verdict, and it never throws. No prompt carries it; the learnings cards read it ("What changed").
2739
+
2726
2740
  ### `replayRun(spineEvents, auditEvents?, { runId? })` / `formatReplay(summary)` / `summarizeForAllLine(summary)` / `formatAllLines(entries)` — `src/replay.js`
2727
2741
 
2728
2742
  `summarizeForAllLine(summary)` reduces one `replayRun` summary to one `--all`/directory-
@@ -3866,6 +3880,17 @@ provider but `anthropic-api`, spine bundling for `formatReplay` on the minting r
3866
3880
  `runid` is noted), and tarball packing — a bundle is a plain directory; use `npm pack` on it
3867
3881
  yourself if you want a tarball.
3868
3882
 
3883
+ ### Cross-run learnings — a ledger of lessons, code only (`src/learn.js`, `src/learnstore.js`, `src/stagewords.js`; `docs/product/ITEM33-BUILD.md` "Addendum 2026-10-22 (rev 2)")
3884
+
3885
+ No model is called anywhere in learnings (a test walks the import closure). Pure rules in `learn.js`; file IO in `learnstore.js`.
3886
+
3887
+ - **Setting:** `learningHints` (`true`/`false`) in `config.json`, absent = OFF. OFF still writes the tally at run end; it only means the planner is handed nothing.
3888
+ - **Run end:** `execute` (`src/userrun.js`, shared by the panel, the CLI and the bundle runner) calls `learnAtRunEnd({ home, runid, events, auditFiles, spec, specHash, workdir, trust })` once, right after the claim settles, in its own try/catch: a failure prints one WARNING and never changes the verdict, exit code or spend. It writes nothing when the engine has no config home.
3889
+ - **Cards:** `buildCards({ runid, events, audit, workdir, spec, model, questions })` → `{ cards, suspect, thin, noise }`. FIXED = a check red then the same check passes later in the run; NEVER FIXED = still red at run end. Excluded and counted: noise runs (provider-red, close-crashed, worker-crash, no close spoke), CHECK-SUSPECT (a no-suppressions hit on a prose comment), TOO-THIN (no error kind and no fix files). Written files come from `attempt-diff` events and the gate audit's allow write/edit rows relative to the tree.
3890
+ - **Fold:** `cardsMatch` / `foldCards`: same job + check, a shared error kind, Jaccard >= 60% on flagged-text tokens (a fit on known cards, not a proof). Code-less cards (kind `other`) fold on the text rule alone, only when both token sets are non-empty (R1, 2026-10-23: UNTESTED on real data; the 60% was fitted on cards with codes). `kindAgrees` is the one kind test.
3891
+ - **Ledger** `<config home>/learn/ledger.json` (mode 600, atomic, secrets masked): rows `{ id, lesson, count, sample, models, workflow, place, level, trust, runIds, firstSeen, lastSeen, specHash }`, plus `pending` (held rubric cards), `suspect`, `health`. There is no check-wide set-aside (R2, 2026-10-23): a lesson seen in several jobs climbs by the climb rules and is read at its level; all-jobs hints come last. `countLedger` (+1 per run per row), `holdPending` / `releasePending` (rubric cards count only after the human Accept; `releaseRun` is called from the door's accept path), `climb` (the level of a lesson, derived at read time by `readableRows` from the rows not decayed at that moment: it can go back down when siblings age past 56 days; the file stores `workflow` only and ignores an older stored level). The ledger keeps EVERY row (nothing is deleted, no `expired` flag is stored; hamr 2026-10-23). Memory is the filter: `readableRows` / `renderHints` skip a row last seen more than 56 days ago (`isDecayed`) and a workflow row of another signed hash (`otherJobVersion`); a lesson seen again counts +1 on its old row. Old files with `status`/`expiredBy` still load; the fields are ignored and dropped at the next write.
3892
+ - **Reading:** `hintsForRun({ home, spec, specHash })` renders once at run start; `runJob(..., { learnHints: { text, ids } })` → `runPlan` hands it to the PLANNER at the draft and the first replan only, and emits a `learnings` spine record with the exact text. `renderHints(ledger, { workflow, place, specHash, now })` is the one render (this job, then this place, then all-jobs, highest count first, 2,000 characters, a hint whole or not at all; all-jobs hints last); a FIXED hint adds `What changed:` (first 3 `+`/`-` lines of the stored diff, 150 chars each, then `(+N more)`; never on NEVER FIXED) and a repeated path in a hint reads as its basename (R3, R5, 2026-10-23); the spine's recorded text is byte-equal to a fresh `renderHints(...).text` from the ledger as it stood. The Audit run detail carries `learnings` (read-only).
3893
+
3869
3894
  ### Settings — the keys file, `config.json`, the monthly $ limit, the Providers tab (PANEL-BUILD.md P4a, `src/keysfile.js`, `src/config.js`, `src/monthly.js`, `src/providerrows.js`, `src/panel/settingsroutes.js`)
3870
3895
 
3871
3896
  Two files in `~/.config/bareloop/`, outside any repo:
@@ -3879,7 +3904,7 @@ Two files in `~/.config/bareloop/`, outside any repo:
3879
3904
  real one). The CLI (`run-u`, `interview`, `author`) and the panel both use it.
3880
3905
  - **`config.json` — every setting, never a key value.** `readConfig({ home })` /
3881
3906
  `updateConfig(patch, { home })` (atomic tmp + rename, mode 600, unknown fields kept).
3882
- Fields: `monthlyLimitUsd` (number above 0; absent or `null` = no limit; any other value present
3907
+ Fields: `learningHints` (true/false; absent = off; see "Cross-run learnings"), `monthlyLimitUsd` (number above 0; absent or `null` = no limit; any other value present
3883
3908
  is a `ConfigError`, refused like an unreadable file), `keys.<ENV NAME>` = `{ shape, baseUrl, legacyMaxTokens }` (the Providers rows' settings: an API shape id, the typed Base URL,
3884
3909
  the "old max_tokens" tick), `models.<ENV NAME>` = a list of `{ model, use, inPerM?, outPerM? }` lines (replaced WHOLE on every save; the customer's own price below). Nothing is built
3885
3910
  in: a row with nothing saved is shape `openai-api`, no address, no lines. A secret-shaped
@@ -4405,7 +4430,7 @@ Check what the person asked for, not what the worker chose to write. bareloop ne
4405
4430
  - **The plan card.** One code-worded row per fact under its line, read by the one reader (`checksOfClose` / `factRows`) the confirm card, the Job tab (`planFromSpec`) and the sign-time `planMatchesClose` share, e.g. `fact · price — money in US dollars, at most 450 US dollars, after "Price", quoted from "Flights"`, then the count row. A `~` rule the drafter listed as not checked reads `line N: "<rule as written>" — <reason>`.
4406
4431
  - **The worker brief.** `rubricGoal({ ..., closeDecl })` adds a code-written brief per line (`lineBriefs`): a facts line gets its facts and its count; a text line that follows gets "every number must be copied exactly as written in a quote you wrote for job line N". `stepFileContract()` names both shapes.
4407
4432
  - **Web addresses as inputs (Part 1c, `src/pagefetch.js`, `src/source.js`).** An Inputs line may be `label: https://...` (also a rubric card's FIRST line; `looksLikeRubricInput`). `fetchPageText(url)` calls barebrowse `connect({ incognito: true, blockPrivateNetwork: true })` (`CONNECT_OPTIONS`) → `goto` → `waitForNetworkIdle({ idle: 1500, timeout: 20000 })` (bounded; a page that never settles is read as it stands) → `snapshot({ mode: 'act' })`, always closing the browser, with our own 90 s deadline (L1: a load-time `browse()` snapshot gave 77,951, 8,801 or 719 characters of the same results page; L2: a page whose text outside the banner / navigation / contentinfo landmarks is under `MIN_CONTENT_CHARS` = 100 (`pageContentChars`) is a stop too, "only the site's own header and menus", and an input with no content never reaches the look or an early ask: the survey is ABSENT, "holds nothing to read", nothing spent); an empty or under-200-character page (`MIN_PAGE_CHARS`), a bot challenge, an unreachable address, a credentialed or secret-shaped address, or a page carrying a secret shape is a plain-words $0 stop on its line. `proveRubricInputs({ ..., fetchUrl })` freezes the page text under `input/<label>.txt` and records `url` on the manifest input row. A FRESH `run-u --spec` start (not `--resume` / `--door` / `--decide`) calls `refreezeUrlInputs`: it fetches every recorded address again, writes that text into the prepared tree, commits it as the new seed and rewrites `source.json` (new `sha256` and `seed`, `draftSha256` keeps what the look saw) BEFORE the worker starts (inside `execute`, AFTER the run's row is listed with its pid and cap, so the Runs list shows it while the page loads; the Run tab reads "fetching the page…" until `source.json` carries `refrozenAt`), so quotes are checked against the run's copy and the worker reads it, never the live page. A failed or empty fetch stops the run (the row is settled at $0 as `failed` with the plain reason, `refused before start`, and the monthly claim is freed), "The run did not start. Nothing spent." A rubric job on a URL or file is recognised by the signed spec's verdict type on a Reuse and on a scheduled fire (`startSession`, `src/panel/authorroutes.js`), so line 1 is not proved as a repo path; a fresh job reads it from the card. Plan card (L3): `settlePersonsWords` (called in `authorClose` before validation) unmarks AI's proposal on a limit made of the person's own numbers, swaps in the one explicit positive time range the person wrote, and drops a `refused` entry for a `~` rule a part of its line carries; the look's fact may carry `forRule` (a rule it numbered from its own knowledge, marked AI's proposal), which removes that rule from the look's not-found list so it is not asked. Test seams: `fetchPageText(url, { connect })`, `deps.fetchUrl` (`run-u`), `deps.fetchUrl` / `deps.lookFn` (`createSession`).
4408
- - **Ask at once, only about intent (I1; the asks are now code-forced from the page map, see "The look reads, code asks" below).** A `~` rule the look cannot tie to a kept page word becomes a question worded by code (`askableFromLook`, `src/pagemap.js`: line and rule named; the older `ruleQuestions` in `src/inputlook.js` is no longer called by the draft path), asked before the confirm turn through the existing question gate (`answerQuestions`; `normalizeQuestions` caps it at `MAX_QUESTIONS`); the answer is a new `~` rule on that line. A rubric job on files never offers the piecemeal try-3 question; its failed-draft stop (`draftStopText({ wholeJob: true })`) says to review the whole job and submit it again.
4433
+ - **Ask at once, only about intent (I1; the asks are now code-forced from the page map, see "The look reads, code asks" below).** A `~` rule the look cannot tie to a kept page word becomes a question worded by code (`askableFromLook`, `src/pagemap.js`: line and rule named), asked before the confirm turn through the existing question gate (`answerQuestions`; `normalizeQuestions` caps it at `MAX_QUESTIONS`); the answer is a new `~` rule on that line. A rubric job on files never offers the piecemeal try-3 question; its failed-draft stop (`draftStopText({ wholeJob: true })`) says to review the whole job and submit it again.
4409
4434
  - **Known limits (documented, not built around).** Airport codes like SFO have no stable label on a flight page: direction is checkable only through the full airport names the page uses. An unanchored `text` fact cannot tell which field a value sits in. `am` / `pm` in lower case and `9:27AM` are malformed times; `v0.41.0` is one token. A fact step's quote proves the value is in a real record, not that it is the RIGHT record: "the cheapest" and "the last 3" are Not checked. Verified on real files only; the live proof (DeepSeek, both jobs through the panel) has not been run.
4410
4435
 
4411
4436
  ## Triggers — a job that starts by itself (panel-only; `src/trigger*.js`, `src/panel/schedules.js`, `schedulestart.js`, `scheduler.js`, `src/schedulewords.js`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bareloop",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Workflows that earn their own design, with receipts — agent-authored workflows for repeated, long, verifiable jobs that improve across runs under an un-gameable gate.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -0,0 +1,116 @@
1
+ // attempt-diff — what each worker TRY changed, as a spine record (ITEM33 addendum
2
+ // 2026-10-22). The spine and the gate audit keep only file names and byte sizes of
3
+ // edits; a learnings card could say WHICH files a passing try touched but never WHAT
4
+ // changed. This module is the ONE owner of that capture. It is a record, never a
5
+ // verdict: every failure is caught and returned as a field, nothing here throws.
6
+ //
7
+ // How: a git TREE snapshot at try start and at try end, each through a TEMPORARY
8
+ // index (GIT_INDEX_FILE + `git add -A` + `git write-tree`), then `git diff <t0> <t1>`.
9
+ // The work branch, HEAD, the real index and the stash list are never touched; the
10
+ // temp index is removed. Only unreferenced loose objects are left in the object store.
11
+
12
+ import { execFile } from 'node:child_process';
13
+ import { promisify } from 'node:util';
14
+ import { mkdtemp, rm } from 'node:fs/promises';
15
+ import { tmpdir } from 'node:os';
16
+ import { existsSync } from 'node:fs';
17
+ import { join, relative, resolve } from 'node:path';
18
+ import { redactSecrets } from './validate.js';
19
+
20
+ const exec = promisify(execFile);
21
+
22
+ /** per-file and per-event byte caps for the recorded diff text */
23
+ export const ATTEMPT_DIFF_FILE_MAX = 4096;
24
+ export const ATTEMPT_DIFF_EVENT_MAX = 16384;
25
+
26
+ /**
27
+ * Cut `text` to at most `max` bytes at a LINE boundary (never mid-line).
28
+ * @param {string} text @param {number} max
29
+ * @returns {{text: string, truncated: boolean, cutLines: number}}
30
+ */
31
+ export function cutAtLine(text, max) {
32
+ if (Buffer.byteLength(text) <= max) return { text, truncated: false, cutLines: 0 };
33
+ const lines = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
34
+ let used = 0;
35
+ let kept = 0;
36
+ for (const l of lines) {
37
+ const b = Buffer.byteLength(l);
38
+ if (used + b > max) break;
39
+ used += b;
40
+ kept++;
41
+ }
42
+ return { text: lines.slice(0, kept).join(''), truncated: true, cutLines: lines.length - kept };
43
+ }
44
+
45
+ /** @param {string} abs @param {string[]} prefixes */
46
+ const inFence = (abs, prefixes) => prefixes.some((p) => abs === p || abs.startsWith(p.endsWith('/') ? p : `${p}/`));
47
+
48
+ // the arbiter's books and installed packages never enter a snapshot, even inside a wide fence
49
+ const NOT_WORK = [':(exclude,glob)**/node_modules/**', ':(exclude,glob)**/.git/**', ':(exclude).smoke', ':(exclude).litectx', ':(exclude)gate-audit.jsonl'];
50
+
51
+ /**
52
+ * Snapshot the FENCE of the working tree to a git tree id via a throwaway index. Only the
53
+ * fence's existing prefixes are added, and with `-f`: a rubric job's `output/` must be seen
54
+ * even when the source folder's own .gitignore lists it (the hidden-git seed ignores that
55
+ * file's meaning; so does this).
56
+ * @param {string} workdir @param {string[]} fence @returns {Promise<string>}
57
+ */
58
+ async function snapshotTree(workdir, fence) {
59
+ const tmp = await mkdtemp(join(tmpdir(), 'bareloop-adiff-'));
60
+ try {
61
+ const env = { ...process.env, GIT_INDEX_FILE: join(tmp, 'index') };
62
+ const specs = fence.filter((p) => existsSync(p)).map((p) => `:(literal)${relative(workdir, p) || '.'}`);
63
+ if (specs.length > 0) await exec('git', ['add', '-A', '-f', '--', ...specs, ...NOT_WORK], { cwd: workdir, env, maxBuffer: 1 << 26 });
64
+ const { stdout } = await exec('git', ['write-tree'], { cwd: workdir, env });
65
+ return stdout.trim();
66
+ } finally {
67
+ await rm(tmp, { recursive: true, force: true });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Begin recording one worker try. Call BEFORE the worker's turn; call `finish()` after
73
+ * (in a `finally`). Never throws.
74
+ * @param {{workdir: string, fence: string[], redact?: (s: string) => string}} o
75
+ * `fence` = the ABSOLUTE write-scope prefixes the gate enforces for this try.
76
+ * @returns {Promise<{finish: () => Promise<null | {files: {path: string, diff: string, truncated: boolean, cutLines: number}[], binary: string[], truncated: boolean, gitError?: string}>}>}
77
+ * `finish()` resolves null when the try changed nothing in scope.
78
+ */
79
+ export async function startAttemptDiff({ workdir, fence, redact = redactSecrets }) {
80
+ /** @type {string|null} */
81
+ let t0 = null;
82
+ /** @type {string|null} */
83
+ let startError = null;
84
+ try { t0 = await snapshotTree(workdir, fence); } catch (e) { startError = String(/** @type {any} */ (e)?.message ?? e); }
85
+ return {
86
+ async finish() {
87
+ try {
88
+ if (t0 === null) throw new Error(`start snapshot failed: ${startError}`);
89
+ const t1 = await snapshotTree(workdir, fence);
90
+ if (t1 === t0) return null;
91
+ const g = (/** @type {string[]} */ a) => exec('git', ['-c', 'core.quotepath=false', ...a], { cwd: workdir, maxBuffer: 1 << 26 });
92
+ const { stdout: names } = await g(['diff', '--name-only', '-z', '--relative', /** @type {string} */ (t0), t1]);
93
+ const paths = names.split('\0').filter(Boolean).filter((p) => inFence(resolve(workdir, p), fence));
94
+ /** @type {{path: string, diff: string, truncated: boolean, cutLines: number}[]} */
95
+ const files = [];
96
+ /** @type {string[]} */
97
+ const binary = [];
98
+ let left = ATTEMPT_DIFF_EVENT_MAX;
99
+ let anyCut = false;
100
+ for (const p of paths) {
101
+ const { stdout } = await g(['diff', '-U1', '--no-color', '--no-ext-diff', '--relative', /** @type {string} */ (t0), t1, '--', `:(literal)${p}`]);
102
+ const path = redact(p);
103
+ if (/^Binary files .* differ$/m.test(stdout) || /^GIT binary patch$/m.test(stdout)) { binary.push(path); continue; }
104
+ const cut = cutAtLine(redact(stdout), Math.min(ATTEMPT_DIFF_FILE_MAX, left));
105
+ left -= Buffer.byteLength(cut.text);
106
+ if (cut.truncated) anyCut = true;
107
+ files.push({ path, diff: cut.text, truncated: cut.truncated, cutLines: cut.cutLines });
108
+ }
109
+ if (files.length === 0 && binary.length === 0) return null;
110
+ return { files, binary, truncated: anyCut };
111
+ } catch (e) {
112
+ return { files: [], binary: [], truncated: false, gitError: redact(String(/** @type {any} */ (e)?.message ?? e)).slice(0, 500) };
113
+ }
114
+ },
115
+ };
116
+ }
@@ -409,6 +409,12 @@ const PLAIN_MAX_BULLETS = 8;
409
409
  const RECORD_MAX_REDS = 40;
410
410
  const RECORD_STR_MAX = 600;
411
411
 
412
+ // Each of these two finds its text on the FIRST line that has it, anchored to a line start: a lazy (.*?) that
413
+ // fails runs to the end of its line, and the same pattern retried at every later start on that line fails the
414
+ // same way (the later text is a part of the earlier one's), so retrying them only made the read quadratic.
415
+ const MISSING_REDS_RE = /(?<=^|[\n\r\u2028\u2029])(?:(?!expected red\(s\) the pipe did not raise: )[^\n\r\u2028\u2029])*expected red\(s\) the pipe did not raise: (.*?)(?: — |\. The floor)/;
416
+ const EXTRA_REDS_RE = /(?<=^|[\n\r\u2028\u2029])(?:(?!red\(s\) the pipe raised that nobody signed: )[^\n\r\u2028\u2029])*red\(s\) the pipe raised that nobody signed: (.*?)(?: — |\. The floor)/;
417
+
412
418
  /** a code-written sentence that carries the person's own words (a rubric-step sorting red): scrubbed and one line, bounded wide enough to stay whole
413
419
  * @param {unknown} s @returns {string} */
414
420
  function fullText(s) {
@@ -759,8 +765,8 @@ const ruleFns = (list) => list.split(';').map((x) => x.trim()).filter(Boolean).m
759
765
  */
760
766
  function miswriteSentences(r) {
761
767
  const who = `Case "${safeText(r.caseId ?? /^case "(.*?)" was graded/.exec(r.detail)?.[1] ?? '')}"`;
762
- const missing = /expected red\(s\) the pipe did not raise: (.*?)(?: — |\. The floor)/.exec(r.detail)?.[1];
763
- const extra = /red\(s\) the pipe raised that nobody signed: (.*?)(?: — |\. The floor)/.exec(r.detail)?.[1];
768
+ const missing = MISSING_REDS_RE.exec(r.detail)?.[1];
769
+ const extra = EXTRA_REDS_RE.exec(r.detail)?.[1];
764
770
  /** @type {string[]} */
765
771
  const out = [];
766
772
  for (const { rule, fn } of missing ? ruleFns(missing) : []) {
@@ -778,7 +778,7 @@ export const COUNTS_ONLY_NAMES = 12;
778
778
  * `"src (also …)"`). Strip the annotation, keep the path.
779
779
  * @param {any} e
780
780
  */
781
- export const cleanEntry = (e) => String(e ?? '').split(' (')[0].trim().replace(/\/+$/, '');
781
+ export const cleanEntry = (e) => String(e ?? '').split(' (')[0].trim().replace(/(?<!\/)\/+$/, '');
782
782
 
783
783
  /**
784
784
  * `git ls-tree -r --name-only <seed>` — the files as of the SEED COMMIT.
package/src/bundle.js CHANGED
@@ -115,10 +115,95 @@ function isBareloopSrcImport(spec) {
115
115
  return spec === 'bareloop' || /^(\.\.?\/)+src\//.test(spec);
116
116
  }
117
117
 
118
- // Every STATIC import statement, clause captured whole (undefined when there
119
- // is no `from` — a bare side-effect `import 'x'`). Deliberately permissive:
120
- // the shape check below is what decides legality, not this regex.
121
- const STATIC_IMPORT_RE = /import\s+(?:([^'";]*?)\s+from\s+)?(['"])([^'"]+)\2/g;
118
+ /**
119
+ * Every STATIC import statement, clause captured whole (undefined when there
120
+ * is no `from` — a bare side-effect `import 'x'`). Deliberately permissive:
121
+ * the shape check below is what decides legality, not this reader.
122
+ *
123
+ * It reads exactly what `/import\s+(?:([^'";]*?)\s+from\s+)?(['"])([^'"]+)\2/g`
124
+ * read (that regex was cubic on many `import` words or a long run of spaces,
125
+ * each start rescanning the text after it). After `import` and its whitespace
126
+ * the statement's specifier quote can only be the FIRST quote or semicolon
127
+ * that follows (the clause may hold neither), so the `from` before it, the
128
+ * clause before that and the closing quote after it are each found by one
129
+ * look at that spot, and the spot is remembered for the next `import` word.
130
+ * The one odd shape the regex also accepted is kept: `import from 'x'` (two
131
+ * or more whitespace characters, `from` straight after them) reads with an
132
+ * empty clause, the regex having given one whitespace character back to it.
133
+ * @param {string} source
134
+ * @returns {{ raw: string, clause: string|undefined, spec: string }[]}
135
+ */
136
+ function staticImportsOf(source) {
137
+ /** @type {{ raw: string, clause: string|undefined, spec: string }[]} */
138
+ const out = [];
139
+ const n = source.length;
140
+ const isSpace = (/** @type {number} */ i) => /\s/.test(source[i]);
141
+ const stops = /['";]/g;
142
+ const quotes = /['"]/g;
143
+ // first quote-or-semicolon at or after `stopFrom` (n when none), reused while the next word still lies before it
144
+ let stopFrom = 0;
145
+ let stopAt = -1;
146
+ const firstStop = (/** @type {number} */ i) => {
147
+ if (stopAt >= 0 && i >= stopFrom && i <= stopAt) return stopAt;
148
+ stops.lastIndex = i;
149
+ stopFrom = i;
150
+ stopAt = stops.exec(source)?.index ?? n;
151
+ return stopAt;
152
+ };
153
+ // what follows the specifier quote at `q`, read once per quote: the end of the statement, or -1
154
+ let tailQuote = -1;
155
+ let tailEnd = -1;
156
+ let closerFrom = 0;
157
+ let closerAt = -1;
158
+ const tailOf = (/** @type {number} */ q) => {
159
+ if (q === tailQuote) return tailEnd;
160
+ tailQuote = q;
161
+ if (!(closerAt >= 0 && q + 1 >= closerFrom && q + 1 <= closerAt)) {
162
+ quotes.lastIndex = q + 1;
163
+ closerFrom = q + 1;
164
+ closerAt = quotes.exec(source)?.index ?? n;
165
+ }
166
+ tailEnd = closerAt > q + 1 && closerAt < n && source[closerAt] === source[q] ? closerAt + 1 : -1;
167
+ return tailEnd;
168
+ };
169
+ // the `from` standing just before the whitespace before quote `q`: its start, or -1; read once per quote
170
+ let fromQuote = -1;
171
+ let fromStart = -1;
172
+ const fromBefore = (/** @type {number} */ q) => {
173
+ if (q === fromQuote) return fromStart;
174
+ fromQuote = q;
175
+ let k = q;
176
+ while (k > 0 && isSpace(k - 1)) k -= 1;
177
+ fromStart = k < q && k >= 4 && source.startsWith('from', k - 4) ? k - 4 : -1;
178
+ return fromStart;
179
+ };
180
+ let from = 0;
181
+ for (;;) {
182
+ const at = source.indexOf('import', from);
183
+ if (at === -1) return out;
184
+ from = at + 1;
185
+ let b = at + 6;
186
+ while (b < n && isSpace(b)) b += 1;
187
+ const blanks = b - (at + 6);
188
+ if (blanks === 0 || b >= n) continue;
189
+ const q = firstStop(b);
190
+ if (q >= n || (source[q] !== '"' && source[q] !== "'")) continue;
191
+ const end = tailOf(q);
192
+ if (end === -1) continue;
193
+ const f = fromBefore(q);
194
+ /** @type {string|undefined} */
195
+ let clause;
196
+ if (q === b) clause = undefined; // `import 'x'`, no clause
197
+ else if (f === b && blanks >= 2) clause = ''; // `import from 'x'`
198
+ else if (f > b && isSpace(f - 1)) {
199
+ let j = f - 1;
200
+ while (isSpace(j - 1)) j -= 1;
201
+ clause = source.slice(b, j);
202
+ } else continue;
203
+ out.push({ raw: source.slice(at, end), clause, spec: source.slice(q + 1, end - 1) });
204
+ from = end;
205
+ }
206
+ }
122
207
  // Dynamic `import('x')` (and `await import('x')`, `import ('x')`, …) — no
123
208
  // clause exists for this form at all, so it is ALWAYS unparsed when it
124
209
  // targets something this bundle would otherwise have to check.
@@ -146,8 +231,7 @@ function isRelative(spec) {
146
231
  function relevantImportsOf(source) {
147
232
  /** @type {{ raw: string, spec: string, clause: string|null, dynamic: boolean }[]} */
148
233
  const out = [];
149
- for (const m of source.matchAll(STATIC_IMPORT_RE)) {
150
- const [raw, clause, , spec] = m;
234
+ for (const { raw, clause, spec } of staticImportsOf(source)) {
151
235
  if (!isRelative(spec) && spec !== 'bareloop') continue;
152
236
  out.push({ raw: raw.trim(), spec, clause: clause === undefined ? null : clause.trim(), dynamic: false });
153
237
  }
@@ -72,7 +72,44 @@ export const SYSTEM_PATH_PREFIXES = ['/usr/', '/bin/', '/sbin/', '/lib/', '/lib6
72
72
  // Every quoted string literal (single/double/backtick). Backtick literals
73
73
  // carrying `${…}` interpolation are filtered out below — they are not a
74
74
  // fixed literal, so they cannot be "a path baked into the script" at all.
75
- const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"|`([^`\\]*(?:\\.[^`\\]*)*)`/g;
75
+ const STRING_BODY = {
76
+ "'": /[^'\\]*(?:\\.[^'\\]*)*/y,
77
+ '"': /[^"\\]*(?:\\.[^"\\]*)*/y,
78
+ '`': /[^`\\]*(?:\\.[^`\\]*)*/y,
79
+ };
80
+
81
+ /**
82
+ * The quoted string literals of `source`, in order: each opening quote reads its body (backslash pairs taken
83
+ * whole, so an escaped quote does not close it) and is a literal only when its own quote closes it. An opening
84
+ * quote that never finds its closer would be retried by every later quote of the same kind inside what it
85
+ * read (each of them is an escaped one and would read the same body to the same dead end), so the end of the
86
+ * failed read is remembered and those retries are skipped, which keeps a long unterminated string linear.
87
+ * @param {string} source
88
+ * @returns {{ value: string, backtick: boolean }[]}
89
+ */
90
+ function stringLiteralsOf(source) {
91
+ /** @type {{ value: string, backtick: boolean }[]} */
92
+ const out = [];
93
+ const deadUntil = { "'": 0, '"': 0, '`': 0 };
94
+ const opener = /['"`]/g;
95
+ let from = 0;
96
+ for (;;) {
97
+ opener.lastIndex = from;
98
+ const hit = opener.exec(source);
99
+ if (hit === null) return out;
100
+ const at = hit.index;
101
+ const quote = /** @type {"'"|'"'|'`'} */ (source[at]);
102
+ from = at + 1;
103
+ if (at < deadUntil[quote]) continue;
104
+ const body = STRING_BODY[quote];
105
+ body.lastIndex = at + 1;
106
+ body.exec(source);
107
+ const end = body.lastIndex;
108
+ if (source[end] !== quote) { deadUntil[quote] = end; continue; }
109
+ out.push({ value: source.slice(at + 1, end), backtick: quote === '`' });
110
+ from = end + 1;
111
+ }
112
+ }
76
113
 
77
114
  /**
78
115
  * F8/F129 — a close judges the cwd the runner gives it, never a path baked
@@ -93,10 +130,7 @@ const STRING_LITERAL_RE = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"
93
130
  export function absolutePathLiteralsOf(source) {
94
131
  /** @type {string[]} */
95
132
  const hits = [];
96
- for (const m of source.matchAll(STRING_LITERAL_RE)) {
97
- const isBacktick = m[3] !== undefined;
98
- const value = m[1] ?? m[2] ?? m[3];
99
- if (value === undefined) continue;
133
+ for (const { value, backtick: isBacktick } of stringLiteralsOf(source)) {
100
134
  if (isBacktick && value.includes('${')) continue; // interpolated, not a fixed literal
101
135
  if (!value.startsWith('/')) continue;
102
136
  if (SYSTEM_PATH_PREFIXES.some((p) => value.startsWith(p))) continue;
package/src/config.js CHANGED
@@ -9,6 +9,7 @@
9
9
  //
10
10
  // Shape (unknown fields are kept, never dropped):
11
11
  // { "monthlyLimitUsd": <number > 0>, // absent = no limit, no check
12
+ // "learningHints": true|false, // absent = false: the planner is handed cross-run learnings (src/learn.js); the tally is kept either way
12
13
  // "keys": { "<ENV NAME>": { "shape": "<shape id>", "baseUrl": "http(s)://…", "legacyMaxTokens": true|false } },
13
14
  // "models": { "<ENV NAME>": [ { "model": "<id>", "use": true|false, "inPerM": n, "outPerM": n } ] } }
14
15
  // An older file's `keys.<ENV>.{name,priceInPerM,priceOutPerM}` is tolerated and read as ONE model
@@ -91,7 +92,7 @@ export function providerFieldProblem(field, v) {
91
92
  }
92
93
 
93
94
  /** A Base URL as stored: trailing slashes dropped. @param {string} v @returns {string} */
94
- export const cleanBaseUrl = (v) => v.replace(/\/+$/, '');
95
+ export const cleanBaseUrl = (v) => v.replace(/(?<!\/)\/+$/, '');
95
96
 
96
97
  /**
97
98
  * THE one rule for a row's model lines, used by config validation and the Settings routes. A price
@@ -128,6 +129,7 @@ export function modelsProblem(list) {
128
129
  */
129
130
  function docProblem(doc) {
130
131
  // monthlyLimitUsd is NOT judged here: `monthlyLimitOf` (src/monthly.js) owns its read-time sentence
132
+ if (doc.learningHints !== undefined && typeof doc.learningHints !== 'boolean') return 'learningHints must be true or false';
131
133
  if (doc.keys !== undefined) {
132
134
  if (doc.keys === null || typeof doc.keys !== 'object' || Array.isArray(doc.keys)) return 'keys must be an object of key variable names';
133
135
  for (const [env, row] of Object.entries(doc.keys)) {
@@ -156,7 +158,7 @@ function docProblem(doc) {
156
158
  * `keys` merges per key variable name, then per field; `models.<ENV>` is REPLACED WHOLE (`null`
157
159
  * clears it). Validated before anything is written; nothing is written on a refusal.
158
160
  *
159
- * Patch fields understood: `monthlyLimitUsd` (finite > 0), `keys.<ENV>.{shape,baseUrl,legacyMaxTokens}`,
161
+ * Patch fields understood: `monthlyLimitUsd` (finite > 0), `learningHints` (true | false), `keys.<ENV>.{shape,baseUrl,legacyMaxTokens}`,
160
162
  * `models.<ENV>` (a list judged by {@link modelsProblem}). The first write of a row's `models` list
161
163
  * removes that row's older `name` / `priceInPerM` / `priceOutPerM` in the SAME write (never two
162
164
  * sources); a retired top-level `anthropicBalanceNote` is dropped on any write. Any other field is
@@ -176,6 +178,7 @@ export function updateConfig(patch, opts = {}) {
176
178
  if (k === 'monthlyLimitUsd' && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
177
179
  throw new ConfigError('monthlyLimitUsd must be a number above 0 (remove the limit to have none)');
178
180
  }
181
+ if (k === 'learningHints' && typeof v !== 'boolean') throw new ConfigError('learningHints must be true or false');
179
182
  next[k] = v;
180
183
  }
181
184
  delete next.anthropicBalanceNote;
package/src/inputlook.js CHANGED
@@ -8,9 +8,8 @@
8
8
  // not on the list is NOT carried into the report - it is moved to "could not check", named. Code never decides two words mean the
9
9
  // same: the AI proposes, the list and the source prove.
10
10
 
11
- import { scanLabels, labelOnList, renderLabelList, normLabel, LABEL_SHAPES } from './labelscan.js';
11
+ import { scanLabels, labelOnList, renderLabelList, normLabel, LABEL_SHAPES, TAG } from './labelscan.js';
12
12
  import { FACT_SHAPES, FACT_OPS } from './facts.js';
13
- import { lineRules } from './rubricstep.js';
14
13
  import { dropRows, dropLine } from './pagedropwords.js';
15
14
 
16
15
  /** the name of the answer tool - the one place the AI's report enters */
@@ -20,7 +19,6 @@ export const LOOK_MAX_HITS = 40;
20
19
  const SNIPPET = 220;
21
20
  const READ_LINE = 320;
22
21
  const READ_LINES = 40;
23
- const TAG = /\[[A-Za-z]+=[^\]\s]*\]/g;
24
22
  const WS = /[\s   ]+/g;
25
23
 
26
24
  /**
@@ -290,47 +288,3 @@ export function labelListsOf(texts) {
290
288
  }
291
289
 
292
290
  export { LABEL_SHAPES };
293
-
294
- /**
295
- * L3: a reason the AI gave, cut only at a sentence end (never mid-sentence): whole sentences while they fit in `max` characters,
296
- * and nothing at all if the first one alone does not fit. A trailing full stop is dropped (the caller closes the clause).
297
- * @param {string} text @param {number} max @returns {string}
298
- */
299
- export function wholeSentences(text, max) {
300
- const sentences = text.replace(/\s+/g, ' ').trim().split(/(?<=[.!?])\s+/).filter(Boolean);
301
- let out = '';
302
- for (const s of sentences) {
303
- const next = out === '' ? s : `${out} ${s}`;
304
- if (next.length > max) break;
305
- out = next;
306
- }
307
- return out.replace(/[.!?]+$/, '');
308
- }
309
-
310
- /**
311
- * I1 (hamr, 2026-10-13, "Ruled by hamr" 2): when the look shows the person's intent on one `~` rule was NOT captured - its words or
312
- * meaning are not in the source - the person is asked about THAT rule at once, named by its line and rule. Only a look entry that names
313
- * a rule number counts; anything else the look could not find is not asked piecemeal (it is Not checked, or the whole job is reviewed).
314
- * The wording is the code's own, never the model's. The keep-or-drop and the cap are `normalizeQuestions` (src/authorflow.js).
315
- * @param {LookNotFound[]} notFound @param {{n: number, text?: string, rule?: string, rules?: string[]}[]} jobLines
316
- * @returns {{line: number, question: string}[]} every candidate question (the caller applies the machine's caps)
317
- */
318
- export function ruleQuestions(notFound, jobLines) {
319
- /** @type {{line: number, question: string}[]} */
320
- const out = [];
321
- /** @type {Set<string>} */
322
- const seen = new Set();
323
- for (const n of Array.isArray(notFound) ? notFound : []) {
324
- if (!Number.isInteger(n.rule)) continue;
325
- const line = jobLines.find((l) => l.n === n.line);
326
- const rule = line ? lineRules(line)[/** @type {number} */ (n.rule) - 1] : undefined;
327
- if (!line || rule === undefined) continue;
328
- const key = `${n.line}:${n.rule}`;
329
- if (seen.has(key)) continue;
330
- seen.add(key);
331
- const words = n.words ? `${JSON.stringify(n.words)} (from your words)` : 'what this rule asks for';
332
- const why = wholeSentences(String(n.why), 200);
333
- out.push({ line: n.line, question: `Line ${n.line}, rule ${n.rule} - "${rule}": I could not find ${words} in your input${why ? ` (${why})` : ''}. What do you mean by it? Your answer is added as a new ~ rule on line ${n.line}.` });
334
- }
335
- return out;
336
- }
package/src/judged.js CHANGED
@@ -323,7 +323,7 @@ export function docQuoteInBlock(text, declQuote, docQuote) {
323
323
  */
324
324
  function hasDocBlockDirectlyAbove(text, fn) {
325
325
  const n = fn.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
326
- const re = new RegExp(`^(?:export\\s+)?(?:default\\s+)?(?:(?:async\\s+)?function\\s*\\*?\\s*${n}\\b`
326
+ const re = new RegExp(`^(?:export\\s+)?(?:default\\s+)?(?:(?:async\\s+)?function\\s*(?:\\*\\s*)?${n}\\b`
327
327
  + `|(?:const|let|var)\\s+${n}\\s*=|(?:static\\s+)?(?:async\\s+)?${n}\\s*\\()`);
328
328
  const hits = text.split('\n').map((l) => l.trim()).filter((l) => re.test(l));
329
329
  if (hits.length !== 1) return false;
@@ -401,7 +401,7 @@ function sharesPrefix(a, b) {
401
401
  * @param {string} text @returns {string[]}
402
402
  */
403
403
  function patternFields(text) {
404
- const m = /^\s*\{([^{}[\]()]*)\}\s*(?:=\s*\{\s*\})?\s*$/.exec(text);
404
+ const m = /^\s*\{([^{}[\]()]*)\}(?:\s*=\s*\{\s*\})?\s*$/.exec(text);
405
405
  if (!m || m[1].includes('...')) return [];
406
406
  /** @type {string[]} */
407
407
  const out = [];
@@ -417,7 +417,7 @@ function patternFields(text) {
417
417
  /** a doc-block line with its comment decoration (opener, leading star, closer) removed
418
418
  * @param {string} line @returns {string} */
419
419
  function undecorated(line) {
420
- return line.trim().replace(/^\/\*+/, '').replace(/\*+\/$/, '').replace(/^\*+/, '').trim();
420
+ return line.trim().replace(/^\/\*+/, '').replace(/(?<!\*)\*+\/$/, '').replace(/^\*+/, '').trim();
421
421
  }
422
422
 
423
423
  /**
package/src/labelscan.js CHANGED
@@ -28,22 +28,31 @@ const MAX_GAP_LABEL = 24;
28
28
  const MIN_PHRASE = 3;
29
29
  /** how many repeated phrases are looked behind */
30
30
  const MAX_PHRASES = 20;
31
- /** a line is scanned up to this many characters, and for this many values (a minified page is one huge line; the bound keeps the scan linear) */
31
+ /** a line is scanned up to this many characters, and for this many values (a minified page is one huge line; the bound caps the work per line, each finder's own regex is kept linear below) */
32
32
  const MAX_LINE = 20000;
33
33
  const MAX_VALUES_PER_LINE = 400;
34
34
  /** only this much of a source is scanned (a source over it is scanned up to a line end) */
35
35
  export const SCAN_MAX_CHARS = 2_000_000;
36
36
 
37
37
  const WS = /[\s   ]+/g;
38
- const TAG = /\[[A-Za-z]+=[^\]\s]*\]/g;
38
+ /**
39
+ * The page reader's bracketed tag (`[ref=549]`), shared by the three places that strip it. The lookahead and lookbehind add no match and drop no
40
+ * match: a `[` that sits inside the run of an earlier `[letters=` (no `]` or white space between) is skipped, because that earlier start reads the same
41
+ * run to the same end - it matches (and consumes this one) or fails (and so does this one). Without them "[a=[a=[a=..." with no `]` is quadratic.
42
+ */
43
+ export const TAG = /\[(?=[A-Za-z]+=)(?<!\[[A-Za-z]+=[^\]\s]*?)[A-Za-z]+=[^\]\s]*\]/g;
39
44
  const MONTH = '(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)';
40
45
  /** value finders in priority order: an earlier shape owns a span a later one would also match */
41
46
  const FINDERS = /** @type {[string, RegExp][]} */ ([
42
47
  ['date', new RegExp(`\\b\\d{4}-\\d{2}-\\d{2}\\b|\\b\\d{1,2}/\\d{1,2}/\\d{2,4}\\b|\\b${MONTH}\\.? \\d{1,2}(?:, \\d{4})?\\b|\\b\\d{1,2} ${MONTH}\\.? \\d{4}\\b`, 'g')],
43
48
  ['time', /\b\d{1,2}:\d{2}(?: ?[AaPp]\.?[Mm]\.?)?(?![\w:])/g],
44
- ['money', /[$€£] ?\d[\d,]*(?:\.\d+)?|\b\d[\d,]*(?:\.\d+)? ?(?:US dollars?|dollars?|USD|euros?|EUR|pounds?|GBP)\b/g],
49
+ // the 2nd shape's leading lookbehind skips a start inside a digit/comma run that an earlier start of the run already covers (that start reads the same
50
+ // run to the same end, so it matches first or fails like this one); without it "1,1,1,...x" is quadratic. The covering start is never one right after
51
+ // a "." - the digits of a decimal can already belong to an earlier match ("$1.5,2 dollars" reads "2 dollars" as its own)
52
+ ['money', /[$€£] ?\d[\d,]*(?:\.\d+)?|(?<!(?<!\.)\b\d[\d,]*?)\b\d[\d,]*(?:\.\d+)? ?(?:US dollars?|dollars?|USD|euros?|EUR|pounds?|GBP)\b/g],
45
53
  ['number', /(?<![\w.,])\d[\d,]*(?:\.\d+)?(?![\w]|\.\d)/g],
46
- ['text', /[A-Za-z]*\d[\w.\-]*/g],
54
+ // (?<![A-Za-z]): a start inside a run of letters is covered by the run's first letter (same match, earlier), so a long run of letters with no digit is linear
55
+ ['text', /(?<![A-Za-z])[A-Za-z]*\d[\w.\-]*/g],
47
56
  ]);
48
57
  const PHRASE = /\b[A-Z][a-z]+(?: [A-Z][a-z]+)+\b/g;
49
58
 
package/src/ladder.js CHANGED
@@ -58,11 +58,11 @@ export const STRIKE_LIMIT = 2;
58
58
  */
59
59
  const VOLATILE = [
60
60
  // TAP's own timing line, dropped WHOLE: its value is the only thing on it
61
- [/^[ \t]*#?[ \t]*duration_ms[ \t]*[:=].*$/gm, ''],
61
+ [/^[ \t]*(?:#[ \t]*)?duration_ms[ \t]*[:=].*$/gm, ''],
62
62
  // ISO-8601 stamps, wherever they appear in a close's output
63
63
  [/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, '<ts>'],
64
64
  // any millisecond figure (`(1.738ms)`, `took 12 ms`)
65
- [/\d+(?:\.\d+)?[ \t]*ms\b/g, '<ms>'],
65
+ [/(?<!\d)\d+(?:\.\d+)?[ \t]*ms\b/g, '<ms>'],
66
66
  ];
67
67
 
68
68
  /**