forgedock 1.28.0 → 1.29.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 +29 -0
- package/bin/engine-cli.mjs +71 -16
- package/bin/lean/batch.test.mjs +2 -2
- package/bin/lean/conformance.mjs +47 -2
- package/bin/lean/conformance.test.mjs +53 -1
- package/bin/lean/context.mjs +333 -0
- package/bin/lean/context.test.mjs +279 -0
- package/bin/lean/effects.mjs +19 -1
- package/bin/lean/lean.mjs +171 -35
- package/bin/lean/lean.test.mjs +413 -7
- package/bin/lean/orchestrate.mjs +80 -3
- package/bin/lean/prefilter.mjs +2 -2
- package/bin/lean/prefilter.test.mjs +3 -0
- package/bin/lean/prompts.mjs +22 -7
- package/bin/lean/recovery.test.mjs +230 -0
- package/bin/lean/render.mjs +25 -5
- package/bin/lean/testmap.mjs +84 -0
- package/bin/lean/testmap.test.mjs +58 -0
- package/bin/lean/verify.mjs +48 -3
- package/bin/lean/verify.test.mjs +81 -3
- package/bin/stats/index.mjs +12 -2
- package/bin/stats/local-source.mjs +13 -0
- package/bin/stats/query.mjs +27 -0
- package/bin/stats/stats.test.mjs +41 -2
- package/commands/orchestrate.md +52 -6
- package/docs/CONFIG.md +15 -0
- package/docs/OUTCOME-CODES.md +14 -0
- package/docs/RELEASE-NOTES.md +9 -0
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +1 -0
- package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
- package/scripts/fixtures/test-map/go/Makefile +5 -0
- package/scripts/fixtures/test-map/go/README.md +1 -0
- package/scripts/fixtures/test-map/go/go.mod +3 -0
- package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
- package/scripts/fixtures/test-map/go/main.go +3 -0
- package/scripts/fixtures/test-map/node/package.json +10 -0
- package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
- package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
- package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
- package/scripts/fixtures/test-map/node/src/a.ts +1 -0
- package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
- package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
- package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
- package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
- package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
- package/scripts/fixtures/test-map/python/uv.lock +1 -0
- package/scripts/repo-facts.sh +146 -0
- package/scripts/repo-facts.test.sh +106 -0
- package/scripts/test-map.sh +211 -0
- package/scripts/test-map.test.sh +165 -0
- package/scripts/wait-ci-green.sh +10 -4
- package/scripts/wait-ci-green.test.sh +9 -0
|
@@ -175,3 +175,32 @@ export function codeForTerminalReason(reason) {
|
|
|
175
175
|
default: return UNCLASSIFIED;
|
|
176
176
|
}
|
|
177
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Batch recovery classes of a lane result (forge#4267). The batch runner recovers `recoverable` rows once
|
|
181
|
+
* (`orchestration.recovery.max_attempts`) and re-queues them; only `human` rows are escalations.
|
|
182
|
+
*/
|
|
183
|
+
export const RECOVERABLE_CODES = Object.freeze([OUTCOME_CODES.CI_RED, OUTCOME_CODES.BASE_CONFLICT, OUTCOME_CODES.BUILDER_BLOCK_UNTYPED, OUTCOME_CODES.CONTRACT_INVALID]);
|
|
184
|
+
/** `builder.blocked` is only reached by a typed human block: credentials, spend, promotion, customer, product-decision. */
|
|
185
|
+
export const HUMAN_CODES = Object.freeze([OUTCOME_CODES.BUILDER_BLOCKED]);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `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
|
|
190
|
+
* edit (`issueEdited`), and `ci-pending` on a conflicting PR (`conflicting`). Human: `builder.blocked`. Anything else
|
|
191
|
+
* (another code, or not a needs-human/ci-pending row) is terminal. Pure, never throws.
|
|
192
|
+
*/
|
|
193
|
+
export function classifyNeedsHuman(row) {
|
|
194
|
+
if (!row || typeof row !== "object") return "terminal";
|
|
195
|
+
const outcome = row.outcome ?? row.terminalReason;
|
|
196
|
+
if (outcome === "ci-pending") return row.conflicting === true ? "recoverable" : "terminal";
|
|
197
|
+
if (outcome !== "needs-human") return "terminal";
|
|
198
|
+
if (HUMAN_CODES.includes(row.code)) return "human";
|
|
199
|
+
if (row.code === OUTCOME_CODES.CONTRACT_INVALID) return row.issueEdited === true ? "recoverable" : "terminal";
|
|
200
|
+
return RECOVERABLE_CODES.includes(row.code) ? "recoverable" : "terminal";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The code a recovery lane works on: a conflicting `ci-pending` is recovered as a base conflict. */
|
|
204
|
+
export function recoveryCodeOf(row) {
|
|
205
|
+
return (row?.outcome ?? row?.terminalReason) === "ci-pending" ? OUTCOME_CODES.BASE_CONFLICT : normalizeCode(row?.code);
|
|
206
|
+
}
|
package/bin/engine-cli.mjs
CHANGED
|
@@ -61,12 +61,13 @@ export function makeIo() {
|
|
|
61
61
|
return stdout;
|
|
62
62
|
};
|
|
63
63
|
// CI wait seam (scripts/wait-ci-green.sh, argv only, no shell). Resolves { rc, text } for any exit code.
|
|
64
|
-
|
|
64
|
+
// `ignore` (forge#4270): check names dropped before deciding (`--ignore-check`); omitted, the argv is unchanged.
|
|
65
|
+
const waitCiGreen = async (pr, repo, timeoutSec, { ignore = [] } = {}) => {
|
|
65
66
|
const script = fileURLToPath(new URL("../scripts/wait-ci-green.sh", import.meta.url));
|
|
66
67
|
try {
|
|
67
68
|
// The engine owns the backoff and the budget: each call is ONE read (no in-script polling, no settle hold;
|
|
68
69
|
// the re-dispatched phase re-runs the full gate before any merge).
|
|
69
|
-
const { stdout } = await pexec("bash", [script, String(pr), "-R", String(repo), "--timeout", String(timeoutSec)],
|
|
70
|
+
const { stdout } = await pexec("bash", [script, String(pr), "-R", String(repo), "--timeout", String(timeoutSec), ...ignore.flatMap((n) => ["--ignore-check", String(n)])],
|
|
70
71
|
{ maxBuffer: 10 * 1024 * 1024, timeout: (timeoutSec + 120) * 1000, env: { ...process.env, FORGE_CI_SETTLE: "0" } });
|
|
71
72
|
return { rc: 0, text: stdout };
|
|
72
73
|
} catch (e) { return { rc: Number.isInteger(e?.code) ? e.code : 2, text: String(e?.stdout ?? "") }; }
|
|
@@ -111,6 +112,27 @@ export function readReviewIncomplete(cwd = process.cwd()) {
|
|
|
111
112
|
return ["needs-human", "degraded"].includes(v) ? v : undefined;
|
|
112
113
|
} catch { return undefined; }
|
|
113
114
|
}
|
|
115
|
+
const warnedContextPack = new Set();
|
|
116
|
+
const stderrWarn = (m) => { if (!warnedContextPack.has(m)) { warnedContextPack.add(m); process.stderr.write(`${m}\n`); } };
|
|
117
|
+
/**
|
|
118
|
+
* forge.yaml context pack (#4275), flat keys under `engine` (the parser flattens one level): `context_pack`
|
|
119
|
+
* (shadow|on|off), `context_pack_sources` (comma list of graph,attempts,ledger,facts), `context_pack_bytes`,
|
|
120
|
+
* `context_pack_days`. undefined when none is set (the core defaults to shadow). An invalid mode warns through `warn`
|
|
121
|
+
* (stderr, once per message per process) and is dropped; the other keys are kept (#4298).
|
|
122
|
+
*/
|
|
123
|
+
export function readContextPack(cwd = process.cwd(), warn = stderrWarn) {
|
|
124
|
+
try {
|
|
125
|
+
const e = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.engine ?? {};
|
|
126
|
+
let mode = String(e.context_pack ?? "").trim().toLowerCase();
|
|
127
|
+
if (mode && !["shadow", "on", "off"].includes(mode)) {
|
|
128
|
+
warn(`forge.yaml engine.context_pack: invalid mode "${mode}" (expected shadow|on|off); using the default, other context_pack_* keys kept`);
|
|
129
|
+
mode = "";
|
|
130
|
+
}
|
|
131
|
+
const out = { ...(mode ? { mode } : {}), ...(e.context_pack_sources != null ? { sources: String(e.context_pack_sources) } : {}),
|
|
132
|
+
...(e.context_pack_bytes != null ? { bytes: Number(e.context_pack_bytes) } : {}), ...(e.context_pack_days != null ? { days: Number(e.context_pack_days) } : {}) };
|
|
133
|
+
return Object.keys(out).length ? out : undefined;
|
|
134
|
+
} catch { return undefined; }
|
|
135
|
+
}
|
|
114
136
|
/**
|
|
115
137
|
* forge.yaml `review.trigger` (shadow|enforce|off) and `review.risk_paths` (#4273): a comma-separated scalar or a block
|
|
116
138
|
* list (` risk_paths:` then ` - glob`). Returns {mode, riskPaths}; undefined when neither is set (the core defaults to
|
|
@@ -206,7 +228,8 @@ export function readMergeWait(cwd = process.cwd()) {
|
|
|
206
228
|
}
|
|
207
229
|
/**
|
|
208
230
|
* forge.yaml `verify:` section (#4167, block style: `verify:` then ` mode: ci`, ` workflow: verify.yml`, ` paths_input`,
|
|
209
|
-
* ` skip_paths` comma-separated, ` max_rounds`) for the core path; undefined when the
|
|
231
|
+
* ` skip_paths` comma-separated, ` max_rounds`, ` check_run: true` (#4270)) for the core path; undefined when the
|
|
232
|
+
* section is absent or unreadable.
|
|
210
233
|
*/
|
|
211
234
|
export function readVerify(cwd = process.cwd()) {
|
|
212
235
|
try {
|
|
@@ -614,7 +637,7 @@ export function lastLocalRun(dir) {
|
|
|
614
637
|
*/
|
|
615
638
|
export async function runFromCli(argv, deps = {}) {
|
|
616
639
|
const issue = parseInt(argv[0], 10);
|
|
617
|
-
if (!Number.isInteger(issue)) throw new Error("usage: forgedock run-issue <issue-number> [--lane <lane>] [--lane-override] [--retry] [--wait-lease]");
|
|
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]");
|
|
618
641
|
assertOpenCodeNativeRuntime({ operation: "forgedock run-issue" });
|
|
619
642
|
const lane = flag(argv, "--lane");
|
|
620
643
|
if (argv.includes("--lane") && (!lane || lane.startsWith("--"))) throw new Error("--lane requires a value: staging or milestone/<slug>");
|
|
@@ -623,7 +646,7 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
623
646
|
const backend = flag(argv, "--backend");
|
|
624
647
|
const model = flag(argv, "--model");
|
|
625
648
|
// Boolean flag: only counts at a flag position, never as another flag's value.
|
|
626
|
-
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file", "--placement", "--placement-reason"].includes(argv[i - 1]));
|
|
649
|
+
const retry = argv.some((a, i) => a === "--retry" && !["--lane", "--repo", "--backend", "--model", "--path", "--result-file", "--placement", "--placement-reason", "--recover", "--recover-attempt"].includes(argv[i - 1]));
|
|
627
650
|
// forge#3574: --wait-lease blocks (bounded by the lease TTL) until a live
|
|
628
651
|
// holder's lease expires or becomes reclaimable. Same flag-position guard.
|
|
629
652
|
const waitLease = argv.some((a, i) => a === "--wait-lease" && !["--lane", "--repo", "--backend", "--model"].includes(argv[i - 1]));
|
|
@@ -636,12 +659,18 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
636
659
|
}
|
|
637
660
|
// The full path takes the lane as given (its spec runs classify-lane), so it must be explicit; no default.
|
|
638
661
|
if (enginePath === "full" && !lane) throw new Error("--lane is required with --path full: staging or milestone/<slug>. No default to prevent accidental production targeting.");
|
|
662
|
+
// #4298: recovery lanes (#4267) exist only on the core path; the legacy full path would silently ignore --recover.
|
|
663
|
+
if (enginePath === "full" && argv.includes("--recover")) throw new Error("--recover is not supported with --path full (recovery lanes run on the core path only)");
|
|
639
664
|
const io = deps.io ?? makeIo();
|
|
640
665
|
const resultFile = flag(argv, "--result-file");
|
|
641
666
|
// #4256: where the dispatcher placed this lane (hybrid execution), recorded in the lane's FORGE:STATE.
|
|
642
667
|
const placementWhere = flag(argv, "--placement");
|
|
643
668
|
const placement = placementWhere === "local" || placementWhere === "remote"
|
|
644
669
|
? { 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]`).
|
|
671
|
+
const recoverCode = flag(argv, "--recover");
|
|
672
|
+
const recoverAttempt = Number(flag(argv, "--recover-attempt"));
|
|
673
|
+
const recover = recoverCode && !recoverCode.startsWith("--") ? { code: recoverCode, attempt: Number.isInteger(recoverAttempt) && recoverAttempt > 0 ? recoverAttempt : 1 } : null;
|
|
645
674
|
const report = (r) => { if (resultFile) (deps.writeAtomic ?? writeResultAtomic)(resultFile, JSON.stringify(r) + "\n"); return r; };
|
|
646
675
|
try {
|
|
647
676
|
await assertRepoMatchesCwd(io, repo, { sleep: deps.sleep });
|
|
@@ -704,9 +733,9 @@ export async function runFromCli(argv, deps = {}) {
|
|
|
704
733
|
let lres;
|
|
705
734
|
try {
|
|
706
735
|
lres = await runCore({ issue, repo: leanRepo, ...(lane ? { lane } : {}), laneOverride, branches: readBranches(), cwd: process.cwd(), dir, io: leanIo, path: enginePath, onProgress,
|
|
707
|
-
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}),
|
|
736
|
+
onTeardown: (fn) => { teardown = fn; }, ...(env.FORGEDOCK_BATCH_ID ? { batchId: env.FORGEDOCK_BATCH_ID } : {}), ...(placement ? { placement } : {}), ...(recover ? { recover } : {}),
|
|
708
737
|
emitTelemetry: deps.emitTelemetry ?? makeEmitTelemetry({ repo: leanRepo }),
|
|
709
|
-
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(readPrecheckCommands() ? { precheck: { commands: readPrecheckCommands() } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
738
|
+
admission: readAdmission(), heavy: readHeavy(), ...readCoreLimits(), ...readEnvConfig(), ...(readMergeWait() ? { mergeWait: readMergeWait() } : {}), ...(readConformanceMode() ? { conformance: readConformanceMode() } : {}), ...(readContextPack() ? { contextPack: readContextPack() } : {}), ...(readReviewIncomplete() ? { reviewIncomplete: readReviewIncomplete() } : {}), ...(readReproTest() ? { reproTest: readReproTest() } : {}), ...(readReviewTrigger() ? { reviewTrigger: readReviewTrigger() } : {}), ...(readVerify() ? { verify: readVerify() } : {}), ...(readPrecheckCommands() ? { precheck: { commands: readPrecheckCommands() } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model") ? { reviewerModel: readLeanSetting("reviewer_model") } : {}) });
|
|
710
739
|
} catch (e) { if (!interrupted) report({ terminalReason: "engine-error", detail: String(e?.message ?? e).slice(0, 500) }); throw e; }
|
|
711
740
|
finally { if (!interrupted) for (const [sig, f] of Object.entries(handlers)) off(sig, f); }
|
|
712
741
|
if (interrupted) return lres; // the signal handler reported and exits
|
|
@@ -1041,7 +1070,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1041
1070
|
sink.batch?.(batch);
|
|
1042
1071
|
sink.line(`batch: ${batch.id} (${batch.dir}); resume with: forgedock orchestrate --resume ${batch.id}`);
|
|
1043
1072
|
const dir = deps.dir ?? runDir();
|
|
1044
|
-
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary } = await import("./lean/orchestrate.mjs");
|
|
1073
|
+
const { orchestrate, formatTable, formatWaitSummary, formatSandboxSummary, formatOutcomeLists } = await import("./lean/orchestrate.mjs");
|
|
1045
1074
|
const leanIo = deps.leanIo ?? (await import("./lean/effects.mjs")).makeLeanIo({ repo });
|
|
1046
1075
|
const { makePrefilter, formatPrefilterSummary } = await import("./lean/prefilter.mjs");
|
|
1047
1076
|
const prefilter = argv.includes("--no-prefilter") ? null
|
|
@@ -1061,15 +1090,20 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1061
1090
|
if (deps.leanModule) {
|
|
1062
1091
|
const { runCore } = deps.leanModule;
|
|
1063
1092
|
const emitTelemetry = deps.emitTelemetry ?? makeEmitTelemetry({ repo });
|
|
1064
|
-
lanes = { adopt: null, runOne: (n, ln, ctx = {}) => runCore({ issue: n, repo, lane: ln, laneOverride, ...(ctx.placement ? { placement: { ...ctx.placement, mode: execMode() } } : {}), branches, cwd, dir, io: leanIo, agentId: `orch_${process.pid}_${n}`, emitTelemetry, batchId: batch.id,
|
|
1065
|
-
admission: readAdmission(cwd), heavy: readHeavy(cwd), ...readCoreLimits(cwd), ...readEnvConfig(cwd), ...(readMergeWait(cwd) ? { mergeWait: readMergeWait(cwd) } : {}), ...(readConformanceMode(cwd) ? { conformance: readConformanceMode(cwd) } : {}), ...(readReviewIncomplete(cwd) ? { reviewIncomplete: readReviewIncomplete(cwd) } : {}), ...(readReproTest(cwd) ? { reproTest: readReproTest(cwd) } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(readPrecheckCommands(cwd) ? { precheck: { commands: readPrecheckCommands(cwd) } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
1093
|
+
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) } : {}), ...(readReviewTrigger(cwd) ? { reviewTrigger: readReviewTrigger(cwd) } : {}), ...(readVerify(cwd) ? { verify: readVerify(cwd) } : {}), ...(readPrecheckCommands(cwd) ? { precheck: { commands: readPrecheckCommands(cwd) } } : {}), ...(model ? { builderModel: model } : {}), ...(readLeanSetting("reviewer_model", cwd) ? { reviewerModel: readLeanSetting("reviewer_model", cwd) } : {}) }) };
|
|
1066
1095
|
} else {
|
|
1067
1096
|
const laneScript = deps.laneScript ?? fileURLToPath(new URL("./forgedock.mjs", import.meta.url));
|
|
1068
1097
|
// #4256: the placement orchestrate decided for a lane (ctx.placement) reaches it as --placement/--placement-reason.
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
|
|
1098
|
+
// #4267: a recovery lane (ctx.recover) reaches it as --recover/--recover-attempt.
|
|
1099
|
+
const placementOf = new Map(), recoverOf = new Map();
|
|
1100
|
+
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)] : [])]; };
|
|
1102
|
+
const withPlacement = (l) => ({ runOne: (n, ln, ctx = {}) => {
|
|
1103
|
+
if (ctx.placement) placementOf.set(n, ctx.placement); else placementOf.delete(n);
|
|
1104
|
+
if (ctx.recover) recoverOf.set(n, ctx.recover); else recoverOf.delete(n);
|
|
1105
|
+
return l.runOne(n, ln, ctx);
|
|
1106
|
+
}, adopt: l.adopt });
|
|
1073
1107
|
const execMod = await import("./lean/execution.mjs");
|
|
1074
1108
|
const exec = deps.execution !== undefined ? deps.execution : readExecution(cwd);
|
|
1075
1109
|
hybrid = exec?.mode === "hybrid" && Number.isInteger(exec.maxRemote) && exec.maxRemote >= 1;
|
|
@@ -1196,6 +1230,23 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1196
1230
|
breaker: orch.breaker, maxDeferrals: orch.maxDeferrals, ...(deps.pollMs ? { pollMs: deps.pollMs } : {}), ...budget, limits, identity, batchId: batch.id, drift,
|
|
1197
1231
|
...(registry ? { sched: { registry, batch: batch.id, repo, cap: orch.globalParallel ?? null }, priorityOf: async (n) => schedMod.priorityFromLabels(await labelNames(n)) } : {}),
|
|
1198
1232
|
...(remote ? { remote } : {}),
|
|
1233
|
+
// #4267: mechanical needs-human outcomes are recovered in the batch; every runner decision is a FORGE:DECISION.
|
|
1234
|
+
...(orch.recovery?.maxAttempts ? { recovery: orch.recovery } : {}),
|
|
1235
|
+
decide: deps.decide ?? (async (d) => {
|
|
1236
|
+
if (d?.issue == null) { sink.line(`decision: ${d?.what} (why: ${d?.why}; alternative: ${d?.alternative})`); return; }
|
|
1237
|
+
const body = `<!-- FORGE:DECISION -->\n## Decision (batch ${batch.id})\n**What**: ${d.what}\n**Why**: ${d.why}\n**Alternative**: ${d.alternative}`;
|
|
1238
|
+
try { await leanIo.gh(["issue", "comment", String(d.issue), "-R", repo, "--body", body]); } catch { /* best effort: the batch log keeps the row */ }
|
|
1239
|
+
}),
|
|
1240
|
+
// A recovery that ended for good without the lane relabelling it (engine-error, deferred): needs-human back, one comment.
|
|
1241
|
+
relabelNeedsHuman: deps.relabelNeedsHuman ?? (async ({ issue, evidence }) => {
|
|
1242
|
+
try {
|
|
1243
|
+
await leanIo.gh(["issue", "comment", String(issue), "-R", repo, "--body", `<!-- FORGE:NEEDS_HUMAN -->\n## Needs human\n${evidence}`]);
|
|
1244
|
+
await leanIo.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", "needs-human"]);
|
|
1245
|
+
} catch { /* best effort: the batch row keeps the evidence */ }
|
|
1246
|
+
}),
|
|
1247
|
+
conflicting: deps.conflicting ?? (async (_n, pr) => {
|
|
1248
|
+
try { return JSON.parse(await leanIo.gh(["pr", "view", String(pr), "-R", repo, "--json", "mergeable"]))?.mergeable === "CONFLICTING"; } catch { return false; }
|
|
1249
|
+
}),
|
|
1199
1250
|
onCrossLane: (e) => {
|
|
1200
1251
|
crossLane.push(e);
|
|
1201
1252
|
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`);
|
|
@@ -1205,7 +1256,7 @@ export async function runBatch({ argv, cwd: callerCwd = process.cwd(), env: call
|
|
|
1205
1256
|
: async (n) => JSON.parse(await leanIo.gh(["issue", "view", String(n), "-R", repo, "--json", "body"])).body,
|
|
1206
1257
|
onUpdate: (r) => sink.row(r),
|
|
1207
1258
|
});
|
|
1208
|
-
const report = ["\n" + formatTable(rows)];
|
|
1259
|
+
const report = ["\n" + formatTable(rows), ...formatOutcomeLists(rows)];
|
|
1209
1260
|
const pfLine = formatPrefilterSummary(rows);
|
|
1210
1261
|
if (pfLine) report.push(pfLine);
|
|
1211
1262
|
let heavyStats = null; // #4169: live heavy-semaphore queue next to the per-phase waits the lanes reported
|
|
@@ -1328,6 +1379,8 @@ export async function schedFromCli(argv, deps = {}) {
|
|
|
1328
1379
|
* forge.yaml `orchestration.breaker` ({errors, window_min}; default 3 engine-errors in 30 min; errors 0 turns it off),
|
|
1329
1380
|
* `orchestration.max_deferrals` (default 3) and `orchestration.global_parallel` (#4191: host-wide lane cap; unset = none). The parser flattens one nested level, so the breaker keys arrive as
|
|
1330
1381
|
* `orchestration.errors|window_min` (or flat `breaker_errors|breaker_window_min`).
|
|
1382
|
+
* `orchestration.recovery.max_attempts` (#4267; arrives as `max_attempts`, or flat `recovery_max_attempts`): recovery lanes per
|
|
1383
|
+
* issue for a mechanical needs-human, default 1, 0 turns recovery off.
|
|
1331
1384
|
*/
|
|
1332
1385
|
export function readOrchestration(cwd = process.cwd()) {
|
|
1333
1386
|
let o = {};
|
|
@@ -1335,7 +1388,9 @@ export function readOrchestration(cwd = process.cwd()) {
|
|
|
1335
1388
|
const int = (v, d) => { const n = Number(v); return v !== undefined && v !== "" && Number.isInteger(n) && n >= 0 ? n : d; };
|
|
1336
1389
|
const errors = int(o.breaker_errors ?? o.errors, 3), windowMin = int(o.breaker_window_min ?? o.window_min, 30);
|
|
1337
1390
|
const gp = int(o.global_parallel, null);
|
|
1338
|
-
|
|
1391
|
+
const recoveryMax = int(o.recovery_max_attempts ?? o.max_attempts, 1);
|
|
1392
|
+
return { breaker: errors > 0 && windowMin > 0 ? { errors, window_min: windowMin } : null, maxDeferrals: int(o.max_deferrals, 3), globalParallel: gp >= 1 ? gp : null,
|
|
1393
|
+
recovery: { maxAttempts: recoveryMax } };
|
|
1339
1394
|
}
|
|
1340
1395
|
|
|
1341
1396
|
/**
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -203,9 +203,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
|
|
|
203
203
|
assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
|
|
204
204
|
assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
|
|
205
205
|
assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
|
|
206
|
-
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null });
|
|
206
|
+
assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 } });
|
|
207
207
|
writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
|
|
208
|
-
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4 });
|
|
208
|
+
assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 } });
|
|
209
209
|
});
|
|
210
210
|
|
|
211
211
|
it("run-issue --result-file writes the lane result, also when the run throws", async () => {
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* "PASS") is incomplete, and incomplete never merges.
|
|
9
9
|
*/
|
|
10
10
|
import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
|
|
11
|
+
import { noComments } from "./context.mjs";
|
|
11
12
|
|
|
12
13
|
export const DISPOSITIONS = Object.freeze(["deliver", "defer", "not-applicable", "post-deploy"]);
|
|
13
14
|
/** Dispositions the engine files as a tracked follow-up issue before anything merges. */
|
|
@@ -40,6 +41,29 @@ export function inIssue(quote, issueText) {
|
|
|
40
41
|
if (q.length < 3) return false;
|
|
41
42
|
return String(issueText ?? "").split("\n").some((l) => norm(l.replace(ITEM_PREFIX_RE, "")) === q);
|
|
42
43
|
}
|
|
44
|
+
// Scan-based (not a one-pass regex), so nested or unclosed markers cannot survive into the scope hash.
|
|
45
|
+
const stripHtmlComments = (s) => noComments(s).replace(/\r\n/g, "\n").trim();
|
|
46
|
+
/**
|
|
47
|
+
* #4265: the human scope of an issue: title, body and trusted comments without the engine's own annotations (HTML comment
|
|
48
|
+
* blocks such as FORGE:STATE, and comments the engine posted, which start with "<!-- FORGE:"). Its hash is the edit signal
|
|
49
|
+
* and the provenance of a frozen contract; quote validation keeps using the full text.
|
|
50
|
+
* #4298 (SEC-5): EVERY HTML comment is cut, human ones too. GitHub does not render them, so they are not the scope a
|
|
51
|
+
* human reads, and an edit that only touches a hidden comment is not a scope edit.
|
|
52
|
+
*/
|
|
53
|
+
export function scopeText(iss, trustedComments = []) {
|
|
54
|
+
const human = (trustedComments ?? []).map((c) => String(c?.body ?? "")).filter((b) => !b.trimStart().startsWith("<!-- FORGE:"));
|
|
55
|
+
return [iss?.title ?? "", stripHtmlComments(iss?.body), ...human.map(stripHtmlComments)].join("\n\n");
|
|
56
|
+
}
|
|
57
|
+
/** #4265: failures whose detail starts with a contract requirement id also quote that requirement, so a needs-human names what is unmet. */
|
|
58
|
+
export function explainFailures(failures, contract) {
|
|
59
|
+
const reqs = new Map((contract?.requirements ?? []).map((r) => [String(r?.id ?? ""), String(r?.quote ?? "")]));
|
|
60
|
+
return (failures ?? []).map((f) => {
|
|
61
|
+
const id = /^([A-Za-z][\w-]*)\b/.exec(String(f?.detail ?? ""))?.[1];
|
|
62
|
+
const q = id && reqs.get(id);
|
|
63
|
+
if (!q) return f;
|
|
64
|
+
return { ...f, detail: `${f.detail} ("${q.length > 160 ? `${q.slice(0, 157)}...` : q}")` };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
43
67
|
export const isTestPath = (p) => TEST_RE.test(String(p));
|
|
44
68
|
export const isIncidentalPath = (p) => INCIDENTAL_RE.test(String(p));
|
|
45
69
|
const cleanPath = (p) => String(p ?? "").trim().replace(/^\.\//, "");
|
|
@@ -71,6 +95,23 @@ export const isBugIssue = (labels) => (Array.isArray(labels) ? labels : []).some
|
|
|
71
95
|
* Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. `repro` (#4271): a bug
|
|
72
96
|
* lane with lean.repro_test on also needs repro_test: {path, id?, command}, or "none" with a repro_test_reason.
|
|
73
97
|
*/
|
|
98
|
+
/**
|
|
99
|
+
* A validate command that diffs against a remote branch tip (`git diff origin/x`, `git diff origin/x HEAD`) breaks as
|
|
100
|
+
* soon as the base moves on, even when the change is fine. Returns the remote ref it diffs against, or null when every
|
|
101
|
+
* `git diff` segment compares against the merge base (`origin/x...HEAD`, `$(git merge-base …)`) or no remote ref.
|
|
102
|
+
* Only revisions count: anything after a standalone `--` is a pathspec (`git diff HEAD -- origin/x.txt`, #4298).
|
|
103
|
+
*/
|
|
104
|
+
export function movingBaseDiff(command) {
|
|
105
|
+
for (const whole of String(command ?? "").split(/&&|\|\||;|\|/)) {
|
|
106
|
+
const seg = whole.split(/(?:^|\s)--(?=\s|$)/)[0];
|
|
107
|
+
if (!/\bgit\b(?:\s+-C\s+\S+)?(?:\s+-c\s+\S+)*\s+diff\b/.test(seg)) continue;
|
|
108
|
+
const ref = /(?:^|[\s=])(origin\/[A-Za-z0-9._\/-]+)/.exec(seg)?.[1];
|
|
109
|
+
if (!ref || seg.includes("...") || /merge-base/.test(seg)) continue;
|
|
110
|
+
return ref.replace(/\.\.?$/, "");
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
74
115
|
export function validateContract(c, issueText, { repro = false } = {}) {
|
|
75
116
|
const problems = [];
|
|
76
117
|
if (!c || typeof c !== "object" || Array.isArray(c)) return { ok: false, problems: ["contract.json is missing or not a JSON object"] };
|
|
@@ -97,7 +138,11 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
97
138
|
else if (!DECISION_SOURCES.includes(k?.source)) problems.push(`${k.id}: decision source must be one of ${DECISION_SOURCES.join("|")}`);
|
|
98
139
|
}
|
|
99
140
|
if (!Array.isArray(c.validate) || !c.validate.length) problems.push("the contract names no validate[] commands");
|
|
100
|
-
else for (const v of c.validate)
|
|
141
|
+
else for (const v of c.validate) {
|
|
142
|
+
if (!v?.id || blank(v?.command)) { problems.push("validate entry without id or command"); continue; }
|
|
143
|
+
const ref = movingBaseDiff(v.command);
|
|
144
|
+
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})\`)`);
|
|
145
|
+
}
|
|
101
146
|
if (repro) {
|
|
102
147
|
const t = c.repro_test;
|
|
103
148
|
if (t === "none") { if (blank(c.repro_test_reason)) problems.push(`repro_test "none" needs a one-line repro_test_reason`); }
|
|
@@ -160,7 +205,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
|
|
|
160
205
|
const leftToCi = !r || (r.ci === true && r.exit === undefined);
|
|
161
206
|
if (ciVerified && leftToCi) continue;
|
|
162
207
|
if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
|
|
163
|
-
else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} failed (exit ${r.exit})` });
|
|
208
|
+
else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} (\`${v.command}\`) failed (exit ${r.exit})` });
|
|
164
209
|
}
|
|
165
210
|
const st = Array.isArray(result?.requirements) ? result.requirements : [];
|
|
166
211
|
for (const r of contract.requirements ?? []) {
|
|
@@ -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 { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath } from "./conformance.mjs";
|
|
5
|
+
import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, explainFailures } from "./conformance.mjs";
|
|
6
6
|
|
|
7
7
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
8
8
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -161,6 +161,21 @@ describe("conformance review: the engine computes the verdict from complete rows
|
|
|
161
161
|
assert.equal(resolveEvidencePath("nope.js", files), null);
|
|
162
162
|
assert.equal(decide(row("R1", { evidence: "scrape.js:4" }), ctx).state, "conforms");
|
|
163
163
|
});
|
|
164
|
+
it("a validate command that diffs against the moving origin/<lane> tip invalidates the contract; a merge-base diff does not", () => {
|
|
165
|
+
const withV = (command) => ({ ...C, validate: [{ id: "V4", command }] });
|
|
166
|
+
const bad = validateContract(withV("./scripts/sync.sh --check-only && git diff --quiet origin/staging -- infra/a infra/b"), ISSUE);
|
|
167
|
+
assert.equal(bad.ok, false);
|
|
168
|
+
assert.ok(bad.problems.some((x) => /^V4: `git diff` against the moving origin\/staging tip/.test(x) && x.includes("origin/staging...HEAD")), bad.problems.join("; "));
|
|
169
|
+
for (const ok of ["git diff --quiet origin/staging...HEAD -- infra/a", "git diff --quiet $(git merge-base HEAD origin/staging) -- infra/a", "node --test test/leak.test.js", "git log origin/staging..HEAD --oneline"]) {
|
|
170
|
+
assert.equal(validateContract(withV(ok), ISSUE).ok, true, ok);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
it("movingBaseDiff finds the remote ref in any git diff segment, and ignores merge-base forms and other commands", () => {
|
|
174
|
+
assert.equal(movingBaseDiff("git diff --name-only origin/main HEAD"), "origin/main");
|
|
175
|
+
assert.equal(movingBaseDiff("git -C sub diff origin/milestone/foo | wc -l"), "origin/milestone/foo");
|
|
176
|
+
assert.equal(movingBaseDiff("git diff HEAD~1"), null);
|
|
177
|
+
assert.equal(movingBaseDiff(""), null);
|
|
178
|
+
});
|
|
164
179
|
it("#4160: reviewSkeleton (object form) lists every required row, scope rows pre-filled with the engine's authorization", () => {
|
|
165
180
|
const k = { ...C, requirements: [...C.requirements, { id: "R9", quote: "Billing pricing is unchanged.", disposition: "defer", why: "w" }] };
|
|
166
181
|
const sk = reviewSkeleton({ contract: k, changedFiles: [...files, "src/billing.js", ".forge-lean/x"], ignore: [".forge-lean/"] });
|
|
@@ -299,3 +314,40 @@ describe("repro_test on bug lanes (#4271)", () => {
|
|
|
299
314
|
assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), precheck({ contract: C, result: res, changedFiles: files, repro: null }));
|
|
300
315
|
});
|
|
301
316
|
});
|
|
317
|
+
|
|
318
|
+
describe("#4265 issue scope and failure explanations", () => {
|
|
319
|
+
const iss = { title: "t", body: "Fix foo.\n\n<!-- FORGE:STATE\n{\"lease\":1}\n-->" };
|
|
320
|
+
const human = { body: "Also bar." };
|
|
321
|
+
it("scopeText ignores the engine's HTML-comment blocks and FORGE comments, keeps human text", () => {
|
|
322
|
+
const base = scopeText(iss, [human]);
|
|
323
|
+
assert.equal(scopeText({ ...iss, body: iss.body.replace("\"lease\":1", "\"lease\":2") }, [human, { body: "<!-- FORGE:CONTRACT -->\n## Builder Contract" }, { body: " <!-- FORGE:STARTED -->\nStarted" }]), base);
|
|
324
|
+
assert.match(base, /Fix foo\.[\s\S]*Also bar\./);
|
|
325
|
+
assert.doesNotMatch(base, /FORGE:STATE|lease/);
|
|
326
|
+
assert.notEqual(scopeText(iss, [human, { body: "Scope: also baz." }]), base);
|
|
327
|
+
assert.notEqual(scopeText({ ...iss, body: "Fix foo and qux." }, [human]), base);
|
|
328
|
+
});
|
|
329
|
+
it("#4298 SEC-5: an edit that only touches a human HTML comment is not a scope edit", () => {
|
|
330
|
+
const withNote = { ...iss, body: `${iss.body}\n<!-- note: maybe also qux -->` };
|
|
331
|
+
assert.equal(scopeText(withNote, [human]), scopeText({ ...withNote, body: withNote.body.replace("maybe also qux", "definitely qux") }, [human]));
|
|
332
|
+
assert.equal(scopeText(withNote, [human]), scopeText(iss, [human]));
|
|
333
|
+
});
|
|
334
|
+
it("#4298: movingBaseDiff reads only the revisions before `--`", () => {
|
|
335
|
+
assert.equal(movingBaseDiff("git diff HEAD -- origin/x.txt"), null);
|
|
336
|
+
assert.equal(movingBaseDiff("git diff --stat -- origin/foo/bar && echo ok"), null);
|
|
337
|
+
assert.equal(movingBaseDiff("git diff origin/main -- src"), "origin/main");
|
|
338
|
+
assert.equal(movingBaseDiff("git diff --name-only origin/main --"), "origin/main");
|
|
339
|
+
});
|
|
340
|
+
it("explainFailures appends the quote of the requirement a detail names, and leaves other details alone", () => {
|
|
341
|
+
const contract = { requirements: [{ id: "R1", quote: "foo must handle null" }, { id: "R2", quote: "x".repeat(300) }] };
|
|
342
|
+
const out = explainFailures([{ code: "c", detail: "R1 reported \"partial\"" }, { code: "c", detail: "R2 missing" }, { code: "c", detail: "validate V1 failed (exit 1)" }, { code: "c", detail: "R9 missing" }], contract);
|
|
343
|
+
assert.equal(out[0].detail, 'R1 reported "partial" ("foo must handle null")');
|
|
344
|
+
assert.ok(out[1].detail.endsWith('...")') && out[1].detail.length < 200);
|
|
345
|
+
assert.equal(out[2].detail, "validate V1 failed (exit 1)");
|
|
346
|
+
assert.equal(out[3].detail, "R9 missing");
|
|
347
|
+
assert.equal(out[0].code, "c");
|
|
348
|
+
});
|
|
349
|
+
it("a failed validate names its command", () => {
|
|
350
|
+
const r = precheck({ contract: { requirements: [], deliverables: [], validate: [{ id: "V1", command: "npm test" }] }, result: { validation: [{ id: "V1", exit: 2 }] }, changedFiles: [] });
|
|
351
|
+
assert.equal(r.failures[0].detail, "validate V1 (`npm test`) failed (exit 2)");
|
|
352
|
+
});
|
|
353
|
+
});
|