forgedock 1.30.0 → 1.31.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 +362 -33
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- 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 +294 -1
- 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 +116 -43
- package/bin/lean/context.test.mjs +139 -4
- 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 +37 -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 +650 -124
- package/bin/lean/lean.test.mjs +1450 -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/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +261 -38
- 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 +113 -1
- package/bin/lean/prefilter.mjs +159 -5
- package/bin/lean/prefilter.test.mjs +152 -3
- 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 +101 -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.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -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 +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- 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 +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- 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/e2e.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* e2e merge gate (#4403): a lane that adds or changes e2e specs, or whose contract has `[type:e2e]` criteria, triggers
|
|
5
|
+
* the repo's e2e run (a PR label or a workflow dispatch) and waits on it before merge. Tiered CI skips e2e on lane PRs,
|
|
6
|
+
* so the required checks alone never run those specs. All GitHub effects go through the injected io.
|
|
7
|
+
*/
|
|
8
|
+
import { ciPollDelayMs } from "../engine.mjs";
|
|
9
|
+
import { matchesGlob, verifiableFiles, skipsCi, runCiVerify, collectFailedLogs, VERIFY_CHECK_NAME, DEFAULT_SKIP_PATHS, DEFAULT_GRACE_MS } from "./verify.mjs";
|
|
10
|
+
|
|
11
|
+
export const E2E_MODES = Object.freeze(["auto", "always", "never"]);
|
|
12
|
+
/** Common e2e layouts across ecosystems (Playwright, Cypress, Nest/Jest e2e, plain e2e/ trees). */
|
|
13
|
+
export const E2E_DEFAULT_PATHS = Object.freeze(["e2e/**", "**/e2e/**", "*.e2e.*", "*.e2e-spec.*", "cypress/**", "playwright/**"]);
|
|
14
|
+
export const NO_TRIGGER = "no e2e_label or e2e_workflow configured";
|
|
15
|
+
|
|
16
|
+
const str = (v) => (v === undefined || v === null ? "" : String(v).trim());
|
|
17
|
+
const list = (v) => (Array.isArray(v) ? v : str(v) ? str(v).split(",") : null)?.map((x) => String(x).trim()).filter(Boolean) ?? null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* forge.yaml `verify:` e2e keys → {mode, label, workflow, pathsInput, checks, paths, blocking}. mode defaults to auto
|
|
21
|
+
* (inert without a label or workflow), blocking to changed (only the lane's own specs block the merge).
|
|
22
|
+
*/
|
|
23
|
+
export function parseE2e(sec = {}) {
|
|
24
|
+
const s = sec && typeof sec === "object" ? sec : {};
|
|
25
|
+
const mode = E2E_MODES.includes(str(s.e2e)) ? str(s.e2e) : "auto";
|
|
26
|
+
const label = /^[A-Za-z0-9._:/ -]+$/.test(str(s.e2e_label)) ? str(s.e2e_label) : null;
|
|
27
|
+
const workflow = /^[A-Za-z0-9._/-]+$/.test(str(s.e2e_workflow)) ? str(s.e2e_workflow) : null;
|
|
28
|
+
const pathsInput = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(str(s.e2e_paths_input)) ? str(s.e2e_paths_input) : null;
|
|
29
|
+
const checks = list(s.e2e_checks) ?? [];
|
|
30
|
+
const paths = list(s.e2e_paths) ?? [...E2E_DEFAULT_PATHS];
|
|
31
|
+
const blocking = str(s.e2e_blocking) === "all" ? "all" : "changed";
|
|
32
|
+
return { mode, label, workflow, pathsInput, checks, paths, blocking };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Changed files (minus the engine's hand-off files) that are e2e specs per `cfg.paths`. */
|
|
36
|
+
export const e2eSpecs = (files, cfg = parseE2e({})) => verifiableFiles(files).filter((f) => (cfg.paths ?? []).some((g) => matchesGlob(f, g)));
|
|
37
|
+
/** True when a contract requirement quote carries a `[type:e2e]` criterion. */
|
|
38
|
+
export const contractWantsE2e = (contract) => (Array.isArray(contract?.requirements) ? contract.requirements : []).some((r) => /\[type:e2e\]/i.test(String(r?.quote ?? "")));
|
|
39
|
+
|
|
40
|
+
/** Whether this lane runs the e2e gate: {run, reason, specs}. */
|
|
41
|
+
export function e2eWanted({ cfg, files, contract } = {}) {
|
|
42
|
+
const c = cfg ?? parseE2e({});
|
|
43
|
+
const specs = e2eSpecs(files, c);
|
|
44
|
+
if (c.mode === "never") return { run: false, reason: "verify.e2e: never", specs };
|
|
45
|
+
if (!c.label && !c.workflow) return { run: false, reason: NO_TRIGGER, specs };
|
|
46
|
+
if (c.mode === "always") {
|
|
47
|
+
return skipsCi(files, DEFAULT_SKIP_PATHS) ? { run: false, reason: "docs-only change", specs } : { run: true, reason: "verify.e2e: always", specs };
|
|
48
|
+
}
|
|
49
|
+
if (specs.length) return { run: true, reason: `${specs.length} e2e spec(s) changed`, specs };
|
|
50
|
+
if (contractWantsE2e(contract)) return { run: true, reason: "contract has [type:e2e] criteria", specs };
|
|
51
|
+
return { run: false, reason: "no e2e spec changed and no [type:e2e] criteria", specs };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The failures attributable to the lane: every failure when it changed no spec; otherwise those whose log names one of
|
|
56
|
+
* its specs (path or basename), plus any whose log is empty (cannot be attributed: fail closed).
|
|
57
|
+
*/
|
|
58
|
+
export function ownFailures(failures, specs) {
|
|
59
|
+
const fs = failures ?? [];
|
|
60
|
+
if (!specs?.length) return [...fs];
|
|
61
|
+
const needles = [...new Set(specs.flatMap((s) => [String(s), String(s).split("/").at(-1)]))];
|
|
62
|
+
return fs.filter((f) => { const l = String(f?.log ?? ""); return !l.trim() || needles.some((n) => l.includes(n)); });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One e2e run of `head`: {result: pass|fail|timeout|unavailable, failures, runUrl, runId, detail}. Workflow path:
|
|
67
|
+
* dispatch `cfg.workflow` (runCiVerify). Label path: label the PR, then wait on the head's e2e check runs, where a
|
|
68
|
+
* skipped run (the unlabelled tier) counts as not run.
|
|
69
|
+
*/
|
|
70
|
+
export async function runE2eGate({ io, pr, branch, head, cfg, specs = [], changedFiles = [], ciMaxMinutes = 360, now = () => Date.now(), sleep, random = Math.random,
|
|
71
|
+
onPoll = async () => {}, exclude = [], graceMs = DEFAULT_GRACE_MS }) {
|
|
72
|
+
const c = cfg ?? parseE2e({});
|
|
73
|
+
if (c.workflow) {
|
|
74
|
+
const v = await runCiVerify({ io, branch, head, changedFiles: specs.length ? specs : changedFiles, verify: { workflow: c.workflow, pathsInput: c.pathsInput, skipPaths: [] },
|
|
75
|
+
ciMaxMinutes, now, sleep, random, onPoll, exclude, graceMs, push: async () => {} });
|
|
76
|
+
if (v.result === "skipped") return { ...v, result: "unavailable", detail: "no changed file to run e2e for" };
|
|
77
|
+
return v;
|
|
78
|
+
}
|
|
79
|
+
const unavailable = (detail) => ({ result: "unavailable", failures: [], runUrl: null, detail });
|
|
80
|
+
if (!c.label) return unavailable(NO_TRIGGER);
|
|
81
|
+
if (!io.addPrLabel || !io.commitChecks) return unavailable("no label or commit-checks seam");
|
|
82
|
+
try { await io.addPrLabel(pr, c.label); }
|
|
83
|
+
catch (e) { return unavailable(`label ${c.label} could not be added to PR #${pr}: ${String(e?.stderr ?? e?.message ?? e).split("\n")[0].slice(0, 200)}`); }
|
|
84
|
+
const startedAt = now();
|
|
85
|
+
const isE2e = (x) => (c.checks.length ? c.checks.includes(x?.name) : x?.name !== VERIFY_CHECK_NAME);
|
|
86
|
+
for (let i = 0; ; i++) {
|
|
87
|
+
let checks = [];
|
|
88
|
+
try { checks = ((await io.commitChecks(head)) ?? []).filter(isE2e); } catch { checks = []; }
|
|
89
|
+
const ran = checks.filter((x) => String(x?.conclusion ?? "").toLowerCase() !== "skipped");
|
|
90
|
+
const names = c.checks.length ? c.checks : [...new Set(checks.map((x) => x.name))];
|
|
91
|
+
const byName = names.map((n) => ran.filter((x) => x.name === n));
|
|
92
|
+
if (names.length && byName.every((rs) => rs.length && rs.every((x) => x.state !== "pending"))) {
|
|
93
|
+
const failed = byName.flatMap((rs) => rs.filter((x) => x.state === "failure"));
|
|
94
|
+
if (!failed.length) return { result: "pass", failures: [], runUrl: null, detail: `${names.length} e2e check(s) green` };
|
|
95
|
+
return { result: "fail", failures: await collectFailedLogs(io, failed), runUrl: failed[0].url ?? null, detail: `${failed.length} e2e check(s) failed: ${failed.map((x) => x.name).join(", ")}` };
|
|
96
|
+
}
|
|
97
|
+
if (!ran.length && now() - startedAt >= graceMs) return unavailable(`no e2e check ran for ${String(head).slice(0, 12)} within ${Math.round(graceMs / 60000)} min of labelling PR #${pr} ${c.label}`);
|
|
98
|
+
if (now() - startedAt >= ciMaxMinutes * 60000) return { result: "timeout", failures: [], runUrl: null, detail: `e2e still pending after ${ciMaxMinutes} min` };
|
|
99
|
+
await onPoll();
|
|
100
|
+
await sleep(ciPollDelayMs(i, random));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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 { parseE2e, e2eSpecs, contractWantsE2e, e2eWanted, ownFailures, runE2eGate, E2E_DEFAULT_PATHS, NO_TRIGGER } from "./e2e.mjs";
|
|
6
|
+
import { VERIFY_CHECK_NAME } from "./verify.mjs";
|
|
7
|
+
|
|
8
|
+
const HEAD = "abc123abc123abc123";
|
|
9
|
+
const RUN = "https://github.com/o/r/actions/runs/42/job/1";
|
|
10
|
+
/** Fake io + clock: `checks` is a list of successive commitChecks answers (the last repeats). */
|
|
11
|
+
function fake(o = {}) {
|
|
12
|
+
const f = { clock: 0, labels: [], dispatched: [], q: [...(o.checks ?? [])] };
|
|
13
|
+
f.io = {
|
|
14
|
+
addPrLabel: async (pr, l) => { if (o.labelError) throw new Error(o.labelError); f.labels.push([pr, l]); },
|
|
15
|
+
commitChecks: async () => (f.q.length > 1 ? f.q.shift() : f.q[0] ?? []),
|
|
16
|
+
failedLogs: async () => o.log ?? "FAIL e2e/a.spec.ts",
|
|
17
|
+
dispatchWorkflow: async (a) => { f.dispatched.push(a); },
|
|
18
|
+
workflowRuns: async () => [{ databaseId: 9, headSha: HEAD, status: "completed", conclusion: o.conclusion ?? "success", url: "https://github.com/o/r/actions/runs/9", name: "e2e" }],
|
|
19
|
+
};
|
|
20
|
+
f.args = (extra = {}) => ({ io: f.io, pr: 77, branch: "feat/issue-7-lean", head: HEAD, cfg: parseE2e({ e2e_label: "full-ci", e2e_checks: "e2e" }), specs: ["e2e/a.spec.ts"],
|
|
21
|
+
ciMaxMinutes: 30, now: () => f.clock, sleep: async (ms) => { f.clock += ms; }, random: () => 0.5, ...extra });
|
|
22
|
+
return f;
|
|
23
|
+
}
|
|
24
|
+
const chk = (state, conclusion = state === "pending" ? null : state, name = "e2e") => ({ name, state, conclusion, url: RUN });
|
|
25
|
+
|
|
26
|
+
describe("e2e config and trigger (#4403)", () => {
|
|
27
|
+
it("parseE2e defaults to auto/changed with the default paths and rejects invalid values", () => {
|
|
28
|
+
assert.deepEqual(parseE2e({}), { mode: "auto", label: null, workflow: null, pathsInput: null, checks: [], paths: [...E2E_DEFAULT_PATHS], blocking: "changed" });
|
|
29
|
+
const c = parseE2e({ e2e: "always", e2e_label: "full-ci", e2e_workflow: "e2e.yml", e2e_paths_input: "specs", e2e_checks: "e2e (1), e2e (2)", e2e_paths: "tests/e2e/**", e2e_blocking: "all" });
|
|
30
|
+
assert.deepEqual(c, { mode: "always", label: "full-ci", workflow: "e2e.yml", pathsInput: "specs", checks: ["e2e (1)", "e2e (2)"], paths: ["tests/e2e/**"], blocking: "all" });
|
|
31
|
+
assert.equal(parseE2e({ e2e: "sometimes" }).mode, "auto");
|
|
32
|
+
assert.equal(parseE2e({ e2e_workflow: "x; rm -rf /" }).workflow, null);
|
|
33
|
+
assert.equal(parseE2e({ e2e_label: "a$(b)" }).label, null);
|
|
34
|
+
});
|
|
35
|
+
it("e2eSpecs matches the default layouts and ignores .forge-lean files", () => {
|
|
36
|
+
const files = ["e2e/a.spec.ts", "apps/web/e2e/b.spec.ts", "src/x.e2e.test.ts", "test/app.e2e-spec.ts", "cypress/e2e/c.cy.ts", "playwright/d.ts", "src/a.ts", "src/a.spec.ts", ".forge-lean/e2e/x"];
|
|
37
|
+
assert.deepEqual(e2eSpecs(files), files.slice(0, 6));
|
|
38
|
+
});
|
|
39
|
+
it("e2eWanted: never never runs; no trigger never runs; auto runs on specs or [type:e2e]; always runs unless docs-only", () => {
|
|
40
|
+
const cfg = parseE2e({ e2e_label: "full-ci" });
|
|
41
|
+
const typed = { requirements: [{ quote: "Rosters render. [type:E2E]" }] };
|
|
42
|
+
assert.equal(e2eWanted({ cfg: { ...cfg, mode: "never" }, files: ["e2e/a.spec.ts"] }).run, false);
|
|
43
|
+
assert.deepEqual(e2eWanted({ cfg: parseE2e({}), files: ["e2e/a.spec.ts"] }), { run: false, reason: NO_TRIGGER, specs: ["e2e/a.spec.ts"] });
|
|
44
|
+
assert.equal(e2eWanted({ cfg, files: ["e2e/a.spec.ts"] }).run, true);
|
|
45
|
+
assert.equal(e2eWanted({ cfg, files: ["src/a.ts"] }).run, false);
|
|
46
|
+
assert.equal(contractWantsE2e(typed), true);
|
|
47
|
+
assert.equal(e2eWanted({ cfg, files: ["src/a.ts"], contract: typed }).run, true);
|
|
48
|
+
assert.equal(e2eWanted({ cfg: { ...cfg, mode: "always" }, files: ["src/a.ts"] }).run, true);
|
|
49
|
+
assert.equal(e2eWanted({ cfg: { ...cfg, mode: "always" }, files: ["README.md"] }).run, false);
|
|
50
|
+
});
|
|
51
|
+
it("ownFailures: by spec path or basename in the log; an empty log or no changed spec counts as the lane's", () => {
|
|
52
|
+
const fs = [{ job: "a", log: "FAIL e2e/a.spec.ts" }, { job: "b", log: "x/a.spec.ts:3 failed" }, { job: "c", log: "FAIL e2e/rosters.spec.ts" }, { job: "d", log: "" }];
|
|
53
|
+
assert.deepEqual(ownFailures(fs, ["e2e/a.spec.ts"]).map((f) => f.job), ["a", "b", "d"]);
|
|
54
|
+
assert.deepEqual(ownFailures(fs, []).map((f) => f.job), ["a", "b", "c", "d"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("runE2eGate (#4403)", () => {
|
|
59
|
+
it("label path: labels the PR; a skipped run is not a pass; waits for the real run", async () => {
|
|
60
|
+
const f = fake({ checks: [[chk("success", "skipped")], [chk("success", "skipped"), chk("pending")], [chk("success", "skipped"), chk("success")]] });
|
|
61
|
+
const r = await runE2eGate(f.args());
|
|
62
|
+
assert.equal(r.result, "pass"); assert.deepEqual(f.labels, [[77, "full-ci"]]);
|
|
63
|
+
assert.ok(f.clock > 0, "it waited");
|
|
64
|
+
});
|
|
65
|
+
it("label path: a red e2e run returns the failed log; other checks are ignored when e2e_checks is set", async () => {
|
|
66
|
+
const f = fake({ checks: [[chk("failure"), chk("failure", "failure", "lint")]], log: "FAIL e2e/a.spec.ts" });
|
|
67
|
+
const r = await runE2eGate(f.args());
|
|
68
|
+
assert.equal(r.result, "fail"); assert.deepEqual(r.failures.map((x) => x.job), ["e2e"]); assert.match(r.failures[0].log, /a\.spec\.ts/);
|
|
69
|
+
});
|
|
70
|
+
it("label path without e2e_checks waits on every check but the lane's own verify check", async () => {
|
|
71
|
+
const f = fake({ checks: [[chk("success", "success", "build"), chk("success", "skipped"), chk("success", "success", VERIFY_CHECK_NAME)], [chk("success", "success", "build"), chk("success")]] });
|
|
72
|
+
const r = await runE2eGate(f.args({ cfg: parseE2e({ e2e_label: "full-ci" }) }));
|
|
73
|
+
assert.equal(r.result, "pass"); assert.ok(f.clock > 0, "the skipped e2e kept it waiting");
|
|
74
|
+
});
|
|
75
|
+
it("label refused, or no e2e run within the grace period, is unavailable; slow e2e is a timeout", async () => {
|
|
76
|
+
assert.equal((await runE2eGate(fake({ labelError: "HTTP 422" }).args())).result, "unavailable");
|
|
77
|
+
assert.equal((await runE2eGate(fake({ checks: [[chk("success", "skipped")]] }).args())).result, "unavailable");
|
|
78
|
+
assert.equal((await runE2eGate(fake({ checks: [[chk("pending")]] }).args())).result, "timeout");
|
|
79
|
+
});
|
|
80
|
+
it("workflow path: dispatches the e2e workflow with the spec paths, wins over the label, judges the run of this head", async () => {
|
|
81
|
+
const f = fake({ conclusion: "failure" });
|
|
82
|
+
const r = await runE2eGate(f.args({ cfg: parseE2e({ e2e_label: "full-ci", e2e_workflow: "e2e.yml", e2e_paths_input: "specs" }) }));
|
|
83
|
+
assert.equal(r.result, "fail"); assert.equal(r.runId, 9); assert.deepEqual(f.labels, []);
|
|
84
|
+
assert.deepEqual(f.dispatched, [{ workflow: "e2e.yml", ref: "feat/issue-7-lean", inputs: { specs: "e2e/a.spec.ts" } }]);
|
|
85
|
+
});
|
|
86
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -15,8 +15,14 @@ import { makeLedger, countLiveLanes, ledgerRoot } from "./ledger.mjs";
|
|
|
15
15
|
import { makeDepCache } from "./depcache.mjs";
|
|
16
16
|
import { makeHeavyIo } from "./heavy.mjs";
|
|
17
17
|
import { gatherContextFacts } from "./context.mjs";
|
|
18
|
+
import { makePitfallStore } from "./pitfalls.mjs";
|
|
18
19
|
import { enumeratingTests, missingEntries } from "./enumerating.mjs";
|
|
19
|
-
import { sequenceCollisions, snapshotLane, verifyRenumber } from "./sequence.mjs";
|
|
20
|
+
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts } from "./sequence.mjs";
|
|
21
|
+
import { duplicateExports } from "./postmerge.mjs";
|
|
22
|
+
import { scopedChecks } from "./checks.mjs";
|
|
23
|
+
import { makeSequenceLeaseIo } from "./seqlease.mjs";
|
|
24
|
+
import { followupMap } from "./conformance.mjs";
|
|
25
|
+
import { toolStats } from "./toolstats.mjs";
|
|
20
26
|
|
|
21
27
|
const pexec = promisify(execFile);
|
|
22
28
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
@@ -55,15 +61,18 @@ export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
|
|
|
55
61
|
: ["--disallowedTools", "Read", "Grep", "Glob", "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Task"]);
|
|
56
62
|
|
|
57
63
|
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
|
|
58
|
-
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null }) => {
|
|
64
|
+
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
|
|
59
65
|
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
60
66
|
const caps = getClaudeCliCapabilities(cwd);
|
|
61
|
-
|
|
67
|
+
// #4391: custom subagents (the explorer) for the builder session; null leaves argv unchanged
|
|
68
|
+
const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : []), ...(agents ? ["--agents", JSON.stringify(agents)] : [])];
|
|
62
69
|
const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
63
|
-
args: [String(issue)], cwd, bin, model, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
70
|
+
args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
64
71
|
logger: { log() {} },
|
|
65
|
-
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
66
|
-
|
|
72
|
+
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
73
|
+
// #4395: a resumed session's first turn carries the prior history, so only a fresh session reports a baseline.
|
|
74
|
+
return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default",
|
|
75
|
+
baselineTokens: resume ? null : (r.baselineTokens ?? null), tools: toolStats(r.toolUses) }; // #4410: tool calls per turn, re-reads
|
|
67
76
|
};
|
|
68
77
|
}
|
|
69
78
|
|
|
@@ -240,22 +249,28 @@ export function makeLeanIo({ repo }) {
|
|
|
240
249
|
runCommand: runCheckCommand,
|
|
241
250
|
/** #4328: a read-only freeze-time probe of a validate command's tool or module (separate from runCommand). */
|
|
242
251
|
probeCommand: runCheckCommand,
|
|
252
|
+
/** #4393: freeze-time execution of a validate command on the base worktree (not read-only; the engine reverts what it changes). */
|
|
253
|
+
preflightRun: runCheckCommand,
|
|
243
254
|
/** #4167 verify-by-CI: `gh workflow run` on the lane branch; inputs become -f key=value. */
|
|
244
255
|
async dispatchWorkflow({ workflow, ref, inputs = {} }) {
|
|
245
256
|
await run("gh")(["workflow", "run", String(workflow), "-R", repo, "--ref", String(ref), ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])]);
|
|
246
257
|
},
|
|
247
|
-
/** Recent workflow_dispatch runs of `workflow` on `branch`, newest first: [{databaseId, headSha, status, conclusion, url, name}]. */
|
|
258
|
+
/** Recent workflow_dispatch runs of `workflow` on `branch`, newest first: [{databaseId, headSha, status, conclusion, url, name, attempt}]. */
|
|
248
259
|
async workflowRuns({ workflow, branch }) {
|
|
249
260
|
return JSON.parse(await run("gh")(["run", "list", "-R", repo, "--workflow", String(workflow), "--branch", String(branch), "--event", "workflow_dispatch",
|
|
250
|
-
"--limit", "10", "--json", "databaseId,headSha,status,conclusion,url,name"]) || "[]");
|
|
261
|
+
"--limit", "10", "--json", "databaseId,headSha,status,conclusion,url,name,attempt"]) || "[]");
|
|
251
262
|
},
|
|
252
|
-
/**
|
|
263
|
+
/** #4387: rerun the failed jobs of an Actions run once (`gh run rerun <id> --failed`); throws when it cannot start. */
|
|
264
|
+
async rerunFailedJobs(runId) {
|
|
265
|
+
await run("gh")(["run", "rerun", String(runId), "--failed", "-R", repo]);
|
|
266
|
+
},
|
|
267
|
+
/** Check runs on a commit (push-triggered CI): [{name, state: success|failure|pending, url, id, conclusion}], same mapping as checkStates; `id` orders attempts (#4387). */
|
|
253
268
|
async commitChecks(sha) {
|
|
254
|
-
const out = String(await run("gh")(["api", "--paginate", `repos/${repo}/commits/${sha}/check-runs`, "--jq", ".check_runs[] | {name, status, conclusion, url: .html_url} | @json"]));
|
|
269
|
+
const out = String(await run("gh")(["api", "--paginate", `repos/${repo}/commits/${sha}/check-runs`, "--jq", ".check_runs[] | {id, name, status, conclusion, url: .html_url} | @json"]));
|
|
255
270
|
return out.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)).map((c) => {
|
|
256
271
|
const v = String(c.status === "completed" ? c.conclusion ?? "" : "").toUpperCase();
|
|
257
272
|
const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(v) ? "failure" : "pending";
|
|
258
|
-
return { name: c.name, state, url: c.url ?? null };
|
|
273
|
+
return { name: c.name, state, url: c.url ?? null, id: c.id ?? null, conclusion: c.status === "completed" ? String(c.conclusion ?? "").toLowerCase() || null : null }; // #4387 id orders attempts; #4403 raw conclusion (skipped = not run)
|
|
259
274
|
});
|
|
260
275
|
},
|
|
261
276
|
/**
|
|
@@ -280,9 +295,7 @@ export function makeLeanIo({ repo }) {
|
|
|
280
295
|
async fileFollowups({ parent, items }) {
|
|
281
296
|
const tsv = items.map((i) => `${i.key}\t${i.why}\t`).join("\n") + "\n";
|
|
282
297
|
const out = await runWithInput("bash", [script("file-deferred-followups.sh"), "--parent", String(parent), "--repo", repo], tsv, 180000);
|
|
283
|
-
|
|
284
|
-
for (const l of out.split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) map[k] = Number(n); }
|
|
285
|
-
return map;
|
|
298
|
+
return followupMap(out, items);
|
|
286
299
|
},
|
|
287
300
|
/**
|
|
288
301
|
* forge#4163: forge.yaml env.setup, once per fresh lane worktree: `bash -c <command>` in cwd with the lane env.
|
|
@@ -303,6 +316,8 @@ export function makeLeanIo({ repo }) {
|
|
|
303
316
|
async structureDelta({ cwd, base }) {
|
|
304
317
|
try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
|
|
305
318
|
},
|
|
319
|
+
/** #4375: the repo's learned-pitfall ledger, local only (~/.forge/pitfalls, FORGE_PITFALLS_DIR). */
|
|
320
|
+
pitfalls: makePitfallStore(),
|
|
306
321
|
/** forge#4275: the context pack's facts for one issue (bin/lean/context.mjs), from gh and git only; sources fail independently. */
|
|
307
322
|
contextFacts: (args) => gatherContextFacts({ ...args, io: { gh: run("gh"), git: run("git"), exists: async (p) => existsSync(p) } }),
|
|
308
323
|
/** forge#4195: scripts/hotspots.sh facts for `paths` in cwd (growth since base when given); null on any failure. */
|
|
@@ -316,7 +331,14 @@ export function makeLeanIo({ repo }) {
|
|
|
316
331
|
/** #4322: sequence-numbered files the lane shares with the base (bin/lean/sequence.mjs); snapshot before, verify after a renumber. */
|
|
317
332
|
sequenceCheck: ({ cwd, base }) => sequenceCollisions({ git: (a) => run("git")(a, { cwd }), base }),
|
|
318
333
|
sequenceSnapshot: ({ cwd, base }) => snapshotLane({ git: (a) => run("git")(a, { cwd }), base }),
|
|
319
|
-
sequenceVerify: ({ cwd, base, before }) => verifyRenumber({ git: (a) => run("git")(a, { cwd }), base, before }),
|
|
334
|
+
sequenceVerify: ({ cwd, base, before, moves }) => verifyRenumber({ git: (a) => run("git")(a, { cwd }), base, before, moves }),
|
|
335
|
+
/** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
|
|
336
|
+
sequenceRenumber: ({ cwd, base, laneRef, regenerate }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate }),
|
|
337
|
+
sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
|
|
338
|
+
sequenceLease: makeSequenceLeaseIo(),
|
|
339
|
+
/** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
|
|
340
|
+
postMergeChecks: async ({ cwd, base }) => { try { return scopedChecks({ repo: cwd, base }); } catch { return []; } },
|
|
341
|
+
duplicateExports: ({ cwd, base }) => duplicateExports({ git: (a) => run("git")(a, { cwd }), cwd, base }),
|
|
320
342
|
/**
|
|
321
343
|
* #4271: scripts/acceptance-red-green.sh for the frozen repro_test in cwd; {base, head} words, or null on any failure.
|
|
322
344
|
* `env` (#4325): the lane env (run TMPDIR, toolchain), merged over process.env, so the command sees what the builder saw.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* Explorer subagent (#4391): the builder does its discovery (greps, reads, call chains) through a cheap custom subagent
|
|
5
|
+
* (`claude --agents`) in a throwaway context that returns a bounded file/line digest, so the expensive main session keeps
|
|
6
|
+
* the conclusion instead of re-reading every discovered byte on each later turn. Opt-in via forge.yaml `lean.explore: on`
|
|
7
|
+
* (default off: builder argv and prompts are unchanged); RUN_TERMINAL carries `explore` so the on/off A/B is a query.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const EXPLORER_NAME = "explorer";
|
|
11
|
+
export const DEFAULT_EXPLORE_MODEL = "haiku";
|
|
12
|
+
const MODEL_RE = /^[A-Za-z0-9._:[\]-]+$/;
|
|
13
|
+
|
|
14
|
+
/** {mode, model} from forge.yaml lean.explore / lean.explore_model; anything but "on" is off, an invalid model is the default. */
|
|
15
|
+
export function parseExploreConfig(raw = {}) {
|
|
16
|
+
const mode = String(raw?.mode ?? "").trim().toLowerCase() === "on" ? "on" : "off";
|
|
17
|
+
const m = String(raw?.model ?? "").trim();
|
|
18
|
+
return { mode, model: MODEL_RE.test(m) ? m : DEFAULT_EXPLORE_MODEL };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const EXPLORER_PROMPT = `You explore a code repository for a developer who will edit it. You are read-only: never edit or write files, never commit, push or change GitHub state. Use Bash only for read commands (git log/show/diff/grep, ls, gh ... view).
|
|
22
|
+
|
|
23
|
+
Answer the question you were given with ONE digest of at most 60 lines (about 6 KB). Every claim carries path:line; never paste whole files or long excerpts. Use exactly these sections, writing "none" for an empty one:
|
|
24
|
+
### Files (path:start-end: why it is relevant)
|
|
25
|
+
### Call sites (path:line: caller -> callee)
|
|
26
|
+
### Tests to extend (test path and test name, and the exact command that runs only that test)
|
|
27
|
+
### Conventions to match (naming, error handling, helpers to reuse, each with a path:line example)
|
|
28
|
+
### Open questions (what you could not determine, and where to look)`;
|
|
29
|
+
|
|
30
|
+
/** The `--agents` object for the builder session, or null when off (argv unchanged). */
|
|
31
|
+
export function explorerAgents(cfg) {
|
|
32
|
+
if (cfg?.mode !== "on") return null;
|
|
33
|
+
return { [EXPLORER_NAME]: { description: "Read-only code explorer: answers one focused question about the repository with a bounded file/line digest (files with line ranges, call sites, tests to extend, conventions).",
|
|
34
|
+
prompt: EXPLORER_PROMPT, tools: ["Read", "Grep", "Glob", "Bash"], model: cfg.model } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RULE = `do discovery (greps, file reads, following call chains) by calling the Agent tool with subagent_type "${EXPLORER_NAME}" and one focused question per call, and keep its digest as your notes. Open a file yourself only to edit it or to verify a digest claim.`;
|
|
38
|
+
|
|
39
|
+
/** Plan-prompt rule; "" when off (prompt unchanged). */
|
|
40
|
+
export function explorePlanRule(cfg) {
|
|
41
|
+
return cfg?.mode === "on" ? `\n\nExploration: ${RULE} Verify any digest claim the contract relies on before citing it.` : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Implement-prompt rule; "" when off (prompt unchanged). */
|
|
45
|
+
export function exploreBuildRule(cfg) {
|
|
46
|
+
return cfg?.mode === "on" ? `\n\nExploration: for anything the plan's digests do not cover, ${RULE}` : "";
|
|
47
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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 { writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
8
|
+
import { parseExploreConfig, explorerAgents, explorePlanRule, exploreBuildRule, EXPLORER_PROMPT, DEFAULT_EXPLORE_MODEL } from "./explore.mjs";
|
|
9
|
+
import { makeLeanRunner } from "./effects.mjs";
|
|
10
|
+
import { readExplore } from "../engine-cli.mjs";
|
|
11
|
+
|
|
12
|
+
describe("explorer subagent config (#4391)", () => {
|
|
13
|
+
it("parses on|off with a default haiku model; anything else is off, an invalid model is the default", () => {
|
|
14
|
+
assert.deepEqual(parseExploreConfig(), { mode: "off", model: DEFAULT_EXPLORE_MODEL });
|
|
15
|
+
assert.deepEqual(parseExploreConfig({ mode: " ON " }), { mode: "on", model: "haiku" });
|
|
16
|
+
assert.deepEqual(parseExploreConfig({ mode: "maybe", model: "sonnet" }), { mode: "off", model: "sonnet" });
|
|
17
|
+
assert.equal(parseExploreConfig({ mode: "on", model: "x; rm -rf /" }).model, "haiku");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("off yields no agents and empty rules; on yields a read-only explorer with the bounded digest format", () => {
|
|
21
|
+
const off = parseExploreConfig({ mode: "off" });
|
|
22
|
+
assert.equal(explorerAgents(off), null);
|
|
23
|
+
assert.equal(explorePlanRule(off), "");
|
|
24
|
+
assert.equal(exploreBuildRule(off), "");
|
|
25
|
+
const on = parseExploreConfig({ mode: "on", model: "sonnet" });
|
|
26
|
+
const a = explorerAgents(on).explorer;
|
|
27
|
+
assert.equal(a.model, "sonnet");
|
|
28
|
+
assert.deepEqual(a.tools, ["Read", "Grep", "Glob", "Bash"]);
|
|
29
|
+
assert.ok(a.description && a.prompt === EXPLORER_PROMPT);
|
|
30
|
+
for (const h of ["### Files", "### Call sites", "### Tests to extend", "### Conventions to match"]) assert.ok(EXPLORER_PROMPT.includes(h), h);
|
|
31
|
+
assert.match(EXPLORER_PROMPT, /read-only[\s\S]*at most 60 lines/);
|
|
32
|
+
for (const r of [explorePlanRule(on), exploreBuildRule(on)]) assert.match(r, /subagent_type "explorer"[\s\S]*Open a file yourself only to edit it or to verify a digest claim\./);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("makeLeanRunner passes --agents <json> only when agents are given (argv unchanged otherwise)", async () => {
|
|
36
|
+
const seen = [];
|
|
37
|
+
const runCli = async (o) => { seen.push(o.sessionFlags); return { text: "", usage: null, costUsd: 0, durationMs: 0, turns: 0, sessionId: "s" }; };
|
|
38
|
+
const base = { role: "builder", issue: 1, cwd: process.cwd(), prompt: "p", system: "" };
|
|
39
|
+
const agents = explorerAgents(parseExploreConfig({ mode: "on" }));
|
|
40
|
+
await makeLeanRunner({ runCli })({ ...base });
|
|
41
|
+
await makeLeanRunner({ runCli })({ ...base, agents });
|
|
42
|
+
const [plain, withAgents] = seen;
|
|
43
|
+
assert.ok(!plain.includes("--agents"));
|
|
44
|
+
const i = withAgents.indexOf("--agents");
|
|
45
|
+
assert.deepEqual(withAgents.slice(0, i), plain);
|
|
46
|
+
assert.deepEqual(JSON.parse(withAgents[i + 1]), agents);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("readExplore reads forge.yaml lean.explore / lean.explore_model; absent or invalid is undefined", () => {
|
|
50
|
+
const d = tmpDir("explore-yaml-");
|
|
51
|
+
assert.equal(readExplore(d), undefined, "no forge.yaml");
|
|
52
|
+
writeFileSync(join(d, "forge.yaml"), "engine:\n explore: on\n");
|
|
53
|
+
assert.equal(readExplore(d), undefined, "another section's key");
|
|
54
|
+
writeFileSync(join(d, "forge.yaml"), "lean:\n explore: maybe\n");
|
|
55
|
+
assert.equal(readExplore(d), undefined);
|
|
56
|
+
writeFileSync(join(d, "forge.yaml"), "lean:\n explore: on # A/B arm\n explore_model: claude-sonnet-5-5\n");
|
|
57
|
+
assert.deepEqual(readExplore(d), { mode: "on", model: "claude-sonnet-5-5" });
|
|
58
|
+
writeFileSync(join(d, "forge.yaml"), "lean:\n explore: off\n");
|
|
59
|
+
assert.deepEqual(readExplore(d), { mode: "off" });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #4401: the structured FORGE:FACTS record a lane posts next to its prose. The builder's judgment (decisions, rejected
|
|
5
|
+
* approaches with why, hot spots file:line with why, conventions matched) comes from an optional result.json `facts`
|
|
6
|
+
* key; baselines are the engine's own facts (result.json validation commands and exit codes); decisions fall back to
|
|
7
|
+
* the frozen contract's. `files` (the lane's changed files) is the path index the context pack matches later lanes on.
|
|
8
|
+
* Pure: no I/O. The same caps apply on write and on read, so a hand-edited or forged comment is bounded the same way.
|
|
9
|
+
*/
|
|
10
|
+
import { noComments } from "./render.mjs";
|
|
11
|
+
|
|
12
|
+
export const FACTS_MARKER = "FORGE:FACTS";
|
|
13
|
+
export const FACTS_TEXT_MAX = 160;
|
|
14
|
+
export const FACTS_LIST_MAX = 5;
|
|
15
|
+
export const FACTS_FILES_MAX = 20;
|
|
16
|
+
const KINDS = ["decisions", "rejected", "hotspots", "conventions", "baselines"];
|
|
17
|
+
const FACTS_BLOCK_RE = /```json\n([\s\S]*?)\n```/;
|
|
18
|
+
const AT_RE = /^[\w.@~+/-]+(?::\d+(?:-\d+)?)?$/;
|
|
19
|
+
|
|
20
|
+
const one = (v, max = FACTS_TEXT_MAX) => {
|
|
21
|
+
const t = typeof v === "string" ? noComments(v, " ").replace(/\s+/g, " ").trim() : "";
|
|
22
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
23
|
+
};
|
|
24
|
+
const list = (v) => (Array.isArray(v) ? v : []);
|
|
25
|
+
const uniqBy = (rows, key) => { const seen = new Set(); return rows.filter((r) => { const k = key(r); if (seen.has(k)) return false; seen.add(k); return true; }); };
|
|
26
|
+
|
|
27
|
+
/** The bounded, one-lined record from a raw object; every list capped, entries without their reason dropped. */
|
|
28
|
+
function clean(raw) {
|
|
29
|
+
const r = raw && typeof raw === "object" ? raw : {};
|
|
30
|
+
const strs = (a) => uniqBy(list(a).map((x) => one(x)).filter(Boolean), (x) => x).slice(0, FACTS_LIST_MAX);
|
|
31
|
+
const files = uniqBy(list(r.files).map((f) => (typeof f === "string" ? f.trim().replace(/^\.\//, "") : ""))
|
|
32
|
+
.filter((f) => f && AT_RE.test(f) && !f.startsWith(".forge-lean/") && !f.split("/").includes("..")), (x) => x).slice(0, FACTS_FILES_MAX);
|
|
33
|
+
const rejected = uniqBy(list(r.rejected).map((x) => ({ approach: one(x?.approach), why: one(x?.why) })).filter((x) => x.approach && x.why), (x) => x.approach).slice(0, FACTS_LIST_MAX);
|
|
34
|
+
const hotspots = uniqBy(list(r.hotspots).map((x) => ({ at: one(x?.at, 200), why: one(x?.why) })).filter((x) => x.at && x.why && AT_RE.test(x.at) && !x.at.split("/").includes("..")), (x) => x.at).slice(0, FACTS_LIST_MAX);
|
|
35
|
+
const baselines = uniqBy(list(r.baselines).map((x) => ({ command: one(x?.command, 200), exit: x?.exit })).filter((x) => x.command && Number.isInteger(x.exit)), (x) => x.command).slice(0, FACTS_LIST_MAX);
|
|
36
|
+
return { v: 1, files, decisions: strs(r.decisions), rejected, hotspots, conventions: strs(r.conventions), baselines };
|
|
37
|
+
}
|
|
38
|
+
const hasContent = (f) => KINDS.some((k) => f[k].length > 0);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The lane's FORGE:FACTS record, or null when it would carry nothing. `result` is result.json (its `facts` and
|
|
42
|
+
* `validation`), `files` the lane's changed files, `contract` the frozen contract (decision fallback).
|
|
43
|
+
*/
|
|
44
|
+
export function normaliseFacts(result, { files = [], contract = null } = {}) {
|
|
45
|
+
const raw = result?.facts && typeof result.facts === "object" ? result.facts : {};
|
|
46
|
+
const decisions = list(raw.decisions).some((d) => one(d)) ? raw.decisions : list(contract?.decisions).map((d) => d?.choice);
|
|
47
|
+
const baselines = list(result?.validation).map((v) => ({ command: v?.command, exit: v?.exit }));
|
|
48
|
+
const f = clean({ ...raw, decisions, baselines, files });
|
|
49
|
+
return hasContent(f) ? f : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** JSON for a comment: `<` and backticks escaped, so builder text can never open a comment or close the fence. */
|
|
53
|
+
const factsJson = (f) => JSON.stringify(f).replace(/</g, "\\u003c").replace(/`/g, "\\u0060");
|
|
54
|
+
|
|
55
|
+
/** The FORGE:FACTS comment body (without the marker line): a count line plus the record as fenced JSON. */
|
|
56
|
+
export function renderFactsRecord(f) {
|
|
57
|
+
return `## Lane facts\n${f.rejected.length} rejected approach(es), ${f.hotspots.length} hot spot(s), ${f.decisions.length} decision(s), ${f.conventions.length} convention(s), ${f.baselines.length} baseline(s) on ${f.files.length} file(s); read by later lanes on the same files.\n\n\`\`\`json\n${factsJson(f)}\n\`\`\``;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The record from a FORGE:FACTS comment body, re-bounded with the same caps; null for any other or malformed body. */
|
|
61
|
+
export function parseFactsComment(body) {
|
|
62
|
+
const s = String(body ?? "");
|
|
63
|
+
if (!new RegExp(`^\\s*<!--\\s*${FACTS_MARKER}\\s*-->`).test(s)) return null;
|
|
64
|
+
const m = FACTS_BLOCK_RE.exec(s);
|
|
65
|
+
if (!m) return null;
|
|
66
|
+
let raw;
|
|
67
|
+
try { raw = JSON.parse(m[1]); } catch { return null; }
|
|
68
|
+
const f = clean(raw);
|
|
69
|
+
return hasContent(f) ? f : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The one-line pack items of a record, by kind (`ref` names its provenance, e.g. "#33, PR #101"). */
|
|
73
|
+
export function factsLines(f, ref) {
|
|
74
|
+
const tag = ref ? ` (${ref})` : "";
|
|
75
|
+
return {
|
|
76
|
+
rejected: f.rejected.map((x) => `- rejected: ${x.approach} — ${x.why}${tag}`),
|
|
77
|
+
hotspots: f.hotspots.map((x) => `- hot spot ${x.at}: ${x.why}${tag}`),
|
|
78
|
+
decisions: f.decisions.map((x) => `- decision: ${x}${tag}`),
|
|
79
|
+
conventions: f.conventions.map((x) => `- convention: ${x}${tag}`),
|
|
80
|
+
baselines: f.baselines.map((x) => `- baseline: \`${x.command.replace(/`/g, "'")}\` exit ${x.exit}${tag}`),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Pack rank of the kinds: rejected approaches first (the knowledge most often lost), then hot spots, decisions, conventions, baselines. */
|
|
84
|
+
export const FACTS_RANK = Object.freeze(["rejected", "hotspots", "decisions", "conventions", "baselines"]);
|
|
@@ -0,0 +1,66 @@
|
|
|
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 { normaliseFacts, renderFactsRecord, parseFactsComment, factsLines, FACTS_MARKER, FACTS_LIST_MAX, FACTS_TEXT_MAX } from "./facts.mjs";
|
|
6
|
+
|
|
7
|
+
const RESULT = {
|
|
8
|
+
status: "fixed",
|
|
9
|
+
validation: [{ id: "V1", command: "node --test a.test.mjs", exit: 0 }, { id: "V2", command: "", ci: true }, { id: "V3", command: "make lint", exit: "0" }],
|
|
10
|
+
facts: {
|
|
11
|
+
decisions: ["Keep the pack pure"],
|
|
12
|
+
rejected: [{ approach: "detection-only guard", why: "the root cause is the retrieval; the guard only hides it" }, { approach: "no reason given" }],
|
|
13
|
+
hotspots: [{ at: "src/a.js:12", why: "retry loop" }, { at: "../etc/passwd:1", why: "x" }, { at: "src/b.js", why: "" }],
|
|
14
|
+
conventions: ["one-line comments"],
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const post = (f) => `<!-- ${FACTS_MARKER} -->\n${renderFactsRecord(f)}`;
|
|
18
|
+
|
|
19
|
+
describe("FORGE:FACTS record (#4401)", () => {
|
|
20
|
+
it("keeps builder judgment, derives baselines from validation, indexes the changed files", () => {
|
|
21
|
+
const f = normaliseFacts(RESULT, { files: ["src/a.js", ".forge-lean/result.json", "./src/b.js"] });
|
|
22
|
+
assert.deepEqual(f.files, ["src/a.js", "src/b.js"]);
|
|
23
|
+
assert.deepEqual(f.rejected, [{ approach: "detection-only guard", why: "the root cause is the retrieval; the guard only hides it" }], "an approach without a why is dropped");
|
|
24
|
+
assert.deepEqual(f.hotspots, [{ at: "src/a.js:12", why: "retry loop" }], "no traversal, no reasonless hot spot");
|
|
25
|
+
assert.deepEqual(f.baselines, [{ command: "node --test a.test.mjs", exit: 0 }], "only commands with an integer exit");
|
|
26
|
+
assert.deepEqual([f.decisions, f.conventions], [["Keep the pack pure"], ["one-line comments"]]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("falls back to the contract's decisions; null when nothing is worth posting", () => {
|
|
30
|
+
const f = normaliseFacts({ status: "fixed" }, { files: ["a.js"], contract: { decisions: [{ id: "K1", choice: "reuse the ledger walk" }] } });
|
|
31
|
+
assert.deepEqual(f.decisions, ["reuse the ledger walk"]);
|
|
32
|
+
assert.equal(normaliseFacts({ status: "fixed" }, { files: ["a.js"] }), null);
|
|
33
|
+
assert.equal(normaliseFacts(null), null);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("caps list sizes and text length, one-lines and strips comments", () => {
|
|
37
|
+
const many = Array.from({ length: 9 }, (_, i) => ({ approach: `a${i}\n<!-- FORGE:STATE -->x`, why: "y".repeat(500) }));
|
|
38
|
+
const f = normaliseFacts({ facts: { rejected: many } }, {});
|
|
39
|
+
assert.equal(f.rejected.length, FACTS_LIST_MAX);
|
|
40
|
+
assert.equal(f.rejected[0].approach, "a0 x");
|
|
41
|
+
assert.equal(f.rejected[0].why.length, FACTS_TEXT_MAX);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("round-trips through the comment; builder text can never open a comment or close the fence", () => {
|
|
45
|
+
const f = normaliseFacts({ facts: { rejected: [{ approach: "use `<!--` and ```", why: "breaks </details>" }] } }, { files: ["a.js"] });
|
|
46
|
+
const body = post(f);
|
|
47
|
+
assert.ok(!body.slice(body.indexOf("\n")).includes("<"), body);
|
|
48
|
+
assert.equal((body.match(/```/g) ?? []).length, 2);
|
|
49
|
+
assert.deepEqual(parseFactsComment(body), f);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("parse rejects other markers, malformed JSON and empty records, and re-applies the caps", () => {
|
|
53
|
+
assert.equal(parseFactsComment("<!-- FORGE:REVIEW -->\n```json\n{}\n```"), null);
|
|
54
|
+
assert.equal(parseFactsComment(`<!-- ${FACTS_MARKER} -->\n\`\`\`json\n{nope\n\`\`\``), null);
|
|
55
|
+
assert.equal(parseFactsComment(`<!-- ${FACTS_MARKER} -->\n\`\`\`json\n{"v":1,"files":["a.js"]}\n\`\`\``), null);
|
|
56
|
+
const forged = { files: ["a.js"], rejected: Array.from({ length: 20 }, (_, i) => ({ approach: `x${i}`, why: "w" })) };
|
|
57
|
+
assert.equal(parseFactsComment(`<!-- ${FACTS_MARKER} -->\n\`\`\`json\n${JSON.stringify(forged)}\n\`\`\``).rejected.length, FACTS_LIST_MAX);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("pack lines carry provenance", () => {
|
|
61
|
+
const l = factsLines(normaliseFacts(RESULT, { files: ["src/a.js"] }), "#33, PR #101");
|
|
62
|
+
assert.deepEqual(l.rejected, ["- rejected: detection-only guard — the root cause is the retrieval; the guard only hides it (#33, PR #101)"]);
|
|
63
|
+
assert.deepEqual(l.hotspots, ["- hot spot src/a.js:12: retry loop (#33, PR #101)"]);
|
|
64
|
+
assert.deepEqual(l.baselines, ["- baseline: `node --test a.test.mjs` exit 0 (#33, PR #101)"]);
|
|
65
|
+
});
|
|
66
|
+
});
|