forgedock 1.31.0 → 1.32.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 +51 -9
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/lean/batch.test.mjs +44 -1
- package/bin/lean/context.mjs +12 -1
- package/bin/lean/context.test.mjs +18 -1
- package/bin/lean/effects.mjs +13 -2
- package/bin/lean/lean.mjs +67 -15
- package/bin/lean/lean.test.mjs +177 -1
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/orchestrate.mjs +45 -6
- package/bin/lean/precheck.test.mjs +5 -0
- package/bin/lean/prefilter.mjs +69 -8
- package/bin/lean/prefilter.test.mjs +109 -2
- package/bin/lean/prompts.mjs +3 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +48 -0
- package/bin/lean/schedd.test.mjs +21 -0
- package/bin/stats/efficiency.mjs +22 -6
- package/bin/stats/efficiency.test.mjs +35 -2
- package/bin/stats/index.mjs +25 -3
- package/bin/stats/local-source.mjs +59 -5
- package/bin/stats/query.mjs +133 -3
- package/bin/stats/stats.test.mjs +181 -5
- package/docs/CONFIG.md +3 -3
- package/docs/STATS.md +13 -0
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -81,7 +81,7 @@ function world(o = {}) {
|
|
|
81
81
|
if (k === "repo view") return `${o.defaultBranch ?? "main"}\n`;
|
|
82
82
|
if (k === "issue view" && a.includes("milestone")) { w.seq.push("lane"); return JSON.stringify({ milestone: o.milestone ? { title: o.milestone } : null }); }
|
|
83
83
|
if (k === "issue view" && o.issueBodies && a[2] === "7" && a.some((x) => String(x).startsWith("number,title,body,state,comments"))) { const b = o.issueBodies.length > 1 ? o.issueBodies.shift() : o.issueBodies[0]; return JSON.stringify({ number: 7, title: o.title ?? "foo breaks", body: b, state: "OPEN", comments: o.viewComments ?? [] }); }
|
|
84
|
-
if (k === "issue view") return JSON.stringify({ number: Number(a[2]), title: a[2] === "7" && o.title ? o.title : "foo breaks", body: ISSUE_BODY, state: w.issueStates?.[a[2]] ?? "OPEN", comments: [], ...(o.labels && a[2] === "7" ? { labels: o.labels } : {}) });
|
|
84
|
+
if (k === "issue view") return JSON.stringify({ number: Number(a[2]), title: a[2] === "7" && o.title ? o.title : "foo breaks", body: a[2] === "7" && o.body ? o.body : ISSUE_BODY, state: w.issueStates?.[a[2]] ?? "OPEN", comments: [], ...(o.labels && a[2] === "7" ? { labels: o.labels } : {}) });
|
|
85
85
|
if (k === "pr edit") { w.prEdits.push(a); return ""; }
|
|
86
86
|
if (k === "label create") { w.labelCreates = [...(w.labelCreates ?? []), a]; return ""; }
|
|
87
87
|
if (k === "issue comment" && w.commentErrors.length) throw new Error(w.commentErrors.shift());
|
|
@@ -4819,6 +4819,22 @@ describe("context pack modes (forge#4275)", () => {
|
|
|
4819
4819
|
assert.deepEqual([m.leaseWrites.at(-1).terminalCode, m.leaseWrites.at(-1).failureClasses], [OUTCOME_CODES.COMPLETED, []]);
|
|
4820
4820
|
});
|
|
4821
4821
|
|
|
4822
|
+
it("#4444 ab: the lane runs its issue's arm, logs it as context_pack and tags context_ab", async () => {
|
|
4823
|
+
const { contextArm } = await import("./context.mjs");
|
|
4824
|
+
const arm = contextArm({ mode: "ab", repo: "o/r", issue: 7 }).mode;
|
|
4825
|
+
const w = withPack(), dir = tmpDir("core-");
|
|
4826
|
+
const r = await run(w, { dir, contextPack: { mode: "ab" } });
|
|
4827
|
+
assert.equal(r.terminalReason, "merged");
|
|
4828
|
+
const plan = builders(w)[0][4];
|
|
4829
|
+
assert.equal(/Context pack \(hints, not proof/.test(plan), arm === "on");
|
|
4830
|
+
const log = readLog(dir, 7);
|
|
4831
|
+
const cp = log.find((e) => e.event === "CONTEXT_PACK"), term = log.find((e) => e.event === "RUN_TERMINAL");
|
|
4832
|
+
assert.deepEqual([cp.mode, cp.context_ab, term.context_pack, term.context_ab], [arm, true, arm, true]);
|
|
4833
|
+
const plain = withPack(), d2 = tmpDir("core-");
|
|
4834
|
+
await run(plain, { dir: d2, contextPack: { mode: "shadow" } });
|
|
4835
|
+
assert.equal("context_ab" in readLog(d2, 7).find((e) => e.event === "RUN_TERMINAL"), false);
|
|
4836
|
+
});
|
|
4837
|
+
|
|
4822
4838
|
it("forge.yaml engine.context_pack* is read flat; invalid mode is ignored", () => {
|
|
4823
4839
|
const d = tmpDir("ctxcfg-");
|
|
4824
4840
|
writeFileSync(join(d, "forge.yaml"), "engine:\n context_pack: on\n context_pack_sources: graph,attempts\n context_pack_bytes: 4096\n");
|
|
@@ -5835,3 +5851,163 @@ describe("#4395 lane profile", () => {
|
|
|
5835
5851
|
assert.equal(resumed.baselineTokens, null);
|
|
5836
5852
|
});
|
|
5837
5853
|
});
|
|
5854
|
+
|
|
5855
|
+
describe("#4450 light lane: one builder session, derived contract, upgrade to the full lane", () => {
|
|
5856
|
+
const RT = { path: "test/foo.test.js", id: "foo handles null", command: "node --test test/foo.test.js" };
|
|
5857
|
+
const BODY = "foo crashes on null input in src/foo.js.\n\nExpected behaviour: foo returns null.\n\n- foo must handle a null argument without throwing.\n- Existing callers of foo keep working.";
|
|
5858
|
+
const RESULT = { status: "fixed", summary: "fixed foo", requirement: "foo must handle a null argument without throwing.", repro_test: RT, tests: [{ command: RT.command, result: "pass", fails_without_fix: true }] };
|
|
5859
|
+
const lworld = (o = {}) => {
|
|
5860
|
+
const w = world({ labels: [{ name: "bug" }], body: BODY, result: RESULT, contract: { ...CONTRACT, repro_test: RT }, ...o });
|
|
5861
|
+
w.numstat = o.numstat ?? "5\t1\tsrc/foo.js\n20\t0\ttest/foo.test.js\n";
|
|
5862
|
+
w.rgCalls = []; w.ran = [];
|
|
5863
|
+
w.io.redGreen = async (x) => { w.rgCalls.push(x.command); return o.repro ?? { base: "FAILED", head: "RAN" }; };
|
|
5864
|
+
w.io.runCommand = async (a) => { w.ran.push(a.command); return { rc: 0, output: "" }; };
|
|
5865
|
+
w.io.apiShape = async () => o.api ?? { status: "none", changes: [], unknown: [] };
|
|
5866
|
+
return w;
|
|
5867
|
+
};
|
|
5868
|
+
const events = (dir, name) => readLog(dir, 7).filter((e) => e.event === name);
|
|
5869
|
+
const ON = { light: { mode: "on" } };
|
|
5870
|
+
|
|
5871
|
+
it("nothing triggers: exactly one builder session (no plan) plus the review; the derived contract is frozen and posted", async () => {
|
|
5872
|
+
const w = lworld(), dir = tmpDir("light-");
|
|
5873
|
+
const r = await run(w, { dir, ...ON });
|
|
5874
|
+
assert.equal(r.terminalReason, "merged");
|
|
5875
|
+
assert.equal(builders(w).length, 1, "one builder session");
|
|
5876
|
+
assert.ok(!/PLANNING ONLY/.test(builders(w)[0][4]));
|
|
5877
|
+
assert.match(builders(w)[0][4], /failing regression test first/);
|
|
5878
|
+
assert.equal(reviewers(w).length, 1, "plus the review call");
|
|
5879
|
+
const contract = w.comments.find((c) => c.includes("<!-- FORGE:CONTRACT -->"));
|
|
5880
|
+
assert.match(contract, /\| R1 \| foo must handle a null argument without throwing\. \| deliver \|/);
|
|
5881
|
+
assert.match(contract, /"repro_test"/);
|
|
5882
|
+
assert.equal(events(dir, "LIGHT_ELIGIBILITY")[0].verdict, "eligible");
|
|
5883
|
+
assert.equal(events(dir, "LIGHT_DONE").length, 1);
|
|
5884
|
+
assert.equal(events(dir, "LIGHT_UPGRADE").length, 0);
|
|
5885
|
+
assert.deepEqual(events(dir, "CONTRACT_FROZEN").map((e) => e.derived), ["light"]);
|
|
5886
|
+
assert.ok(w.ran.includes(RT.command), "the engine ran the targeted tests");
|
|
5887
|
+
assert.ok(w.rgCalls.length >= 1, "red/green repro gate ran");
|
|
5888
|
+
const lite = w.comments.filter((c) => c.includes("<!-- FORGE:LITE -->"));
|
|
5889
|
+
assert.equal(lite.length, 1, "#4452: one FORGE:LITE receipt");
|
|
5890
|
+
assert.match(lite[0], /\*\*Outcome:\*\* light/);
|
|
5891
|
+
assert.match(lite[0], /\*\*Diff:\*\* 2 file\(s\), \+25\/-1/);
|
|
5892
|
+
assert.match(lite[0], /\*\*Red\/green:\*\* base FAILED → head RAN/);
|
|
5893
|
+
assert.match(lite[0], /targeted tests: 1 command\(s\), passed/);
|
|
5894
|
+
assert.match(lite[0], /\*\*Cost:\*\* \$\d+\.\d{4} \(true per-session cost/);
|
|
5895
|
+
assert.match(lite[0], /\*\*Eligibility:\*\* eligible \(mode on\)/);
|
|
5896
|
+
assert.equal(typeof events(dir, "LIGHT_DONE")[0].cost_usd, "number");
|
|
5897
|
+
});
|
|
5898
|
+
|
|
5899
|
+
for (const [name, code, o] of [
|
|
5900
|
+
["oversized diff", "diff_lines", { numstat: "80\t3\tsrc/foo.js\n20\t0\ttest/foo.test.js\n" }],
|
|
5901
|
+
["non-red repro", "repro_not_red", { repro: { base: "PASSED", head: "RAN" } }],
|
|
5902
|
+
["API change", "api_shape", { api: { status: "changed", changes: [{ file: "src/foo.js", symbol: "foo", change: "sync-async", before: "def", after: "async def" }], unknown: [] } }],
|
|
5903
|
+
["risk path", "risk_path", { changed: ["src/auth/session.js", "test/foo.test.js"], numstat: "4\t0\tsrc/auth/session.js\n" }],
|
|
5904
|
+
]) {
|
|
5905
|
+
it(`${name}: upgrades to the full lane with the digest, keeping the light commits`, async () => {
|
|
5906
|
+
const w = lworld(o), dir = tmpDir("light-up-");
|
|
5907
|
+
await run(w, { dir, ...ON });
|
|
5908
|
+
const up = events(dir, "LIGHT_UPGRADE");
|
|
5909
|
+
assert.equal(up.length, 1);
|
|
5910
|
+
assert.ok(up[0].reasons.some((x) => x.code === code), JSON.stringify(up[0].reasons));
|
|
5911
|
+
const b = builders(w);
|
|
5912
|
+
assert.ok(b.length >= 3, "light session, then the full lane's plan and implement");
|
|
5913
|
+
assert.ok(!/PLANNING ONLY/.test(b[0][4]));
|
|
5914
|
+
assert.match(b[1][4], /PLANNING ONLY/);
|
|
5915
|
+
assert.match(b[1][4], /This is a rerun: the branch already holds commits/);
|
|
5916
|
+
assert.match(b[1][4], /Resume digest of the last run on this branch/);
|
|
5917
|
+
assert.match(b[1][4], new RegExp(`Light-lane upgrade[\\s\\S]*- ${code}:`));
|
|
5918
|
+
const later = w.calls.slice(w.calls.indexOf(b[0]), w.calls.indexOf(b[2])).filter((c) => c[0] === "git"); // light session -> full implement
|
|
5919
|
+
assert.ok(!later.some((c) => ["reset", "rebase", "clean"].includes(c[1]) || (c[1] === "checkout" && c.includes("-B")) || (c[1] === "branch" && c.includes("-D")) || (c[1] === "push" && c.some((x) => /^--force|^-f$/.test(String(x))))), "the light work is never discarded");
|
|
5920
|
+
assert.equal(events(dir, "LIGHT_DONE").length, 0);
|
|
5921
|
+
const lite = w.comments.filter((c) => c.includes("<!-- FORGE:LITE -->"));
|
|
5922
|
+
assert.equal(lite.length, 1, "#4452: the upgraded light lane still posts its receipt");
|
|
5923
|
+
assert.match(lite[0], new RegExp(`\\*\\*Upgrade reasons:\\*\\*[\\s\\S]*- ${code}:`));
|
|
5924
|
+
});
|
|
5925
|
+
}
|
|
5926
|
+
|
|
5927
|
+
it("#4453 the run result carries light, the eligibility verdict and the upgrade reason codes (for the three-way bench)", async () => {
|
|
5928
|
+
const lit = await run(lworld(), { dir: tmpDir("light-res-"), ...ON });
|
|
5929
|
+
assert.equal(lit.light, true); assert.equal(lit.light_eligibility, "eligible"); assert.ok(!("light_upgrade" in lit));
|
|
5930
|
+
const up = await run(lworld({ repro: { base: "PASSED", head: "RAN" } }), { dir: tmpDir("light-res-up-"), ...ON });
|
|
5931
|
+
assert.equal(up.light, false); assert.equal(up.light_eligibility, "eligible"); assert.ok(up.light_upgrade.includes("repro_not_red"), JSON.stringify(up.light_upgrade));
|
|
5932
|
+
const off = await run(lworld(), { dir: tmpDir("light-res-off-"), light: { mode: "off" } });
|
|
5933
|
+
assert.equal(off.light, false); assert.equal(off.light_eligibility, "off"); assert.ok(!("light_upgrade" in off));
|
|
5934
|
+
assert.equal(typeof off.cost_usd, "number"); assert.ok(Number.isFinite(off.wall_ms) && Number.isFinite(off.model_ms), "#4451 fields unchanged");
|
|
5935
|
+
});
|
|
5936
|
+
|
|
5937
|
+
it("shadow (the default) only reports the verdict: the lane keeps plan -> implement", async () => {
|
|
5938
|
+
const w = lworld(), dir = tmpDir("light-sh-");
|
|
5939
|
+
assert.equal((await run(w, { dir })).terminalReason, "merged");
|
|
5940
|
+
const e = events(dir, "LIGHT_ELIGIBILITY")[0];
|
|
5941
|
+
assert.equal(e.mode, "shadow"); assert.equal(e.verdict, "eligible"); assert.equal(e.light, false);
|
|
5942
|
+
assert.equal(builders(w).length, 2);
|
|
5943
|
+
assert.match(builders(w)[0][4], /PLANNING ONLY/);
|
|
5944
|
+
assert.ok(!/Light-lane upgrade/.test(builders(w)[0][4]));
|
|
5945
|
+
assert.ok(!w.comments.some((c) => c.includes("<!-- FORGE:LITE -->")), "#4452: no receipt outside a light lane");
|
|
5946
|
+
});
|
|
5947
|
+
|
|
5948
|
+
/** #4451: lworld plus counting fakes for the overhead the light lane skips; each call notes its position in w.calls */
|
|
5949
|
+
const overhead = (o = {}) => {
|
|
5950
|
+
const w = lworld(o);
|
|
5951
|
+
w.over = { pack: [], hot: [], structure: [] };
|
|
5952
|
+
w.io.contextFacts = async () => { w.over.pack.push(w.calls.length); return { sources: {}, items: [] }; };
|
|
5953
|
+
w.io.hotspots = async (x) => { w.over.hot.push(w.calls.length); return { enabled: true, files: [], base: x.base ?? null }; };
|
|
5954
|
+
w.io.structureDelta = async () => { w.over.structure.push(w.calls.length); return { package_edges_added: [], package_edges_removed: [], import_pairs: [] }; };
|
|
5955
|
+
return w;
|
|
5956
|
+
};
|
|
5957
|
+
it("#4451 a light lane that is not upgraded makes no context-pack, structure or hotspot calls", async () => {
|
|
5958
|
+
const w = overhead(), dir = tmpDir("light-over-");
|
|
5959
|
+
const r = await run(w, { dir, ...ON, contextPack: { mode: "on" } });
|
|
5960
|
+
assert.equal(r.terminalReason, "merged");
|
|
5961
|
+
assert.equal(events(dir, "LIGHT_DONE").length, 1);
|
|
5962
|
+
assert.deepEqual(w.over, { pack: [], hot: [], structure: [] });
|
|
5963
|
+
assert.equal(events(dir, "CONTEXT_PACK").length, 0);
|
|
5964
|
+
// only the merge-time record (local numstat, no structure-delta call) remains; no implement-time delta
|
|
5965
|
+
assert.deepEqual(events(dir, "STRUCTURE_DELTA").map((e) => e.phase), ["merge"]);
|
|
5966
|
+
assert.equal(events(dir, "RUN_TERMINAL")[0].light, true);
|
|
5967
|
+
});
|
|
5968
|
+
it("#4451 an upgraded light lane computes the hotspots and the context pack before the plan call and runs the structure delta", async () => {
|
|
5969
|
+
const w = overhead({ numstat: "80\t3\tsrc/foo.js\n20\t0\ttest/foo.test.js\n" }), dir = tmpDir("light-over-up-");
|
|
5970
|
+
await run(w, { dir, ...ON, contextPack: { mode: "on" } });
|
|
5971
|
+
assert.equal(events(dir, "LIGHT_UPGRADE").length, 1);
|
|
5972
|
+
const plan = w.calls.indexOf(builders(w)[1]);
|
|
5973
|
+
// each fake notes w.calls.length when called, i.e. the index the next call takes: <= plan means before the plan call
|
|
5974
|
+
assert.ok(w.over.hot.some((i) => i <= plan) && w.over.pack.some((i) => i <= plan), JSON.stringify({ plan, ...w.over }));
|
|
5975
|
+
assert.ok(w.over.hot.every((i) => i > w.calls.indexOf(builders(w)[0])), "not before the light session");
|
|
5976
|
+
assert.ok(w.over.structure.length >= 1);
|
|
5977
|
+
assert.equal(events(dir, "RUN_TERMINAL")[0].light, false);
|
|
5978
|
+
});
|
|
5979
|
+
it("#4451 a full lane (light off) keeps its context-pack, structure and hotspot calls", async () => {
|
|
5980
|
+
const w = overhead(), dir = tmpDir("light-over-off-");
|
|
5981
|
+
await run(w, { dir, light: { mode: "off" }, contextPack: { mode: "on" } });
|
|
5982
|
+
assert.ok(w.over.pack.length >= 1 && w.over.hot.length >= 1 && w.over.structure.length >= 1, JSON.stringify(w.over));
|
|
5983
|
+
});
|
|
5984
|
+
it("#4451 the light targeted tests run concurrently, never more than FORGE_HOST_PARALLELISM in flight", async () => {
|
|
5985
|
+
const cmds = ["node --test t1.js", "node --test t2.js", "node --test t3.js", "node --test t4.js"];
|
|
5986
|
+
const w = lworld({ result: { ...RESULT, tests: cmds.map((command) => ({ command, result: "pass" })) } }), dir = tmpDir("light-par-");
|
|
5987
|
+
let live = 0, max = 0;
|
|
5988
|
+
const seen = [];
|
|
5989
|
+
w.io.runCommand = async (a) => { live++; max = Math.max(max, live); seen.push(a.env?.FORGE_HOST_PARALLELISM); await new Promise((r) => setTimeout(r, 5)); live--; return { rc: 0, output: "" }; };
|
|
5990
|
+
const prev = process.env.FORGE_HOST_PARALLELISM;
|
|
5991
|
+
process.env.FORGE_HOST_PARALLELISM = "2";
|
|
5992
|
+
try { await run(w, { dir, ...ON }); } finally { if (prev === undefined) delete process.env.FORGE_HOST_PARALLELISM; else process.env.FORGE_HOST_PARALLELISM = prev; }
|
|
5993
|
+
assert.equal(events(dir, "LIGHT_DONE").length, 1);
|
|
5994
|
+
assert.equal(max, 2, "two in flight at once, capped at the host parallelism");
|
|
5995
|
+
assert.ok(seen.every((x) => x === undefined || x === "1"), `each command gets its worker share: ${seen}`);
|
|
5996
|
+
});
|
|
5997
|
+
it("#4451 lane telemetry records wall time and model time separately", async () => {
|
|
5998
|
+
const w = lworld(), dir = tmpDir("light-time-");
|
|
5999
|
+
const r = await run(w, { dir, ...ON });
|
|
6000
|
+
const model = events(dir, "PHASE_ATTEMPT_USAGE").reduce((n, e) => n + e.duration_ms, 0);
|
|
6001
|
+
assert.ok(model > 0);
|
|
6002
|
+
assert.equal(r.model_ms, model);
|
|
6003
|
+
assert.ok(Number.isFinite(r.wall_ms));
|
|
6004
|
+
const term = w.telemetry.find((t) => t.event === "RUN_TERMINAL");
|
|
6005
|
+
assert.equal(term.model_ms, model); assert.ok(Number.isFinite(term.wall_ms));
|
|
6006
|
+
});
|
|
6007
|
+
|
|
6008
|
+
it("a resumed lane never goes light, even in mode on", async () => {
|
|
6009
|
+
const w = lworld({ remote: "abc\trefs/heads/feat/issue-7-lean\n" }), dir = tmpDir("light-res-");
|
|
6010
|
+
await run(w, { dir, ...ON });
|
|
6011
|
+
assert.equal(events(dir, "LIGHT_DONE").length + events(dir, "LIGHT_UPGRADE").length, 0);
|
|
6012
|
+
});
|
|
6013
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Light-lane execution (#4450, parent #4442). An eligible issue (light.mjs, mode on) runs ONE builder session: failing
|
|
5
|
+
* test first, then the fix, then the targeted tests. No plan session, no pre-frozen contract: the contract record is
|
|
6
|
+
* derived afterwards from the diff and the test (deriveLightContract) and checked by the same validateContract as a
|
|
7
|
+
* planned one. The deterministic gates are kept (targeted tests, red/green repro, the API-shape detector of #4449);
|
|
8
|
+
* any upgrade trigger hands the lane to the full plan -> freeze -> implement path, with the light work kept on the
|
|
9
|
+
* branch and carried over as a digest (#4400). runLightStep takes every effect as a callback, so lean.mjs only wires it.
|
|
10
|
+
*/
|
|
11
|
+
import { classifyPath } from "./routing.mjs";
|
|
12
|
+
import { upgradeTriggers } from "./light.mjs";
|
|
13
|
+
import { inIssue } from "./conformance.mjs";
|
|
14
|
+
import { runChecks } from "./precheck.mjs";
|
|
15
|
+
import { LEAN_DIR, RESULT_FILE, IMPLEMENTATION_FILE, FORGEDOCK_CLI, hostRules } from "./prompts.mjs";
|
|
16
|
+
|
|
17
|
+
const UPGRADE_NOTE_MAX = 10;
|
|
18
|
+
const DETAIL_MAX = 200;
|
|
19
|
+
const one = (s, max = DETAIL_MAX) => {
|
|
20
|
+
const t = String(s ?? "").replace(/<!--|--!?>/g, " ").replace(/\s+/g, " ").trim().replace(/</g, "<");
|
|
21
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** The one builder session of a light lane: failing test, fix, targeted tests, result.json with what the contract is derived from. */
|
|
25
|
+
export function lightBuildPrompt(n, opts = {}) {
|
|
26
|
+
return `Resolve issue #${n} (read it with gh issue view ${n} --comments) in ONE session; this is a light lane for a small, well-specified issue. There is no plan step and no frozen contract: the engine derives the contract from your diff and your test.
|
|
27
|
+
|
|
28
|
+
${hostRules(opts)}
|
|
29
|
+
|
|
30
|
+
1. Write the failing regression test first and run it: it must fail on the current code for the reason the issue describes.
|
|
31
|
+
2. Make the smallest fix that makes it pass. Keep the public API shape unchanged (signatures, sync/async, generator/function, exported names); the engine checks it.
|
|
32
|
+
3. Run the targeted tests (the new test plus the existing tests of the files you changed), run the repo's formatter on the files you changed, and commit. Do not push or touch GitHub.
|
|
33
|
+
4. Run \`node ${FORGEDOCK_CLI} check\` in the worktree and fix until it passes.
|
|
34
|
+
|
|
35
|
+
When done and committed, write ${IMPLEMENTATION_FILE} (markdown, no HTML comments):
|
|
36
|
+
### Approach
|
|
37
|
+
### Changes (file: what)
|
|
38
|
+
### Verification (each test command, its result, and whether the new test fails without the fix)
|
|
39
|
+
|
|
40
|
+
Finally write ${RESULT_FILE}: {"status":"fixed","summary":"one line","requirement":"<one sentence of the issue, verbatim, that this fix delivers>","repro_test":{"path":"<test file>","id":"<test name>","command":"<exact command that runs only that test>"},"tests":[{"command":"<targeted test command>","result":"pass|fail: ...","fails_without_fix":true|false|"unknown"}]}
|
|
41
|
+
If the issue is invalid, already fixed, blocked, or bigger than a small fix (more than a couple of files, a design decision), do not grind: write {"status":"upgrade","summary":"<why>"} and stop. The engine then runs the full lane, keeping what you committed.`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** "-" (binary) and junk count 0. */
|
|
45
|
+
const count = (v) => (/^\d+$/.test(String(v ?? "")) ? Number(v) : 0);
|
|
46
|
+
/** A numstat path, rename forms resolved to the new path: `a => b`, `dir/{a => b}/f`. */
|
|
47
|
+
const newPath = (p) => {
|
|
48
|
+
const s = String(p ?? "").trim();
|
|
49
|
+
const brace = /^(.*)\{(.*) => (.*)\}(.*)$/.exec(s);
|
|
50
|
+
if (brace) return `${brace[1]}${brace[3]}${brace[4]}`.replace(/\/\/+/g, "/");
|
|
51
|
+
const arrow = s.indexOf(" => ");
|
|
52
|
+
return arrow >= 0 ? s.slice(arrow + 4) : s;
|
|
53
|
+
};
|
|
54
|
+
/** `git diff --numstat` output -> {files:[{path, added, deleted}]} for upgradeTriggers; the engine's own dir is skipped. */
|
|
55
|
+
export function parseNumstat(out) {
|
|
56
|
+
const files = [];
|
|
57
|
+
for (const line of String(out ?? "").split("\n")) {
|
|
58
|
+
const m = /^(\S+)\t(\S+)\t(.+)$/.exec(line.trim() ? line : "");
|
|
59
|
+
if (!m) continue;
|
|
60
|
+
const path = newPath(m[3]);
|
|
61
|
+
if (!path || path === LEAN_DIR || path.startsWith(`${LEAN_DIR}/`)) continue;
|
|
62
|
+
files.push({ path, added: count(m[1]), deleted: count(m[2]) });
|
|
63
|
+
}
|
|
64
|
+
return { files };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** detectApiShape output (apishape.mjs) -> upgradeTriggers apiShape strings; a missing or unreadable result is `unknown` (#4449). */
|
|
68
|
+
export function apiShapeReasons(r) {
|
|
69
|
+
if (!r || typeof r !== "object" || !["none", "changed", "unknown"].includes(r.status)) return ["api shape: no detector result (unknown)"];
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const c of Array.isArray(r.changes) ? r.changes : []) out.push(one(`${c?.file ?? "?"}: ${c?.symbol ?? "?"} ${c?.change ?? "changed"}`));
|
|
72
|
+
for (const u of Array.isArray(r.unknown) ? r.unknown : []) out.push(one(`${u?.file ?? "?"}: unknown${u?.lang ? ` (${u.lang})` : ""}${u?.reason ? `, ${u.reason}` : ""}`));
|
|
73
|
+
if (r.status !== "none" && !out.length) out.push(`api shape: ${r.status}`);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The contract record of a light lane, from the diff and the builder's test (pure). requirement: the builder's verbatim
|
|
79
|
+
* issue sentence, else the issue title; deliverables: the changed non-test files; validate: the repro command plus each
|
|
80
|
+
* targeted test command; repro_test: the builder's, else "none" with a reason. validateContract judges it like any other.
|
|
81
|
+
*/
|
|
82
|
+
export function deriveLightContract({ title = "", issueText = "", result = null, files = [] } = {}) {
|
|
83
|
+
const r = result && typeof result === "object" ? result : {};
|
|
84
|
+
const said = typeof r.requirement === "string" ? r.requirement.trim() : "";
|
|
85
|
+
const quote = said && inIssue(said, issueText) ? said : String(title ?? "").trim();
|
|
86
|
+
const paths = [...new Set((Array.isArray(files) ? files : []).map((f) => String(f ?? "").trim()).filter((f) => f && f !== LEAN_DIR && !f.startsWith(`${LEAN_DIR}/`)))];
|
|
87
|
+
const code = paths.filter((f) => classifyPath(f) !== "test");
|
|
88
|
+
const t = r.repro_test;
|
|
89
|
+
const repro = t && typeof t === "object" && !Array.isArray(t) && String(t.path ?? "").trim() && String(t.command ?? "").trim()
|
|
90
|
+
? { path: String(t.path).trim(), id: String(t.id ?? "").trim(), command: String(t.command).trim() } : null;
|
|
91
|
+
const commands = [...new Set([repro?.command, ...(Array.isArray(r.tests) ? r.tests : []).map((x) => (x && typeof x === "object" ? String(x.command ?? "").trim() : ""))].filter(Boolean))];
|
|
92
|
+
return {
|
|
93
|
+
requirements: [{ id: "R1", quote, disposition: "deliver" }],
|
|
94
|
+
deliverables: code.map((path, i) => ({ id: `D${i + 1}`, path, change: "light-lane fix (derived from the diff)", requirements: ["R1"] })),
|
|
95
|
+
validate: commands.map((command, i) => ({ id: `V${i + 1}`, command })),
|
|
96
|
+
...(repro ? { repro_test: repro } : { repro_test: "none", repro_test_reason: "the light-lane builder named no repro test" }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The full lane's prompt addition after an upgrade: why the light lane stopped (bounded, one line per reason). */
|
|
101
|
+
export function upgradeNote(reasons) {
|
|
102
|
+
const rs = (Array.isArray(reasons) ? reasons : []).filter((x) => x && x.code);
|
|
103
|
+
if (!rs.length) return "";
|
|
104
|
+
const lines = rs.slice(0, UPGRADE_NOTE_MAX).map((x) => `- ${one(x.code, 40)}: ${one(x.detail)}`);
|
|
105
|
+
if (rs.length > UPGRADE_NOTE_MAX) lines.push(`- … ${rs.length - UPGRADE_NOTE_MAX} more`);
|
|
106
|
+
return `\n\nLight-lane upgrade: a single light builder session already worked on this issue and its commits are on the branch. The engine moved the issue to the full lane because:\n${lines.join("\n")}\nKeep the committed work that fits the issue; plan and fix what these reasons show.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* #4451: the light targeted tests, concurrently through precheck.mjs runChecks (one heavy slot per running command, the
|
|
111
|
+
* worker cap `parallelism` divided among them) with no round budget: every command runs, none is deferred to CI.
|
|
112
|
+
* run({command, workers}) -> {rc, output}. Returns [{command, rc, output}] in input order.
|
|
113
|
+
*/
|
|
114
|
+
export async function runLightTests({ commands, parallelism = 1, run, acquire, now = () => Date.now() }) {
|
|
115
|
+
// the per-command limit stays far below the budget, so runChecks never sees a budget cut: a timeout (rc 124) stays a failure
|
|
116
|
+
const { results } = await runChecks({ commands, parallelism, budgetMs: Number.MAX_SAFE_INTEGER, perCommandMs: 2 ** 40, now, acquire, run: ({ command, workers }) => run({ command, workers }) });
|
|
117
|
+
return results.map(({ command, rc, output }) => ({ command, rc, output }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One light lane step. ctx callbacks (all async unless noted): build() runs the one builder session; readResult();
|
|
122
|
+
* checkpoint() commits uncommitted work; changedFiles(); numstat() -> `git diff --numstat` text; runTests(commands) ->
|
|
123
|
+
* [{command, rc, output}] or null (no runner); fixTests(failed) resumes the builder once; reproOf(contract) -> {base, head}
|
|
124
|
+
* or null; apiShape() -> detectApiShape output or null; validate(contract) -> {ok, problems} (sync). Plus title, issueText,
|
|
125
|
+
* riskPaths, cfg. Returns {upgrade:false, contract, files, repro, testFailures} or {upgrade:true, reasons, files, ...}, plus the
|
|
126
|
+
* gate inputs of the FORGE:LITE receipt (#4452): diff (parseNumstat), apiShape (apiShapeReasons), testCommands, testRuns.
|
|
127
|
+
* Never throws: an internal error is the upgrade reason light_error.
|
|
128
|
+
*/
|
|
129
|
+
export async function runLightStep(ctx) {
|
|
130
|
+
const up = (reasons, extra = {}) => ({ upgrade: true, reasons, contract: null, files: [], repro: null, testFailures: 0, diff: null, apiShape: null, testCommands: [], testRuns: null, ...extra });
|
|
131
|
+
try {
|
|
132
|
+
await ctx.build();
|
|
133
|
+
const result = await ctx.readResult();
|
|
134
|
+
await ctx.checkpoint();
|
|
135
|
+
const files = await ctx.changedFiles();
|
|
136
|
+
if (!files.length) return up([{ code: "no_commit", detail: `the light builder made no commit (status ${one(result?.status ?? "none", 40)})` }]);
|
|
137
|
+
if (result?.status !== "fixed") return up([{ code: "not_fixed", detail: `light builder status ${one(result?.status ?? "none", 40)}${result?.summary ? `: ${one(result.summary)}` : ""}` }], { files });
|
|
138
|
+
const contract = deriveLightContract({ title: ctx.title, issueText: ctx.issueText, result, files });
|
|
139
|
+
const vc = ctx.validate(contract);
|
|
140
|
+
if (!vc?.ok) return up([{ code: "contract_invalid", detail: `derived contract: ${one((vc?.problems ?? [])[0] ?? "invalid")}` }], { files });
|
|
141
|
+
|
|
142
|
+
let testFailures = 0;
|
|
143
|
+
const commands = contract.validate.map((v) => v.command);
|
|
144
|
+
let runs = await ctx.runTests(commands);
|
|
145
|
+
let failed = (runs ?? []).filter((x) => x?.rc !== 0);
|
|
146
|
+
if (failed.length) {
|
|
147
|
+
testFailures = 1;
|
|
148
|
+
await ctx.fixTests(failed);
|
|
149
|
+
runs = await ctx.runTests(commands);
|
|
150
|
+
failed = (runs ?? []).filter((x) => x?.rc !== 0);
|
|
151
|
+
if (failed.length) testFailures = 2;
|
|
152
|
+
}
|
|
153
|
+
const now = await ctx.changedFiles();
|
|
154
|
+
// #4451: the three deterministic gates are independent, so they run concurrently
|
|
155
|
+
const [repro, numstat, shape] = await Promise.all([ctx.reproOf(contract), ctx.numstat(), ctx.apiShape()]);
|
|
156
|
+
const diff = parseNumstat(numstat), apiShape = apiShapeReasons(shape);
|
|
157
|
+
const t = upgradeTriggers({ diff, repro, testFailures, riskPaths: ctx.riskPaths ?? [], apiShape, cfg: ctx.cfg ?? {} });
|
|
158
|
+
const latest = deriveLightContract({ title: ctx.title, issueText: ctx.issueText, result: (await ctx.readResult()) ?? result, files: now });
|
|
159
|
+
return { upgrade: t.upgrade, reasons: t.reasons, contract: t.upgrade ? null : (ctx.validate(latest)?.ok ? latest : contract), files: now, repro, testFailures,
|
|
160
|
+
diff, apiShape, testCommands: commands, testRuns: runs == null ? null : (runs ?? []).length };
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return up([{ code: "light_error", detail: one(e?.message ?? e) }]);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const usd = (x) => (typeof x === "number" && Number.isFinite(x) ? `$${x.toFixed(4)}` : "n/a");
|
|
167
|
+
const RECEIPT_ROWS_MAX = 10;
|
|
168
|
+
const bullets = (xs) => {
|
|
169
|
+
const lines = xs.slice(0, RECEIPT_ROWS_MAX).map((x) => ` - ${x}`);
|
|
170
|
+
if (xs.length > RECEIPT_ROWS_MAX) lines.push(` - … ${xs.length - RECEIPT_ROWS_MAX} more`);
|
|
171
|
+
return lines;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The FORGE:LITE receipt of a light lane (#4452, parent #4442), without the marker (pure): outcome, diff stat, red/green
|
|
176
|
+
* repro, gates (targeted tests, API shape, derived contract), cost and eligibility, plus the upgrade reasons when the lane
|
|
177
|
+
* moved to the full pipeline. `costUsd` is the lane's true cost so far: the sum of #4407 per-session deltas, never a
|
|
178
|
+
* running session total. `lr` is runLightStep's return; `eligibility` is lightEligibility's.
|
|
179
|
+
*/
|
|
180
|
+
export function renderLiteReceipt({ lr = {}, eligibility = null, costUsd = null, contractHash = null } = {}) {
|
|
181
|
+
const r = lr && typeof lr === "object" ? lr : {};
|
|
182
|
+
const upgraded = !!r.upgrade;
|
|
183
|
+
const rows = Array.isArray(r.diff?.files) ? r.diff.files : null;
|
|
184
|
+
const nonTest = rows ? rows.filter((f) => classifyPath(String(f.path ?? "")) !== "test") : [];
|
|
185
|
+
const add = (xs) => xs.reduce((n, f) => n + (Number(f.added) || 0), 0), del = (xs) => xs.reduce((n, f) => n + (Number(f.deleted) || 0), 0);
|
|
186
|
+
const diff = rows
|
|
187
|
+
? `${rows.length} file(s), +${add(rows)}/-${del(rows)} (non-test: ${nonTest.length} file(s), ${add(nonTest) + del(nonTest)} line(s))`
|
|
188
|
+
: `n/a${Array.isArray(r.files) && r.files.length ? ` (${r.files.length} changed file(s))` : ""}`;
|
|
189
|
+
const rp = r.repro && typeof r.repro === "object" ? r.repro : null;
|
|
190
|
+
const repro = rp ? `base ${one(rp.base || "?", 40)} → head ${one(rp.head || "?", 40)}${rp.base === "FAILED" ? " (red on base)" : " (not red on base)"}` : "n/a";
|
|
191
|
+
const cmds = Array.isArray(r.testCommands) ? r.testCommands : [];
|
|
192
|
+
const fails = Number(r.testFailures) || 0;
|
|
193
|
+
const tests = r.testRuns == null ? (cmds.length ? `${cmds.length} command(s), no runner` : "not run")
|
|
194
|
+
: `${cmds.length} command(s), ${fails ? (fails >= 2 ? "failed twice" : "failed once, passed after one fix") : "passed"}`;
|
|
195
|
+
const api = Array.isArray(r.apiShape) ? (r.apiShape.length ? `${r.apiShape.length} change(s)/unknown` : "unchanged") : "not checked";
|
|
196
|
+
const contract = upgraded ? "not derived (upgraded)" : contractHash ? `derived, valid (\`${one(contractHash, 20)}\`)` : "derived, valid";
|
|
197
|
+
const e = eligibility && typeof eligibility === "object" ? eligibility : {};
|
|
198
|
+
const eReasons = (Array.isArray(e.reasons) ? e.reasons : []).filter((x) => x && x.code).map((x) => `${one(x.code, 40)}: ${one(x.detail)}`);
|
|
199
|
+
const lines = [
|
|
200
|
+
`## Light lane ${upgraded ? "upgraded to the full lane" : "receipt"}`,
|
|
201
|
+
`**Outcome:** ${upgraded ? "upgraded" : "light"}`,
|
|
202
|
+
`**Diff:** ${diff}`,
|
|
203
|
+
`**Red/green:** ${repro}`,
|
|
204
|
+
"**Gates:**",
|
|
205
|
+
` - targeted tests: ${tests}`,
|
|
206
|
+
...bullets(cmds.map((c) => `\`${one(c, 120)}\``)).map((x) => ` ${x}`),
|
|
207
|
+
` - API shape: ${api}`,
|
|
208
|
+
...(Array.isArray(r.apiShape) ? bullets(r.apiShape.map((x) => one(x))).map((x) => ` ${x}`) : []),
|
|
209
|
+
` - contract: ${contract}`,
|
|
210
|
+
`**Cost:** ${usd(costUsd)} (true per-session cost, #4407 deltas)`,
|
|
211
|
+
`**Eligibility:** ${one(e.verdict ?? "unknown", 20)} (mode ${one(e.mode ?? "?", 10)})${e.verdict === "eligible" && !eReasons.length ? ": repro or expected behaviour, predicted files within limits, size S, no risk path" : ""}`,
|
|
212
|
+
...bullets(eReasons),
|
|
213
|
+
];
|
|
214
|
+
if (upgraded) {
|
|
215
|
+
const ups = (Array.isArray(r.reasons) ? r.reasons : []).filter((x) => x && x.code).map((x) => `${one(x.code, 40)}: ${one(x.detail)}`);
|
|
216
|
+
lines.push("**Upgrade reasons:**", ...bullets(ups.length ? ups : ["unknown"]));
|
|
217
|
+
}
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
}
|