forgedock 1.29.0 → 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 +102 -38
- 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 +32 -4
- package/bin/lean/context.test.mjs +71 -12
- 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 +207 -56
- package/bin/lean/lean.test.mjs +336 -9
- package/bin/lean/orchestrate.mjs +132 -83
- package/bin/lean/precheck.mjs +55 -0
- package/bin/lean/precheck.test.mjs +93 -0
- package/bin/lean/prefilter.mjs +96 -14
- package/bin/lean/prefilter.test.mjs +111 -2
- 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 +271 -7
- package/bin/lean/render.mjs +13 -0
- package/bin/lean/sequence.mjs +102 -0
- package/bin/lean/sequence.test.mjs +112 -0
- package/bin/lean/testmap.mjs +57 -3
- package/bin/lean/testmap.test.mjs +53 -1
- package/bin/lean/verify.mjs +7 -2
- package/bin/lean/verify.test.mjs +8 -2
- package/commands/deploy-pr.md +5 -4
- package/commands/orchestrate.md +7 -4
- package/commands/review-pr-staging.md +6 -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/code-index.sh +33 -33
- package/scripts/code-index.test.sh +21 -2
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
- package/scripts/repo-facts.test.sh +5 -5
- package/scripts/review-prepare.sh +28 -6
- package/scripts/review-prepare.test.sh +22 -2
- package/scripts/test-map.sh +186 -34
- package/scripts/test-map.test.sh +250 -7
- package/scripts/verify-phase-trail.test.sh +44 -44
- package/scripts/wait-ci-green.sh +1 -1
- package/scripts/wait-ci-green.test.sh +4 -0
|
@@ -48,6 +48,8 @@ export const OUTCOME_CODES = Object.freeze({
|
|
|
48
48
|
CLI_TIMEOUT: "cli.timeout",
|
|
49
49
|
CLI_SPAWN_FAILED: "cli.spawn_failed", // forge#4129: deterministic spawn failure (E2BIG, ENOENT, EACCES, ...)
|
|
50
50
|
ENV_SETUP_FAILED: "env.setup_failed", // forge#4163: forge.yaml env.setup failed before the builder; an environment error, not a build failure
|
|
51
|
+
ENV_TOOL_MISSING: "env.tool_missing", // forge#4323: a validate command could not run because a host tool or module is missing; an environment error, not a conformance failure
|
|
52
|
+
ENV_REPRO_UNRUNNABLE: "env.repro_unrunnable", // forge#4334: the repro harness could not run the repro test on head; an environment error, not a conformance failure
|
|
51
53
|
SANDBOX_START_FAILED: "sandbox.start_failed", // forge#4254: a lane sandbox could not start; needs-human, not relaunched
|
|
52
54
|
SANDBOX_CAPACITY: "sandbox.capacity", // forge#4254: the sandbox provider had no capacity; deferred with backoff
|
|
53
55
|
LEASE_HELD: "lease.held",
|
|
@@ -180,13 +182,13 @@ export function codeForTerminalReason(reason) {
|
|
|
180
182
|
* Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
|
|
181
183
|
* (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
|
|
182
184
|
*/
|
|
183
|
-
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID]);
|
|
185
|
+
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID, OUTCOME_CODES.ENV_TOOL_MISSING, OUTCOME_CODES.ENV_REPRO_UNRUNNABLE]);
|
|
184
186
|
/** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
|
|
185
187
|
export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
186
188
|
|
|
187
189
|
/**
|
|
188
190
|
* `recoverable` | `human` | `terminal` for a lane result row ({outcome|terminalReason, code, issueEdited?, conflicting?}).
|
|
189
|
-
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `contract.invalid` after a mid-run issue
|
|
191
|
+
* Recoverable: needs-human `ci.red`, `base.conflict`, `builder.block_untyped`, `env.tool_missing`, `env.repro_unrunnable`, `contract.invalid` after a mid-run issue
|
|
190
192
|
* edit (`issueEdited`), and `ci-pending` on a conflicting PR (`conflicting`). Human: `builder.blocked`. Anything else
|
|
191
193
|
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
192
194
|
*/
|
package/bin/engine/phases.mjs
CHANGED
|
@@ -349,9 +349,10 @@ export async function openDependency(issue, io) {
|
|
|
349
349
|
return { unreadable: true, detail: `dependency #${dep} of issue ${issue} could not be read: ${e?.message || e}` };
|
|
350
350
|
}
|
|
351
351
|
const st = typeof d?.state === "string" ? d.state.toLowerCase() : "";
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
352
|
+
// #4153 / #4337: a decomposed parent, open or closed, is a tracker; the edge resolves to its sub-issues (satisfied
|
|
353
|
+
// when all close). No usable trail (none, untrusted, revoked, unreadable) keeps the parent itself open (fail closed).
|
|
354
|
+
if (isDecomposedRecord(d) && (st === "open" || st === "closed")) {
|
|
355
|
+
const subs = await decomposedSubissues(dep, io);
|
|
355
356
|
if (!subs) { open.push(dep); continue; }
|
|
356
357
|
for (const s of subs) {
|
|
357
358
|
let sr;
|
|
@@ -363,6 +364,7 @@ export async function openDependency(issue, io) {
|
|
|
363
364
|
else if (ss !== "closed") return { unreadable: true, detail: `sub-issue #${s} of decomposed dependency #${dep} has no readable state` };
|
|
364
365
|
}
|
|
365
366
|
}
|
|
367
|
+
else if (st === "open") open.push(dep);
|
|
366
368
|
else if (st !== "closed") return { unreadable: true, detail: `dependency #${dep} of issue ${issue} has no readable state` };
|
|
367
369
|
}
|
|
368
370
|
return open.length ? { open: [...new Set(open)].filter((n) => n !== Number(issue)) } : null;
|
|
@@ -388,6 +390,21 @@ export async function decomposedSubissues(issue, io) {
|
|
|
388
390
|
return subs.length ? subs : null;
|
|
389
391
|
}
|
|
390
392
|
|
|
393
|
+
/**
|
|
394
|
+
* #4337: reopen a CLOSED `workflow:decomposed` issue whose decomposition trusted GitHub state shows revoked
|
|
395
|
+
* (`decompositionRevoked`). `true` only when it was reopened; an open issue, no/untrusted revocation, or any read or
|
|
396
|
+
* write error is `false` (never throws), so the closed tracker keeps gating its dependents.
|
|
397
|
+
*/
|
|
398
|
+
export async function reopenRevokedDecomposition(issue, io) {
|
|
399
|
+
try {
|
|
400
|
+
const rec = await readIssueRecord(issue, io);
|
|
401
|
+
if (!rec || rec.empty || String(rec.state).toLowerCase() !== "closed" || !isDecomposedRecord(rec)) return false;
|
|
402
|
+
if (!(await decompositionRevoked(issue, io))) return false;
|
|
403
|
+
await io.gh(["api", "-X", "PATCH", `repos/{owner}/{repo}/issues/${issue}`, "-f", "state=open"]);
|
|
404
|
+
return true;
|
|
405
|
+
} catch { return false; }
|
|
406
|
+
}
|
|
407
|
+
|
|
391
408
|
/** True when a REST/`gh --json` issue record carries the `workflow:decomposed` label. */
|
|
392
409
|
export const isDecomposedRecord = (rec) => (rec?.labels ?? []).some((l) => (l?.name ?? l) === "workflow:decomposed");
|
|
393
410
|
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -166,6 +166,10 @@ export function readReproTest(cwd = process.cwd()) {
|
|
|
166
166
|
return ["off", "prefer"].includes(v) ? v : undefined;
|
|
167
167
|
} catch { return undefined; }
|
|
168
168
|
}
|
|
169
|
+
/** forge.yaml `decomposition.close_parent` (#3736, #4337): true closes a decomposed parent at decomposition; anything else keeps it open. */
|
|
170
|
+
export function readCloseParent(cwd = process.cwd()) {
|
|
171
|
+
try { return String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.decomposition?.close_parent ?? "").trim() === "true"; } catch { return false; }
|
|
172
|
+
}
|
|
169
173
|
function readLeanSetting(key, cwd = process.cwd()) {
|
|
170
174
|
try {
|
|
171
175
|
const v = String(parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine?.[`lean_${key}`] ?? "").trim();
|
|
@@ -280,6 +284,23 @@ export function readPrecheckCommands(cwd = process.cwd()) {
|
|
|
280
284
|
}
|
|
281
285
|
return out.length ? out : undefined;
|
|
282
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* forge.yaml `lean.precheck.max_minutes` (#4331): the pre-PR check round budget in minutes (1..120). The one-level
|
|
289
|
+
* parser flattens a nested `precheck:` block under `lean:` to `lean.max_minutes`; flat `lean.precheck_max_minutes` also
|
|
290
|
+
* works and wins. undefined when absent, invalid or unreadable (runCore then uses verify.precheck_total_seconds, default 600 s).
|
|
291
|
+
*/
|
|
292
|
+
export function readPrecheckBudget(cwd = process.cwd()) {
|
|
293
|
+
try {
|
|
294
|
+
const l = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.lean ?? {};
|
|
295
|
+
const raw = String(l.precheck_max_minutes ?? l.max_minutes ?? "").trim(), n = Number(raw);
|
|
296
|
+
return raw !== "" && Number.isFinite(n) && n >= 1 && n <= 120 ? n : undefined;
|
|
297
|
+
} catch { return undefined; }
|
|
298
|
+
}
|
|
299
|
+
/** runCore `precheck` option: {commands, maxMinutes} from learned.test_commands and lean.precheck.max_minutes; {} when neither is set. */
|
|
300
|
+
function readPrecheckOptions(cwd = process.cwd()) {
|
|
301
|
+
const commands = readPrecheckCommands(cwd), maxMinutes = readPrecheckBudget(cwd);
|
|
302
|
+
return commands || maxMinutes ? { precheck: { ...(commands ? { commands } : {}), ...(maxMinutes ? { maxMinutes } : {}) } } : {};
|
|
303
|
+
}
|
|
283
304
|
/**
|
|
284
305
|
* forge.yaml `env` for core lanes (#4163): `env.toolchain` ("<pm>@<x.y.z>" or "off") and `env.setup` (a command run once
|
|
285
306
|
* per fresh worktree before the builder); `env.sparse_max_kb` (#4221: sparse lane checkout threshold, 0 = off). Returns runCore options; {} when absent.
|
|
@@ -637,7 +658,7 @@ export function lastLocalRun(dir) {
|
|
|
637
658
|
*/
|
|
638
659
|
export async function runFromCli(argv, deps = {}) {
|
|
639
660
|
const issue = parseInt(argv[0], 10);
|
|
640
|
-
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n]] [--retry] [--wait-lease]");
|
|
661
|
+
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--path core|full] [--recover <code> [--recover-attempt n] [--recover-nonfinal]] [--retry] [--wait-lease]");
|
|
641
662
|
assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
|
|
642
663
|
const lane = flag(argv, "--lane");
|
|
643
664
|
if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
|
|
@@ -667,10 +688,11 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
667
688
|
const placementWhere = flag(argv, "--placement");
|
|
668
689
|
const placement = placementWhere === "local" || placementWhere === "remote"
|
|
669
690
|
? { where: placementWhere, reason: String(flag(argv, "--placement-reason") ?? "").slice(0, 300), mode: readExecution()?.mode ?? "local" } : null;
|
|
670
|
-
// #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`)
|
|
691
|
+
// #4267: a batch recovery lane for a mechanical needs-human (`--recover <code> [--recover-attempt n]`); #4302: `--recover-nonfinal`
|
|
692
|
+
// marks an attempt the runner may follow with another, so the lane leaves the needs-human relabel to the runner.
|
|
671
693
|
const recoverCode = flag(argv, "--recover");
|
|
672
694
|
const recoverAttempt = Number(flag(argv, "--recover-attempt"));
|
|
673
|
-
const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1 } : null;
|
|
695
|
+
const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1, final: !argv.includes("--recover-nonfinal") } : null;
|
|
674
696
|
const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
|
|
675
697
|
try {
|
|
676
698
|
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
@@ -735,7 +757,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
735
757
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
736
758
|
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
737
759
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
738
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(
|
|
760
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readCloseParent() ? { closeParent: true } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...readPrecheckOptions(), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
739
761
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
740
762
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
741
763
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1071,6 +1093,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1071
1093
|
sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
|
|
1072
1094
|
const dir = deps.dir ?? runDir();
|
|
1073
1095
|
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists } = await import("./lean/orchestrate.mjs");
|
|
1096
|
+
const { formatPrecheckSummary } = await import("./lean/precheck.mjs");
|
|
1074
1097
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1075
1098
|
const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
|
|
1076
1099
|
const prefilter = argv.includes("--no-prefilter") ? null
|
|
@@ -1091,14 +1114,14 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1091
1114
|
const { runCore } = deps.leanModule;
|
|
1092
1115
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1093
1116
|
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), ...(ctx.recover ? { recover: ctx.recover } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1094
|
-
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(
|
|
1117
|
+
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readContextPack(cwd) ? { contextPack: readContextPack(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readCloseParent(cwd) ? { closeParent: true } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...readPrecheckOptions(cwd), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
1095
1118
|
} else {
|
|
1096
1119
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1097
1120
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
1098
1121
|
// #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
|
|
1099
1122
|
const placementOf = new Map(), recoverOf = new Map();
|
|
1100
1123
|
const laneArgs = (n, ln) => { const pl = placementOf.get(n), rc = recoverOf.get(n); return ["--repo", repo, ...(ln ? ["--lane", ln] : []), ...(laneOverride ? ["--lane-override"] : []), ...(model ? ["--model", model] : []),
|
|
1101
|
-
...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1)] : [])]; };
|
|
1124
|
+
...(pl ? ["--placement", pl.where, "--placement-reason", pl.reason ?? ""] : []), ...(rc ? ["--recover", rc.code, "--recover-attempt", String(rc.attempt ?? 1), ...(rc.final === false ? ["--recover-nonfinal"] : [])] : [])]; };
|
|
1102
1125
|
const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => {
|
|
1103
1126
|
if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n);
|
|
1104
1127
|
if (ctx.recover) recoverOf.set(n, ctx.recover); else recoverOf.delete(n);
|
|
@@ -1153,6 +1176,9 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1153
1176
|
}
|
|
1154
1177
|
}
|
|
1155
1178
|
const orch = deps.orchestration ?? readOrchestration(cwd);
|
|
1179
|
+
// #4304: recovery lanes run on the core path only (run-issue throws on --recover with full), so a full-path repo gets none.
|
|
1180
|
+
const recoveryOn = Boolean(orch.recovery?.maxAttempts) && (deps.enginePath !== undefined ? deps.enginePath : readEnginePath(cwd)) !== "full";
|
|
1181
|
+
if (orch.recovery?.maxAttempts && !recoveryOn) sink.line("recovery: off (engine.path is full; recovery lanes run on the core path only)");
|
|
1156
1182
|
// #4190: usage-limit holds live in the host store keyed by the account the next lane would use.
|
|
1157
1183
|
const limitsMod = await import("./lean/limits.mjs");
|
|
1158
1184
|
const limits = deps.limits ?? limitsMod.openLimits(limitsMod.limitsFile(env));
|
|
@@ -1224,38 +1250,50 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1224
1250
|
} catch { near = false; }
|
|
1225
1251
|
if (near) await runGcPhase("admission");
|
|
1226
1252
|
};
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
:
|
|
1257
|
-
|
|
1258
|
-
|
|
1253
|
+
// #4318: SIGINT/SIGTERM of a foreground dispatcher run the batch-exit relabel, then exit 128+signo (the lane pattern above).
|
|
1254
|
+
let exitRelabel = null, interrupted = false;
|
|
1255
|
+
const on = deps.processOn ?? ((s, f) => process.on(s, f)), off = deps.processOff ?? ((s, f) => process.removeListener(s, f));
|
|
1256
|
+
const exitProc = deps.exit ?? ((c) => process.exit(c));
|
|
1257
|
+
const SIGNO = { SIGINT: 2, SIGTERM: 15 };
|
|
1258
|
+
const handlers = daemon ? {} : Object.fromEntries(Object.keys(SIGNO).map((sig) => [sig, async () => {
|
|
1259
|
+
if (interrupted) return;
|
|
1260
|
+
interrupted = true;
|
|
1261
|
+
try { if (exitRelabel) await exitRelabel(`interrupted by ${sig}`); } catch { /* exit anyway */ }
|
|
1262
|
+
exitProc(128 + SIGNO[sig]);
|
|
1263
|
+
}]));
|
|
1264
|
+
for (const [sig, f] of Object.entries(handlers)) on(sig, f);
|
|
1265
|
+
let rows;
|
|
1266
|
+
try {
|
|
1267
|
+
rows = await orchestrate({
|
|
1268
|
+
beforeAdmit,
|
|
1269
|
+
issues, parallel, prefilter, resolveLane: laneOf, store: batch.store, getParallel: batch.readParallel, ...lanes,
|
|
1270
|
+
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1271
|
+
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1272
|
+
...(remote ? { remote } : {}),
|
|
1273
|
+
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1274
|
+
...(recoveryOn ? { recovery: orch.recovery } : {}),
|
|
1275
|
+
decide: deps.decide ?? (async (d) => {
|
|
1276
|
+
if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
|
|
1277
|
+
const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
|
|
1278
|
+
try { await leanIo.gh(["issue", "comment", String(d.issue), "-R", repo, "--body", body]); } catch { /* best effort: the batch log keeps the row */ }
|
|
1279
|
+
}),
|
|
1280
|
+
// A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
|
|
1281
|
+
relabelNeedsHuman: deps.relabelNeedsHuman ?? makeRelabelNeedsHuman({ gh: (a) => leanIo.gh(a), repo }),
|
|
1282
|
+
// #4318: a dispatcher signal relabels the queued recoveries before it exits (the daemon resumes its batches instead)
|
|
1283
|
+
...(daemon ? {} : { onExit: (fn) => { exitRelabel = fn; } }),
|
|
1284
|
+
conflicting: deps.conflicting ?? (async (_n, pr) => {
|
|
1285
|
+
try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
|
|
1286
|
+
}),
|
|
1287
|
+
onCrossLane: (e) => {
|
|
1288
|
+
crossLane.push(e);
|
|
1289
|
+
sink.line(`cross-lane: #${e.issue} (${e.lane}) depends on #${e.dependency} (${e.dependencyLane}); its fix will not be on ${e.lane}: re-milestone #${e.dependency} or wait for a milestone sync`);
|
|
1290
|
+
},
|
|
1291
|
+
readBody: maint || registry || hybrid
|
|
1292
|
+
? async (n) => { const v = JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body,labels"])); labelsOf.set(n, (v.labels ?? []).map((x) => x.name)); return v.body; }
|
|
1293
|
+
: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
1294
|
+
onUpdate: (r) => sink.row(r),
|
|
1295
|
+
});
|
|
1296
|
+
} finally { for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
1259
1297
|
const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
|
|
1260
1298
|
const pfLine = formatPrefilterSummary(rows);
|
|
1261
1299
|
if (pfLine) report.push(pfLine);
|
|
@@ -1263,6 +1301,8 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1263
1301
|
try { heavyStats = deps.heavyStats !== undefined ? deps.heavyStats : (await import("./lean/heavy.mjs")).openHeavy(schedMod.schedDir()).stats(); } catch { heavyStats = null; }
|
|
1264
1302
|
const waitLine = formatWaitSummary(rows, heavyStats);
|
|
1265
1303
|
if (waitLine) report.push(waitLine);
|
|
1304
|
+
const precheckLine = formatPrecheckSummary(rows); // #4331
|
|
1305
|
+
if (precheckLine) report.push(precheckLine);
|
|
1266
1306
|
let day = null;
|
|
1267
1307
|
try { day = execBudget ? execBudget.dayTotals() : null; } catch { day = null; }
|
|
1268
1308
|
const sbxLine = formatSandboxSummary(rows, { budget: execConf?.budget ?? null, day });
|
|
@@ -1276,6 +1316,30 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1276
1316
|
return { rows, crossLane, batch: batch.id, reclaimed, ok };
|
|
1277
1317
|
}
|
|
1278
1318
|
|
|
1319
|
+
/**
|
|
1320
|
+
* #4318: the batch runner's needs-human relabel. Re-reads the issue first: closed, or a live FORGE:STATE lease (the runner
|
|
1321
|
+
* never holds one, so it is another actor's) → "skipped". Label first: a failed label add → "failed" with no comment (a
|
|
1322
|
+
* resume retries); then the capped FORGE:NEEDS_HUMAN comment, whose failure keeps the label → true.
|
|
1323
|
+
*/
|
|
1324
|
+
export function makeRelabelNeedsHuman({ gh, repo, now = () => Date.now() }) {
|
|
1325
|
+
return async ({ issue, evidence }) => {
|
|
1326
|
+
const { stateOf } = await import("./lean/context.mjs");
|
|
1327
|
+
try {
|
|
1328
|
+
const v = JSON.parse(await gh(["issue", "view", String(issue), "-R", repo, "--json", "state,body"]));
|
|
1329
|
+
if (String(v?.state ?? "").toUpperCase() === "CLOSED") return "skipped";
|
|
1330
|
+
const lease = stateOf(v?.body)?.lease;
|
|
1331
|
+
if (lease && Number(lease.until) > now()) return "skipped";
|
|
1332
|
+
} catch { /* unreadable: relabel as before */ }
|
|
1333
|
+
try { await gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]); } catch { return "failed"; }
|
|
1334
|
+
try {
|
|
1335
|
+
// #4315: the evidence carries the full lane reason; capped so it never exceeds GitHub's comment limit
|
|
1336
|
+
const { capComment } = await import("./lean/render.mjs");
|
|
1337
|
+
await gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(`<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`)]);
|
|
1338
|
+
} catch { /* best effort: the label is on, the batch row keeps the evidence */ }
|
|
1339
|
+
return true;
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1279
1343
|
/** The batch report's "reclaimed" line (#4183). */
|
|
1280
1344
|
export function formatReclaimed(r) {
|
|
1281
1345
|
if (!r?.enabled) return "reclaimed: off (hygiene.auto_gc: false)";
|
package/bin/engine.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { execFileSync } from "node:child_process";
|
|
|
9
9
|
import { createHash, scryptSync } from "node:crypto";
|
|
10
10
|
import { hostname as osHostname, homedir } from "node:os";
|
|
11
11
|
import { appendEvent, readLog, deriveState, rewriteLog, scopeEvents } from "./engine/runlog.mjs";
|
|
12
|
-
import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency } from "./engine/phases.mjs";
|
|
12
|
+
import { pickPhase, TERMINAL_REASONS, issueSnapshot, hasInvestigationExit, postCycleMarker, nextCycleNumber, newestCycle, cycleResetProvable, remediationHeadStale, decompositionRevoked, openDependency, reopenRevokedDecomposition } from "./engine/phases.mjs";
|
|
13
13
|
import { reconcileState } from "./engine/reconcile.mjs";
|
|
14
14
|
import { makeProjector } from "./engine/projector.mjs";
|
|
15
15
|
import { VALID_BACKENDS, resolveRunTmpRoot, runTmpDir } from "./runner.mjs";
|
|
@@ -570,6 +570,8 @@ export async function runIssue(opts) {
|
|
|
570
570
|
// revocation alone suffices now that the size gate is gone. Anything else keeps the terminal; an
|
|
571
571
|
// unreadable comment trail fails closed.
|
|
572
572
|
if (state.terminalReason === "decomposed" && await decompositionRevoked(issue, io)) {
|
|
573
|
+
// #4337: a parent closed at decomposition is reopened on GitHub too, or the pre-phase divergence check ends it invalid.
|
|
574
|
+
await reopenRevokedDecomposition(issue, io);
|
|
573
575
|
reopenUncommit = ["build", "decompose"];
|
|
574
576
|
} else if (state.terminalReason !== "engine-error" && state.terminalReason !== "phase-complete" &&
|
|
575
577
|
state.terminalReason !== "needs-human") {
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
9
|
import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
|
|
10
10
|
import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
|
|
11
|
-
import { orchestrateFromCli, runFromCli, readOrchestration } from "../engine-cli.mjs";
|
|
11
|
+
import { orchestrateFromCli, runFromCli, readOrchestration, makeRelabelNeedsHuman } from "../engine-cli.mjs";
|
|
12
12
|
|
|
13
13
|
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir, also for spawned dispatchers.
|
|
14
14
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("batch-sched-");
|
|
@@ -232,3 +232,64 @@ describe("lane launch env (#4179)", () => {
|
|
|
232
232
|
assert.deepEqual(spawned[0].argv, ["/x/forgedock.mjs", "run-issue", "7", "--repo", "o/r", "--result-file", batch.resultFile(7)]);
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
|
+
|
|
236
|
+
describe("#4318: the batch dispatcher's signal relabel and the needs-human relabel hook", () => {
|
|
237
|
+
it("SIGINT of a foreground dispatcher relabels a queued recovery with the signal as cause, then exits 130; handlers are removed", async () => {
|
|
238
|
+
const leanIo = {
|
|
239
|
+
gh: async (a) => (a.includes("milestone") ? JSON.stringify({ milestone: null }) : JSON.stringify({ body: "" })),
|
|
240
|
+
git: async (a) => (a[0] === "ls-remote" ? "abc\trefs/heads/staging\n" : ""),
|
|
241
|
+
};
|
|
242
|
+
const handlers = new Map(), removed = [], exits = [], relabels = [];
|
|
243
|
+
const l = console.log;
|
|
244
|
+
console.log = () => {};
|
|
245
|
+
try {
|
|
246
|
+
await orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], { dir: tmpDir("sig-"), batchRoot: tmpDir("sig-batch-"), allowTmpfs: true,
|
|
247
|
+
emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" }, leanIo, sched: null, decide: () => {},
|
|
248
|
+
orchestration: { breaker: null, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 2 } },
|
|
249
|
+
processOn: (s, f) => handlers.set(s, f), processOff: (s, f) => { if (handlers.get(s) === f) removed.push(s); }, exit: (c) => exits.push(c),
|
|
250
|
+
relabelNeedsHuman: async (x) => { relabels.push(x); return true; },
|
|
251
|
+
leanModule: { runCore: async (o) => {
|
|
252
|
+
if (o.issue === 1 && !o.recover) return { terminalReason: "needs-human", code: "ci.red", detail: "Required check(s) failed on PR #55: test" };
|
|
253
|
+
if (o.issue === 2) await handlers.get("SIGINT")(); // issue 1's attempt is queued behind issue 2 (K=1)
|
|
254
|
+
return { terminalReason: "merged", pr: 50 + o.issue };
|
|
255
|
+
} } });
|
|
256
|
+
} finally { console.log = l; }
|
|
257
|
+
assert.deepEqual(exits, [130]);
|
|
258
|
+
assert.equal(relabels.length, 1); assert.equal(relabels[0].issue, 1);
|
|
259
|
+
assert.match(relabels[0].evidence, /the batch stopped \(interrupted by SIGINT\)/);
|
|
260
|
+
assert.deepEqual(removed.sort(), ["SIGINT", "SIGTERM"]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("makeRelabelNeedsHuman", () => {
|
|
264
|
+
const LIVE = (until) => `body\n<!-- FORGE:STATE\n${JSON.stringify({ v: 1, issue: 7, lease: { by: "core_1", until, host: "h", pid: 1 } })}\n-->`;
|
|
265
|
+
const hook = (view, fail = {}) => {
|
|
266
|
+
const calls = [];
|
|
267
|
+
const gh = async (a) => {
|
|
268
|
+
calls.push(a[1]);
|
|
269
|
+
if (a[1] === "view") return JSON.stringify(view);
|
|
270
|
+
if (fail[a[1]]) throw new Error(`${a[1]} failed`);
|
|
271
|
+
return "";
|
|
272
|
+
};
|
|
273
|
+
return { calls, run: () => makeRelabelNeedsHuman({ gh, repo: "o/r", now: () => 1000 })({ issue: 7, evidence: "why" }) };
|
|
274
|
+
};
|
|
275
|
+
it("a closed issue or a live lease is skipped without a write", async () => {
|
|
276
|
+
for (const view of [{ state: "CLOSED", body: "" }, { state: "OPEN", body: LIVE(5000) }]) {
|
|
277
|
+
const h = hook(view);
|
|
278
|
+
assert.equal(await h.run(), "skipped");
|
|
279
|
+
assert.deepEqual(h.calls, ["view"]);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
it("an expired lease relabels: the label first, then the comment", async () => {
|
|
283
|
+
const h = hook({ state: "OPEN", body: LIVE(500) });
|
|
284
|
+
assert.equal(await h.run(), true);
|
|
285
|
+
assert.deepEqual(h.calls, ["view", "edit", "comment"]);
|
|
286
|
+
});
|
|
287
|
+
it("a failed label add posts no comment and reports failed; a failed comment keeps the label", async () => {
|
|
288
|
+
const a = hook({ state: "OPEN", body: "" }, { edit: true });
|
|
289
|
+
assert.equal(await a.run(), "failed");
|
|
290
|
+
assert.deepEqual(a.calls, ["view", "edit"]);
|
|
291
|
+
const b = hook({ state: "OPEN", body: "" }, { comment: true });
|
|
292
|
+
assert.equal(await b.run(), true);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -18,8 +18,8 @@ export const DECISION_SOURCES = Object.freeze(["doc", "lint", "precedent", "spec
|
|
|
18
18
|
const REQ_STATUS = ["implemented", "partial", "missing", "deferred", "post-deploy", "not-applicable"];
|
|
19
19
|
const COVER_STATUS = ["covered", "narrowed", "missing"];
|
|
20
20
|
const PRESERVE_STATUS = ["intact", "violated", "unverified"];
|
|
21
|
-
/**
|
|
22
|
-
const EVIDENCE_RE = /([
|
|
21
|
+
/** A path:line inside one evidence token; the path may hold ( ) [ ] segments (route groups, [slug], [...slug]) (#4327). */
|
|
22
|
+
const EVIDENCE_RE = /([^:]+):(\d+)/;
|
|
23
23
|
/** Test files in any common ecosystem: test dirs, *.test.* / *.spec.*, test_*.*, *_test.*, *Test(s).java|kt|cs|php|scala, *_spec.rb, tests.rs, conftest.py, snapshots. */
|
|
24
24
|
const TEST_RE = /(^|\/)(test|tests|__tests__|spec|specs|testdata|__snapshots__)\/|\.(test|spec)\.[a-z0-9]+$|(^|\/)test_[^/]+\.[a-z0-9]+$|_test\.[a-z0-9]+$|Tests?\.(java|kt|kts|cs|php|scala|swift)$|_spec\.rb$|(^|\/)tests\.rs$|(^|\/)conftest\.py$|\.bats$|\.snap$/;
|
|
25
25
|
/** Files any change may touch without being a deliverable: changelogs and dependency lockfiles. */
|
|
@@ -112,6 +112,42 @@ export function movingBaseDiff(command) {
|
|
|
112
112
|
return null;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Option names whose value a tool writes (output, report, cache or temp dirs), not reads (#4325). */
|
|
116
|
+
const WRITE_OPT = /^-(?:o|O|-?[\w-]*(?:out|dir|temp|tmp|cache|report|junit|xml|log)[\w-]*)$/i;
|
|
117
|
+
const TMP_PATH = /\$(?:\{TMPDIR(?::-[^}]*)?\}|TMPDIR)\/([^\s"'`;|&<>()=]+)/g;
|
|
118
|
+
/**
|
|
119
|
+
* #4325: the `$TMPDIR/<name>` paths a frozen command reads without creating them itself. The run TMPDIR is per run, so such
|
|
120
|
+
* a file (written by the builder before) is gone for the engine's red-green check and for any rerun. A path counts as
|
|
121
|
+
* created when it, or a directory above it, was earlier the target of `>`/`>>`, `tee`, `touch|mkdir|mktemp`, the last
|
|
122
|
+
* argument of `cp|mv|ln|install`, an output-like option (`-o`, `--output`, `--basetemp`, `--junitxml`, `--cache-dir`…) or an
|
|
123
|
+
* env assignment (`X=$TMPDIR/y`). Returns the read paths in first-use order (deduped), e.g. ["$TMPDIR/req.txt"].
|
|
124
|
+
*/
|
|
125
|
+
export function tmpdirInputs(command) {
|
|
126
|
+
const made = [], reads = [];
|
|
127
|
+
const createdAbove = (p) => made.some((m) => p === m || p.startsWith(`${m}/`));
|
|
128
|
+
for (const seg of String(command ?? "").split(/&&|\|\||;|\||\n/)) {
|
|
129
|
+
const toks = seg.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/^["']+|["']+$/g, ""));
|
|
130
|
+
const words = toks.filter((t) => !/^[A-Za-z_]\w*=/.test(t));
|
|
131
|
+
const cmd = (words[0] ?? "").replace(/^.*\//, "");
|
|
132
|
+
toks.forEach((tok, i) => {
|
|
133
|
+
for (const m of tok.matchAll(TMP_PATH)) {
|
|
134
|
+
const path = m[1].replace(/\/+$/, "");
|
|
135
|
+
const before = tok.slice(0, m.index), prev = toks[i - 1] ?? "";
|
|
136
|
+
const opt = /^(-[\w-]+)=["']?$/.exec(before)?.[1] ?? (before === "" && /^-/.test(prev) ? prev : null);
|
|
137
|
+
const write = /\d*>>?$|&>$/.test(before) || (before === "" && /^(?:\d*>>?|&>)$/.test(prev))
|
|
138
|
+
|| /^[A-Za-z_]\w*=["']?$/.test(before) || (opt !== null && WRITE_OPT.test(opt))
|
|
139
|
+
|| cmd === "tee" || ["touch", "mkdir", "mktemp"].includes(cmd)
|
|
140
|
+
|| (["cp", "mv", "ln", "install"].includes(cmd) && i === toks.length - 1);
|
|
141
|
+
if (write) made.push(path);
|
|
142
|
+
else if (!createdAbove(path) && !reads.includes(`$TMPDIR/${path}`)) reads.push(`$TMPDIR/${path}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return reads;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const tmpInputProblem = (id, p) => `${id}: reads ${p}, which the command does not create; the run TMPDIR is per run, so the engine's rerun and red-green check will not find it. Create the file inside the command (\`… > ${p} && …\`) or use a path the repo tracks`;
|
|
150
|
+
|
|
115
151
|
export function validateContract(c, issueText, { repro = false } = {}) {
|
|
116
152
|
const problems = [];
|
|
117
153
|
if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
|
|
@@ -142,11 +178,13 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
142
178
|
if (!v?.id || blank(v?.command)) { problems.push("validate entry without id or command"); continue; }
|
|
143
179
|
const ref = movingBaseDiff(v.command);
|
|
144
180
|
if (ref) problems.push(`${v.id}: \`git diff\` against the moving ${ref} tip fails as soon as the base moves on, even when the change is fine; compare against the merge base instead (\`${ref}...HEAD\` or \`$(git merge-base HEAD ${ref})\`)`);
|
|
181
|
+
for (const p of tmpdirInputs(v.command)) problems.push(tmpInputProblem(v.id, p));
|
|
145
182
|
}
|
|
146
183
|
if (repro) {
|
|
147
184
|
const t = c.repro_test;
|
|
148
185
|
if (t === "none") { if (blank(c.repro_test_reason)) problems.push(`repro_test "none" needs a one-line repro_test_reason`); }
|
|
149
186
|
else if (!t || typeof t !== "object" || Array.isArray(t) || blank(t.path) || blank(t.command)) problems.push(`this is a bug: repro_test must be {"path","id","command"} (a test that fails on the base and passes after the fix) or "none" with a repro_test_reason`);
|
|
187
|
+
else for (const p of tmpdirInputs(t.command)) problems.push(tmpInputProblem("repro_test", p));
|
|
150
188
|
}
|
|
151
189
|
if (JSON.stringify(c).length > MAX_CONTRACT_CHARS) problems.push(`the contract is over ${MAX_CONTRACT_CHARS} characters; keep quotes to the requirement sentence and descriptions short`);
|
|
152
190
|
return { ok: problems.length === 0, problems };
|
|
@@ -187,14 +225,51 @@ export function applyAmendments(frozen, amendments, issueText = null) {
|
|
|
187
225
|
return { contract: c, applied, toTrack, problems };
|
|
188
226
|
}
|
|
189
227
|
|
|
228
|
+
const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
|
|
229
|
+
const NODE_MISSING_RE = /Cannot find (?:module|package) '([^']+)'/;
|
|
230
|
+
const CMD_NOT_FOUND_RE = /([^\s:'"`]+): command not found/, SH_NOT_FOUND_RE = /([^\s:'"`]+): not found\b/;
|
|
231
|
+
const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
|
|
232
|
+
/**
|
|
233
|
+
* forge#4323: the host tool or module a failed command could not find, from its {rc, output}: bash/sh "X: command not found"
|
|
234
|
+
* (or rc 127), python "No module named x" (top-level package), node "Cannot find module|package 'x'" (bare specifiers only).
|
|
235
|
+
* Returns {tool, kind: command|python-module|node-module} or null. A path, a relative specifier or a name the repo tracks
|
|
236
|
+
* (`repoHas(name)`) is null: a module the change forgot to create is a code defect, not the environment.
|
|
237
|
+
*/
|
|
238
|
+
export function missingTool({ rc, output } = {}, { repoHas = () => false, command = "" } = {}) {
|
|
239
|
+
const out = String(output ?? "");
|
|
240
|
+
const local = (name) => !name || name.startsWith(".") || name.startsWith("/") || name.startsWith("file:") || repoHas(name);
|
|
241
|
+
const py = PY_MISSING_RE.exec(out);
|
|
242
|
+
if (py) { const tool = String(py[1] ?? py[2]).split(".")[0]; return local(tool) ? null : { tool, kind: "python-module" }; }
|
|
243
|
+
const js = NODE_MISSING_RE.exec(out);
|
|
244
|
+
if (js) {
|
|
245
|
+
const spec = js[1], scoped = spec.startsWith("@");
|
|
246
|
+
if (!scoped && spec.includes("/")) return null;
|
|
247
|
+
const tool = scoped ? spec.split("/").slice(0, 2).join("/") : spec;
|
|
248
|
+
return local(tool) || (scoped && !tool.includes("/")) ? null : { tool, kind: "node-module" };
|
|
249
|
+
}
|
|
250
|
+
const cmd = CMD_NOT_FOUND_RE.exec(out)?.[1] ?? (rc === 127 ? SH_NOT_FOUND_RE.exec(out)?.[1] ?? String(command).trim().split(/\s+/)[0] : null);
|
|
251
|
+
if (!cmd || cmd.includes("/") || local(cmd)) return null;
|
|
252
|
+
return { tool: cmd, kind: "command" };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** `repoHas` for missingTool: true when any tracked path has a segment equal to `name` or `name.py`. */
|
|
256
|
+
export function repoTracks(paths) {
|
|
257
|
+
const segs = new Set();
|
|
258
|
+
for (const p of paths ?? []) for (const x of String(p).split("/")) segs.add(x);
|
|
259
|
+
return (name) => segs.has(name) || segs.has(`${name}.py`);
|
|
260
|
+
}
|
|
261
|
+
|
|
190
262
|
/**
|
|
191
263
|
* Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
|
|
192
264
|
* requirements not implemented, deferrals without a tracking issue, and unexempted hotspot growth. Returns {ok, failures[]}; each failure carries a
|
|
193
|
-
* `kind` (scope_creep, validate_missing, validate_failed, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green) so callers can
|
|
265
|
+
* `kind` (scope_creep, validate_missing, validate_failed, validate_env, requirement_unimplemented, untracked_deferral, hotspot_growth, repro_red_green, repro_env) so callers can
|
|
194
266
|
* tell the builder's own failed checks apart from bookkeeping gaps. `ciVerified` (#4167): the current head passed the
|
|
195
267
|
* engine's CI verification, so a validate id left to CI is not missing; a recorded non-zero exit still fails. `repro` (#4271): the repro_test red-green words, checked when given.
|
|
268
|
+
* `validateRuns` (#4323): the engine's rerun {[id]: {rc, output}} of validate commands recorded as failed. A rerun that passes
|
|
269
|
+
* counts as passed; one that could not find a host tool or module (missingTool, with `repoHas`) is kind validate_env, code
|
|
270
|
+
* env.tool_missing (skipped on a CI-verified head), not a conformance failure.
|
|
196
271
|
*/
|
|
197
|
-
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null }) {
|
|
272
|
+
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false }) {
|
|
198
273
|
const failures = [];
|
|
199
274
|
const creep = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(contract, f));
|
|
200
275
|
if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
|
|
@@ -205,7 +280,14 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
205
280
|
const leftToCi = !r || (r.ci === true && r.exit === undefined);
|
|
206
281
|
if (ciVerified && leftToCi) continue;
|
|
207
282
|
if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
|
|
208
|
-
else if (r.exit !== 0)
|
|
283
|
+
else if (r.exit !== 0) {
|
|
284
|
+
const rerun = validateRuns?.[v.id];
|
|
285
|
+
if (rerun?.rc === 0) continue;
|
|
286
|
+
const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
|
|
287
|
+
if (miss && ciVerified) continue;
|
|
288
|
+
if (miss) failures.push({ code: C.ENV_TOOL_MISSING, kind: "validate_env", tool: miss.tool, detail: `validate ${v.id} (\`${v.command}\`) could not run: the host is missing ${KIND_NAME[miss.kind]} '${miss.tool}' (environment, not the change)` });
|
|
289
|
+
else failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
|
|
290
|
+
}
|
|
209
291
|
}
|
|
210
292
|
const st = Array.isArray(result?.requirements) ? result.requirements : [];
|
|
211
293
|
for (const r of contract.requirements ?? []) {
|
|
@@ -224,13 +306,20 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
224
306
|
/**
|
|
225
307
|
* #4271: the frozen repro_test's red-green proof ({base, head} words from scripts/acceptance-red-green.sh). Anything but
|
|
226
308
|
* HEAD=RAN BASE=FAILED fails; code conformance.unimplemented, so it blocks in shadow too and goes back to the builder.
|
|
309
|
+
* #4334: HEAD=UNRUNNABLE or no words (the harness could not run the test) is env.repro_unrunnable, kind repro_env: an
|
|
310
|
+
* environment error, never a builder resume.
|
|
227
311
|
*/
|
|
228
312
|
export function reproFailures(contract, { base, head } = {}) {
|
|
229
313
|
if (head === "RAN" && base === "FAILED") return [];
|
|
230
314
|
const t = contract?.repro_test ?? {};
|
|
231
315
|
const name = [t.path, t.id].filter((x) => !blank(x)).join(" ") || "repro_test";
|
|
316
|
+
if (!head || head === "UNRUNNABLE") {
|
|
317
|
+
return [{ code: C.ENV_REPRO_UNRUNNABLE, kind: "repro_env",
|
|
318
|
+
detail: `repro test ${name} (\`${t.command ?? ""}\`): the harness could not run the test on head (environment, not the change); got BASE=${base ?? "UNRUNNABLE"} HEAD=${head ?? "UNRUNNABLE"}` }];
|
|
319
|
+
}
|
|
320
|
+
const skipped = base === "SKIPPED" ? " (base not run: head did not pass)" : "";
|
|
232
321
|
return [{ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "repro_red_green",
|
|
233
|
-
detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head
|
|
322
|
+
detail: `repro test ${name} (\`${t.command ?? ""}\`) must fail on the base and pass on head; got BASE=${base ?? "UNRUNNABLE"} HEAD=${head}${skipped}` }];
|
|
234
323
|
}
|
|
235
324
|
|
|
236
325
|
const EXEMPT_RE = /hotspot-exempt:\s*\S/i;
|
|
@@ -288,10 +377,42 @@ export function resolveEvidencePath(p, changedFiles = []) {
|
|
|
288
377
|
const n = normalizeEvidencePath(p, { changedFiles });
|
|
289
378
|
return n && changedFiles.includes(n) ? n : null;
|
|
290
379
|
}
|
|
380
|
+
const count = (s, ch) => s.split(ch).length - 1;
|
|
381
|
+
/** Drops a leading or trailing bracket that has no partner inside the path: `(src/a.js` -> `src/a.js`, `(g)/a.tsx` stays. */
|
|
382
|
+
function trimUnbalanced(p) {
|
|
383
|
+
for (let again = true; again && p;) {
|
|
384
|
+
again = false;
|
|
385
|
+
for (const [o, c] of [["(", ")"], ["[", "]"]]) {
|
|
386
|
+
if (p.startsWith(o) && count(p, o) > count(p, c)) { p = p.slice(1); again = true; }
|
|
387
|
+
if (p.endsWith(c) && count(p, c) > count(p, o)) { p = p.slice(0, -1); again = true; }
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return p;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* The first path:line anywhere in the evidence text, verbatim (#4327: `(group)`, `[slug]`, `[...slug]` segments kept).
|
|
394
|
+
* Backticks and quotes are ignored, a markdown link `[label](path:line)` yields its target, and only brackets with no
|
|
395
|
+
* partner inside the path (prose like `(src/a.js:3)`) are dropped.
|
|
396
|
+
*/
|
|
397
|
+
export function evidencePathLine(text) {
|
|
398
|
+
for (let tok of String(text ?? "").replace(/[`'"]/g, "").split(/[\s,;<>]+/)) {
|
|
399
|
+
const link = tok.lastIndexOf("](");
|
|
400
|
+
if (link >= 0) tok = tok.slice(link + 2);
|
|
401
|
+
const m = EVIDENCE_RE.exec(tok);
|
|
402
|
+
const path = m && trimUnbalanced(m[1]);
|
|
403
|
+
if (path) return { path, line: m[2] };
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
291
407
|
/** The path:line in an evidence string, with the path normalized; null when there is none. */
|
|
292
408
|
function evidenceOf(text, ctx) {
|
|
293
|
-
const m =
|
|
294
|
-
return m ? { path: normalizeEvidencePath(m
|
|
409
|
+
const m = evidencePathLine(text);
|
|
410
|
+
return m ? { path: normalizeEvidencePath(m.path, ctx), line: m.line } : null;
|
|
411
|
+
}
|
|
412
|
+
const MISSING_PATH_RE = /^[^:]+: evidence path .+ does not exist$/;
|
|
413
|
+
/** #4327: true when every problem of an incomplete review is an evidence path that did not resolve (a re-ask can fix it). */
|
|
414
|
+
export function evidencePathOnly(problems = []) {
|
|
415
|
+
return problems.length > 0 && problems.every((p) => MISSING_PATH_RE.test(String(p)));
|
|
295
416
|
}
|
|
296
417
|
const reviewedFiles = (changedFiles, ignore) => changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)));
|
|
297
418
|
|