forgedock 1.29.1 → 1.30.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/bin/engine/outcome-codes.mjs +4 -2
- package/bin/engine/phases.mjs +20 -3
- package/bin/engine-cli.mjs +94 -36
- package/bin/engine.mjs +3 -1
- package/bin/lean/batch.test.mjs +62 -1
- package/bin/lean/conformance.mjs +129 -8
- package/bin/lean/conformance.test.mjs +77 -3
- package/bin/lean/context.mjs +17 -12
- package/bin/lean/context.test.mjs +36 -5
- package/bin/lean/effects.mjs +20 -6
- package/bin/lean/enumerating.mjs +189 -0
- package/bin/lean/enumerating.test.mjs +97 -0
- package/bin/lean/lean.mjs +189 -43
- package/bin/lean/lean.test.mjs +305 -6
- package/bin/lean/orchestrate.mjs +104 -85
- package/bin/lean/precheck.mjs +55 -0
- package/bin/lean/precheck.test.mjs +93 -0
- package/bin/lean/prefilter.mjs +21 -4
- package/bin/lean/prefilter.test.mjs +24 -1
- package/bin/lean/preflight.mjs +133 -0
- package/bin/lean/preflight.test.mjs +61 -0
- package/bin/lean/prompts.mjs +37 -3
- package/bin/lean/recovery.test.mjs +75 -0
- package/bin/lean/sequence.mjs +102 -0
- package/bin/lean/sequence.test.mjs +112 -0
- package/bin/lean/testmap.mjs +43 -6
- package/bin/lean/testmap.test.mjs +38 -1
- package/bin/lean/verify.mjs +7 -2
- package/bin/lean/verify.test.mjs +8 -2
- package/commands/orchestrate.md +3 -1
- package/docs/CONFIG.md +8 -1
- package/docs/OUTCOME-CODES.md +6 -4
- package/docs/RELEASE-NOTES.md +10 -0
- package/package.json +1 -1
- package/scripts/acceptance-red-green.sh +13 -4
- package/scripts/acceptance-red-green.test.sh +14 -0
- package/scripts/test-map.sh +63 -17
- package/scripts/test-map.test.sh +55 -0
package/bin/lean/orchestrate.mjs
CHANGED
|
@@ -59,7 +59,9 @@
|
|
|
59
59
|
* `maxAttempts`) under the same admission as any lane, so its dependents stay queued. Only a final lane relabels needs-human
|
|
60
60
|
* itself; a non-final attempt that ends needs-human/ci-pending without a successor is relabelled here, once (#4302). A recovery
|
|
61
61
|
* still queued when the batch exits (breaker, budget, hold, or a resume that does not relaunch it) is relabelled once with the
|
|
62
|
-
* full persisted lane reason (#4315)
|
|
62
|
+
* full persisted lane reason (#4315); also when the loop throws, and through `onExit(fn)` (the caller's signal handler
|
|
63
|
+
* awaits `fn(cause)`), each once (#4318). `relabelNeedsHuman` may return `false`/"failed" (not recorded, a resume retries)
|
|
64
|
+
* or "skipped" (closed or leased issue: recorded); any other result counts as relabelled.
|
|
63
65
|
* A merged recovery row carries `recovered: <code>`; any other end is final with `recovery` evidence. Human codes (`builder.blocked`) are never recovered. Every operational decision the runner takes
|
|
64
66
|
* (recovery, engine-error relaunch, dependency requeue, sub-issues added) goes to `decide({issue, what, why, alternative})`,
|
|
65
67
|
* fire-and-forget: the runner never waits on it.
|
|
@@ -160,7 +162,7 @@ const toMap = (o) => new Map(Object.entries(o ?? {}).map(([k, v]) => [Number(k),
|
|
|
160
162
|
export async function orchestrate({ issues, parallel = 3, runOne, readBody, prefilter = null, resolveLane = null, now = () => Date.now(), onUpdate = () => {}, onCrossLane = () => {},
|
|
161
163
|
store = null, adopt = null, getParallel = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
|
|
162
164
|
classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null,
|
|
163
|
-
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null }) {
|
|
165
|
+
recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null }) {
|
|
164
166
|
const saved = store?.load?.() ?? null;
|
|
165
167
|
const order = saved ? [...saved.order] : [];
|
|
166
168
|
const set = new Set(order);
|
|
@@ -349,6 +351,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
349
351
|
const pfCost = recovering.has(n) ? 0 : (pf?.cost_usd ?? 0);
|
|
350
352
|
const row = { issue: n, outcome: r?.terminalReason ?? "engine-error", pr: r?.pr ?? null, cost_usd: (r?.cost_usd ?? 0) + pfCost, minutes: Math.round((now() - rec.startedAt) / 6000) / 10, detail: r?.detail, ...(r?.code ? { code: r.code } : {}), ...(pf ? { prefilter: pf } : {}),
|
|
351
353
|
...(r?.waits && typeof r.waits === "object" ? { waits: r.waits } : {}), ...(Number.isInteger(r?.heavyQueueMax) ? { heavyQueueMax: r.heavyQueueMax } : {}),
|
|
354
|
+
...(Number.isFinite(r?.precheckMs) ? { precheck_ms: r.precheckMs } : {}), ...(Number.isInteger(r?.precheckDeferred) && r.precheckDeferred > 0 ? { precheck_deferred: r.precheckDeferred } : {}), // #4331
|
|
352
355
|
...(rec?.placement ? { placement: rec.placement } : {}) };
|
|
353
356
|
const depList = (Array.isArray(r?.dependencies) ? r.dependencies : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
354
357
|
const subs = (Array.isArray(r?.subissues) ? r.subissues : []).filter((d) => Number.isSafeInteger(d) && d > 0 && d !== n);
|
|
@@ -408,7 +411,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
408
411
|
}
|
|
409
412
|
// #4267: a recovery pass ends here; a recoverable row under the attempt cap is requeued as a recovery lane.
|
|
410
413
|
const prior = recovering.get(n) ?? null;
|
|
411
|
-
|
|
414
|
+
// #4318: the full persisted lane reason (capped by the comment writer), not its first 300 chars
|
|
415
|
+
const evidence = () => recoveryEvidence({ code: prior.code, attempt: prior.attempt, outcome: row.outcome, resultCode: row.code, detail: row.detail, full: true });
|
|
412
416
|
if (prior) {
|
|
413
417
|
recovering.delete(n);
|
|
414
418
|
// #4298: the row reports the whole issue: earlier attempts' cost and minutes are summed into the recovery's.
|
|
@@ -511,95 +515,110 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
|
|
|
511
515
|
}
|
|
512
516
|
save();
|
|
513
517
|
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (
|
|
518
|
+
const heldDetail = (n) => tripped?.detail ?? (driftHeld.has(n) ? `drift: ${driftHeld.get(n)} flagged` : budgetStop ? `execution budget: ${budgetStop}` : budgetHeld ? "maintenance share" : "not run");
|
|
519
|
+
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
520
|
+
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
521
|
+
// #4318: also on a throw out of the loop and on a signal (`onExit`); `cause` overrides the held reason. Runs once per process.
|
|
522
|
+
let exitRelabel = null;
|
|
523
|
+
const relabelQueued = (cause = null) => (exitRelabel ??= (async () => {
|
|
524
|
+
for (const [n, q] of recovering) {
|
|
525
|
+
if (running.has(n) || results.has(n) || relabelled.has(n) || typeof relabelNeedsHuman !== "function") continue;
|
|
526
|
+
const why = cause ?? (heldDetail(n) === "not run" ? "batch stopped" : heldDetail(n));
|
|
527
|
+
const evidence = recoveryEvidence({ code: q.code, attempt: q.prev_attempt, outcome: q.outcome, resultCode: q.result_code, detail: q.detail, full: true,
|
|
528
|
+
queued: q.attempt, cause: why, recoveryMax });
|
|
529
|
+
let res;
|
|
530
|
+
try { res = await relabelNeedsHuman({ issue: n, evidence }); } catch { res = "failed"; }
|
|
531
|
+
if (res !== false && res !== "failed") relabelled.add(n); // a failed label add is retried by a resume
|
|
522
532
|
}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
// #4183: a hook (owned-resource GC near the host ceiling) before lanes are admitted; its errors never stop the batch.
|
|
537
|
-
if (beforeAdmit && !tripped && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
|
|
538
|
-
budgetStop = null;
|
|
539
|
-
const wanted = new Set(); // issues that stay offered in the host registry after this pass
|
|
540
|
-
if (!tripped && !holdUntil && budget) {
|
|
541
|
-
while (running.size < K()) {
|
|
542
|
-
const ready = pending.filter(isReady);
|
|
543
|
-
const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
|
|
544
|
-
featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
|
|
545
|
-
if (n === null) { budgetHeld = ready.length > 0; break; }
|
|
546
|
-
if (!grant(n, wanted)) break;
|
|
547
|
-
pending.splice(pending.indexOf(n), 1);
|
|
548
|
-
if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
|
|
549
|
-
start(n); started++;
|
|
533
|
+
save();
|
|
534
|
+
})());
|
|
535
|
+
if (typeof onExit === "function") { try { onExit(relabelQueued); } catch { /* best effort */ } }
|
|
536
|
+
|
|
537
|
+
const K = () => { let k = null; try { k = getParallel?.(); } catch { k = null; } return Number.isInteger(k) && k >= 1 ? k : parallel; };
|
|
538
|
+
try {
|
|
539
|
+
while (pending.length || running.size) {
|
|
540
|
+
// Resolve skips first: a dependency that finished unsatisfied (or never can) skips its dependents.
|
|
541
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
542
|
+
const n = pending[i];
|
|
543
|
+
if (results.has(n)) { pending.splice(i, 1); continue; }
|
|
544
|
+
const bad = edges(n).find((d) => results.has(d) && !SATISFIED.has(results.get(d).outcome));
|
|
545
|
+
if (bad !== undefined) { pending.splice(i, 1); skip(n, `dependency #${bad} ended ${results.get(bad).outcome}`); }
|
|
550
546
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
547
|
+
const t = now();
|
|
548
|
+
const holdUntil = heldUntil();
|
|
549
|
+
let started = 0;
|
|
550
|
+
driftHeld.clear();
|
|
551
|
+
const isReady = (n) => {
|
|
552
|
+
if (!((notBefore.get(n) ?? 0) <= t && edges(n).every((d) => results.has(d) && SATISFIED.has(results.get(d).outcome)))) return false;
|
|
553
|
+
if (!drift?.holds) return true;
|
|
554
|
+
let pkg = null;
|
|
555
|
+
try { pkg = drift.holds(n, { body: bodies.get(n) ?? "", lane: lanes.get(n)?.lane ?? null }); } catch { pkg = null; }
|
|
556
|
+
if (pkg) driftHeld.set(n, pkg);
|
|
557
|
+
return !pkg;
|
|
558
|
+
};
|
|
559
|
+
budgetHeld = false;
|
|
560
|
+
// #4183: a hook (owned-resource GC near the host ceiling) before lanes are admitted; its errors never stop the batch.
|
|
561
|
+
if (beforeAdmit && !tripped && !holdUntil && running.size < K() && pending.some(isReady)) { try { await beforeAdmit(); } catch { /* ignored */ } }
|
|
562
|
+
budgetStop = null;
|
|
563
|
+
const wanted = new Set(); // issues that stay offered in the host registry after this pass
|
|
564
|
+
if (!tripped && !holdUntil && budget) {
|
|
565
|
+
while (running.size < K()) {
|
|
566
|
+
const ready = pending.filter(isReady);
|
|
567
|
+
const n = pickNext(ready, { classes, share: budget.share, whenIdle: budget.whenIdle, started: dispatch.started, maint: dispatch.maint,
|
|
568
|
+
featuresPending: pending.some((p) => !clsOf(classes, p).maintenance) });
|
|
569
|
+
if (n === null) { budgetHeld = ready.length > 0; break; }
|
|
570
|
+
if (!grant(n, wanted)) break;
|
|
571
|
+
pending.splice(pending.indexOf(n), 1);
|
|
572
|
+
if (lanes.get(n)?.ok !== false) { dispatch.started++; if (clsOf(classes, n).maintenance) dispatch.maint++; }
|
|
573
|
+
start(n); started++;
|
|
574
|
+
}
|
|
575
|
+
} else if (!tripped && !holdUntil && reg) {
|
|
576
|
+
// Priority first, pending order within a priority; the free per-batch K is offered so the host queue keeps FIFO.
|
|
577
|
+
const ready = pending.filter(isReady).map((n, i) => [n, i]).sort((a, b) => prioOf(a[0]) - prioOf(b[0]) || a[1] - b[1]).map(([n]) => n);
|
|
578
|
+
const want = ready.filter((n) => lanes.get(n)?.ok !== false && !isRemote(n)).slice(0, Math.max(0, K() - running.size));
|
|
579
|
+
offerFresh(want);
|
|
580
|
+
for (const n of want) wanted.add(n);
|
|
581
|
+
for (const n of ready) {
|
|
582
|
+
if (running.size >= K() || !grant(n, wanted)) break;
|
|
583
|
+
pending.splice(pending.indexOf(n), 1); start(n); started++;
|
|
584
|
+
}
|
|
585
|
+
} else if (!tripped && !holdUntil) {
|
|
586
|
+
for (let i = 0; i < pending.length && running.size < K();) {
|
|
587
|
+
const n = pending[i];
|
|
588
|
+
if (isReady(n) && grant(n, new Set())) { pending.splice(i, 1); start(n); started++; }
|
|
589
|
+
else i++;
|
|
590
|
+
}
|
|
560
591
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
592
|
+
if (reg) withdrawExcept(wanted);
|
|
593
|
+
if (tripped && !running.size) break; // breaker: let running lanes finish, keep the rest pending for a resume
|
|
594
|
+
if (budgetStop && !running.size) break; // execution budget (stop): the same, the rest end held
|
|
595
|
+
// Wake for a delayed issue, the end of a usage-limit hold, or (with a live K) a poll.
|
|
596
|
+
const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
|
|
597
|
+
if (holdUntil) waits.push(holdUntil);
|
|
598
|
+
let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
|
|
599
|
+
if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
600
|
+
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
601
|
+
if (tripped) wake = null;
|
|
602
|
+
if (running.size) {
|
|
603
|
+
if (wake === null) await Promise.race(running.values());
|
|
604
|
+
else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
|
|
605
|
+
} else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
|
|
606
|
+
else if (pending.length && offered.size) await sleep(wake); // waiting for a host slot
|
|
607
|
+
else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
|
|
608
|
+
else if (pending.length && budgetHeld) break; // maintenance the budget does not admit now: held for a later batch
|
|
609
|
+
else if (pending.length && driftHeld.size) break; // held by a drift flag (block_dispatch): pending for a later batch
|
|
610
|
+
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
611
|
+
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
612
|
+
save();
|
|
566
613
|
}
|
|
567
614
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
const waits = pending.map((n) => notBefore.get(n) ?? 0).filter((x) => x > t);
|
|
573
|
-
if (holdUntil) waits.push(holdUntil);
|
|
574
|
-
let wake = waits.length ? Math.max(0, Math.min(...waits) - t) : null;
|
|
575
|
-
if (getParallel && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
|
|
576
|
-
if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
|
|
577
|
-
if (tripped) wake = null;
|
|
578
|
-
if (running.size) {
|
|
579
|
-
if (wake === null) await Promise.race(running.values());
|
|
580
|
-
else { const ac = new AbortController(); await Promise.race([...running.values(), sleep(wake, ac.signal)]); ac.abort(); }
|
|
581
|
-
} else if (started) continue; // only lane-refused rows settled (synchronously): re-resolve skips first
|
|
582
|
-
else if (pending.length && offered.size) await sleep(wake); // waiting for a host slot
|
|
583
|
-
else if (pending.length && waits.length) await sleep(Math.max(0, Math.min(...waits) - t));
|
|
584
|
-
else if (pending.length && budgetHeld) break; // maintenance the budget does not admit now: held for a later batch
|
|
585
|
-
else if (pending.length && driftHeld.size) break; // held by a drift flag (block_dispatch): pending for a later batch
|
|
586
|
-
else if (pending.length) { // nothing runnable, nothing running, nothing waiting on time: a dependency cycle
|
|
587
|
-
for (const n of pending.splice(0)) skip(n, "dependency cycle");
|
|
588
|
-
save();
|
|
589
|
-
}
|
|
615
|
+
} catch (e) {
|
|
616
|
+
// #4318: an exception out of the loop still relabels the queued recoveries before it propagates
|
|
617
|
+
try { await relabelQueued(`batch failed: ${firstLine(e?.message ?? e)}`); } catch { /* best effort */ }
|
|
618
|
+
throw e;
|
|
590
619
|
}
|
|
591
620
|
if (reg) withdrawExcept(new Set());
|
|
592
|
-
|
|
593
|
-
// #4315: a recovery queued but never launched (the batch stopped: breaker, budget, hold; or a resume that did not relaunch a
|
|
594
|
-
// lost run) left the issue without needs-human, since a non-final attempt does not relabel. Relabel it once, with the full reason.
|
|
595
|
-
for (const [n, q] of recovering) {
|
|
596
|
-
if (running.has(n) || results.has(n) || relabelled.has(n) || typeof relabelNeedsHuman !== "function") continue;
|
|
597
|
-
const cause = heldDetail(n) === "not run" ? "batch stopped" : heldDetail(n);
|
|
598
|
-
const evidence = recoveryEvidence({ code: q.code, attempt: q.prev_attempt, outcome: q.outcome, resultCode: q.result_code, detail: q.detail, full: true,
|
|
599
|
-
queued: q.attempt, cause, recoveryMax });
|
|
600
|
-
try { await relabelNeedsHuman({ issue: n, evidence }); } catch { /* best effort */ }
|
|
601
|
-
relabelled.add(n);
|
|
602
|
-
}
|
|
621
|
+
await relabelQueued();
|
|
603
622
|
save();
|
|
604
623
|
return order.map((n) => results.get(n) ?? { issue: n, outcome: "held", pr: null, cost_usd: 0, minutes: 0, detail: heldDetail(n),
|
|
605
624
|
...(recovering.has(n) ? { recovery: { code: recovering.get(n).code, attempts: recoveries.get(n) ?? recovering.get(n).attempt, outcome: recovering.get(n).outcome ?? "needs-human",
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Pre-PR declared check commands (#4240), one round (#4331): the commands run concurrently up to the host worker cap
|
|
5
|
+
* (the caller holds one heavy-step slot for the whole round), each timed, inside one total budget. A command that never
|
|
6
|
+
* started before the budget ran out, or that the budget cut short, is left to CI (`deferred-to-ci`), never a failure.
|
|
7
|
+
* Repo-agnostic: commands are opaque `bash -c` strings run through the injected `run`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** rc of a command killed by its timeout (effects.mjs runCheckCommand). */
|
|
11
|
+
const TIMEOUT_RC = 124;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {{commands:string[], parallelism?:number, budgetMs:number, perCommandMs:number, now?:() => number,
|
|
15
|
+
* run:(a:{command:string, timeoutMs:number}) => Promise<{rc:number, output?:string}>}} p
|
|
16
|
+
* @returns {Promise<{results:{command:string, rc:number, output:string, ms:number}[], deferred:string[], ms:number}>}
|
|
17
|
+
* results in input order (commands that ran and were not cut by the budget); deferred in input order
|
|
18
|
+
*/
|
|
19
|
+
export async function runChecks({ commands, parallelism = 1, budgetMs, perCommandMs, now = () => Date.now(), run }) {
|
|
20
|
+
const list = Array.isArray(commands) ? commands : [];
|
|
21
|
+
const workers = Math.max(1, Math.floor(Number(parallelism)) || 1);
|
|
22
|
+
const start = now(), deadline = start + (Number(budgetMs) > 0 ? Number(budgetMs) : 0);
|
|
23
|
+
const per = Number(perCommandMs) > 0 ? Number(perCommandMs) : Infinity;
|
|
24
|
+
const slots = new Array(list.length).fill(null);
|
|
25
|
+
let next = 0;
|
|
26
|
+
async function worker() {
|
|
27
|
+
while (next < list.length) {
|
|
28
|
+
const i = next++, command = list[i], left = deadline - now();
|
|
29
|
+
if (left <= 0) { slots[i] = { deferred: true }; continue; }
|
|
30
|
+
const timeoutMs = Math.min(per, left), t = now();
|
|
31
|
+
let r;
|
|
32
|
+
try { r = await run({ command, timeoutMs }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
|
|
33
|
+
const rc = Number.isInteger(r?.rc) ? r.rc : 1;
|
|
34
|
+
// the budget, not the command's own timeout, cut it short: CI runs it
|
|
35
|
+
if (rc === TIMEOUT_RC && timeoutMs < per) { slots[i] = { deferred: true }; continue; }
|
|
36
|
+
slots[i] = { command, rc, output: String(r?.output ?? ""), ms: Math.max(0, now() - t) };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await Promise.all(Array.from({ length: Math.min(workers, list.length) }, worker));
|
|
40
|
+
const results = [], deferred = [];
|
|
41
|
+
slots.forEach((s, i) => { if (s?.deferred) deferred.push(list[i]); else if (s) results.push(s); });
|
|
42
|
+
return { results, deferred, ms: Math.max(0, now() - start) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* #4331 batch-report line: pre-PR check wall time summed over the rows' `precheck_ms`, and the commands deferred to CI
|
|
47
|
+
* (`precheck_deferred`). Null when no lane ran pre-PR checks.
|
|
48
|
+
*/
|
|
49
|
+
export function formatPrecheckSummary(rows) {
|
|
50
|
+
const ran = (rows ?? []).filter((r) => Number.isFinite(r?.precheck_ms) && r.precheck_ms >= 0);
|
|
51
|
+
if (!ran.length) return null;
|
|
52
|
+
const ms = ran.reduce((a, r) => a + r.precheck_ms, 0);
|
|
53
|
+
const deferred = ran.reduce((a, r) => a + (Number.isInteger(r.precheck_deferred) ? r.precheck_deferred : 0), 0);
|
|
54
|
+
return `precheck: ${(ms / 60000).toFixed(1)}m total over ${ran.length} lane${ran.length === 1 ? "" : "s"}${deferred ? `, ${deferred} deferred to CI` : ""}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { runChecks, formatPrecheckSummary } from "./precheck.mjs";
|
|
9
|
+
import { readPrecheckBudget } from "../engine-cli.mjs";
|
|
10
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
11
|
+
|
|
12
|
+
/** A fake run: each call takes `cost[command]` ms of the shared clock (default 1000) and returns rc[command] (default 0). */
|
|
13
|
+
function harness({ cost = {}, rc = {} } = {}) {
|
|
14
|
+
const h = { t: 0, live: 0, peak: 0, calls: [] };
|
|
15
|
+
h.now = () => h.t;
|
|
16
|
+
h.run = async ({ command, timeoutMs }) => {
|
|
17
|
+
h.calls.push([command, timeoutMs]); h.peak = Math.max(h.peak, ++h.live);
|
|
18
|
+
await new Promise((r) => setImmediate(r));
|
|
19
|
+
const c = cost[command] ?? 1000;
|
|
20
|
+
h.t += Math.min(c, timeoutMs); h.live--;
|
|
21
|
+
return c > timeoutMs ? { rc: 124, output: "timed out" } : { rc: rc[command] ?? 0, output: `${command} out` };
|
|
22
|
+
};
|
|
23
|
+
return h;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("runChecks (#4331)", () => {
|
|
27
|
+
it("runs up to `parallelism` commands at once and returns results in input order with ms", async () => {
|
|
28
|
+
const h = harness();
|
|
29
|
+
const r = await runChecks({ commands: ["a", "b", "c", "d", "e"], parallelism: 3, budgetMs: 600000, perCommandMs: 300000, now: h.now, run: h.run });
|
|
30
|
+
assert.equal(h.peak, 3);
|
|
31
|
+
assert.deepEqual(r.results.map((x) => x.command), ["a", "b", "c", "d", "e"]);
|
|
32
|
+
assert.ok(r.results.every((x) => x.rc === 0 && Number.isFinite(x.ms) && x.ms >= 0));
|
|
33
|
+
assert.deepEqual(r.deferred, []);
|
|
34
|
+
assert.ok(r.ms > 0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("parallelism 1 is serial; 0, NaN or missing fall back to 1", async () => {
|
|
38
|
+
for (const p of [1, 0, NaN, undefined, "x"]) {
|
|
39
|
+
const h = harness();
|
|
40
|
+
await runChecks({ commands: ["a", "b", "c"], parallelism: p, budgetMs: 600000, perCommandMs: 300000, now: h.now, run: h.run });
|
|
41
|
+
assert.equal(h.peak, 1, String(p));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("past the budget the remaining commands are deferred to CI, not failed; a running one gets what is left", async () => {
|
|
46
|
+
const h = harness();
|
|
47
|
+
const r = await runChecks({ commands: ["a", "b", "c"], parallelism: 1, budgetMs: 1500, perCommandMs: 300000, now: h.now, run: h.run });
|
|
48
|
+
assert.deepEqual(h.calls, [["a", 1500], ["b", 500]]);
|
|
49
|
+
assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["a", 0]], "b was cut by the budget (rc 124): deferred, not a failure");
|
|
50
|
+
assert.deepEqual(r.deferred, ["b", "c"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("a command's own timeout (not the budget) stays a failure; a throw is rc 127", async () => {
|
|
54
|
+
const h = harness({ cost: { slow: 5000 } });
|
|
55
|
+
const r = await runChecks({ commands: ["slow", "boom", "bad"], parallelism: 2, budgetMs: 600000, perCommandMs: 2000, now: h.now,
|
|
56
|
+
run: (a) => (a.command === "boom" ? Promise.reject(new Error("spawn failed")) : a.command === "bad" ? Promise.resolve({ rc: 2, output: "lint" }) : h.run(a)) });
|
|
57
|
+
assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["slow", 124], ["boom", 127], ["bad", 2]]);
|
|
58
|
+
assert.match(r.results[1].output, /spawn failed/);
|
|
59
|
+
assert.deepEqual(r.deferred, []);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("no commands: nothing runs", async () => {
|
|
63
|
+
let n = 0;
|
|
64
|
+
const r = await runChecks({ commands: [], parallelism: 4, budgetMs: 1000, perCommandMs: 1000, now: () => 0, run: async () => { n++; return { rc: 0 }; } });
|
|
65
|
+
assert.deepEqual([n, r.results, r.deferred, r.ms], [0, [], [], 0]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("formatPrecheckSummary (#4331)", () => {
|
|
70
|
+
it("sums precheck_ms over the lanes that ran checks and counts deferrals; null when none did", () => {
|
|
71
|
+
assert.equal(formatPrecheckSummary([{ precheck_ms: 90000 }, { precheck_ms: 30000, precheck_deferred: 2 }, { issue: 3 }]), "precheck: 2.0m total over 2 lanes, 2 deferred to CI");
|
|
72
|
+
assert.equal(formatPrecheckSummary([{ precheck_ms: 6000 }]), "precheck: 0.1m total over 1 lane");
|
|
73
|
+
assert.equal(formatPrecheckSummary([{ issue: 1 }]), null);
|
|
74
|
+
assert.equal(formatPrecheckSummary(undefined), null);
|
|
75
|
+
});
|
|
76
|
+
it("orchestrate rows carry the lane's precheckMs/precheckDeferred as precheck_ms/precheck_deferred for the report line", async () => {
|
|
77
|
+
const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
|
|
78
|
+
runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { precheckMs: 90000, precheckDeferred: 1 }, 2: { precheckMs: 30000, precheckDeferred: 0 } })[n] }) });
|
|
79
|
+
assert.deepEqual(rows.map((r) => [r.issue, r.precheck_ms, r.precheck_deferred]), [[1, 90000, 1], [2, 30000, undefined], [3, undefined, undefined]]);
|
|
80
|
+
assert.equal(formatPrecheckSummary(rows), "precheck: 2.0m total over 2 lanes, 1 deferred to CI");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", () => {
|
|
85
|
+
const withYaml = (text, fn) => { const d = tmpDir("precheck-"); writeFileSync(join(d, "forge.yaml"), text); return fn(d); };
|
|
86
|
+
it("reads the nested block and the flat key; invalid or absent is undefined", () => {
|
|
87
|
+
assert.equal(withYaml("lean:\n precheck:\n max_minutes: 7\n", readPrecheckBudget), 7);
|
|
88
|
+
assert.equal(withYaml("lean:\n precheck_max_minutes: 15\n repro_test: off\n", readPrecheckBudget), 15);
|
|
89
|
+
for (const bad of ["0", "-1", "abc", "121"]) assert.equal(withYaml(`lean:\n precheck_max_minutes: ${bad}\n`, readPrecheckBudget), undefined, bad);
|
|
90
|
+
assert.equal(withYaml("verify:\n mode: local\n", readPrecheckBudget), undefined);
|
|
91
|
+
assert.equal(readPrecheckBudget(tmpDir("precheck-")), undefined);
|
|
92
|
+
});
|
|
93
|
+
});
|
package/bin/lean/prefilter.mjs
CHANGED
|
@@ -54,9 +54,20 @@ const isMarked = (c) => String(c?.body ?? "").trimStart().startsWith(`<!-- ${PRE
|
|
|
54
54
|
* CONTRIBUTOR, never `[bot]`) plus the token's own user (`gh api user`; an App installation token cannot read it).
|
|
55
55
|
*/
|
|
56
56
|
export async function ownLogins(gh) {
|
|
57
|
+
return (await resolveOwnLogins(gh)).logins;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* #4318: {logins, resolved}. When `gh api user` fails (an App installation token), the App's own slug from `gh api app`
|
|
61
|
+
* (a differently named App); `resolved` is false when neither lookup answered, so the batch memo may ask once more.
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveOwnLogins(gh) {
|
|
57
64
|
const out = new Set([bareLogin(APP_SLUG)]);
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
let resolved = false;
|
|
66
|
+
try { const me = bareLogin(await gh(["api", "user", "--jq", ".login"])); if (me) { out.add(me); resolved = true; } } catch { /* installation token */ }
|
|
67
|
+
if (!resolved) {
|
|
68
|
+
try { const slug = bareLogin(await gh(["api", "app", "--jq", ".slug"])); if (slug) { out.add(slug); resolved = true; } } catch { /* the App slug only */ }
|
|
69
|
+
}
|
|
70
|
+
return { logins: [...out], resolved };
|
|
60
71
|
}
|
|
61
72
|
/**
|
|
62
73
|
* #4305: why one green run is no proof for this issue, without an API call: flaky wording or a `flaky` label, or an
|
|
@@ -195,8 +206,14 @@ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAU
|
|
|
195
206
|
|
|
196
207
|
/** Per-issue hook for `orchestrate({ prefilter })`; `lane` is the issue's own resolved lane (#4125). */
|
|
197
208
|
export function makePrefilter(opts) {
|
|
198
|
-
|
|
199
|
-
|
|
209
|
+
// #4315: one identity lookup per batch, and only when a marker comment needs it; #4318: an unresolved one is retried once
|
|
210
|
+
let own = null, tries = 0;
|
|
211
|
+
const selfLogins = opts.selfLogins ?? (() => (own ??= (async () => {
|
|
212
|
+
tries++;
|
|
213
|
+
const r = await resolveOwnLogins(opts.io.gh);
|
|
214
|
+
if (!r.resolved && tries < 2) own = null;
|
|
215
|
+
return r.logins;
|
|
216
|
+
})()));
|
|
200
217
|
return (issue, lane) => prefilterIssue({ ...opts, selfLogins, issue, ...(lane ? { lane } : {}) });
|
|
201
218
|
}
|
|
202
219
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { prefilterIssue, flakyReason, makePrefilter, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
5
|
+
import { prefilterIssue, flakyReason, makePrefilter, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
|
|
6
6
|
import { parsePrefilterVerdict, prefilterPrompt } from "./prompts.mjs";
|
|
7
7
|
import { orchestrate, formatTable } from "./orchestrate.mjs";
|
|
8
8
|
import { triageFromCli } from "../engine-cli.mjs";
|
|
@@ -315,3 +315,26 @@ describe("orchestrate with the pre-filter (#4123)", () => {
|
|
|
315
315
|
assert.ok(logs.some((s) => /^#12 → lane-refused \(lane\.default_branch/.test(s)));
|
|
316
316
|
});
|
|
317
317
|
});
|
|
318
|
+
|
|
319
|
+
describe("#4318: the engine's own identity under an installation token or a differently named App", () => {
|
|
320
|
+
it("ownLogins falls back to the App slug from `gh api app` when `gh api user` fails", async () => {
|
|
321
|
+
const gh = async (a) => { if (a[1] === "user") throw new Error("Resource not accessible by integration"); if (a[1] === "app") return "Other-Forge\n"; return ""; };
|
|
322
|
+
assert.deepEqual((await resolveOwnLogins(gh)), { logins: ["rapiercraft-forgedock", "other-forge"], resolved: true });
|
|
323
|
+
assert.deepEqual(await ownLogins(gh), ["rapiercraft-forgedock", "other-forge"]);
|
|
324
|
+
const both = async () => { throw new Error("401"); };
|
|
325
|
+
assert.deepEqual(await resolveOwnLogins(both), { logins: ["rapiercraft-forgedock"], resolved: false });
|
|
326
|
+
});
|
|
327
|
+
it("an unresolved lookup is retried once in the batch, then the App-slug-only set is kept", async () => {
|
|
328
|
+
const link = "Run: https://github.com/o/r/actions/runs/500";
|
|
329
|
+
const real = { author: { login: "Other-Forge" }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:PREFILTER -->\n## Pre-filter" };
|
|
330
|
+
const w = world({ issues: { 34: { title: "shell-compat failed on staging", body: `Workflow failed.\n${link}`, comments: [real] } },
|
|
331
|
+
runs: { 500: { id: 500, workflow_id: 7, name: "shell-compat", head_branch: "staging", head_sha: "1111111111111111", created_at: "2026-10-10T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/500" } },
|
|
332
|
+
workflowRuns: { "7:staging": [{ id: 600, conclusion: "success", created_at: "2026-10-12T00:00:00Z", html_url: "https://github.com/o/r/actions/runs/600", head_sha: "8d9f6ba0aaaaaaaa" }] },
|
|
333
|
+
shaRuns: { "8d9f6ba0aaaaaaaa": [{ id: 600, conclusion: "success" }] } });
|
|
334
|
+
let lookups = 0; const g0 = w.io.gh;
|
|
335
|
+
w.io.gh = async (a) => (a[0] === "api" && (a[1] === "user" || a[1] === "app") ? (lookups++, (() => { throw new Error("401"); })()) : g0(a));
|
|
336
|
+
const run = makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io });
|
|
337
|
+
await run(34, "staging"); await run(34, "staging"); await run(34, "staging");
|
|
338
|
+
assert.equal(lookups, 4, "user + app, twice: one retry, then cached");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* forge#4328: freeze-time preflight of a contract's validate commands. Pure: derives the host tools a command needs
|
|
5
|
+
* (each segment's bare executable, Python modules a bare python imports via -c/-m, Node packages `node -e` code imports)
|
|
6
|
+
* and a read-only bash probe for each. The engine runs the probes (lean.mjs) before the implement session, so a validate
|
|
7
|
+
* command the host cannot run is corrected in the plan instead of found after the build (#4323 env.tool_missing).
|
|
8
|
+
*/
|
|
9
|
+
import { builtinModules } from "node:module";
|
|
10
|
+
|
|
11
|
+
export const PREFLIGHT_CAP = 20;
|
|
12
|
+
const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
|
|
13
|
+
const PY_RE = /^python(\d+(\.\d+)?)?$/;
|
|
14
|
+
const PY_ARG_FLAGS = new Set(["-W", "-X", "-Q"]);
|
|
15
|
+
const sq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
16
|
+
|
|
17
|
+
/** Quote-aware split of a bash command into segments (on &&, ||, |, ;, newline) of unquoted words; `$(...)` stays one word. */
|
|
18
|
+
export function shellSegments(command) {
|
|
19
|
+
const segs = [];
|
|
20
|
+
let words = [], word = "", has = false, q = null, depth = 0;
|
|
21
|
+
const endWord = () => { if (has) words.push(word); word = ""; has = false; };
|
|
22
|
+
const endSeg = () => { endWord(); if (words.length) segs.push(words); words = []; };
|
|
23
|
+
const s = String(command ?? "");
|
|
24
|
+
for (let i = 0; i < s.length; i++) {
|
|
25
|
+
const ch = s[i];
|
|
26
|
+
if (q === "'") { if (ch === "'") q = null; else word += ch; continue; }
|
|
27
|
+
if (ch === "\\" && i + 1 < s.length) { word += s[++i]; has = true; continue; }
|
|
28
|
+
if (q === '"') { if (ch === '"') q = null; else word += ch; continue; }
|
|
29
|
+
if (depth) { word += ch; if (ch === "(") depth++; else if (ch === ")") depth--; continue; }
|
|
30
|
+
if (ch === "'" || ch === '"') { q = ch; has = true; continue; }
|
|
31
|
+
if (ch === "$" && s[i + 1] === "(") { word += "$("; i++; depth = 1; has = true; continue; }
|
|
32
|
+
if (ch === " " || ch === "\t") { endWord(); continue; }
|
|
33
|
+
if (ch === "\n" || ch === ";" || ch === "|" || ch === "&") { if (ch === "&" && s[i + 1] !== "&") { endWord(); continue; } endSeg(); if (s[i + 1] === ch) i++; continue; }
|
|
34
|
+
if ((ch === "(" || ch === ")" || ch === "{" || ch === "}") && !has) continue;
|
|
35
|
+
word += ch; has = true;
|
|
36
|
+
}
|
|
37
|
+
endSeg();
|
|
38
|
+
return segs;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Top-level module names a Python `-c` program imports (absolute imports only). */
|
|
42
|
+
export function pythonImports(code) {
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const line of String(code ?? "").split(/[;\n]/)) {
|
|
45
|
+
const imp = /^\s*import\s+(.+)$/.exec(line), from = /^\s*from\s+([\w.]+)\s+import\b/.exec(line);
|
|
46
|
+
if (imp) for (const part of imp[1].split(",")) { const m = /^\s*([A-Za-z_]\w*)/.exec(part); if (m) out.push(m[1]); }
|
|
47
|
+
else if (from && !from[1].startsWith(".")) out.push(from[1].split(".")[0]);
|
|
48
|
+
}
|
|
49
|
+
for (const m of String(code ?? "").matchAll(/(?:__import__|import_module)\(\s*['"]([A-Za-z_][\w.]*)['"]/g)) out.push(m[1].split(".")[0]);
|
|
50
|
+
return [...new Set(out)].filter((x) => x !== "__future__");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Package names (bare specifiers, cut to the package) a `node -e` program imports; builtins, node:, relative and absolute are not packages. */
|
|
54
|
+
export function nodeImports(code) {
|
|
55
|
+
const specs = [], c = String(code ?? "");
|
|
56
|
+
for (const re of [/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, /\bimport\s*\(\s*['"]([^'"]+)['"]/g, /\bfrom\s*['"]([^'"]+)['"]/g, /(?:^|[;\n])\s*import\s*['"]([^'"]+)['"]/g]) for (const m of c.matchAll(re)) specs.push(m[1]);
|
|
57
|
+
const out = [];
|
|
58
|
+
for (const spec of specs) {
|
|
59
|
+
if (/^(\.|\/|node:|file:|data:)/.test(spec)) continue;
|
|
60
|
+
const parts = spec.split("/"), pkg = spec.startsWith("@") ? (parts.length > 1 ? parts.slice(0, 2).join("/") : null) : parts[0];
|
|
61
|
+
if (pkg && !builtinModules.includes(pkg)) out.push(pkg);
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(out)];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const PY_PROBE = "import importlib.util,sys;sys.exit(0 if importlib.util.find_spec(sys.argv[1]) else 3)";
|
|
67
|
+
const NODE_PROBE = "const m=require('module'),p=require('path'),f=require('fs'),n=process.argv[1];if(m.builtinModules.includes(n))process.exit(0);for(let d=process.cwd();;){if(f.existsSync(p.join(d,'node_modules',n,'package.json')))process.exit(0);const u=p.dirname(d);if(u===d)process.exit(3);d=u}";
|
|
68
|
+
|
|
69
|
+
/** The program text and modules of one python/node segment's arguments. */
|
|
70
|
+
function moduleNeeds(exe, args) {
|
|
71
|
+
if (PY_RE.test(exe)) {
|
|
72
|
+
for (let i = 0; i < args.length; i++) {
|
|
73
|
+
const a = args[i];
|
|
74
|
+
if (a === "-c") return pythonImports(args[i + 1]);
|
|
75
|
+
if (a.startsWith("-c") && a.length > 2) return pythonImports(a.slice(2));
|
|
76
|
+
if (a === "-m") return args[i + 1] ? [args[i + 1].split(".")[0]] : [];
|
|
77
|
+
if (a.startsWith("-m") && a.length > 2) return [a.slice(2).split(".")[0]];
|
|
78
|
+
if (PY_ARG_FLAGS.has(a)) { i++; continue; }
|
|
79
|
+
if (!a.startsWith("-")) return [];
|
|
80
|
+
}
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
if (exe === "node") {
|
|
84
|
+
for (let i = 0; i < args.length; i++) {
|
|
85
|
+
const a = args[i];
|
|
86
|
+
if (["-e", "--eval", "-p", "--print"].includes(a)) return nodeImports(args[i + 1]);
|
|
87
|
+
const eq = /^--(?:eval|print)=(.*)$/s.exec(a);
|
|
88
|
+
if (eq) return nodeImports(eq[1]);
|
|
89
|
+
if (!a.startsWith("-")) return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The probes for `contract.validate[]`: {probes: [{ids, tool, kind, interp, needs, probe}], dropped}. `needs` is the command
|
|
97
|
+
* probe (tool) a module probe depends on: skip the module probe when its interpreter is missing. A name `repoHas(name)` holds
|
|
98
|
+
* or a deliverable's change text names (the change adds it) is not probed; paths and `$...` words are never probed.
|
|
99
|
+
*/
|
|
100
|
+
export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIGHT_CAP } = {}) {
|
|
101
|
+
const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
|
|
102
|
+
const named = (name) => new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
|
|
103
|
+
const skip = (name) => !name || name.includes("/") && !name.startsWith("@") || name.startsWith("$") || repoHas(name) || named(name);
|
|
104
|
+
const byKey = new Map();
|
|
105
|
+
const add = (id, kind, tool, interp = null) => {
|
|
106
|
+
const key = `${kind}|${tool}|${interp ?? ""}`;
|
|
107
|
+
const p = byKey.get(key);
|
|
108
|
+
if (p) { if (!p.ids.includes(id)) p.ids.push(id); return; }
|
|
109
|
+
const probe = kind === "command" ? `command -v -- ${sq(tool)} >/dev/null 2>&1`
|
|
110
|
+
: kind === "python-module" ? `${sq(interp)} -c ${sq(PY_PROBE)} ${sq(tool)} >/dev/null 2>&1`
|
|
111
|
+
: `node -e ${sq(NODE_PROBE)} ${sq(tool)} >/dev/null 2>&1`;
|
|
112
|
+
byKey.set(key, { ids: [id], tool, kind, interp, needs: kind === "command" ? null : interp, probe });
|
|
113
|
+
};
|
|
114
|
+
for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
|
|
115
|
+
if (!v?.id || typeof v.command !== "string") continue;
|
|
116
|
+
for (const seg of shellSegments(v.command)) {
|
|
117
|
+
let i = 0;
|
|
118
|
+
while (i < seg.length && (/^[A-Za-z_]\w*=/.test(seg[i]) || ["!", "time", "exec", "command", "env"].includes(seg[i]))) i++;
|
|
119
|
+
const exe = seg[i];
|
|
120
|
+
if (!exe || exe.includes("/") || exe.startsWith("$") || exe.startsWith("-")) continue;
|
|
121
|
+
if (!skip(exe)) add(v.id, "command", exe);
|
|
122
|
+
const kind = exe === "node" ? "node-module" : "python-module";
|
|
123
|
+
for (const mod of moduleNeeds(exe, seg.slice(i + 1))) if (!skip(mod)) add(v.id, kind, mod, exe);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const all = [...byKey.values()];
|
|
127
|
+
return { probes: all.slice(0, cap), dropped: Math.max(0, all.length - cap) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One contract problem per missed probe: names the validate ids and the tool, and how to fix the plan (never a global install). */
|
|
131
|
+
export function preflightProblems(misses) {
|
|
132
|
+
return (misses ?? []).map((m) => `${m.ids.join(", ")}: needs ${KIND_NAME[m.kind] ?? m.kind} '${m.tool}'${m.interp ? ` (for ${m.interp})` : ""}, which this host lacks, so the validate command cannot run here; run the check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts) or use a check that does not need it, never a global install`);
|
|
133
|
+
}
|