forgedock 1.30.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/outcome-codes.mjs +3 -2
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +338 -2
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +128 -44
- package/bin/lean/context.test.mjs +157 -5
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +48 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +712 -134
- package/bin/lean/lean.test.mjs +1626 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +104 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/lean/ledger.mjs
CHANGED
|
@@ -48,9 +48,11 @@ export function dockerLabels({ batch, lane, issue, expires } = {}) {
|
|
|
48
48
|
return [`forgedock.owner=1`, `forgedock.batch=${v(batch)}`, `forgedock.lane=${v(lane)}`, `forgedock.issue=${v(issue)}`, `forgedock.expires=${v(expires)}`];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
/** #4432: with a `repo`, the lane file is `<batch>/<owner>__<repo>__<lane>.json` (issue numbers, hence lane names, are per repo). */
|
|
52
|
+
export function makeLedger({ root = ledgerRoot(), batch = "adhoc", lane, owner, issue, repo = null, host = hostname(), pid = process.pid, now = () => Date.now(), ttlMs = LEDGER_TTL_MS, fs = nodeFs } = {}) {
|
|
52
53
|
const dir = join(root, safeSegment(batch));
|
|
53
|
-
const
|
|
54
|
+
const repoPrefix = repo ? `${String(repo).split("/").map(safeSegment).join("__")}__` : "";
|
|
55
|
+
const path = join(dir, `${repoPrefix}${safeSegment(lane ?? `lane-${issue}`)}.json`);
|
|
54
56
|
const read = () => { try { const j = JSON.parse(fs.readFileSync(path, "utf8")); return j && typeof j === "object" && Array.isArray(j.resources) ? j : null; } catch { return null; } };
|
|
55
57
|
const write = (j) => {
|
|
56
58
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -58,7 +60,7 @@ export function makeLedger({ root = ledgerRoot(), batch = "adhoc", lane, owner,
|
|
|
58
60
|
fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\n");
|
|
59
61
|
fs.renameSync(tmp, path);
|
|
60
62
|
};
|
|
61
|
-
const fresh = () => { const c = now(); return { v: 1, owner, batch: String(batch), lane: String(lane ?? `lane-${issue}`), issue, host, pid, created: c, expires: c + ttlMs, resources: [] }; };
|
|
63
|
+
const fresh = () => { const c = now(); return { v: 1, owner, batch: String(batch), lane: String(lane ?? `lane-${issue}`), issue, ...(repo ? { repo: String(repo) } : {}), host, pid, created: c, expires: c + ttlMs, resources: [] }; };
|
|
62
64
|
const same = (r, kind, value) => r.kind === kind && String(r.value) === String(value);
|
|
63
65
|
return {
|
|
64
66
|
path,
|
package/bin/lean/ledger.test.mjs
CHANGED
|
@@ -54,6 +54,24 @@ describe("lane resource ledger (#4179)", () => {
|
|
|
54
54
|
});
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
describe("repo-scoped lane files (#4432)", () => {
|
|
58
|
+
it("ledgers of two repos with the same lane are separate files", () => {
|
|
59
|
+
const root = tmpDir("ledger-");
|
|
60
|
+
const one = makeLedger({ root, batch: "adhoc", lane: "lean-2", owner: "core_1", issue: 2, repo: "acme/one" });
|
|
61
|
+
const two = makeLedger({ root, batch: "adhoc", lane: "lean-2", owner: "core_2", issue: 2, repo: "acme/two" });
|
|
62
|
+
assert.equal(one.path, join(root, "adhoc", "acme__one__lean-2.json"));
|
|
63
|
+
assert.notEqual(one.path, two.path);
|
|
64
|
+
one.register("worktree", "/one/.claude/worktrees/lean-2");
|
|
65
|
+
two.register("worktree", "/two/.claude/worktrees/lean-2");
|
|
66
|
+
one.release("worktree", "/one/.claude/worktrees/lean-2");
|
|
67
|
+
assert.deepEqual(two.entries().map((r) => r.value), ["/two/.claude/worktrees/lean-2"], "one repo's teardown leaves the other's resources");
|
|
68
|
+
assert.equal(JSON.parse(readFileSync(two.path, "utf8")).repo, "acme/two");
|
|
69
|
+
assert.equal(JSON.parse(readFileSync(two.path, "utf8")).lane, "lean-2", "the lane field is unchanged");
|
|
70
|
+
const legacy = makeLedger({ root, batch: "adhoc", lane: "lean-2", issue: 2 });
|
|
71
|
+
assert.equal(legacy.path, join(root, "adhoc", "lean-2.json"), "no repo: unchanged path");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
57
75
|
describe("live lane count (#4182)", () => {
|
|
58
76
|
it("counts only live, same-host, unexpired lane files across batches, and deletes nothing", async () => {
|
|
59
77
|
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Light-lane eligibility and upgrade triggers (#4448, parent #4442). Rules first: an issue is eligible for the light
|
|
5
|
+
* lane (one builder session, deterministic gates) when it has a concrete repro or expected behaviour, predicts 1..max_files
|
|
6
|
+
* non-test files, is size S, touches no risk path and is not an investigation, post-deploy or decision issue. A missing
|
|
7
|
+
* repro or file prediction is `ambiguous` (the caller's cheap classifier decides). During a light lane, any upgrade
|
|
8
|
+
* trigger (oversized diff, repro not red on base, two test failures, risk path, public-API-shape change from #4449)
|
|
9
|
+
* moves the lane to the full pipeline. forge.yaml `lean.light` {mode: off|shadow (default)|on, max_lines: 40,
|
|
10
|
+
* max_files: 2}: off is never eligible, shadow reports the verdict without changing the lane shape, on lets an eligible
|
|
11
|
+
* issue run light (lane wiring: #4450). Pure: no I/O, no model call.
|
|
12
|
+
*/
|
|
13
|
+
import { riskClassOf, riskPathHits } from "./review-trigger.mjs";
|
|
14
|
+
import { classifyPath } from "./routing.mjs";
|
|
15
|
+
import { laneSize } from "./budget.mjs";
|
|
16
|
+
import { isInvestigation } from "./conformance.mjs";
|
|
17
|
+
import { postDeployOnly } from "./prefilter.mjs";
|
|
18
|
+
|
|
19
|
+
export const LIGHT_MODES = Object.freeze(["off", "shadow", "on"]);
|
|
20
|
+
export const LIGHT_DEFAULTS = Object.freeze({ mode: "shadow", maxLines: 40, maxFiles: 2 });
|
|
21
|
+
|
|
22
|
+
const posInt = (v) => { const s = String(v ?? "").trim(); return /^\d+$/.test(s) && Number(s) > 0 ? Number(s) : null; };
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* forge.yaml lean section -> {mode, maxLines, maxFiles}. The one-level parser flattens a nested `light:` block to
|
|
26
|
+
* `lean.mode|max_lines|max_files`; flat `lean.light_mode|light_max_lines|light_max_files` also work and win.
|
|
27
|
+
* An unknown mode is shadow; a limit that is not a positive integer keeps its default.
|
|
28
|
+
*/
|
|
29
|
+
export function parseLightConfig(lean = {}) {
|
|
30
|
+
const l = lean && typeof lean === "object" ? lean : {};
|
|
31
|
+
const m = String(l.light_mode ?? l.mode ?? "").trim().toLowerCase();
|
|
32
|
+
return {
|
|
33
|
+
mode: LIGHT_MODES.includes(m) ? m : LIGHT_DEFAULTS.mode,
|
|
34
|
+
maxLines: posInt(l.light_max_lines) ?? posInt(l.max_lines) ?? LIGHT_DEFAULTS.maxLines,
|
|
35
|
+
maxFiles: posInt(l.light_max_files) ?? posInt(l.max_files) ?? LIGHT_DEFAULTS.maxFiles,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A parsed config, or the defaults merged with whatever valid fields `cfg` carries. */
|
|
40
|
+
const cfgOf = (cfg) => {
|
|
41
|
+
const c = cfg && typeof cfg === "object" ? cfg : {};
|
|
42
|
+
return {
|
|
43
|
+
mode: LIGHT_MODES.includes(c.mode) ? c.mode : LIGHT_DEFAULTS.mode,
|
|
44
|
+
maxLines: posInt(c.maxLines) ?? LIGHT_DEFAULTS.maxLines,
|
|
45
|
+
maxFiles: posInt(c.maxFiles) ?? LIGHT_DEFAULTS.maxFiles,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const uniquePaths = (xs) => [...new Set((Array.isArray(xs) ? xs : []).map((p) => String(p ?? "").trim().replace(/^\.\//, "")).filter(Boolean))].sort();
|
|
50
|
+
const labelNames = (labels) => (Array.isArray(labels) ? labels : []).map((l) => String(l?.name ?? l ?? "").trim()).filter(Boolean);
|
|
51
|
+
const sortReasons = (rs) => rs.sort((a, b) => a.code.localeCompare(b.code) || a.detail.localeCompare(b.detail));
|
|
52
|
+
|
|
53
|
+
const REPRO_RE = /steps to reproduce|\brepro(duction|duce)?\b|\bexpected[ _-]+(behaviou?r|result|output)\b|\bactual[ _-]+(behaviou?r|result|output)\b/i;
|
|
54
|
+
const FENCE_RE = /^\s*(```|~~~)/m;
|
|
55
|
+
const DECISION_LABEL_RE = /^(type[:/]\s*)?(decision|needs-decision|product-decision|rfc|adr)$/i;
|
|
56
|
+
const DECISION_TITLE_RE = /^\s*(decision|rfc|adr)[(:]/i;
|
|
57
|
+
const POST_DEPLOY_LABELS = new Set(["post-deploy", "awaiting-promotion"]);
|
|
58
|
+
|
|
59
|
+
/** Risk details for `files`: a built-in risk word or a declared review.risk_paths glob. */
|
|
60
|
+
function riskDetails(files, riskPaths) {
|
|
61
|
+
const declared = new Set(riskPathHits(files, riskPaths));
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const f of files) {
|
|
64
|
+
const cls = riskClassOf(f);
|
|
65
|
+
if (cls) out.push(`${f} (${cls})`);
|
|
66
|
+
else if (declared.has(f)) out.push(`${f} (review.risk_paths)`);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Light-lane eligibility of one issue: {mode, verdict, eligible, light, reasons:[{code, detail}]}.
|
|
73
|
+
* verdict: off | eligible | ineligible (a hard reason: files, size, risk_path, investigate, post_deploy, decision) |
|
|
74
|
+
* ambiguous (only soft reasons: no_repro, files_unknown). `light` is the lane shape: true only in mode on when eligible.
|
|
75
|
+
*/
|
|
76
|
+
export function lightEligibility({ title = "", body = "", labels = [], predictedFiles = [], riskPaths = [], cfg = {} } = {}) {
|
|
77
|
+
const c = cfgOf(cfg);
|
|
78
|
+
if (c.mode === "off") return { mode: "off", verdict: "off", eligible: false, light: false, reasons: [] };
|
|
79
|
+
const hard = [], soft = [];
|
|
80
|
+
const text = String(body ?? "").replace(/<!--[\s\S]*?-->/g, " ");
|
|
81
|
+
if (!FENCE_RE.test(text) && !REPRO_RE.test(text)) soft.push({ code: "no_repro", detail: "no repro, expected behaviour or code block in the body" });
|
|
82
|
+
|
|
83
|
+
const files = uniquePaths(predictedFiles);
|
|
84
|
+
const nonTest = files.filter((f) => classifyPath(f) !== "test");
|
|
85
|
+
if (nonTest.length > c.maxFiles) hard.push({ code: "files", detail: `${nonTest.length} predicted non-test files (max ${c.maxFiles})` });
|
|
86
|
+
else if (!nonTest.length) soft.push({ code: "files_unknown", detail: "no predicted non-test files" });
|
|
87
|
+
|
|
88
|
+
const names = labelNames(labels);
|
|
89
|
+
const size = laneSize({ labels: names });
|
|
90
|
+
if (size && size !== "S") hard.push({ code: "size", detail: `size:${size}` });
|
|
91
|
+
|
|
92
|
+
for (const d of riskDetails(files, riskPaths)) hard.push({ code: "risk_path", detail: d });
|
|
93
|
+
|
|
94
|
+
if (isInvestigation(title)) hard.push({ code: "investigate", detail: "investigate( issue" });
|
|
95
|
+
const pdLabel = names.find((n) => POST_DEPLOY_LABELS.has(n.toLowerCase()));
|
|
96
|
+
if (pdLabel) hard.push({ code: "post_deploy", detail: `label ${pdLabel}` });
|
|
97
|
+
else if (postDeployOnly(body)) hard.push({ code: "post_deploy", detail: "every checklist item is a post-deploy check" });
|
|
98
|
+
const decLabel = names.find((n) => DECISION_LABEL_RE.test(n));
|
|
99
|
+
if (decLabel) hard.push({ code: "decision", detail: `label ${decLabel}` });
|
|
100
|
+
else if (DECISION_TITLE_RE.test(String(title ?? ""))) hard.push({ code: "decision", detail: "decision title" });
|
|
101
|
+
|
|
102
|
+
const verdict = hard.length ? "ineligible" : soft.length ? "ambiguous" : "eligible";
|
|
103
|
+
const eligible = verdict === "eligible";
|
|
104
|
+
return { mode: c.mode, verdict, eligible, light: c.mode === "on" && eligible, reasons: sortReasons([...hard, ...soft]) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const lineCount = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : 0; };
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Upgrade triggers of a light lane: {upgrade, reasons:[{code, detail}]}. diff: {files:[{path, added, deleted}]}
|
|
111
|
+
* (git numstat; "-" binary counts 0). repro: {base, head} words of scripts/acceptance-red-green.sh. testFailures: count
|
|
112
|
+
* of failed targeted-test runs. apiShape: public-API-shape changes from #4449 (strings or {symbol, kind}).
|
|
113
|
+
* Codes: diff_lines, diff_files (non-test files only), repro_not_red, tests_failed_twice, risk_path (every path), api_shape.
|
|
114
|
+
*/
|
|
115
|
+
export function upgradeTriggers({ diff = null, repro = null, testFailures = 0, riskPaths = [], apiShape = [], cfg = {} } = {}) {
|
|
116
|
+
const c = cfgOf(cfg);
|
|
117
|
+
const reasons = [];
|
|
118
|
+
const rows = (Array.isArray(diff?.files) ? diff.files : []).filter((f) => f && String(f.path ?? "").trim());
|
|
119
|
+
const nonTest = rows.filter((f) => classifyPath(String(f.path).trim()) !== "test");
|
|
120
|
+
const lines = nonTest.reduce((n, f) => n + lineCount(f.added) + lineCount(f.deleted), 0);
|
|
121
|
+
if (lines > c.maxLines) reasons.push({ code: "diff_lines", detail: `${lines} non-test lines (max ${c.maxLines})` });
|
|
122
|
+
const nFiles = uniquePaths(nonTest.map((f) => f.path)).length;
|
|
123
|
+
if (nFiles > c.maxFiles) reasons.push({ code: "diff_files", detail: `${nFiles} non-test files (max ${c.maxFiles})` });
|
|
124
|
+
const base = repro && typeof repro === "object" ? String(repro.base ?? "") : "";
|
|
125
|
+
if (base !== "FAILED") reasons.push({ code: "repro_not_red", detail: base ? `repro on base: ${base}` : "no repro result" });
|
|
126
|
+
const fails = Number(testFailures);
|
|
127
|
+
if (Number.isFinite(fails) && fails >= 2) reasons.push({ code: "tests_failed_twice", detail: `${fails} failed test runs` });
|
|
128
|
+
for (const d of riskDetails(uniquePaths(rows.map((f) => f.path)), riskPaths)) reasons.push({ code: "risk_path", detail: d });
|
|
129
|
+
for (const a of Array.isArray(apiShape) ? apiShape : []) {
|
|
130
|
+
const d = typeof a === "string" ? a.trim() : a && typeof a === "object" ? [a.symbol, a.kind].filter(Boolean).map(String).join(": ") : "";
|
|
131
|
+
if (d) reasons.push({ code: "api_shape", detail: d });
|
|
132
|
+
}
|
|
133
|
+
sortReasons(reasons);
|
|
134
|
+
return { upgrade: reasons.length > 0, reasons };
|
|
135
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { LIGHT_MODES, LIGHT_DEFAULTS, parseLightConfig, lightEligibility, upgradeTriggers } from "./light.mjs";
|
|
9
|
+
import { readLight } from "../engine-cli.mjs";
|
|
10
|
+
|
|
11
|
+
const ON = { mode: "on", maxLines: 40, maxFiles: 2 };
|
|
12
|
+
const BODY = "## Steps to reproduce\n```js\nchunk([1, 2], 0)\n```\nExpected behaviour: a ValueError.";
|
|
13
|
+
/** A fully eligible issue; override one field to break one rule. */
|
|
14
|
+
const issue = (o = {}) => ({ title: "fix(chunk): reject n=0", body: BODY, labels: ["bug", "size:S"], predictedFiles: ["src/chunk.py", "tests/test_chunk.py"], riskPaths: [], cfg: ON, ...o });
|
|
15
|
+
const codes = (r) => r.reasons.map((x) => x.code);
|
|
16
|
+
const numstat = (...rows) => ({ files: rows.map(([path, added, deleted = 0]) => ({ path, added, deleted })) });
|
|
17
|
+
const RED = { base: "FAILED", head: "RAN" };
|
|
18
|
+
|
|
19
|
+
describe("parseLightConfig (#4448)", () => {
|
|
20
|
+
it("defaults to shadow, 40 lines, 2 files", () => {
|
|
21
|
+
assert.deepEqual(LIGHT_MODES, ["off", "shadow", "on"]);
|
|
22
|
+
assert.deepEqual(parseLightConfig(), LIGHT_DEFAULTS);
|
|
23
|
+
assert.deepEqual(parseLightConfig(null), { mode: "shadow", maxLines: 40, maxFiles: 2 });
|
|
24
|
+
});
|
|
25
|
+
it("reads the flattened nested keys; flat light_* keys win; junk keeps the default", () => {
|
|
26
|
+
assert.deepEqual(parseLightConfig({ mode: "ON", max_lines: "60", max_files: "3" }), { mode: "on", maxLines: 60, maxFiles: 3 });
|
|
27
|
+
assert.deepEqual(parseLightConfig({ mode: "on", light_mode: "off", max_lines: "60", light_max_lines: "80", light_max_files: "1" }), { mode: "off", maxLines: 80, maxFiles: 1 });
|
|
28
|
+
assert.deepEqual(parseLightConfig({ mode: "enforce", max_lines: "0", max_files: "-1" }), LIGHT_DEFAULTS);
|
|
29
|
+
assert.deepEqual(parseLightConfig({ light_max_lines: "4x" }), LIGHT_DEFAULTS);
|
|
30
|
+
});
|
|
31
|
+
it("readLight reads forge.yaml lean.light; absent forge.yaml is the defaults", () => {
|
|
32
|
+
const d = tmpDir("light-yaml-");
|
|
33
|
+
assert.deepEqual(readLight(d), LIGHT_DEFAULTS);
|
|
34
|
+
writeFileSync(join(d, "forge.yaml"), "lean:\n light:\n mode: on\n max_lines: 50\n max_files: 3\n");
|
|
35
|
+
assert.deepEqual(readLight(d), { mode: "on", maxLines: 50, maxFiles: 3 });
|
|
36
|
+
writeFileSync(join(d, "forge.yaml"), "lean:\n light_mode: off\n");
|
|
37
|
+
assert.equal(readLight(d).mode, "off");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("lightEligibility rules (#4448)", () => {
|
|
42
|
+
it("accepts a well-specified size:S issue with 1-2 predicted files", () => {
|
|
43
|
+
const r = lightEligibility(issue());
|
|
44
|
+
assert.deepEqual(r, { mode: "on", verdict: "eligible", eligible: true, light: true, reasons: [] });
|
|
45
|
+
});
|
|
46
|
+
it("repro or expected behaviour: present accepts, absent is ambiguous (never a model call)", () => {
|
|
47
|
+
for (const body of ["Repro: `x()` throws", "Expected result: 3", "```\nboom\n```", "actual output differs"]) assert.equal(lightEligibility(issue({ body })).verdict, "eligible", body);
|
|
48
|
+
const r = lightEligibility(issue({ body: "It is broken. <!-- steps to reproduce -->" }));
|
|
49
|
+
assert.equal(r.verdict, "ambiguous");
|
|
50
|
+
assert.equal(r.eligible, false);
|
|
51
|
+
assert.equal(r.light, false);
|
|
52
|
+
assert.deepEqual(codes(r), ["no_repro"]);
|
|
53
|
+
});
|
|
54
|
+
it("predicted files: 1 or 2 non-test files accept, 3 reject, tests do not count, none is ambiguous", () => {
|
|
55
|
+
assert.equal(lightEligibility(issue({ predictedFiles: ["src/a.py"] })).verdict, "eligible");
|
|
56
|
+
assert.equal(lightEligibility(issue({ predictedFiles: ["src/a.py", "src/b.py", "tests/test_a.py", "src/c.test.ts"] })).verdict, "eligible");
|
|
57
|
+
const r = lightEligibility(issue({ predictedFiles: ["src/a.py", "src/b.py", "src/c.py"] }));
|
|
58
|
+
assert.equal(r.verdict, "ineligible");
|
|
59
|
+
assert.deepEqual(codes(r), ["files"]);
|
|
60
|
+
assert.deepEqual(codes(lightEligibility(issue({ predictedFiles: [] }))), ["files_unknown"]);
|
|
61
|
+
assert.equal(lightEligibility(issue({ predictedFiles: ["tests/test_a.py"] })).verdict, "ambiguous");
|
|
62
|
+
assert.equal(lightEligibility(issue({ predictedFiles: ["src/a.py", "src/b.py", "src/c.py"], cfg: { ...ON, maxFiles: 3 } })).verdict, "eligible");
|
|
63
|
+
});
|
|
64
|
+
it("size: size:S accepts, size:M and size:L reject", () => {
|
|
65
|
+
assert.equal(lightEligibility(issue({ labels: [{ name: "size:S" }] })).verdict, "eligible");
|
|
66
|
+
assert.equal(lightEligibility(issue({ labels: [] })).verdict, "eligible");
|
|
67
|
+
for (const s of ["size:M", "size:L"]) {
|
|
68
|
+
const r = lightEligibility(issue({ labels: ["bug", s] }));
|
|
69
|
+
assert.equal(r.verdict, "ineligible", s);
|
|
70
|
+
assert.deepEqual(codes(r), ["size"]);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
it("risk path: a built-in risk word or a declared review.risk_paths glob rejects; a clean path accepts", () => {
|
|
74
|
+
const w = lightEligibility(issue({ predictedFiles: ["src/auth/login.ts"] }));
|
|
75
|
+
assert.deepEqual(codes(w), ["risk_path"]);
|
|
76
|
+
assert.match(w.reasons[0].detail, /src\/auth\/login\.ts \(auth\)/);
|
|
77
|
+
const g = lightEligibility(issue({ predictedFiles: ["src/detect/rules.ts"], riskPaths: "src/detect/**" }));
|
|
78
|
+
assert.deepEqual(codes(g), ["risk_path"]);
|
|
79
|
+
assert.match(g.reasons[0].detail, /review\.risk_paths/);
|
|
80
|
+
assert.equal(lightEligibility(issue({ predictedFiles: ["src/chunk.py"], riskPaths: ["src/detect/**"] })).verdict, "eligible");
|
|
81
|
+
});
|
|
82
|
+
it("kind: investigate(, post-deploy and decision issues reject; a plain bug accepts", () => {
|
|
83
|
+
assert.deepEqual(codes(lightEligibility(issue({ title: "investigate(lean): why lanes stall" }))), ["investigate"]);
|
|
84
|
+
assert.deepEqual(codes(lightEligibility(issue({ labels: ["awaiting-promotion"] }))), ["post_deploy"]);
|
|
85
|
+
assert.deepEqual(codes(lightEligibility(issue({ labels: ["post-deploy"] }))), ["post_deploy"]);
|
|
86
|
+
const pd = "Post-deploy checks.\n```\nx\n```\n- [ ] `R3: verify in prod` — post-deploy: needs a deploy\n";
|
|
87
|
+
assert.deepEqual(codes(lightEligibility(issue({ body: pd }))), ["post_deploy"]);
|
|
88
|
+
for (const labels of [["decision"], ["needs-decision"], ["type:decision"], ["product-decision"], ["RFC"], ["adr"]]) assert.deepEqual(codes(lightEligibility(issue({ labels }))), ["decision"], labels[0]);
|
|
89
|
+
for (const title of ["decision(lean): pick a queue", "RFC: light lanes", "adr(engine): leases"]) assert.deepEqual(codes(lightEligibility(issue({ title }))), ["decision"], title);
|
|
90
|
+
assert.equal(lightEligibility(issue({ title: "fix: decision table off by one", labels: ["bug"] })).verdict, "eligible");
|
|
91
|
+
});
|
|
92
|
+
it("hard reasons win over soft ones; reasons are sorted and deterministic", () => {
|
|
93
|
+
const o = issue({ body: "broken", labels: ["size:L", "decision"], predictedFiles: ["src/billing.ts", "a.py", "b.py"] });
|
|
94
|
+
const r = lightEligibility(o);
|
|
95
|
+
assert.equal(r.verdict, "ineligible");
|
|
96
|
+
assert.deepEqual(codes(r), ["decision", "files", "no_repro", "risk_path", "size"]);
|
|
97
|
+
assert.deepEqual(lightEligibility({ ...o, predictedFiles: [...o.predictedFiles].reverse() }), r);
|
|
98
|
+
});
|
|
99
|
+
it("tolerates missing and junk input", () => {
|
|
100
|
+
assert.equal(lightEligibility().verdict, "ambiguous");
|
|
101
|
+
assert.doesNotThrow(() => lightEligibility({ title: null, body: null, labels: "x", predictedFiles: "y", riskPaths: 3, cfg: null }));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("lightEligibility modes (#4448)", () => {
|
|
106
|
+
it("off never returns eligible, even for a fully eligible issue", () => {
|
|
107
|
+
assert.deepEqual(lightEligibility(issue({ cfg: { mode: "off" } })), { mode: "off", verdict: "off", eligible: false, light: false, reasons: [] });
|
|
108
|
+
});
|
|
109
|
+
it("shadow returns the verdict without changing the lane shape", () => {
|
|
110
|
+
const r = lightEligibility(issue({ cfg: { mode: "shadow" } }));
|
|
111
|
+
assert.equal(r.verdict, "eligible");
|
|
112
|
+
assert.equal(r.eligible, true);
|
|
113
|
+
assert.equal(r.light, false);
|
|
114
|
+
assert.equal(lightEligibility(issue({ cfg: {} })).mode, "shadow", "default mode");
|
|
115
|
+
assert.equal(lightEligibility(issue({ cfg: { mode: "shadow" }, labels: ["size:M"] })).verdict, "ineligible");
|
|
116
|
+
});
|
|
117
|
+
it("on runs an eligible issue light and never an ineligible one", () => {
|
|
118
|
+
assert.equal(lightEligibility(issue()).light, true);
|
|
119
|
+
assert.equal(lightEligibility(issue({ labels: ["size:M"] })).light, false);
|
|
120
|
+
assert.equal(lightEligibility(issue({ body: "broken" })).light, false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("upgradeTriggers (#4448)", () => {
|
|
125
|
+
const quiet = { diff: numstat(["src/chunk.py", 30, 10], ["tests/test_chunk.py", 200]), repro: RED, testFailures: 1, cfg: ON };
|
|
126
|
+
it("no trigger on a small red-green diff", () => {
|
|
127
|
+
assert.deepEqual(upgradeTriggers(quiet), { upgrade: false, reasons: [] });
|
|
128
|
+
});
|
|
129
|
+
it("oversized diff by lines: 41 non-test lines fire, 40 do not, test lines are ignored, max_lines is honoured", () => {
|
|
130
|
+
const r = upgradeTriggers({ ...quiet, diff: numstat(["src/chunk.py", 31, 10], ["tests/test_chunk.py", 500]) });
|
|
131
|
+
assert.equal(r.upgrade, true);
|
|
132
|
+
assert.deepEqual(codes(r), ["diff_lines"]);
|
|
133
|
+
assert.match(r.reasons[0].detail, /41 non-test lines \(max 40\)/);
|
|
134
|
+
assert.equal(upgradeTriggers({ ...quiet, diff: numstat(["src/a.py", 41], ["src/b.py", "-", "-"]), cfg: { ...ON, maxLines: 41 } }).upgrade, false);
|
|
135
|
+
});
|
|
136
|
+
it("oversized diff by files: 3 non-test files fire, 2 do not", () => {
|
|
137
|
+
assert.deepEqual(codes(upgradeTriggers({ ...quiet, diff: numstat(["src/a.py", 1], ["src/b.py", 1], ["src/c.py", 1]) })), ["diff_files"]);
|
|
138
|
+
assert.equal(upgradeTriggers({ ...quiet, diff: numstat(["src/a.py", 1], ["src/b.py", 1], ["tests/test_a.py", 1]) }).upgrade, false);
|
|
139
|
+
});
|
|
140
|
+
it("non-red repro: anything but FAILED on base fires, a missing repro included", () => {
|
|
141
|
+
for (const base of ["RAN", "RAN_NOTHING", "UNRUNNABLE", "SKIPPED"]) assert.deepEqual(codes(upgradeTriggers({ ...quiet, repro: { base, head: "RAN" } })), ["repro_not_red"], base);
|
|
142
|
+
assert.deepEqual(codes(upgradeTriggers({ ...quiet, repro: null })), ["repro_not_red"]);
|
|
143
|
+
assert.equal(upgradeTriggers({ ...quiet, repro: RED }).upgrade, false);
|
|
144
|
+
});
|
|
145
|
+
it("tests fail twice: 2 failures fire, 1 does not", () => {
|
|
146
|
+
assert.deepEqual(codes(upgradeTriggers({ ...quiet, testFailures: 2 })), ["tests_failed_twice"]);
|
|
147
|
+
assert.equal(upgradeTriggers({ ...quiet, testFailures: 1 }).upgrade, false);
|
|
148
|
+
});
|
|
149
|
+
it("risk path: a risk word or declared glob on any diff path fires, tests included; a clean diff does not", () => {
|
|
150
|
+
assert.deepEqual(codes(upgradeTriggers({ ...quiet, diff: numstat(["src/session.ts", 2]) })), ["risk_path"]);
|
|
151
|
+
assert.deepEqual(codes(upgradeTriggers({ ...quiet, diff: numstat(["src/a.py", 2], ["tests/test_payments.py", 2]) })), ["risk_path"]);
|
|
152
|
+
assert.deepEqual(codes(upgradeTriggers({ ...quiet, diff: numstat(["lib/detect.py", 2]), riskPaths: "lib/detect.py" })), ["risk_path"]);
|
|
153
|
+
assert.equal(upgradeTriggers({ ...quiet, riskPaths: ["lib/**"] }).upgrade, false);
|
|
154
|
+
});
|
|
155
|
+
it("public-API-shape changes (#4449) plug in as one more input", () => {
|
|
156
|
+
const r = upgradeTriggers({ ...quiet, apiShape: ["ichunked: generator -> function", { symbol: "chunk", kind: "signature" }, "", null] });
|
|
157
|
+
assert.deepEqual(r.reasons, [{ code: "api_shape", detail: "chunk: signature" }, { code: "api_shape", detail: "ichunked: generator -> function" }]);
|
|
158
|
+
});
|
|
159
|
+
it("reasons are sorted and deterministic; junk input does not throw", () => {
|
|
160
|
+
const o = { diff: numstat(["src/z.py", 30], ["src/auth.py", 30], ["src/m.py", 1]), repro: null, testFailures: 3, cfg: ON };
|
|
161
|
+
const r = upgradeTriggers(o);
|
|
162
|
+
assert.deepEqual(codes(r), ["diff_files", "diff_lines", "repro_not_red", "risk_path", "tests_failed_twice"]);
|
|
163
|
+
assert.deepEqual(upgradeTriggers({ ...o, diff: { files: [...o.diff.files].reverse() } }), r);
|
|
164
|
+
assert.doesNotThrow(() => upgradeTriggers({ diff: { files: [null, { path: "" }, { path: 3, added: "x" }] }, testFailures: "nan", apiShape: "x", cfg: null }));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("light.mjs purity (#4448)", () => {
|
|
169
|
+
it("imports no filesystem, process or network module", () => {
|
|
170
|
+
const src = readFileSync(new URL("./light.mjs", import.meta.url), "utf8");
|
|
171
|
+
for (const m of ["node:fs", "node:child_process", "node:net", "node:http", "node:https"]) assert.equal(src.includes(`"${m}"`), false, m);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -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
|
+
}
|