bareloop 0.44.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/bareloop.context.md +31 -5
  3. package/package.json +1 -1
  4. package/src/attemptdiff.js +116 -0
  5. package/src/authorflow.js +1 -1
  6. package/src/authorjob.js +25 -1
  7. package/src/authorreadout.js +13 -2
  8. package/src/authorrun.js +8 -2
  9. package/src/authorscout.js +1 -1
  10. package/src/bundle.js +90 -6
  11. package/src/close-integrity.js +39 -5
  12. package/src/config.js +5 -2
  13. package/src/fitcheck.js +166 -0
  14. package/src/inputlook.js +1 -47
  15. package/src/judged.js +3 -3
  16. package/src/labelscan.js +13 -4
  17. package/src/ladder.js +2 -2
  18. package/src/learn.js +943 -0
  19. package/src/learnstore.js +154 -0
  20. package/src/modellist.js +1 -1
  21. package/src/pagedropwords.js +47 -1
  22. package/src/pagedry.js +124 -0
  23. package/src/pagefetch.js +2 -1
  24. package/src/pagemap.js +7 -2
  25. package/src/pagerules.js +35 -13
  26. package/src/panel/authorroutes.js +1 -1
  27. package/src/panel/authorsession.js +37 -6
  28. package/src/panel/index.html +100 -0
  29. package/src/panel/jobplan.js +12 -7
  30. package/src/panel/server.js +8 -125
  31. package/src/planrun.js +40 -6
  32. package/src/promptregisters.js +4 -0
  33. package/src/providerrows.js +3 -3
  34. package/src/rubricstep.js +36 -6
  35. package/src/run.js +4 -2
  36. package/src/schedulewords.js +1 -1
  37. package/src/source.js +1 -1
  38. package/src/stagewords.js +144 -0
  39. package/src/text.js +4 -2
  40. package/src/userrun.js +28 -0
  41. package/src/validate.js +10 -5
  42. package/src/worktree.js +8 -2
  43. package/types/attemptdiff.d.ts +38 -0
  44. package/types/authorjob.d.ts +10 -2
  45. package/types/config.d.ts +1 -1
  46. package/types/fitcheck.d.ts +113 -0
  47. package/types/inputlook.d.ts +0 -23
  48. package/types/labelscan.d.ts +6 -0
  49. package/types/learn.d.ts +575 -0
  50. package/types/learnstore.d.ts +107 -0
  51. package/types/pagedropwords.d.ts +13 -0
  52. package/types/pagedry.d.ts +37 -0
  53. package/types/pagerules.d.ts +7 -1
  54. package/types/panel/authorsession.d.ts +4 -2
  55. package/types/panel/jobplan.d.ts +5 -2
  56. package/types/panel/server.d.ts +1 -0
  57. package/types/planrun.d.ts +13 -3
  58. package/types/reuse.d.ts +1 -1
  59. package/types/rubricstep.d.ts +5 -0
  60. package/types/run.d.ts +8 -2
  61. package/types/stagewords.d.ts +90 -0
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;
@@ -0,0 +1,166 @@
1
+ // THE FIT CHECK (docs/product/ITEM33-BUILD.md "Addendum 2026-10-21 - fit check build spec", F243, fit POC v6). A rubric job, at drafting, once, after the
2
+ // inputs are frozen and before the look: ONE paid AI call splits each job line into the short parts it asks for (words copied from that line) and says,
3
+ // for each, whether the saved page shows it, with a quote copied from the page. The AI never decides: plain code checks every ask against its own
4
+ // line and every quote against the page, and a part not found becomes one question to the person (never a stop by itself, never a count). Failure is
5
+ // OPEN - a wrong stop is the costly mistake - so a call that does not finish is `absent` and drafting goes on. The call/book/retry pattern is
6
+ // src/pagemap.js's `runPageMap`.
7
+
8
+ import { createRequire } from 'node:module';
9
+ import { priceOf, scrubRaw, tallyCalls, capStop, callCasualty } from './text.js';
10
+ import { redactSecrets } from './validate.js';
11
+ import { isTransportFailure } from './transport.js';
12
+ import { isRateLimited, rateLimitWaitMs } from './ratelimit.js';
13
+ import { isOutputCutOff } from './authorflow.js';
14
+ import { AUTHOR_CALL_TIMEOUT_MS, AUTHOR_SCOUT_MAX_TOKENS, SURVEY_CAUSES } from './authorscout.js';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const { Loop } = require('bare-agent');
18
+
19
+ /** the label of the one paid call, as the drafting book and the progress line see it */
20
+ export const FIT_CALL_LABEL = 'fit-check';
21
+ /** what the chat says when the check did not finish (drafting goes on) */
22
+ export const FIT_ABSENT_NOTE = 'The fit check did not finish; drafting goes on.';
23
+ /** the answer that keeps drafting after the fit question (any case, a closing full stop allowed) */
24
+ export const FIT_GO_ON = 'go on';
25
+ /** @param {unknown} text @returns {boolean} */
26
+ export const isGoOn = (text) => norm(String(text ?? '').trim().replace(/\.$/, '')) === FIT_GO_ON;
27
+
28
+ export const FIT_SYSTEM = 'You check whether saved web pages show what a job asks for. You never invent words: every ask must be copied from its job line and every quote must be copied from the page, character for character.';
29
+
30
+ /** collapse whitespace, lower case: the one spelling every fit comparison uses @param {unknown} s @returns {string} */
31
+ export const norm = (s) => String(s).replace(/\s+/g, ' ').trim().toLowerCase();
32
+
33
+ /**
34
+ * What the AI is told: the job lines (the numbered text only, no `~` rules) and the WHOLE text of every input under its label. Adapted from the POC
35
+ * call (.claude/scratch/fit-poc6/run.mjs): the asks are copied from each job line instead of hand-written.
36
+ * @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>}} o
37
+ * @returns {string}
38
+ */
39
+ export function fitPrompt({ jobLines, texts }) {
40
+ const job = jobLines.map((l) => `LINE ${l.n}: ${l.text}`).join('\n');
41
+ const pages = Object.entries(texts).map(([label, text]) => `THE PAGE (the input "${label}"):\n${text}`).join('\n\n');
42
+ return `THE JOB:\n${job}\n\n${pages}\n\n`
43
+ + 'For EACH job line, split it into the short parts it asks for (for example where it starts, where it ends, a price, a time), using ONLY words copied from that line. '
44
+ + 'Every line has at least one part. For EACH part, say whether the page shows it the way the job means it. A page that shows the opposite (for example a flight that arrives where the job says it leaves from, or a different city) does NOT show it. '
45
+ + 'If found, copy a short piece of page text that shows it, character for character. If not found, quote is null. '
46
+ + 'Reply with JSON only, no prose, no code fence: {"asks":[{"line":<line number>,"ask":"<the part, copied from that line>","found":true|false,"quote":"<page text or null>"}]}';
47
+ }
48
+
49
+ /** @typedef {{line: number, ask: string, found: boolean, quote: string|null, madeUp: boolean, ignored?: 'number'}} FitAsk */
50
+
51
+ /** an ask that is only a number (a count the job gives, not something the page shows): digits, optional spaces, commas, dots @param {string} ask @returns {boolean} */
52
+ const isNumberOnly = (ask) => /^\d[\d\s,.]*$/.test(norm(ask));
53
+
54
+ /**
55
+ * Read the AI's reply and decide, all in code. Malformed (no repair): not JSON, another form, a line that is not a job line, an ask not copied from its own
56
+ * line, found not a boolean, a quote neither text nor null, or a job line with no ask. An ask is found only if `found` is true AND its quote is on a page;
57
+ * `found` with a quote that is not on any page (or no quote) is made up and counts as not found.
58
+ * @param {unknown} text the reply
59
+ * @param {{n: number, text: string}[]} jobLines
60
+ * @param {Record<string, string>} texts the frozen input texts by label
61
+ * @returns {{status: 'malformed'}|{status: 'ok', state: 'fits'|'ask', asks: FitAsk[], notFound: {line: number, ask: string}[]}}
62
+ */
63
+ export function decideFit(text, jobLines, texts) {
64
+ /** @type {any} */
65
+ let j;
66
+ try { j = JSON.parse(String(text ?? '').trim()); } catch { return { status: 'malformed' }; }
67
+ if (j === null || typeof j !== 'object' || Array.isArray(j) || !Array.isArray(j.asks)) return { status: 'malformed' };
68
+ const lines = new Map(jobLines.map((l) => [l.n, norm(l.text)]));
69
+ const pages = Object.values(texts).map(norm);
70
+ /** @type {FitAsk[]} */
71
+ const asks = [];
72
+ for (const a of j.asks) {
73
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) return { status: 'malformed' };
74
+ if (!Number.isInteger(a.line) || !lines.has(a.line)) return { status: 'malformed' };
75
+ if (typeof a.ask !== 'string' || norm(a.ask) === '' || !(/** @type {string} */ (lines.get(a.line))).includes(norm(a.ask))) return { status: 'malformed' };
76
+ if (typeof a.found !== 'boolean') return { status: 'malformed' };
77
+ const quote = a.quote ?? null;
78
+ if (quote !== null && typeof quote !== 'string') return { status: 'malformed' };
79
+ const q = quote === null ? '' : norm(quote);
80
+ const onPage = q !== '' && pages.some((p) => p.includes(q));
81
+ // a bare number is ignored: kept in the record (flagged), never counted, never asked about
82
+ asks.push({ line: a.line, ask: a.ask, found: a.found && onPage, quote, madeUp: a.found && !onPage, ...(isNumberOnly(a.ask) ? { ignored: /** @type {'number'} */ ('number') } : {}) });
83
+ }
84
+ if (!jobLines.every((l) => asks.some((a) => a.line === l.n))) return { status: 'malformed' };
85
+ const notFound = asks.filter((a) => !a.ignored && !a.found).map((a) => ({ line: a.line, ask: a.ask }));
86
+ return { status: 'ok', state: notFound.length === 0 ? 'fits' : 'ask', asks, notFound };
87
+ }
88
+
89
+ /** the not-found asks as the person reads them: `<ask> (line N), <ask> (line N)` @param {{line: number, ask: string}[]} notFound @returns {string} */
90
+ export const notFoundList = (notFound) => notFound.map((x) => `${x.ask} (line ${x.line})`).join(', ');
91
+
92
+ /**
93
+ * The one question, worded by code.
94
+ * @param {{found: number, total: number, notFound: {line: number, ask: string}[]}} o
95
+ * @returns {string}
96
+ */
97
+ export const fitQuestionText = ({ found, total, notFound }) => redactSecrets(
98
+ `I found ${found} of ${total} of your asks on this page. Not found: ${notFoundList(notFound)}. Type go on to keep drafting, or anything else to stop and change the job or the page.`,
99
+ );
100
+
101
+ /** the default loop seam - one spelling of how this repo drives a model */
102
+ const defaultLoop = (/** @type {any} */ { system, onLlmResult, provider, rates }) =>
103
+ new Loop({ provider, system, onLlmResult, ...(rates ? { rates } : {}) });
104
+
105
+ /**
106
+ * Run the fit check: one call (one retry on a malformed reply or a transport failure or a 429). Never throws on a call casualty; every failure is `absent`.
107
+ * @param {{jobLines: {n: number, text: string}[], texts: Record<string, string>, provider?: any, maxTokens?: number, ceilingUsd?: number|null,
108
+ * callTimeoutMs?: number, onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string, raw?: string}) => void,
109
+ * createLoop?: Function, rates?: {in: number, out: number}|null, callOptions?: Record<string, any>, sleep?: (ms: number) => Promise<void>}} o
110
+ * @returns {Promise<{state: 'fits'|'ask'|'absent', asks: FitAsk[], notFound: {line: number, ask: string}[], reason: string|null, cause: string|null,
111
+ * calls: {label: string, costUsd: number|null, unpricedRounds: number}[], budgetStop: 'cap-halt'|'pricing-red'|null}>}
112
+ */
113
+ export async function runFitCheck({
114
+ jobLines, texts, provider = null, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null, callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS,
115
+ onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {}, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
116
+ }) {
117
+ const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
118
+ ? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
119
+ const priced = rates ? { rates } : {};
120
+ /** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
121
+ const calls = [];
122
+ let attempts = 0;
123
+ const record = (/** @type {any} */ r) => {
124
+ const call = { label: FIT_CALL_LABEL, ...priceOf(r) };
125
+ calls.push(call);
126
+ const raw = scrubRaw({ label: FIT_CALL_LABEL, attempt: attempts, text: r?.text });
127
+ onCall({ ...call, raw: raw.text, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
128
+ };
129
+ const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
130
+ state: /** @type {'absent'} */ ('absent'), asks: [], notFound: [], reason, cause, calls, budgetStop,
131
+ });
132
+ const content = fitPrompt({ jobLines, texts });
133
+ let malformed = 0;
134
+ let transportRetried = false;
135
+ let rateRetried = false;
136
+ for (;;) {
137
+ // the cap is checked BEFORE every call, never inside one
138
+ const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
139
+ if (halt) return absent(halt === 'cap-halt' ? `the fit check was not made: the $${ceilingUsd} ceiling was already spent` : 'the fit check was not made: spend could not be priced, so the ceiling could not be enforced', SURVEY_CAUSES.NOT_FUNDED, halt);
140
+ attempts += 1;
141
+ const loop = createLoop({ system: FIT_SYSTEM, onLlmResult: undefined, provider, ...priced });
142
+ /** @type {any} */
143
+ let r;
144
+ try {
145
+ r = await loop.run([{ role: 'user', content }], [], { cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions });
146
+ } catch (e) {
147
+ if (isRateLimited(e) && !rateRetried) {
148
+ const wait = rateLimitWaitMs(e);
149
+ if (wait !== null) { rateRetried = true; await sleep(wait); continue; }
150
+ }
151
+ const casualty = callCasualty(e) ?? (isTransportFailure(e) ? String(/** @type {any} */ (e)?.message ?? e) : null);
152
+ if (casualty === null) throw e;
153
+ // the failed attempt may already be billed: booked UNPRICED, so the spend stays "not complete" (never a $0)
154
+ record({ error: casualty });
155
+ if (!transportRetried) { transportRetried = true; continue; }
156
+ return absent(`the fit check call failed: ${redactSecrets(casualty)}`, SURVEY_CAUSES.CALL_FAILED);
157
+ }
158
+ record(r);
159
+ if (isOutputCutOff(r)) return absent('the fit check answer was cut off', SURVEY_CAUSES.UNPARSEABLE);
160
+ if (r?.error) return absent(`the fit check call failed: ${redactSecrets(String(r.error))}`, SURVEY_CAUSES.CALL_FAILED);
161
+ const d = decideFit(r?.text, jobLines, texts);
162
+ if (d.status === 'ok') return { state: d.state, asks: d.asks, notFound: d.notFound, reason: null, cause: null, calls, budgetStop: null };
163
+ malformed += 1;
164
+ if (malformed >= 2) return absent('the fit check answer was not readable twice', SURVEY_CAUSES.UNPARSEABLE);
165
+ }
166
+ }
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
  /**