forgedock 1.42.0 → 1.43.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-cli.mjs +76 -17
- package/bin/lean/autochildren.mjs +103 -0
- package/bin/lean/autochildren.test.mjs +126 -0
- package/bin/lean/batch.mjs +8 -2
- package/bin/lean/batch.test.mjs +29 -2
- package/bin/lean/conformance.mjs +121 -8
- package/bin/lean/conformance.test.mjs +132 -2
- package/bin/lean/fixround.test.mjs +9 -1
- package/bin/lean/guardgate.mjs +66 -7
- package/bin/lean/guardgate.test.mjs +109 -3
- package/bin/lean/heavy.test.mjs +11 -1
- package/bin/lean/lean.mjs +40 -16
- package/bin/lean/lean.test.mjs +66 -5
- package/bin/lean/orchestrate-children.test.mjs +83 -0
- package/bin/lean/orchestrate.mjs +51 -12
- package/bin/lean/postmerge.mjs +5 -2
- package/bin/lean/postmerge.test.mjs +7 -0
- package/bin/lean/preflight.mjs +21 -0
- package/bin/lean/preflight.test.mjs +15 -1
- package/bin/lean/prompts.mjs +19 -4
- package/bin/lean/schedd.mjs +79 -15
- package/bin/lean/schedd.test.mjs +92 -2
- package/bin/lean/toolstate.mjs +19 -3
- package/bin/lean/toolstate.test.mjs +22 -0
- package/bin/lean/verify.mjs +9 -1
- package/bin/lean/verify.test.mjs +4 -0
- package/commands/orchestrate.md +2 -2
- package/docs/CONFIG.md +8 -2
- package/docs/RELEASE-NOTES.md +3 -0
- package/package.json +1 -1
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +7 -0
- package/scripts/review-pr-staging-scope.test.sh +3 -3
- package/scripts/review-prepare.sh +3 -0
- package/scripts/review-staging-pre.sh +88 -43
- package/scripts/review-staging-pre.test.sh +42 -1
|
@@ -5,7 +5,7 @@ import assert from "node:assert/strict";
|
|
|
5
5
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
6
|
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { oldRanges, selectGuards, runGuards, commandFor, basePathOf, guardFailures, supersessionComment, supersededIds, loadGuards } from "./guardgate.mjs";
|
|
8
|
+
import { oldRanges, selectGuards, runGuards, commandFor, basePathOf, failingNames, guardFailures, supersessionComment, supersededIds, loadGuards } from "./guardgate.mjs";
|
|
9
9
|
import { applyAmendments, precheck } from "./conformance.mjs";
|
|
10
10
|
import { builderImplementPrompt, conformanceResumePrompt } from "./prompts.mjs";
|
|
11
11
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
@@ -19,13 +19,13 @@ const put = (cwd, p, body) => { mkdirSync(join(cwd, p, ".."), { recursive: true
|
|
|
19
19
|
const TEST_SRC = (v) => `import test from "node:test";\nimport assert from "node:assert/strict";\nimport { price } from "./a.mjs";\ntest("price is ${v}", () => assert.equal(price(), ${v}));\n`;
|
|
20
20
|
|
|
21
21
|
/** A repo whose fix commit (merge_sha) made price() return 2, guarded by src/a.test.mjs; the lane starts from it. */
|
|
22
|
-
function fixture() {
|
|
22
|
+
function fixture(testSrc = TEST_SRC(2)) {
|
|
23
23
|
const d = tmpDir("guardgate-");
|
|
24
24
|
sh(d, "init", "-q", "-b", "main");
|
|
25
25
|
put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n");
|
|
26
26
|
sh(d, "add", "-A"); sh(d, "commit", "-qm", "init");
|
|
27
27
|
put(d, "src/a.mjs", "export function price() {\n return 2;\n}\n");
|
|
28
|
-
put(d, "src/a.test.mjs",
|
|
28
|
+
put(d, "src/a.test.mjs", testSrc);
|
|
29
29
|
sh(d, "add", "-A"); sh(d, "commit", "-qm", "fix: price");
|
|
30
30
|
const sha = sh(d, "rev-parse", "HEAD").trim();
|
|
31
31
|
put(d, "src/other.mjs", "export const x = 1;\n");
|
|
@@ -127,6 +127,24 @@ test("a base test that cannot run in the cap, or has no known runner, is skipped
|
|
|
127
127
|
assert.equal(existsSync(join(d, basePathOf("src/a.test.mjs"))), false);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test("a verify.guard_command template wins over the contract and the fallbacks (#4792)", async () => {
|
|
131
|
+
const gc = { ".py": "scripts/pytest {path}" };
|
|
132
|
+
assert.equal(commandFor("tests/test_a__guardbase.py", {}, gc), "scripts/pytest tests/test_a__guardbase.py");
|
|
133
|
+
assert.equal(commandFor("tests/test_a__guardbase.py", { validate: [{ command: "pytest -q tests/test_a.py" }] }, gc), "scripts/pytest tests/test_a__guardbase.py");
|
|
134
|
+
assert.equal(commandFor("tests/test_a__guardbase.py", {}, { ".py": "uv run pytest -q" }), "uv run pytest -q tests/test_a__guardbase.py");
|
|
135
|
+
assert.equal(commandFor("src/a.test__guardbase.mjs", {}, gc), "node --test src/a.test__guardbase.mjs");
|
|
136
|
+
const { d, guard } = fixture();
|
|
137
|
+
put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n");
|
|
138
|
+
commit(d, "revert");
|
|
139
|
+
const git = gitIn(d);
|
|
140
|
+
const { selected, mergeBase } = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
|
|
141
|
+
const seen = [];
|
|
142
|
+
const r = await runGuards({ selected, mergeBase, contract: {}, guardCommand: { ".mjs": "run-it {path}" }, git, runCommand: async (a) => { seen.push(a); return { rc: 0, output: "" }; }, cwd: d, env: {} });
|
|
143
|
+
assert.equal(r.runs.length, 1);
|
|
144
|
+
assert.equal(seen[0].command, "run-it src/a.test__guardbase.mjs");
|
|
145
|
+
assert.equal(seen[0].cwd, d);
|
|
146
|
+
});
|
|
147
|
+
|
|
130
148
|
test("pytest-style guards run through the contract's own validate command with the path substituted", () => {
|
|
131
149
|
assert.equal(commandFor("tests/test_a__guardbase.py", { validate: [{ command: "pytest -q tests/test_a.py" }] }), "pytest -q tests/test_a__guardbase.py");
|
|
132
150
|
assert.equal(commandFor("tests/test_a__guardbase.py", {}), "python -m pytest -q tests/test_a__guardbase.py");
|
|
@@ -189,3 +207,91 @@ test("an additive enum change that updates its coverage test fails the guard wit
|
|
|
189
207
|
const withIt = precheck({ contract: a.contract, ...base });
|
|
190
208
|
assert.deepEqual(withIt.failures.filter((f) => f.kind === "guard_broken"), []);
|
|
191
209
|
});
|
|
210
|
+
|
|
211
|
+
// #4968: a guard test already red on the merge base must not block the lane
|
|
212
|
+
const FLAKY = (v) => `${TEST_SRC(v)}test("env dependent", () => assert.fail("red on base"));\n`;
|
|
213
|
+
const revert = (d) => { put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n"); commit(d, "revert"); };
|
|
214
|
+
|
|
215
|
+
test("a guard test that already fails on base is skipped (fails on base), not guard_broken", async () => {
|
|
216
|
+
const { d, guard } = fixture(FLAKY(2));
|
|
217
|
+
put(d, "src/a.mjs", "export function price() {\n return 1 + 1;\n}\n"); // harmless edit of the guarded line
|
|
218
|
+
commit(d, "refactor");
|
|
219
|
+
const { selected, r } = await gate(d, guard);
|
|
220
|
+
assert.equal(selected.length, 1);
|
|
221
|
+
assert.equal(r.failures.length, 0, JSON.stringify(r));
|
|
222
|
+
assert.ok(r.skipped.some((x) => /guard #7 .*fails on base/.test(x)), r.skipped.join("|"));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a test that passes on base and fails on the lane is blocked, with no skip", async () => {
|
|
226
|
+
const { d, guard } = fixture();
|
|
227
|
+
revert(d);
|
|
228
|
+
const { r } = await gate(d, guard);
|
|
229
|
+
assert.equal(r.failures.length, 1);
|
|
230
|
+
assert.deepEqual(r.skipped, []);
|
|
231
|
+
assert.match(r.failures[0].detail, /failing tests: price is 2:/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("a new failure in a file that already had one is blocked and its name leads the detail", async () => {
|
|
235
|
+
const { d, guard } = fixture(FLAKY(2));
|
|
236
|
+
revert(d);
|
|
237
|
+
const { r } = await gate(d, guard);
|
|
238
|
+
assert.equal(r.failures.length, 1, JSON.stringify({ skipped: r.skipped }));
|
|
239
|
+
const detail = r.failures[0].detail;
|
|
240
|
+
assert.match(detail, /failing tests: price is 2:\n/, "only the new failure is named");
|
|
241
|
+
assert.ok(detail.indexOf("failing tests:") < detail.indexOf("env dependent"), "names precede the output tail (any reporter)");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a passing guard adds no baseline worktree, and a failing one removes its worktree", async () => {
|
|
245
|
+
const pass = fixture();
|
|
246
|
+
const calls = [];
|
|
247
|
+
const spy = (cwd) => async (args) => { calls.push(args[0]); return sh(cwd, ...args); };
|
|
248
|
+
put(pass.d, "src/a.mjs", "export function price() {\n return 1 + 1;\n}\n"); commit(pass.d, "refactor");
|
|
249
|
+
let git = spy(pass.d);
|
|
250
|
+
let { selected, mergeBase } = await selectGuards({ guards: [pass.guard], files: files(pass.d), git, base: "origin/main" });
|
|
251
|
+
calls.length = 0;
|
|
252
|
+
const ok = await runGuards({ selected, mergeBase, contract: {}, git, runCommand, cwd: pass.d, env: process.env });
|
|
253
|
+
assert.equal(ok.failures.length, 0);
|
|
254
|
+
assert.ok(!calls.includes("worktree"), calls.join(","));
|
|
255
|
+
const bad = fixture();
|
|
256
|
+
revert(bad.d);
|
|
257
|
+
git = spy(bad.d);
|
|
258
|
+
({ selected, mergeBase } = await selectGuards({ guards: [bad.guard], files: files(bad.d), git, base: "origin/main" }));
|
|
259
|
+
calls.length = 0;
|
|
260
|
+
const r = await runGuards({ selected, mergeBase, contract: {}, git, runCommand, cwd: bad.d, env: process.env });
|
|
261
|
+
assert.equal(r.failures.length, 1);
|
|
262
|
+
assert.ok(calls.includes("worktree"));
|
|
263
|
+
assert.equal(sh(bad.d, "worktree", "list").trim().split("\n").length, 1, "baseline worktree removed");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("the baseline rerun uses the verify.guard_command runner, so a real break still blocks", async () => {
|
|
267
|
+
const { d, guard } = fixture();
|
|
268
|
+
revert(d);
|
|
269
|
+
const git = gitIn(d);
|
|
270
|
+
const { selected, mergeBase } = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
|
|
271
|
+
const seen = [];
|
|
272
|
+
// run-it only exists through guard_command: the lane copy fails, the base copy passes
|
|
273
|
+
const r = await runGuards({ selected, mergeBase, contract: {}, guardCommand: { ".mjs": "run-it {path}" }, git, runCommand: async (a) => {
|
|
274
|
+
seen.push(a);
|
|
275
|
+
return a.cwd === d ? { rc: 1, output: "not ok 1 - price is 2\n" } : { rc: 0, output: "ok 1 - price is 2\n" };
|
|
276
|
+
}, cwd: d, env: {} });
|
|
277
|
+
assert.deepEqual(seen.map((s) => s.command), ["run-it src/a.test__guardbase.mjs", "run-it src/a.test.mjs"]);
|
|
278
|
+
assert.notEqual(seen[1].cwd, d, "baseline runs in the merge-base worktree");
|
|
279
|
+
assert.equal(r.failures.length, 1, JSON.stringify(r.skipped));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a baseline that cannot run is skipped, never blocking", async () => {
|
|
283
|
+
const { d, guard } = fixture();
|
|
284
|
+
revert(d);
|
|
285
|
+
const git = async (a) => { if (a[0] === "worktree" && a[1] === "add") throw new Error("no worktree"); return sh(d, ...a); };
|
|
286
|
+
const { selected, mergeBase } = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
|
|
287
|
+
const r = await runGuards({ selected, mergeBase, contract: {}, git, runCommand, cwd: d, env: process.env });
|
|
288
|
+
assert.equal(r.failures.length, 0);
|
|
289
|
+
assert.match(r.skipped[0], /baseline unavailable \(no worktree\)/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("failingNames reads TAP, spec and pytest output and ignores skips", () => {
|
|
293
|
+
assert.deepEqual(failingNames("ok 1 - a\nnot ok 2 - b c\n not ok 1 - nested # SKIP why\nnot ok 3 - d # TODO x"), ["b c"]);
|
|
294
|
+
assert.deepEqual(failingNames(" ✖ spec one (1.2ms)\n✖ failing tests:\n ✖ spec one (1.2ms)"), ["spec one"]);
|
|
295
|
+
assert.deepEqual(failingNames("FAILED tests/t.py::test_x - assert 1 == 2"), ["tests/t.py::test_x"]);
|
|
296
|
+
assert.equal(failingNames("all fine"), null);
|
|
297
|
+
});
|
package/bin/lean/heavy.test.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import { tmpDir } from "../tests/_tmp.mjs";
|
|
|
9
9
|
import { guardChain, isPassThroughWrapper, SHIM_LOOP_MAX, classifyStep, heavyConfig, openHeavy, acquireHeavy, writeHeavyShims, heavyShimScript, sumWaits, runWrapped, HEAVY_BINS, isHeavyProcess } from "./heavy.mjs";
|
|
10
10
|
import { checkAdmission, ADMISSION_DEFAULTS, localLaneCap } from "./admission.mjs";
|
|
11
11
|
import { formatAlertLines, formatWaitSummary } from "./orchestrate.mjs";
|
|
12
|
-
import { readHeavy, liveLaneCap } from "../engine-cli.mjs";
|
|
12
|
+
import { readHeavy, readAdmission, liveLaneCap } from "../engine-cli.mjs";
|
|
13
13
|
|
|
14
14
|
// Injected pids, clock and host: `live` is the set of pids the registry sees as alive; `mem` the MemAvailable in MB.
|
|
15
15
|
const world = ({ mem = 12000, cores = 4, swapFree = 0 } = {}) => {
|
|
@@ -336,4 +336,14 @@ describe("validation steps and the local lane cap (#4777)", () => {
|
|
|
336
336
|
assert.equal(liveLaneCap(1, { cfg: {}, host: { cores: () => 8, memAvailableMb: () => 64000 } })(), 1);
|
|
337
337
|
assert.equal(liveLaneCap(3, { cfg: { enabled: false }, host: { cores: () => 8, memAvailableMb: () => 64000 } })(), 3);
|
|
338
338
|
});
|
|
339
|
+
|
|
340
|
+
it("batch cap follows the repo forge.yaml lane_cpu_reservation / lane_mem_mb, not the process cwd (#4828)", () => {
|
|
341
|
+
const d = tmpDir("lane-cap-");
|
|
342
|
+
writeFileSync(join(d, "forge.yaml"), "engine:\n lane_cpu_reservation: 4\n lane_mem_mb: 4096\n");
|
|
343
|
+
const host = { cores: () => 16, memAvailableMb: () => 2048 + 3 * 4096 + 10 };
|
|
344
|
+
assert.equal(liveLaneCap(null, { cfg: readAdmission(d), host })(), 3, "memory binds at 4096 MB per lane");
|
|
345
|
+
assert.equal(liveLaneCap(null, { cfg: {}, host })(), 6, "defaults: 2048 MB per lane");
|
|
346
|
+
writeFileSync(join(d, "forge.yaml"), "engine:\n lane_cpu_reservation: 8\n");
|
|
347
|
+
assert.equal(liveLaneCap(null, { cfg: readAdmission(d), host: { cores: () => 16, memAvailableMb: () => 64000 } })(), 2);
|
|
348
|
+
});
|
|
339
349
|
});
|
package/bin/lean/lean.mjs
CHANGED
|
@@ -14,12 +14,12 @@ import { ciPollDelayMs, ENGINE_VERSION, DEFAULT_MAX_SESSION_LIMIT_PAUSES, DEFAUL
|
|
|
14
14
|
import { openDependency, laneMergedPrs, dependencyOnLane, isTrustedComment, decomposedSubissues, isDecomposedRecord, reopenRevokedDecomposition } from "../engine/phases.mjs";
|
|
15
15
|
import { createHash, randomBytes } from "node:crypto";
|
|
16
16
|
import { otherLaneOwner } from "./lanelock.mjs";
|
|
17
|
-
import { TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
|
|
17
|
+
import { describesUnrunnableCommand, removedPathsOf, TRACKED, normalizeKindModes, blockingFailures, unverifiedRequirements, mutationTargets, mutationFailures, runMutationCheck, MUTATION_SURVIVED, isBugIssue, scopeText, explainFailures, inIssue, validateContract, cliUsageError, normalizeRunnerCommand, applyAmendments, precheck, repoTracks, dropScopeValidates, tagOutOfBand, parseReview, decide, mergeReviewRows, evidencePathOnly, closePlan, followupItems, bucketTally, pendingTracking, unnamedEdges, structureNote, impliedDocDeliverables } from "./conformance.mjs";
|
|
18
18
|
import { checkAdmission, hostParallelism, ADMISSION_RETRY_SECONDS } from "./admission.mjs";
|
|
19
19
|
import { namedPaths, isTrustedIssueComment } from "./prefilter.mjs";
|
|
20
20
|
import { checksAt } from "./postdeploy-check.mjs";
|
|
21
21
|
import { readRollouts, detectNewOffKeys } from "./activation.mjs";
|
|
22
|
-
import { preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
22
|
+
import { testRunnerProblems, preflightProbes, preflightProblems, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
|
|
23
23
|
import { resolveLane, isDefaultLane, isShippedLane, DEFAULT_STAGING_BRANCH } from "./lane.mjs";
|
|
24
24
|
import { splitGate, rationaleLine } from "./decompose.mjs";
|
|
25
25
|
import { dockerLabels, LEDGER_TTL_MS } from "./ledger.mjs";
|
|
@@ -37,7 +37,7 @@ import { hostCapabilityBlock, contractHostNeeds } from "./execution.mjs";
|
|
|
37
37
|
const MISSING_TOOL_RE = /command not found|not found in PATH|no such file or directory.*\b(?:docker|promtool)\b|\b(?:docker|promtool)\b[^\n]{0,80}(?:not (?:installed|found|available)|no such file|cannot connect|daemon)|cannot connect to the docker daemon|no docker daemon|no module named|ModuleNotFoundError|\bENOENT\b[^\n]{0,60}spawn/i;
|
|
38
38
|
/** #4685: true when a builder's block summary names a missing tool or module (environment), not a credential. */
|
|
39
39
|
export function missingToolBlock(summary) { return MISSING_TOOL_RE.test(String(summary ?? "")); }
|
|
40
|
-
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, decisionNote, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
|
|
40
|
+
import { REVIEWER_SYSTEM, LEAN_DIR, RESULT_FILE, INVESTIGATION_FILE, IMPLEMENTATION_FILE, CONTRACT_FILE, builderPlanPrompt, builderImplementPrompt, contractCorrectionPrompt, conformanceResumePrompt, reviewerPrompt, investigationVerdict, decisionNote, reviewerRepairPrompt, REVIEW_DIFF_MAX_BYTES, blockCorrectionPrompt, HUMAN_BLOCK_REASONS, structurePrompt, hotspotPlanNote, hotspotBuildRule, reproPlanNote, unrunnableValidateNote, contextPackNote, docsPrompt, ciFailurePrompt, ciUnavailablePrompt, precheckFailurePrompt, contextRestartPrompt, turnBudgetWrapupPrompt, syncChecks, GROUPING_SYSTEM, enumeratingPlanNote, enumeratingBuildRule, invariantsPlanNote, invariantsBuildRule, missingEntriesPrompt, sequencePrompt, sequenceSyncNote, sequenceSyncPrompt, fixSessionPrompt, postMergeFailurePrompt, duplicateExportsPrompt, migrationGeneratorPrompt, i18nCopiesPrompt } from "./prompts.mjs";
|
|
41
41
|
import { postMergeCommands } from "./postmerge.mjs";
|
|
42
42
|
import { guardAfterMerge, withHistoryRows } from "./guards.mjs";
|
|
43
43
|
import { loadGuards, selectGuards, runGuards, supersededIds, supersessionComment } from "./guardgate.mjs";
|
|
@@ -156,6 +156,9 @@ export function classifyBlock(result, issue) {
|
|
|
156
156
|
}
|
|
157
157
|
if (b.kind === "human") {
|
|
158
158
|
const remainder = (Array.isArray(b.remainder) ? b.remainder : []).filter((x) => x && typeof x.title === "string" && x.title.trim() && typeof x.body === "string").slice(0, MAX_SUBISSUES).map((x) => ({ title: x.title.trim(), body: x.body })); // #4730
|
|
159
|
+
if (b.why === "product-decision" && describesUnrunnableCommand(`${result?.summary ?? ""} ${b.reason ?? ""} ${b.detail ?? ""}`)) { // #4857
|
|
160
|
+
return { kind: "untyped", problem: "an unrunnable validate command is an environment defect, never a product-decision: run an equivalent through the repo's toolchain and record it as a substitute-validate amendment" };
|
|
161
|
+
}
|
|
159
162
|
return HUMAN_BLOCK_REASONS.includes(b.why) ? { kind: "human", why: b.why, ...(remainder.length ? { remainder } : {}) }
|
|
160
163
|
: { kind: "untyped", problem: `"${String(b.why ?? "").slice(0, 80)}" is not a human-only reason (${HUMAN_BLOCK_REASONS.join(", ")})` };
|
|
161
164
|
}
|
|
@@ -202,6 +205,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
202
205
|
* @param {"off"|"prefer"} [o.reproTest] forge.yaml lean.repro_test (#4271): prefer (default) freezes a red-green repro_test on bug lanes; off is today's contract
|
|
203
206
|
* @param {object} [o.refactorModule] refactor:split step module (default ./refactor.mjs; injectable for tests, #4211)
|
|
204
207
|
* @param {number} [o.splitMaxLines] refactor:split moved-line budget per PR (default 4000); the rest becomes a chained issue (#4211)
|
|
208
|
+
* @param {object} [o.testRunners] forge.yaml verification.test_runners (#4857): {path prefix: runner command prefix}
|
|
205
209
|
* @param {string} [o.toolchainOverride] forge.yaml env.toolchain: "<pm>@<x.y.z>" or "off" (#4163)
|
|
206
210
|
* @param {string} [o.envSetup] forge.yaml env.setup: run once per fresh worktree before the builder (#4163)
|
|
207
211
|
* @param {string} [o.laneInstructions] forge.yaml engine.lane_instructions (#4395): repo-relative file appended to fresh sessions instead of CLAUDE.md
|
|
@@ -217,7 +221,7 @@ export function isProtectedLaneBranch(b, { issue, lane = null, branches = {}, de
|
|
|
217
221
|
*/
|
|
218
222
|
export async function runCore(o) {
|
|
219
223
|
const { issue, repo, lane: requestedLane = null, laneOverride = false, branches = {}, cwd, dir, io, builderModel = null, reviewerModel = DEFAULT_REVIEWER_MODEL, fixModel = null,
|
|
220
|
-
toolchainOverride = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
224
|
+
toolchainOverride = null, testRunners = null, envSetup = null, ciMaxMinutes = 360, conformance = "shadow", conformanceKinds = null, conformanceMutation = "off", reviewIncomplete = "needs-human", reproTest = "prefer", closeParent = false, mergeWait = "required", maxSessionLimitPauses = DEFAULT_MAX_SESSION_LIMIT_PAUSES, cliIdleMinutes = 15, cliMaxMinutes = 180, cliBusyMinutes = 45, contextCeilingTokens = 120000, turnBudgets = null, laneLimits = null, maxMergeRetries = 5, sessionLimitMaxWaitMs = DEFAULT_SESSION_LIMIT_WAIT_MS, onProgress = () => {}, emitTelemetry = null, random = Math.random } = o;
|
|
221
225
|
const now = io.now ?? (() => Date.now());
|
|
222
226
|
const vcfg = { ...parseVerify({}), ...(o.verify ?? {}) };
|
|
223
227
|
const learnedCommands = (Array.isArray(o.precheck?.commands) ? o.precheck.commands : []).map((c) => String(c ?? "").trim()).filter(Boolean);
|
|
@@ -640,7 +644,7 @@ export async function runCore(o) {
|
|
|
640
644
|
} catch (e) { throw Object.assign(e, { laneCode: OUTCOME_CODES.CI_DCO }); }
|
|
641
645
|
};
|
|
642
646
|
const gitPush = async (args) => {
|
|
643
|
-
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev }); await signOffUnpushed(); } // #4590: never push .forge-lean/ or tool caches this branch added
|
|
647
|
+
if (!args.includes("--delete")) { await untrackToolState({ git, base, ev, keep: await keepPaths() }); await signOffUnpushed(); } // #4590: never push .forge-lean/ or tool caches this branch added
|
|
644
648
|
try { return await retrying(() => git(args), { transient: PUSH_TRANSIENT_RE, permanent: PUSH_PERMANENT_RE, attempts: 4, sleep: sleepFn, random }); }
|
|
645
649
|
catch (e) {
|
|
646
650
|
if (e?.transientExhausted) { e.deferCode = OUTCOME_CODES.PUSH_TRANSIENT; e.deferDetail = `git push kept failing transiently after 4 attempts: ${String(e?.stderr ?? e?.message ?? e).slice(0, 300)}`; }
|
|
@@ -825,6 +829,7 @@ export async function runCore(o) {
|
|
|
825
829
|
const digest = budgetDigest({ size, budget, used: tb.record().build, digest: res?.status === "budget-exceeded" ? res.digest : null, stat: facts.stat, check: facts.check });
|
|
826
830
|
throw Object.assign(new Error(digest), { laneCode: OUTCOME_CODES.BUDGET_EXCEEDED });
|
|
827
831
|
}
|
|
832
|
+
const keepPaths = async () => ((await readContract())?.deliverables ?? []).map((d) => d?.path).filter((x) => typeof x === "string"); // #4959: deliverables are never untracked as generated files
|
|
828
833
|
const readContract = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${CONTRACT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
829
834
|
const readResult = async () => { try { const j = JSON.parse(await io.fs.read(`${wt}/${RESULT_FILE}`)); return j && typeof j === "object" ? j : null; } catch { return null; } };
|
|
830
835
|
/** Containers/volumes the builder reported in result.json `resources` go into the ledger, so teardown removes them. */
|
|
@@ -1546,7 +1551,7 @@ export async function runCore(o) {
|
|
|
1546
1551
|
enumList = io.enumeratingTests ? ((await io.enumeratingTests({ cwd: wt, paths: namedPaths(iss.body ?? "") }).catch(() => null)) ?? []) : [];
|
|
1547
1552
|
facts = await factsPath([wt, cwd], io.fs?.exists);
|
|
1548
1553
|
let ctxPack = frozen || lightTry ? null : await contextPackStep();
|
|
1549
|
-
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + invariantsPlanNote(vcfg.invariants) + reproPlanNote(reproOn()) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1554
|
+
const planPrompt = () => builderPlanPrompt(issue, tcOpts()) + hotspotPlanNote(hotFacts) + enumeratingPlanNote(enumList) + invariantsPlanNote(vcfg.invariants) + reproPlanNote(reproOn()) + unrunnableValidateNote(testRunners) + explorePlanRule(exploreCfg) + (ctxCfg.mode === "on" ? contextPackNote(ctxPack) : "");
|
|
1550
1555
|
let lightDone = false, lightCommits = false;
|
|
1551
1556
|
if (lightTry) {
|
|
1552
1557
|
liveKeys = ["implementation"]; stage = "implement"; phaseLedger.enter("implement");
|
|
@@ -1622,8 +1627,8 @@ export async function runCore(o) {
|
|
|
1622
1627
|
let vc = validateContract(contract, issueText, { repro: reproOn() });
|
|
1623
1628
|
const missed = await preflightValidates(contract); // #4328: validate commands the host cannot run share the one correction
|
|
1624
1629
|
let misses = missed;
|
|
1625
|
-
if ((!vc.ok || missed.length) && sessionId) {
|
|
1626
|
-
const problems = [...vc.problems, ...preflightProblems(missed)];
|
|
1630
|
+
if ((!vc.ok || missed.length || testRunnerProblems(contract, testRunners).length) && sessionId) {
|
|
1631
|
+
const problems = [...vc.problems, ...preflightProblems(missed), ...testRunnerProblems(contract, testRunners)];
|
|
1627
1632
|
T.decisions.push(`Contract rejected (${problems[0]}); resumed once`);
|
|
1628
1633
|
await builder(++attemptN, contractCorrectionPrompt(problems), sessionId, false, planPrompt, { trigger: "contract_correction" });
|
|
1629
1634
|
contract = dropScope(await readContract());
|
|
@@ -1876,6 +1881,7 @@ export async function runCore(o) {
|
|
|
1876
1881
|
}
|
|
1877
1882
|
{ const sq = await sequenceHandoff("hand-off"); if (sq?.done) return sq.done; }
|
|
1878
1883
|
/** #4406: same-app duplicate exports (verify.duplicate_exports): one "rename or reuse" round, then an anomaly. True when a fix round ran. */
|
|
1884
|
+
let engineWhy = null; // the reason an amendment gets when the engine demanded the change (a blank builder why is not its fault)
|
|
1879
1885
|
async function duplicateGate(trigger) {
|
|
1880
1886
|
if (!io.duplicateExports || isSplit || vcfg.duplicateExports === false) return false;
|
|
1881
1887
|
const found = async () => (await io.duplicateExports({ cwd: wt, base }).catch(() => null)) ?? [];
|
|
@@ -1884,6 +1890,7 @@ export async function runCore(o) {
|
|
|
1884
1890
|
const text = dups.map((d) => `duplicate export \`${d.symbol}\`: ${d.path} and ${(d.others ?? []).join(", ")}`).join("\n");
|
|
1885
1891
|
ev({ event: "DUPLICATE_EXPORTS", trigger, count: dups.length });
|
|
1886
1892
|
T.decisions.push(`Duplicate export(s) (${trigger}): ${dups.slice(0, 5).map((d) => `${d.symbol} in ${d.path} vs ${(d.others ?? []).join(", ")}`).join("; ")}; builder resumed once`);
|
|
1893
|
+
engineWhy = `engine-demanded rename: duplicate export(s) ${dups.slice(0, 5).map((d) => d.symbol).join(", ")} (${trigger})`;
|
|
1887
1894
|
await fixRound("duplicate", duplicateExportsPrompt(dups, vcfg.mode), [], "duplicate", text);
|
|
1888
1895
|
result = (await readResult()) ?? result;
|
|
1889
1896
|
files = await changedFiles();
|
|
@@ -1975,7 +1982,7 @@ export async function runCore(o) {
|
|
|
1975
1982
|
async function ciVerify(trigger) {
|
|
1976
1983
|
let fixes = 0;
|
|
1977
1984
|
for (;;) {
|
|
1978
|
-
await untrackToolState({ git, base, ev }); // #4621: gitPush's untrack commit would move HEAD after `head` was read; do it first so head is the pushed tip
|
|
1985
|
+
await untrackToolState({ git, base, ev, keep: await keepPaths() }); // #4621: gitPush's untrack commit would move HEAD after `head` was read; do it first so head is the pushed tip
|
|
1979
1986
|
await signOffUnpushed(); // #4651: likewise the sign-off rewrite
|
|
1980
1987
|
const head = String(await git(["rev-parse", "HEAD"])).trim();
|
|
1981
1988
|
if (head === ciVerifiedHead || ciFellBack) return { fixes };
|
|
@@ -2081,10 +2088,11 @@ export async function runCore(o) {
|
|
|
2081
2088
|
genPaths ??= (hotFacts ? hotFacts.generated_paths : light ? [] : (await hotspotsOf({ paths: [] }))?.generated_paths) ?? [];
|
|
2082
2089
|
const wantsDecision = (Array.isArray(result?.amendments) ? result.amendments : []).some((x) => x?.kind === "decision");
|
|
2083
2090
|
baseTree ??= wantsDecision ? String(await git(["ls-tree", "-r", "--name-only", base]).catch(() => "")).split("\n").filter(Boolean) : null;
|
|
2091
|
+
const addedPaths = wantsDecision ? String(await git(["diff", "--name-only", "--diff-filter=A", `${base}...HEAD`]).catch(() => "")).split("\n").filter(Boolean) : [];
|
|
2084
2092
|
const own = [...(Array.isArray(result?.amendments) ? result.amendments : []), ...impliedDocs];
|
|
2085
2093
|
const ownKeys = new Set(own.map((x) => JSON.stringify(x)));
|
|
2086
2094
|
const all = [...priorAmendments.filter((x) => !ownKeys.has(JSON.stringify(x))), ...own];
|
|
2087
|
-
const a = applyAmendments(frozen, all, issueText, { generatedPaths: genPaths, baseFiles: baseTree?.length ? baseTree : null });
|
|
2095
|
+
const a = applyAmendments(frozen, all, issueText, { generatedPaths: genPaths, baseFiles: baseTree?.length ? baseTree : null, addedPaths, defaultWhy: engineWhy });
|
|
2088
2096
|
const sig = JSON.stringify(a.applied);
|
|
2089
2097
|
if (a.applied.length && sig !== lastAmend) {
|
|
2090
2098
|
lastAmend = sig;
|
|
@@ -2115,7 +2123,7 @@ export async function runCore(o) {
|
|
|
2115
2123
|
await renew();
|
|
2116
2124
|
const rr = await callRunner({ role: "reviewer", issue, cwd: wt, model: reviewerModel, resume: null, system: REVIEWER_SYSTEM, readOnly: true,
|
|
2117
2125
|
prompt: repair ? reviewerRepairPrompt({ missing: d.missing ?? [], problems: d.failures.map((f) => f.detail), rows: first })
|
|
2118
|
-
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE }) });
|
|
2126
|
+
: reviewerPrompt({ issue: iss, comments: trustedComments, contract: effective, result, files, diff, stat, base, structure: structureLine, ignore: IGNORE, investigationVerdict: investigationVerdict(await io.fs.read(`${wt}/.forge-lean/investigation.md`).catch(() => null)) }) });
|
|
2119
2127
|
await renew();
|
|
2120
2128
|
await attempt("review", ++reviewN, rr, null, "review");
|
|
2121
2129
|
const parsed = parseReview(rr?.text);
|
|
@@ -2173,7 +2181,6 @@ export async function runCore(o) {
|
|
|
2173
2181
|
}
|
|
2174
2182
|
const verifierSection = () => { const n = renderVerifierNote(verifierRun); return n ? `\n\n${n}` : ""; };
|
|
2175
2183
|
/** #4323: rerun once each validate command the builder recorded as failed, with the lane env; {[id]: {rc, output}} or null. */
|
|
2176
|
-
function rerunFailedValidates(contract) { return phaseLedger.during("validate", () => rerunFailedValidatesRaw(contract)); }
|
|
2177
2184
|
async function rerunFailedValidatesRaw(contract) {
|
|
2178
2185
|
if (!io.runCommand) return null;
|
|
2179
2186
|
const runs = Array.isArray(result?.validation) ? result.validation : [], out = {};
|
|
@@ -2195,6 +2202,20 @@ export async function runCore(o) {
|
|
|
2195
2202
|
if (Object.keys(out).length) ev({ event: "VALIDATE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
2196
2203
|
return out;
|
|
2197
2204
|
}
|
|
2205
|
+
/** #4979 SPEC-2: rerun the frozen command of every substituted validate, so precheck accepts a substitute only for a command that cannot run. */
|
|
2206
|
+
async function rerunSubstitutedOriginals(contract) {
|
|
2207
|
+
const out = {};
|
|
2208
|
+
if (!io.runCommand) return out;
|
|
2209
|
+
const env = laneEnv();
|
|
2210
|
+
for (const v of contract.validate ?? []) {
|
|
2211
|
+
if (!v.substituted?.from) continue;
|
|
2212
|
+
let x;
|
|
2213
|
+
try { x = await heavyStep("precheck", async () => (await runWithTimeoutRetry((timeoutMs) => io.runCommand({ command: String(v.substituted.from), cwd: wt, timeoutMs, env }), vcfg.precheckTimeoutSeconds * 1000)).r); } catch (e) { x = { rc: 127, output: String(e?.message ?? e) }; }
|
|
2214
|
+
out[v.id] = { rc: Number.isInteger(x?.rc) ? x.rc : 1, output: String(x?.output ?? "") };
|
|
2215
|
+
}
|
|
2216
|
+
if (Object.keys(out).length) ev({ event: "SUBSTITUTE_RERUN", runs: Object.entries(out).map(([id, x]) => ({ id, rc: x.rc })) });
|
|
2217
|
+
return out;
|
|
2218
|
+
}
|
|
2198
2219
|
/** #4693: select the guards this diff intersects and rerun their base test files; zero cost when none is selected. Best effort: an error skips the gate. */
|
|
2199
2220
|
let guardSel = [], guardNoted = ""; let guardEv = null; // #4705: this pass's guard_run event, emitted by the caller once the added wall time is known
|
|
2200
2221
|
async function guardGate(files) {
|
|
@@ -2204,7 +2225,7 @@ export async function runCore(o) {
|
|
|
2204
2225
|
const picked = guards.length ? await selectGuards({ guards, files, git: (a) => git(a), base }) : { selected: [], mergeBase: null };
|
|
2205
2226
|
sel = picked.selected; guardSel = sel;
|
|
2206
2227
|
if (!sel.length) return [];
|
|
2207
|
-
r = await runGuards({ selected: sel, mergeBase: picked.mergeBase, contract: effective, git: (a) => git(a), runCommand: (a) => io.runCommand(a), cwd: wt, env: laneEnv(), parallelism: Number(process.env.FORGE_HOST_PARALLELISM) || 2 });
|
|
2228
|
+
r = await runGuards({ selected: sel, mergeBase: picked.mergeBase, contract: effective, guardCommand: vcfg.guardCommand, git: (a) => git(a), runCommand: (a) => io.runCommand(a), cwd: wt, env: laneEnv(), parallelism: Number(process.env.FORGE_HOST_PARALLELISM) || 2 });
|
|
2208
2229
|
} catch (e) { T.anomalies.push(`Guard gate skipped: ${String(e?.message ?? e).split("\n")[0].slice(0, 160)}`); guardEv = { event: "guard_run", selected: sel.map((x) => x.guard.issue), ran: 0, broken: 0, skipped: 0, wall_ms: 0, error: true }; return []; }
|
|
2209
2230
|
const note = `Guard gate selected ${sel.map((x) => `#${x.guard.issue} (${x.reason})`).join("; ")}${r.skipped.length ? `; skipped: ${r.skipped.join("; ")}` : ""}${r.failures.length && shadow ? "; shadow: would block" : ""}`;
|
|
2210
2231
|
if (note !== guardNoted) { guardNoted = note; T.decisions.push(note); }
|
|
@@ -2235,15 +2256,18 @@ export async function runCore(o) {
|
|
|
2235
2256
|
const ciVerified = remoteVerify && ciVerifiedHead !== null && ciVerifiedHead === gateHead;
|
|
2236
2257
|
const hotspots = await hotspotsOf({ base, paths: files }); // #4502
|
|
2237
2258
|
const gateT0 = now(); let validateMs = 0;
|
|
2238
|
-
|
|
2259
|
+
// #4693: guards run beside the validate rerun; SPEC-11: the substituted-original rerun shares the one validate phase
|
|
2260
|
+
const validatePhase = phaseLedger.during("validate", async () => ({ runs: await rerunFailedValidatesRaw(effective), subs: await rerunSubstitutedOriginals(effective) })).finally(() => { validateMs = now() - gateT0; });
|
|
2261
|
+
const [{ runs: validateRuns, subs: substitutedRuns }, guardFailures] = await Promise.all([validatePhase, guardGate(files)]);
|
|
2239
2262
|
// #4705: only the part not hidden behind the validate rerun; 0 when the gate errored or skipped
|
|
2240
2263
|
const guardRunMs = guardEv && !guardEv.error ? Math.max(0, now() - gateT0 - validateMs) : 0;
|
|
2241
2264
|
guardAddedMs += guardRunMs;
|
|
2242
2265
|
if (guardEv) ev({ ...guardEv, added_wall_ms: guardRunMs });
|
|
2243
|
-
const repoHas = validateRuns && Object.keys(validateRuns).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
2266
|
+
const repoHas = (validateRuns && Object.keys(validateRuns).length) || Object.keys(substitutedRuns ?? {}).length ? repoTracks(String(await git(["ls-files"]).catch(() => "")).split("\n").map((x) => x.trim()).filter(Boolean)) : undefined;
|
|
2244
2267
|
// #4626: post-deploy check ids resolve against the lane base's checks file or this PR's
|
|
2245
2268
|
const checkIds = effective.requirements?.some((r) => r.disposition === "post-deploy") ? new Set(Object.keys(await checksAt(git, [base, "HEAD"], wt))) : null;
|
|
2246
|
-
const
|
|
2269
|
+
const removedPaths = (effective.validate ?? []).some((v) => v.substituted) ? await removedPathsOf(git, base) : []; // #4990
|
|
2270
|
+
const pc = precheck({ contract: effective, result, changedFiles: files, ignore: IGNORE, ciVerified, hotspots, repro: await reproOf(effective), validateRuns, repoHas, frozen, checkIds, guardFailures, newOffKeys: await newOffKeysOf(), substitutedRuns, removedPaths });
|
|
2247
2271
|
// #4350: a scope-check validate stale only by amended deliverables is judged by the engine's scope gate instead
|
|
2248
2272
|
if (pc.superseded?.length) {
|
|
2249
2273
|
ev({ event: "VALIDATE_SUPERSEDED", runs: pc.superseded });
|
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { parseVerify } from "./verify.mjs";
|
|
|
15
15
|
import { worktreeFingerprint } from "./precheck.mjs";
|
|
16
16
|
import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderStructure, renderContract, renderAmendments, parseAmendmentsComment, contractJson, parseContractComment, sanitizeMd, capComment, section, cell, NR, MAX_COMMENT_CHARS } from "./render.mjs";
|
|
17
17
|
import { orchestrate, parseDeps, formatTable, pickNext } from "./orchestrate.mjs";
|
|
18
|
-
import { REVIEWER_SYSTEM, REVIEW_RUBRIC, approxTokens, builderPlanPrompt, builderImplementPrompt, reviewerPrompt, decisionNote, hotspotPlanNote, hotspotBuildRule } from "./prompts.mjs";
|
|
18
|
+
import { REVIEWER_SYSTEM, REVIEW_RUBRIC, approxTokens as rawTokens, FORGEDOCK_CLI, builderPlanPrompt, builderImplementPrompt, reviewerPrompt, investigationVerdict, decisionNote, hotspotPlanNote, hotspotBuildRule } from "./prompts.mjs";
|
|
19
19
|
import { parseReview, unnamedEdges, structureNote, applyAmendments, validateContract, scopeText, DECISION_SOURCES } from "./conformance.mjs";
|
|
20
20
|
import { mapRecord } from "../engine/telemetry-seam.mjs";
|
|
21
21
|
import { readLog as readRepoLog } from "../engine/runlog.mjs";
|
|
@@ -30,6 +30,10 @@ import { readAdmission, readConformanceMode, readReviewIncomplete, readEnvConfig
|
|
|
30
30
|
import { prepareToolchain } from "./toolchain.mjs";
|
|
31
31
|
import { isExternalKill } from "./hostkill.mjs";
|
|
32
32
|
import { runFromCli, orchestrateFromCli, ENGINE_PATHS, readMaintenance } from "../engine-cli.mjs";
|
|
33
|
+
// #4969: prompt budgets are measured with the install root as a fixed placeholder, so they never depend on the checkout location
|
|
34
|
+
const INSTALL_ROOT = FORGEDOCK_CLI.replace(/[\\/]bin[\\/]forgedock\.mjs$/, "");
|
|
35
|
+
const INSTALL_PATH_RE = /\S*?[\\/](?=bin[\\/][\w.\\/-]+\.mjs)/g;
|
|
36
|
+
const approxTokens = (s) => rawTokens(String(s).replaceAll(INSTALL_ROOT, "<root>").replace(INSTALL_PATH_RE, "<root>/"));
|
|
33
37
|
// orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir.
|
|
34
38
|
process.env.FORGEDOCK_SCHED_DIR = tmpDir("lean-sched-");
|
|
35
39
|
|
|
@@ -573,6 +577,14 @@ describe("core work-on", () => {
|
|
|
573
577
|
assert.ok(w.comments.some((c) => /FORGE:DECOMPOSED/.test(c) && /size: 40 files, 4000 lines .* oversize yes/.test(c)));
|
|
574
578
|
});
|
|
575
579
|
|
|
580
|
+
it("#4857 a product-decision block over an unrunnable validate is untyped (one correction resume), a real one stays human", () => {
|
|
581
|
+
const u = classifyBlock({ summary: "V2 cannot run: poetry found no pyproject at the repo root", block: { kind: "human", why: "product-decision" } }, 1);
|
|
582
|
+
assert.equal(u.kind, "untyped");
|
|
583
|
+
assert.match(u.problem, /substitute-validate/);
|
|
584
|
+
assert.equal(classifyBlock({ summary: "needs a pricing decision", block: { kind: "human", why: "product-decision" } }, 1).kind, "human");
|
|
585
|
+
assert.equal(classifyBlock({ summary: "V1 needs the prod key", block: { kind: "human", why: "credentials" } }, 1).kind, "human");
|
|
586
|
+
});
|
|
587
|
+
|
|
576
588
|
it("#4385 split siblings that declare a shared hub file are serialised (chained, no duplicate transitive edge); disjoint or undeclared files stay parallel", () => {
|
|
577
589
|
const hub = ["src/domain/index.ts", "./src/domain/events.ts"];
|
|
578
590
|
const BIG = { files: 40, lines: 2000 }; // #4396: shared or undeclared files are admitted only over the size budget
|
|
@@ -1354,6 +1366,24 @@ describe("conformance shadow mode (the default)", () => {
|
|
|
1354
1366
|
assert.match(nhBody(w), /timed out \(exit 124\) even after one longer retry/);
|
|
1355
1367
|
}
|
|
1356
1368
|
});
|
|
1369
|
+
it("#4979 the engine reruns a substituted validate's frozen command: a runnable failing one blocks, an unrunnable one lets the substitute stand", async () => {
|
|
1370
|
+
const amendments = [{ kind: "substitute-validate", validate: "V1", command: "node --test test/foo2.test.js", why: "the frozen path does not exist" }];
|
|
1371
|
+
const validation = [{ id: "V1", command: "node --test test/foo2.test.js", exit: 0 }];
|
|
1372
|
+
const lane = (frozenOut) => {
|
|
1373
|
+
const w = world({ validation, amendments, resumeValidation: validation, resumeAmendments: amendments }), ran = [];
|
|
1374
|
+
w.io.runCommand = async (a) => { ran.push(a); return a.command === "node --test test/foo.test.js" ? frozenOut : { rc: 0, output: "" }; };
|
|
1375
|
+
return { w, ran };
|
|
1376
|
+
};
|
|
1377
|
+
const dir = tmpDir("subst-");
|
|
1378
|
+
const bad = lane({ rc: 1, output: "not ok 1 - foo handles null\nAssertionError: expected 1" });
|
|
1379
|
+
const rb = await run(bad.w, { conformance: "enforce", dir });
|
|
1380
|
+
assert.equal(rb.terminalReason, "needs-human"); assert.equal(bad.w.merges.length, 0);
|
|
1381
|
+
assert.match(nhBody(bad.w), /frozen command runs and fails \(exit 1\)/);
|
|
1382
|
+
assert.ok(bad.ran.some((a) => a.command === "node --test test/foo.test.js" && a.cwd === WT && a.env), "the frozen command is rerun in the worktree with the lane env");
|
|
1383
|
+
assert.deepEqual(readLog(dir, 7).filter((e) => e.event === "SUBSTITUTE_RERUN").map((e) => e.runs), [[{ id: "V1", rc: 1 }], [{ id: "V1", rc: 1 }]]);
|
|
1384
|
+
const good = lane({ rc: 1, output: "no tests ran: no test files matched test/foo.test.js" });
|
|
1385
|
+
assert.equal((await run(good.w, { conformance: "enforce" })).terminalReason, "merged");
|
|
1386
|
+
});
|
|
1357
1387
|
it("#4323 a rerun that fails for a repo reason stays conformance.unimplemented; one that passes merges", async () => {
|
|
1358
1388
|
const v = [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }];
|
|
1359
1389
|
const w = world({ validation: v, resumeValidation: v });
|
|
@@ -1784,7 +1814,7 @@ describe("hotspot prevention (forge#4195): plan facts, build rule, unexempted gr
|
|
|
1784
1814
|
const plan = builderPlanPrompt(7) + hotspotPlanNote(facts([HOT, { ...HOT, path: "a/b.py" }, { ...HOT, path: "c/d.py" }]));
|
|
1785
1815
|
// 1850: #4404 (verify out_of_band) and #4396 (split size rule) grew the plan prompt to ~1803 tokens on CI paths; 1900: #4589 (check timeout is not a block, one block-rule line). 2050: #4724 (deferred-row bucket rule).
|
|
1786
1816
|
assert.ok(approxTokens(plan) <= 2150, `plan prompt with hotspots is ${approxTokens(plan)} tokens`);
|
|
1787
|
-
assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1650); // 1600: #4591 (never hand-name migration files) and #4596 (i18n translate rule), one line each; 1650: #4726 (e2e rule)
|
|
1817
|
+
assert.ok(approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([]))) <= 1650, `implement prompt with the hotspot rule is ${approxTokens(builderImplementPrompt() + hotspotBuildRule(facts([])))} of 1650 tokens`); // 1600: #4591 (never hand-name migration files) and #4596 (i18n translate rule), one line each; 1650: #4726 (e2e rule)
|
|
1788
1818
|
});
|
|
1789
1819
|
|
|
1790
1820
|
it("#4519 generated_paths: both prompts name the globs (even with the gate off), and an amendment adding one blocks", async () => {
|
|
@@ -2220,7 +2250,7 @@ describe("comment templates", () => {
|
|
|
2220
2250
|
it("plan and implement prompts carry the stage templates and the contract schema within budget", () => {
|
|
2221
2251
|
const plan = builderPlanPrompt(7), impl = builderImplementPrompt();
|
|
2222
2252
|
assert.ok(approxTokens(plan) <= 2050, `plan prompt is ${approxTokens(plan)} tokens`); // 2050: #4769 (activation-row rule); 2000: #4730 (owner-step remainder); 1950: #4724 (deferred-row bucket rule)
|
|
2223
|
-
assert.ok(approxTokens(impl) <= 1650, `implement prompt is ${approxTokens(impl)} tokens`); // 1650: #4726 e2e rule; 1550: #4596 i18n translate rule
|
|
2253
|
+
assert.ok(approxTokens(impl) <= 1650, `implement prompt is ${approxTokens(impl)} of 1650 tokens`); // 1650: #4726 e2e rule; 1550: #4596 i18n translate rule
|
|
2224
2254
|
for (const h of ["### What Was Claimed", "### What We Found", "### Root Cause", "### Affected Files", "### Sibling Sweep", "### History", "### Recommendation", "### Related Issues",
|
|
2225
2255
|
"investigation.md", "contract.json", "PLANNING ONLY", '"requirements"', '"deliverables"', '"preserve"', '"validate"', "verbatim", "defer", "post-deploy"]) assert.ok(plan.includes(h), h);
|
|
2226
2256
|
for (const h of ["### Approach", "### Changes", "### Acceptance Criteria Status", "### Verification", "### Notes", "implementation.md", "result.json",
|
|
@@ -4144,7 +4174,7 @@ describe("batch robustness (#4112-#4115)", () => {
|
|
|
4144
4174
|
const prior = { body: `<!-- FORGE:AMENDMENT -->\n${renderAmendments([{ kind: "supersedes", target: "#4785", why: sup.why }], {}, { contract: contractHash(contractJson(CONTRACT)), amendments: [sup] })}`, author_association: "OWNER", user: { type: "User", login: "op" } };
|
|
4145
4175
|
const dir = tmpDir("guard-recover-");
|
|
4146
4176
|
const w = world({ prList: [], remote: "abc\trefs/heads/feat/issue-7-lean", issueComments: [frozenComment(), prior], baseFiles: { ":src/foo.js": "old" } });
|
|
4147
|
-
w.io.runCommand = async () => ({ rc: 1, output: "guard test failed" });
|
|
4177
|
+
w.io.runCommand = async (a) => ({ rc: String(a?.cwd).includes("guardbase-") ? 0 : 1, output: "guard test failed" }); // #4968: the base tree passes, so the lane tree is what broke
|
|
4148
4178
|
await run(w, { dir, worktreeRoot: dir, recover: { code: "ci.red", attempt: 1 }, loadGuards: async () => [mkGuard(4785), mkGuard(9)] });
|
|
4149
4179
|
const text = JSON.stringify([w.comments, w.calls.filter((c) => c[0] === "runner").map((c) => c[4])]);
|
|
4150
4180
|
assert.match(text, /conformance\.guard_broken.{0,400}?guard\\":\\"#9\\"/, "precheck reports #9 as guard_broken");
|
|
@@ -6780,7 +6810,8 @@ describe("#4395 lane profile", () => {
|
|
|
6780
6810
|
assert.match(p, /Issue independent read-only calls \(reads, greps, git log\/blame, gh\) in the same turn or one shell call\./);
|
|
6781
6811
|
assert.match(p, /Read each file's ranges once .* and keep notes; don't re-read it\./);
|
|
6782
6812
|
}
|
|
6783
|
-
assert.ok(approxTokens(builderImplementPrompt()) <= 1650
|
|
6813
|
+
assert.ok(approxTokens(builderImplementPrompt()) <= 1650, `implement prompt is ${approxTokens(builderImplementPrompt())} of 1650 tokens`);
|
|
6814
|
+
assert.ok(approxTokens(builderPlanPrompt(7)) <= 2050, `plan prompt is ${approxTokens(builderPlanPrompt(7))} of 2050 tokens`);
|
|
6784
6815
|
});
|
|
6785
6816
|
|
|
6786
6817
|
it("runCliBackendAsync reports the first stream turn's context as baselineTokens; makeLeanRunner drops it on resume", async () => {
|
|
@@ -7068,3 +7099,33 @@ describe("mutation check (engine.conformance_mutation, #4913)", () => {
|
|
|
7068
7099
|
assert.equal(r.code, "conformance.mutation_survived"); assert.equal(w.merges.length, 0);
|
|
7069
7100
|
});
|
|
7070
7101
|
});
|
|
7102
|
+
|
|
7103
|
+
describe("investigation verdict for the reviewer (#4959)", () => {
|
|
7104
|
+
it("investigationVerdict reads the Verdict line", () => {
|
|
7105
|
+
assert.equal(investigationVerdict("**Verdict**: validated\n**Confidence**: high"), "VALIDATED");
|
|
7106
|
+
assert.equal(investigationVerdict("**Verdict**: CONFIRMED"), "CONFIRMED");
|
|
7107
|
+
assert.equal(investigationVerdict("no verdict"), null);
|
|
7108
|
+
assert.equal(investigationVerdict(null), null);
|
|
7109
|
+
});
|
|
7110
|
+
it("reviewerPrompt carries the verdict block and rubric rule, and is unchanged without it", () => {
|
|
7111
|
+
const args = { issue: { number: 7, title: "t", body: "b" }, contract: {}, result: {}, files: [], diff: "x" };
|
|
7112
|
+
assert.ok(reviewerPrompt({ ...args, investigationVerdict: "VALIDATED" }).includes("<investigation_verdict>VALIDATED</investigation_verdict>"));
|
|
7113
|
+
assert.ok(!reviewerPrompt(args).includes("</investigation_verdict>"));
|
|
7114
|
+
assert.ok(reviewerPrompt({ ...args, investigationVerdict: "VALIDATED" }).includes("is not a failing row"));
|
|
7115
|
+
assert.ok(!REVIEW_RUBRIC.includes("is not a failing row"));
|
|
7116
|
+
});
|
|
7117
|
+
});
|
|
7118
|
+
|
|
7119
|
+
describe("prompt budgets (#4969)", () => {
|
|
7120
|
+
it("do not depend on the install path length", () => {
|
|
7121
|
+
const impl = builderImplementPrompt();
|
|
7122
|
+
assert.ok(impl.includes(INSTALL_ROOT), "the implement prompt names files under the install root");
|
|
7123
|
+
const deep = `/${"very-long-checkout-directory/".repeat(10)}forgedock`;
|
|
7124
|
+
assert.ok(deep.length >= 200);
|
|
7125
|
+
const moved = impl.replaceAll(INSTALL_ROOT, deep);
|
|
7126
|
+
assert.notEqual(moved, impl);
|
|
7127
|
+
assert.equal(approxTokens(moved), approxTokens(impl));
|
|
7128
|
+
assert.equal(approxTokens(impl.replaceAll(INSTALL_ROOT, "C:\\fd")), approxTokens(impl));
|
|
7129
|
+
assert.ok(approxTokens(moved) <= 1650, `implement prompt from a deep checkout is ${approxTokens(moved)} of 1650 tokens`);
|
|
7130
|
+
});
|
|
7131
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4965: the scheduler pass that adds epic and late children to a running batch.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { orchestrate } from "./orchestrate.mjs";
|
|
7
|
+
|
|
8
|
+
const merged = { terminalReason: "merged" };
|
|
9
|
+
const until = async (f, ms = 3000) => { const t = Date.now(); while (!f()) { if (Date.now() - t > ms) throw new Error("timeout"); await new Promise((r) => setTimeout(r, 5)); } };
|
|
10
|
+
|
|
11
|
+
/** A batch with epic #1 running behind a gate; `list` is what the one list call returns. */
|
|
12
|
+
async function run({ mode, list, pollS = 0.001, elsewhere = [], epics = [], bodies = {}, extra = {} }) {
|
|
13
|
+
const started = [], log = [], decisions = [];
|
|
14
|
+
let calls = 0, open;
|
|
15
|
+
const g = new Promise((r) => { open = r; });
|
|
16
|
+
const done = orchestrate({ issues: [1], parallel: 3, pollMs: 5, trusted: () => true, readBody: async (n) => bodies[n] ?? "", onUpdate: (r) => log.push(r), decide: (d) => decisions.push(d),
|
|
17
|
+
autoChildren: { mode, pollS, epics }, getChildren: async () => { calls++; return { list: typeof list === "function" ? list() : list, elsewhere }; },
|
|
18
|
+
runOne: async (n) => { started.push(n); if (n === 1) await g; return merged; }, ...extra });
|
|
19
|
+
return { started, log, decisions, calls: () => calls, open, done };
|
|
20
|
+
}
|
|
21
|
+
const L = (...kids) => [{ number: 1, body: "", labels: [] }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [] }))];
|
|
22
|
+
|
|
23
|
+
describe("#4965 auto_add_children", () => {
|
|
24
|
+
it("epic: adds open children of a batch issue, one decision and one added row listing them", async () => {
|
|
25
|
+
const r = await run({ mode: "epic", list: L(2, 3), bodies: { 3: "Blocked by #2" } });
|
|
26
|
+
await until(() => r.started.includes(2) && r.started.includes(3));
|
|
27
|
+
r.open(); await r.done;
|
|
28
|
+
const added = r.log.filter((x) => x.outcome === "added");
|
|
29
|
+
assert.equal(added.length, 1);
|
|
30
|
+
assert.match(added[0].detail, /#2, #3; edges #3 blocked by #2/);
|
|
31
|
+
assert.equal(r.decisions.filter((d) => /auto_add_children/.test(d.what)).length, 1);
|
|
32
|
+
assert.ok(r.started.indexOf(2) < r.started.indexOf(3));
|
|
33
|
+
});
|
|
34
|
+
it("decomposed (default) and off never call getChildren", async () => {
|
|
35
|
+
for (const mode of ["decomposed", "off"]) {
|
|
36
|
+
const r = await run({ mode, list: L(2) });
|
|
37
|
+
await new Promise((x) => setTimeout(x, 40));
|
|
38
|
+
r.open(); await r.done;
|
|
39
|
+
assert.equal(r.calls(), 0, mode);
|
|
40
|
+
assert.deepEqual(r.started, [1]);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
it("needs-human children and children live in another batch are not added", async () => {
|
|
44
|
+
const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] }];
|
|
45
|
+
const r = await run({ mode: "all", list, elsewhere: [3] });
|
|
46
|
+
await until(() => r.started.includes(2));
|
|
47
|
+
await new Promise((x) => setTimeout(x, 40));
|
|
48
|
+
r.open(); await r.done;
|
|
49
|
+
assert.deepEqual([...r.started].sort(), [1, 2]);
|
|
50
|
+
});
|
|
51
|
+
it("an --epic root outside the batch adds its children; late children join on a later pass", async () => {
|
|
52
|
+
let list = [{ number: 50, body: "", labels: [] }, { number: 51, body: "**Parent**: #50", labels: [] }];
|
|
53
|
+
const r = await run({ mode: "epic", epics: [50], list: () => list });
|
|
54
|
+
await until(() => r.started.includes(51));
|
|
55
|
+
list = [...list, { number: 52, body: "**Parent**: #50", labels: [] }];
|
|
56
|
+
await until(() => r.started.includes(52));
|
|
57
|
+
r.open(); await r.done;
|
|
58
|
+
assert.equal(r.log.filter((x) => x.outcome === "added").length, 2);
|
|
59
|
+
});
|
|
60
|
+
it("a failing child scan is reported once as a decision, retried, and the batch still runs", async () => {
|
|
61
|
+
let calls = 0;
|
|
62
|
+
const r = await run({ mode: "epic", list: L(), extra: { getChildren: async () => { calls++; throw new Error("Unknown JSON field"); } } });
|
|
63
|
+
await new Promise((x) => setTimeout(x, 60));
|
|
64
|
+
r.open(); await r.done;
|
|
65
|
+
assert.ok(calls >= 2, "retried");
|
|
66
|
+
assert.equal(r.decisions.filter((d) => /child scan failed/.test(d.what)).length, 1);
|
|
67
|
+
assert.deepEqual(r.started, [1]);
|
|
68
|
+
});
|
|
69
|
+
it("scans at most once per poll window: one list call per pass", async () => {
|
|
70
|
+
const r = await run({ mode: "epic", list: L(), pollS: 3600 });
|
|
71
|
+
await new Promise((x) => setTimeout(x, 80));
|
|
72
|
+
r.open(); await r.done;
|
|
73
|
+
assert.equal(r.calls(), 1);
|
|
74
|
+
});
|
|
75
|
+
it("a decomposed lane's sub-issues join in decomposed|epic|all, not in off", async () => {
|
|
76
|
+
for (const [mode, joins] of [["decomposed", true], ["epic", true], ["all", true], ["off", false]]) {
|
|
77
|
+
const started = [];
|
|
78
|
+
await orchestrate({ issues: [1], parallel: 3, pollMs: 5, readBody: async () => "", autoChildren: { mode, pollS: 3600 }, getChildren: async () => ({ list: [], elsewhere: [] }),
|
|
79
|
+
runOne: async (n) => { started.push(n); return n === 1 ? { terminalReason: "decomposed", subissues: [2] } : merged; } });
|
|
80
|
+
assert.equal(started.includes(2), joins, mode);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|