bareloop 0.7.0 → 0.8.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 +283 -0
- package/README.md +4 -2
- package/bareloop.context.md +156 -18
- package/package.json +1 -1
- package/src/exits.js +8 -2
- package/src/job.js +43 -6
- package/src/ledger.js +57 -9
- package/src/plan.js +31 -100
- package/src/planrun.js +318 -62
- package/src/ralph.js +8 -1
- package/src/reuse.js +67 -4
- package/src/run.js +21 -2
- package/src/validate.js +121 -0
- package/types/exits.d.ts +14 -0
- package/types/job.d.ts +13 -2
- package/types/plan.d.ts +23 -38
- package/types/planrun.d.ts +116 -16
- package/types/ralph.d.ts +16 -0
- package/types/reuse.d.ts +5 -1
- package/types/run.d.ts +14 -1
- package/types/validate.d.ts +50 -0
package/src/exits.js
CHANGED
|
@@ -33,8 +33,14 @@ import { globToPrefix } from './validate.js';
|
|
|
33
33
|
* `not ok`/`FAILED` names — the mechanical gap the F46 conversion mechanism
|
|
34
34
|
* feeds the worker (F28 reintroduced). This cap is a defensive backstop above
|
|
35
35
|
* boundGap's own envelope: a normal bounded gap passes through intact, and only
|
|
36
|
-
* a pathological seam that returned an unbounded gap would ever be trimmed.
|
|
37
|
-
|
|
36
|
+
* a pathological seam that returned an unbounded gap would ever be trimmed.
|
|
37
|
+
*
|
|
38
|
+
* Exported for the ONE reason `boundGap`/`GAP_KEEP_TRIM_MARKER` are (src/ralph.js):
|
|
39
|
+
* the replan brief re-bounds this same artifact one seam later (`closeGapBlock`), and
|
|
40
|
+
* a second hand-spelled ceiling there would be a second answer to "how big may a
|
|
41
|
+
* check gap be" — the two would drift on exactly the property that matters, whether
|
|
42
|
+
* the `not ok`/`FAILED` names survive. */
|
|
43
|
+
export const CHECK_GAP_MAX = 12000;
|
|
38
44
|
|
|
39
45
|
/** @typedef {{ type: string, pass: boolean, detail?: string, fault?: string }} ExitResult
|
|
40
46
|
* `fault` present means the INSTRUMENT could not judge (unwired snapshot/seam,
|
package/src/job.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// judgment call. Minting policy is product doctrine, not job-authorable.
|
|
15
15
|
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
|
-
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
|
|
17
|
+
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
|
|
18
18
|
|
|
19
19
|
// The menus below ARE the close-authoring hierarchy (PRD §7) and ship frozen:
|
|
20
20
|
// they are read at call time, so a mutable export would let adopter code
|
|
@@ -123,7 +123,12 @@ const CLOSE_FIELDS = {
|
|
|
123
123
|
};
|
|
124
124
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
125
125
|
|
|
126
|
-
/** @typedef {{code: string, path: string, detail?: string, verb?: string}} Red — `verb`
|
|
126
|
+
/** @typedef {{code: string, path: string, detail?: string, verb?: string, lib?: string}} Red — `verb` and `lib` ride request-reds as structured data (the ledger keys on them, never on prose).
|
|
127
|
+
* `lib` is the TERRITORY the demand lands against, stamped HERE at the emit site
|
|
128
|
+
* (the typed-lib rule): one code, two catalogues — a locked TOOL verb is demand
|
|
129
|
+
* against the worker-surface package, a locked VERDICT type is demand against
|
|
130
|
+
* bareloop's OWN menu. Inferring it downstream is how a bareloop refusal gets
|
|
131
|
+
* filed as an upstream bug (the BA-2 misattribution class). */
|
|
127
132
|
|
|
128
133
|
/**
|
|
129
134
|
* Validate an operator-owned job spec (`schema: "job-v1"`). Never throws on
|
|
@@ -278,6 +283,21 @@ function validateClose(close, at, red) {
|
|
|
278
283
|
return true;
|
|
279
284
|
}
|
|
280
285
|
|
|
286
|
+
/**
|
|
287
|
+
* The F49 reject's OPERATOR-side detail. ONE spelling for both regex fields —
|
|
288
|
+
* they differ only in which untimed evaluator the pattern reaches — so the two
|
|
289
|
+
* reds can never drift into saying different things about the same hazard.
|
|
290
|
+
*
|
|
291
|
+
* The second sentence is the part an operator cannot derive: on the agent side
|
|
292
|
+
* F49 is self-DoS, but these two patterns are compiled and run in BARELOOP'S OWN
|
|
293
|
+
* PROCESS, where the hang blocks the event loop the in-process stall fuse lives
|
|
294
|
+
* on (F67 — a guard sharing the fate of the process it guards). The run then
|
|
295
|
+
* dies to the outside watchdog and reads as a model stall, so without this red a
|
|
296
|
+
* bad regex in a SIGNED spec is diagnosed as a provider problem.
|
|
297
|
+
* @param {string} evaluator the consumer that would run it, named concretely
|
|
298
|
+
*/
|
|
299
|
+
const redosDetail = (evaluator) => `nested unbounded quantifier (e.g. (a+)+ , (\\d*)* , (x+){1,}) — a catastrophic-backtracking footgun that can hang ${evaluator}, untimed (F49). It runs in bareloop's own process, so the hang blocks the event loop the stall fuse lives on (F67) and the run dies to the outside watchdog looking like a model stall; rewrite without a repeated group inside a repeat`;
|
|
300
|
+
|
|
281
301
|
/**
|
|
282
302
|
* The predicate BODY contract — cmd/expect/judged/gapKeep. Shared verbatim by
|
|
283
303
|
* the predicate close and the Layer 2 named checks (decision 1: a check runs
|
|
@@ -330,6 +350,13 @@ function predicateBody(o, at, red) {
|
|
|
330
350
|
// the fake green this floor exists to catch). Alternation stays
|
|
331
351
|
// fully expressible with non-capturing branches: `(?:a|b) (\d+)`.
|
|
332
352
|
else if (groups > 1) red('invalid-value', `${at}.judged.pattern`, `${groups} capture groups — the count is read from group 1 only; use exactly one capture group and non-capturing (?:…) branches`);
|
|
353
|
+
// F49, operator side: `runClose` execs this against the close's whole
|
|
354
|
+
// stdout+stderr with no timeout. Gated on `groups >= 0` — that is the
|
|
355
|
+
// COMPILED flag in disguise (the catch sets -1), and the scan documents
|
|
356
|
+
// that it assumes syntactically valid input.
|
|
357
|
+
if (groups >= 0 && hasNestedQuantifier(j.pattern)) {
|
|
358
|
+
red('invalid-value', `${at}.judged.pattern`, redosDetail('the close reader (runClose)'));
|
|
359
|
+
}
|
|
333
360
|
}
|
|
334
361
|
if (!Number.isInteger(j.min) || j.min < 1) {
|
|
335
362
|
red('invalid-value', `${at}.judged.min`, 'integer >= 1 — a floor of 0 is satisfied by judging nothing, which is the check it is meant to make');
|
|
@@ -348,8 +375,15 @@ function predicateBody(o, at, red) {
|
|
|
348
375
|
if (o.gapKeep !== undefined) {
|
|
349
376
|
if (!isNonEmptyString(o.gapKeep)) red('invalid-value', `${at}.gapKeep`, 'regex source string — lines matching it survive the gap bound (F28)');
|
|
350
377
|
else {
|
|
351
|
-
|
|
378
|
+
let compiled = false;
|
|
379
|
+
try { new RegExp(o.gapKeep, 'm'); compiled = true; }
|
|
352
380
|
catch { red('invalid-value', `${at}.gapKeep`, 'must compile as a RegExp'); }
|
|
381
|
+
// F49, operator side and the measured one: `boundGap` runs this per LINE
|
|
382
|
+
// over the whole close stream, untimed. Measured: `(a+)+$` against a
|
|
383
|
+
// 40-char line does not finish in 20s.
|
|
384
|
+
if (compiled && hasNestedQuantifier(o.gapKeep)) {
|
|
385
|
+
red('invalid-value', `${at}.gapKeep`, redosDetail('the gap bound (boundGap), once per line of close output'));
|
|
386
|
+
}
|
|
353
387
|
}
|
|
354
388
|
}
|
|
355
389
|
}
|
|
@@ -486,8 +520,9 @@ function validatePlanShape(spec, red, reds) {
|
|
|
486
520
|
else if (LOCKED_VERDICTS.includes(spec.verdictType)) {
|
|
487
521
|
// declared-but-locked (disclosure ≠ admission, the tool-menu pattern):
|
|
488
522
|
// the type rides as a structured field — the ledger keys admission demand
|
|
489
|
-
// on it, never on prose
|
|
490
|
-
|
|
523
|
+
// on it, never on prose. `lib: 'bareloop'`: the refused catalogue is OURS
|
|
524
|
+
// (VERDICT_TYPES), so this demand is never an upstream ask.
|
|
525
|
+
reds.push({ code: 'request-red', path: 'verdictType', verb: spec.verdictType, lib: 'bareloop', detail: `"${spec.verdictType}" is declared-but-locked — not at this rung (v1 admits green only); this red IS the admission evidence, never a grant` });
|
|
491
526
|
demanded = CLASS_BY_VERDICT[spec.verdictType];
|
|
492
527
|
} else if (!VERDICT_TYPES.includes(spec.verdictType)) {
|
|
493
528
|
red('invalid-value', 'verdictType', `menu: ${VERDICT_TYPES.join('|')} — an unknown type is a typo, never a request`);
|
|
@@ -529,7 +564,9 @@ function validatePlanShape(spec, red, reds) {
|
|
|
529
564
|
red('invalid-value', 'tools', `non-empty unique subset of ${TOOL_MENU.join('|')}`);
|
|
530
565
|
} else {
|
|
531
566
|
for (const t of spec.tools.filter((/** @type {string} */ t) => LOCKED_TOOLS.includes(t))) {
|
|
532
|
-
|
|
567
|
+
// `lib: 'bare-agent'`: a locked TOOL verb is demand against the
|
|
568
|
+
// worker-surface package that would have to expose it (the original case)
|
|
569
|
+
reds.push({ code: 'request-red', path: 'tools', verb: t, lib: 'bare-agent', detail: `"${t}" is locked-but-listed — this red IS the admission evidence, never a grant; granted menu: ${TOOL_MENU.join('|')}` });
|
|
533
570
|
}
|
|
534
571
|
const unknown = spec.tools.filter((/** @type {string} */ t) => !TOOL_MENU.includes(t) && !LOCKED_TOOLS.includes(t));
|
|
535
572
|
if (unknown.length) red('invalid-value', 'tools', `unknown tool(s) ${unknown.join(', ')} — menu: ${TOOL_MENU.join('|')}`);
|
package/src/ledger.js
CHANGED
|
@@ -117,9 +117,15 @@ const ASKS = /** @type {Record<string, (o: {lib: string, verb: string, detail: s
|
|
|
117
117
|
'runtime-red': (o) => `${o.lib}: \`${o.verb}\` threw at runtime — ${o.detail}`,
|
|
118
118
|
'provider-red': (o) => `bare-agent: the provider path failed — ${o.detail}`,
|
|
119
119
|
'pricing-red': (o) => `bare-agent: a provider result carried no priced cost (unpriced is never free, F6) — ${o.detail}`,
|
|
120
|
-
|
|
120
|
+
// same rule as request-red below (capability-gap is its cap-halted form): the
|
|
121
|
+
// ask is seeded at the STAMPED target, so a bareloop-catalogue refusal cannot
|
|
122
|
+
// render as "bare-agent: …" — fixing the occurrence's lib alone would leave the
|
|
123
|
+
// misattribution intact in the one field a human actually files from
|
|
124
|
+
'capability-gap': (o) => `${o.lib}: locked \`${o.verb}\` was requested and the run cap-halted — admission candidate`,
|
|
121
125
|
'broken-close': (o) => `job owner: the close itself cannot run — ${o.detail}`,
|
|
122
|
-
|
|
126
|
+
// the target is the STAMPED lib, never a hardcoded package: a bareloop-catalogue
|
|
127
|
+
// refusal (a locked verdict type) must not seed an ask that reads "bare-agent: …"
|
|
128
|
+
'request-red': (o) => `${o.lib}: admission — locked \`${o.verb}\` requested by a job spec — demand evidence, never a grant`,
|
|
123
129
|
'retention-red': (o) => `litectx: on-green retention failed — ${o.detail}`,
|
|
124
130
|
'config-red': (o) => `bareloop: drafting friction at ${o.verb} — ${o.detail}`,
|
|
125
131
|
});
|
|
@@ -160,9 +166,20 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
|
|
|
160
166
|
// a repeated signature indicts the drafting prompt/schema, i.e. bareloop
|
|
161
167
|
add(ev, 'config-red', 'bareloop', String(ev.path ?? 'config').split('.')[0] || 'config', `${ev.code} at ${ev.path}: ${ev.detail ?? ''}`);
|
|
162
168
|
} else if (ev.type === 'job-red' && ev.code === 'request-red') {
|
|
163
|
-
// the structured
|
|
164
|
-
// for spines written before the field existed
|
|
165
|
-
|
|
169
|
+
// the structured fields win; the prose-quoted verb stays as the fallback
|
|
170
|
+
// for spines written before the field existed, and the hardcoded
|
|
171
|
+
// 'bare-agent' lib stays as the fallback for the same reason — it was the
|
|
172
|
+
// only territory `request-red` had when the field did not exist.
|
|
173
|
+
//
|
|
174
|
+
// One code, TWO territories: a locked TOOL verb is demand against the
|
|
175
|
+
// worker-surface package; a locked VERDICT type (and, when D13 lands, a
|
|
176
|
+
// genre refusal) is demand against bareloop's OWN catalogue and must never
|
|
177
|
+
// seed an upstream ask. The lib is stamped at the emit site (src/job.js)
|
|
178
|
+
// per the typed-lib rule — deriving it here from the code or the path is
|
|
179
|
+
// exactly the BA-2 misattribution class. A new bareloop-territory
|
|
180
|
+
// request-red therefore routes correctly by stamping its own lib, with no
|
|
181
|
+
// change to this branch.
|
|
182
|
+
add(ev, 'request-red', ev.lib ?? 'bare-agent', ev.verb ?? (String(ev.detail ?? '').match(QUOTED_VERB_RE) ?? [])[1] ?? 'unknown', ev.detail);
|
|
166
183
|
} else if (ev.type === 'escalation') {
|
|
167
184
|
if (ev.category === 'broken-close') {
|
|
168
185
|
add(ev, 'broken-close', 'consumer', 'close', ev.detail);
|
|
@@ -211,10 +228,41 @@ export function classifyIncidents(events, { spine = 'spine' } = {}) {
|
|
|
211
228
|
|
|
212
229
|
if (capHalted) {
|
|
213
230
|
// capability-gap: the run hit its cap in a spine that also asked for a
|
|
214
|
-
// locked verb — the strongest admission evidence there is (design table)
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
// locked verb — the strongest admission evidence there is (design table).
|
|
232
|
+
// STILL DORMANT, exactly as the module header says: a request-red is a
|
|
233
|
+
// job-validation red, validateJob returns not-ok on any red and run.js
|
|
234
|
+
// returns on a validation red, so a request-red and a cap-halt cannot share
|
|
235
|
+
// one spine today. What follows is a latent-consistency fix at a documented
|
|
236
|
+
// dormant seam, not a live bug fix.
|
|
237
|
+
//
|
|
238
|
+
// The territory comes from the OCCURRENCE, never from a hardcoded package.
|
|
239
|
+
// capability-gap inherits request-red's two territories: a locked TOOL verb
|
|
240
|
+
// is demand against the worker-surface package, a locked VERDICT type is
|
|
241
|
+
// demand against bareloop's OWN catalogue. The request-red rows above
|
|
242
|
+
// already carry the lib stamped at their emit site (src/job.js), so reading
|
|
243
|
+
// it here is the typed-lib rule; re-hardcoding 'bare-agent' would seed an
|
|
244
|
+
// upstream ask for our own refusal — the BA-2 misattribution class. The
|
|
245
|
+
// sibling request-red site was fixed on this branch and this one was missed;
|
|
246
|
+
// a fix landing at one of two identical sites is the class already paid for
|
|
247
|
+
// once here (the ripgrep install that landed in ci.yml but not publish.yml).
|
|
248
|
+
//
|
|
249
|
+
// The dedup key is (verb, lib), not verb alone. The ledger's own occurrence
|
|
250
|
+
// key already carries lib, so folding on the verb would merge two
|
|
251
|
+
// territories into one row and attribute it to whichever lib the Set
|
|
252
|
+
// happened to hold first — an arbitrary target, which IS the defect. One
|
|
253
|
+
// gap per (territory, verb) costs nothing and each carries an honest one.
|
|
254
|
+
// The key is JSON, not a joined string: a delimiter that can appear in
|
|
255
|
+
// either half makes ("a-b","c") and ("a","b-c") the same key, and picking
|
|
256
|
+
// an "impossible" delimiter is a guess about names neither of these two
|
|
257
|
+
// vocabularies has promised to keep.
|
|
258
|
+
const halt = events.find((e) => e.type === 'cap-halt');
|
|
259
|
+
const seen = new Set();
|
|
260
|
+
// .filter() snapshots before add() appends, so the loop cannot feed itself
|
|
261
|
+
for (const o of occs.filter((x) => x.class === 'request-red')) {
|
|
262
|
+
const dedup = JSON.stringify([o.lib, o.verb]);
|
|
263
|
+
if (seen.has(dedup)) continue;
|
|
264
|
+
seen.add(dedup);
|
|
265
|
+
add(halt, 'capability-gap', o.lib, o.verb, `locked "${o.verb}" requested and the run cap-halted`);
|
|
218
266
|
}
|
|
219
267
|
}
|
|
220
268
|
return occs;
|
package/src/plan.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// plain parsed data; every failure is a named {code, path, detail} red.
|
|
26
26
|
|
|
27
27
|
import { TOOL_MENU, LOCKED_TOOLS, WRITE_VERBS, checkMenu } from './job.js';
|
|
28
|
-
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals } from './validate.js';
|
|
28
|
+
import { globToPrefix, scopeContained, isObj, isNonEmptyString, sweepSecretLiterals, hasNestedQuantifier } from './validate.js';
|
|
29
29
|
|
|
30
30
|
/** the closed exit menu (PRD v1.12 §3 + decision 1's `check-passes`): the
|
|
31
31
|
* shell evaluates every form with its own fixed code, never a command — the
|
|
@@ -102,8 +102,28 @@ const STEP_FIELDS = ['id', 'action', 'tools', 'rounds', 'target', 'exit', 'model
|
|
|
102
102
|
* construction — the agent picks a tier, never names an arbitrary model string
|
|
103
103
|
* (the tier→model mapping and any effort params are the RUNNER's, per provider).
|
|
104
104
|
* opus is deliberately absent: reserved for fresh builds hamr explicitly assigns,
|
|
105
|
-
* never plan-selectable.
|
|
106
|
-
|
|
105
|
+
* never plan-selectable.
|
|
106
|
+
*
|
|
107
|
+
* 2026-08-06 — haiku dropped, on hamr's order: "haiku should be dropped to see if
|
|
108
|
+
* it passes, if it's agent poor work or harness, like always." This is a REVERSIBLE
|
|
109
|
+
* ATTRIBUTION PROBE, not a finding that haiku is incapable. What the $0 archive read
|
|
110
|
+
* (186 spines / 71 accepted plans / 255 steps) actually shows is a CONFOUND, in both
|
|
111
|
+
* directions: only 20 steps ever declared a tier at all — haiku 12 (9 of them on a
|
|
112
|
+
* REPLANNED plan, 6 the LAST step of their plan, bounds 3–12 rounds, 2 ever green)
|
|
113
|
+
* against explicitly-declared sonnet 8 (bounds 14–32, 5 green). The planner picks
|
|
114
|
+
* haiku for steps it judges mechanical, and replanned steps sit on runs already in
|
|
115
|
+
* trouble, so neither column is a controlled read. All three bareagent-u runs of
|
|
116
|
+
* 2026-08-06 tiered the REPLANNED step down (haiku/6, haiku/8, haiku/12) and all
|
|
117
|
+
* three failed, against sonnet initial steps at 30–40 rounds — a confound sitting
|
|
118
|
+
* on the programme's most recent capability read. Removing the tier is how it gets
|
|
119
|
+
* attributed: with only one tier expressible, a still-failing replan is agent or
|
|
120
|
+
* harness, not the tier.
|
|
121
|
+
*
|
|
122
|
+
* The `model` FIELD and its validator branch stay: a one-entry menu means a plan (or
|
|
123
|
+
* a stored bridge) declaring `model:"sonnet"` still validates, and restoring haiku is
|
|
124
|
+
* a one-token edit. This narrows only what the AGENT may express — `--model haiku`
|
|
125
|
+
* remains the OPERATOR's probe knob (PRD v1.36), untouched in the runner scripts. */
|
|
126
|
+
export const STEP_MODELS = Object.freeze(['sonnet']);
|
|
107
127
|
const EXIT_FIELDS = {
|
|
108
128
|
'artifact-written': ['type', 'path', 'pattern'],
|
|
109
129
|
'tree-changed': ['type', 'scope'],
|
|
@@ -112,103 +132,14 @@ const EXIT_FIELDS = {
|
|
|
112
132
|
};
|
|
113
133
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
114
134
|
|
|
115
|
-
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (len && src[i + len] === '?') len++; // the lazy modifier is part of the same token
|
|
124
|
-
return len;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* F49 — a heuristic reject for the catastrophic-backtracking footgun: an
|
|
129
|
-
* unbounded quantifier applied to a group whose body itself repeats unboundedly
|
|
130
|
-
* (`(a+)+`, `(\d*)*`, `([a-z]+){1,}`) — including through a redundant wrapping
|
|
131
|
-
* group (`((a+))+`, `(?:(a+))*`), which is the SAME exponential class as `(a+)+`
|
|
132
|
-
* and is caught by propagating the inner repeat up through the wrapper. Such a
|
|
133
|
-
* pattern can hang `RegExp.test` for
|
|
134
|
-
* seconds on a short crafted body, and `evalExits` runs the agent-authored
|
|
135
|
-
* `artifact-written` pattern with NO timeout. This is self-DoS only — the agent
|
|
136
|
-
* authors both the pattern and (via the worker) the artifact, so a hang burns
|
|
137
|
-
* only its own run's wall-clock; there is NO arbiter compromise (it cannot
|
|
138
|
-
* escape the fence, forge a green, or leak a secret). So the reject targets the
|
|
139
|
-
* dominant exponential class and fails it as a mechanical gap at the validation
|
|
140
|
-
* gate, before any tokens burn. A full ReDoS analyzer needs a real regex engine
|
|
141
|
-
* (an external native dep we do not take for a LOW issue); exotic
|
|
142
|
-
* overlapping-alternation blowup is out of scope by decision.
|
|
143
|
-
*
|
|
144
|
-
* SCOPE IS ASYMMETRIC, both directions named on purpose:
|
|
145
|
-
* - false NEGATIVE: overlapping-alternation blowup (`(a|ab)+`-class) is not
|
|
146
|
-
* detected — out of scope (self-DoS only, no arbiter compromise).
|
|
147
|
-
* - false POSITIVE: a group whose repetitions are disambiguated by a literal
|
|
148
|
-
* anchor/delimiter (`(?:^- .+$\n?)+`, `(?:CHANGELOG:.+\n)+`) is FLAGGED even
|
|
149
|
-
* though a real engine runs it linearly — the scan sees the nested-quantifier
|
|
150
|
-
* SHAPE, not the disambiguation. Rejecting it is the FAIL-SAFE direction (it
|
|
151
|
-
* never admits an unsafe pattern), and the cost is bounded: the plan drafter
|
|
152
|
-
* gets a mechanical gap and rewrites (drop the outer `+`, or match once).
|
|
153
|
-
* Detecting "safe because anchored" needs the same real engine we declined,
|
|
154
|
-
* and guessing it wrong would ADMIT an exponential pattern — so the shape
|
|
155
|
-
* reject stands, and the over-rejection is a named, accepted limitation.
|
|
156
|
-
*
|
|
157
|
-
* The input is guaranteed to compile as a RegExp (checked first by the caller),
|
|
158
|
-
* so this scan assumes valid, balanced JS regex syntax.
|
|
159
|
-
* @param {string} src a compiled regex source string
|
|
160
|
-
* @returns {boolean} true iff a nested unbounded quantifier is present
|
|
161
|
-
*/
|
|
162
|
-
export function hasNestedQuantifier(src) {
|
|
163
|
-
/** @type {{ quant: boolean }[]} */
|
|
164
|
-
const stack = [];
|
|
165
|
-
for (let i = 0; i < src.length; i++) {
|
|
166
|
-
const c = src[i];
|
|
167
|
-
if (c === '\\') { i++; continue; } // escaped atom — the next char is a literal
|
|
168
|
-
if (c === '[') { // character class: quantifier chars inside are literals
|
|
169
|
-
i++;
|
|
170
|
-
if (src[i] === '^') i++;
|
|
171
|
-
// NO POSIX leading-`]`-is-a-literal rule: JS is not POSIX here. `[]` is the
|
|
172
|
-
// EMPTY class (matches nothing) and `[^]` the any-char idiom — both close at
|
|
173
|
-
// that first `]`. Skipping it ran the scan past the class's real end and
|
|
174
|
-
// swallowed every quantifier after it, so `x[^](a+)+$` read as safe while
|
|
175
|
-
// RegExp.test on it does not finish in 15s on a 31-char body. A false
|
|
176
|
-
// NEGATIVE is the dangerous direction (F49), so the scan follows JS.
|
|
177
|
-
//
|
|
178
|
-
// Honest limit on the monotonicity claim: this is a PARSE correction, not
|
|
179
|
-
// the "add rejections only" tightening F49's rule contemplates, and it is
|
|
180
|
-
// not strictly rejection-monotonic. Measured over 14,862 compilable
|
|
181
|
-
// generated patterns (2,369 previously rejected): 2 rejections disappear,
|
|
182
|
-
// both pathological `[]`-bearing shapes whose old `true` came from the very
|
|
183
|
-
// misparse being fixed, and both verified to finish RegExp.test in ~0ms.
|
|
184
|
-
// Every entry of the checked-in REDOS_BAD/GOOD/OVERREJECTED corpus is
|
|
185
|
-
// unchanged. So no real hazard was un-rejected — but the claim is
|
|
186
|
-
// "measured, no hazard lost", never "monotonic by construction".
|
|
187
|
-
while (i < src.length && src[i] !== ']') { if (src[i] === '\\') i++; i++; }
|
|
188
|
-
continue;
|
|
189
|
-
}
|
|
190
|
-
if (c === '(') { stack.push({ quant: false }); continue; }
|
|
191
|
-
if (c === ')') {
|
|
192
|
-
const g = stack.pop();
|
|
193
|
-
const qlen = unboundedQuantLen(src, i + 1);
|
|
194
|
-
if (g && g.quant && qlen) return true; // group repeats unboundedly AND its body did too
|
|
195
|
-
// The group is an unbounded-repeated atom of its parent when it is directly
|
|
196
|
-
// re-quantified (qlen) OR its own body already repeats unboundedly (g.quant):
|
|
197
|
-
// a redundant wrapper — ((a+))+ , (?:(a+))* , (((\d*)))+ — is the SAME
|
|
198
|
-
// exponential class as (a+)+, so an inner repeat must propagate THROUGH the
|
|
199
|
-
// enclosing group or the outer quantifier's close never sees it. Propagation
|
|
200
|
-
// is MONOTONIC (it only ever SETS quant=true → only ever adds rejections): it
|
|
201
|
-
// can widen over-rejection — the named FAIL-SAFE direction — but can never
|
|
202
|
-
// introduce a false negative, which is the dangerous one (F49).
|
|
203
|
-
if ((qlen || (g && g.quant)) && stack.length) stack[stack.length - 1].quant = true;
|
|
204
|
-
i += qlen;
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
const qlen = unboundedQuantLen(src, i); // a quantifier applying to the preceding atom, inside the current group
|
|
208
|
-
if (qlen) { if (stack.length) stack[stack.length - 1].quant = true; i += qlen - 1; }
|
|
209
|
-
}
|
|
210
|
-
return false;
|
|
211
|
-
}
|
|
135
|
+
/** F49's catastrophic-backtracking reject. RE-EXPORTED from validate.js, never
|
|
136
|
+
* re-declared: THREE untimed regex fields are split across the two documents —
|
|
137
|
+
* this one (`artifact-written.pattern`, agent-authored) and the operator's
|
|
138
|
+
* `judged.pattern` + `gapKeep` in job.js. plan.js imports job.js, so job.js
|
|
139
|
+
* cannot import plan.js; housing the scan in the shared primitives module is
|
|
140
|
+
* what lets both reject through ONE inventory (the SECRET_PATTERNS precedent —
|
|
141
|
+
* a second copy is a shape one side rejects and the other admits). */
|
|
142
|
+
export { hasNestedQuantifier };
|
|
212
143
|
|
|
213
144
|
/**
|
|
214
145
|
* The close, as the ordered STAGE LIST the plan flow actually executes (PRD
|