forgedock 1.35.0 → 1.37.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/README.md +78 -324
- package/bin/bot-token-status.mjs +26 -0
- package/bin/engine/events.mjs +161 -0
- package/bin/engine/events.test.mjs +142 -0
- package/bin/engine/outcome-codes.mjs +39 -1
- package/bin/engine/phases.mjs +65 -5
- package/bin/engine/telemetry-seam.mjs +5 -4
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +372 -46
- package/bin/engine.mjs +3 -1
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +108 -5
- package/bin/hooks/reread-nudge.mjs +15 -4
- package/bin/labels.json +50 -0
- package/bin/lean/batch.mjs +52 -6
- package/bin/lean/batch.test.mjs +176 -2
- package/bin/lean/conformance.e2e.test.mjs +1 -1
- package/bin/lean/conformance.mjs +136 -11
- package/bin/lean/conformance.test.mjs +148 -1
- package/bin/lean/decisions.mjs +181 -0
- package/bin/lean/decisions.test.mjs +71 -0
- package/bin/lean/effects.mjs +32 -14
- package/bin/lean/execution.mjs +62 -16
- package/bin/lean/execution.test.mjs +59 -2
- package/bin/lean/gatewatch.mjs +31 -0
- package/bin/lean/gatewatch.test.mjs +59 -0
- package/bin/lean/guardgate.mjs +170 -0
- package/bin/lean/guardgate.test.mjs +153 -0
- package/bin/lean/guards.mjs +243 -0
- package/bin/lean/guards.test.mjs +177 -0
- package/bin/lean/heavy.mjs +54 -8
- package/bin/lean/heavy.test.mjs +74 -1
- package/bin/lean/hostkill.mjs +1 -1
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/integration.mjs +276 -0
- package/bin/lean/integration.test.mjs +189 -0
- package/bin/lean/lane.mjs +5 -2
- package/bin/lean/lean.mjs +169 -30
- package/bin/lean/lean.test.mjs +360 -24
- package/bin/lean/lifecycle.mjs +129 -0
- package/bin/lean/lifecycle.test.mjs +118 -0
- package/bin/lean/light.mjs +1 -1
- package/bin/lean/milestonesync.mjs +130 -0
- package/bin/lean/milestonesync.test.mjs +133 -0
- package/bin/lean/orchestrate.mjs +132 -20
- package/bin/lean/parked-sweep.mjs +45 -0
- package/bin/lean/parked-sweep.test.mjs +71 -0
- package/bin/lean/postdeploy-check.mjs +181 -0
- package/bin/lean/postdeploy-check.test.mjs +206 -0
- package/bin/lean/postdeploy-monitor.mjs +97 -0
- package/bin/lean/postdeploy-monitor.test.mjs +147 -0
- package/bin/lean/precheck.mjs +6 -4
- package/bin/lean/precheck.test.mjs +17 -2
- package/bin/lean/prefilter.mjs +106 -21
- package/bin/lean/prefilter.test.mjs +86 -6
- package/bin/lean/probe.mjs +4 -3
- package/bin/lean/probe.test.mjs +1 -1
- package/bin/lean/prompts.mjs +23 -9
- package/bin/lean/resume.test.mjs +1 -1
- package/bin/lean/retry.mjs +28 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +36 -3
- package/bin/lean/sandbox-remote.mjs +11 -4
- package/bin/lean/schedd.mjs +77 -13
- package/bin/lean/schedd.test.mjs +131 -5
- package/bin/lean/sequence.mjs +24 -0
- package/bin/lean/sequence.test.mjs +31 -1
- package/bin/lean/signoff.mjs +66 -0
- package/bin/lean/signoff.test.mjs +102 -0
- package/bin/lean/train.mjs +177 -0
- package/bin/lean/train.test.mjs +113 -0
- package/bin/pool/cli.mjs +2 -0
- package/bin/pool/health.mjs +30 -0
- package/bin/review-context.mjs +298 -0
- package/bin/runner.mjs +26 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/compounding.mjs +157 -0
- package/bin/stats/compounding.test.mjs +74 -0
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +62 -3
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/bootstrap.md +10 -8
- package/commands/deploy-info.md +3 -0
- package/commands/issue.md +2 -0
- package/commands/milestone.md +43 -54
- package/commands/orchestrate/phase-4-execution.md +2 -0
- package/commands/orchestrate.md +15 -3
- package/commands/pipeline-status.md +20 -0
- package/commands/review-pr.md +1 -0
- package/commands/work-on/build/implement.md +1 -1
- package/commands/work-on/build.md +4 -2
- package/commands/work-on/investigate.md +3 -1
- package/commands/work-on/remediate.md +1 -1
- package/docs/CONFIG.md +120 -2
- package/docs/EVENTS.md +65 -0
- package/docs/OUTCOME-CODES.md +9 -1
- package/docs/STATS.md +51 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/docs/site/start-a-new-project.md +1 -1
- package/docs/spec/label-state-machine.md +6 -0
- package/package.json +1 -1
- package/pool/install/install-agent.sh +4 -2
- package/pool/install/install-router.sh +4 -2
- package/pool/install/install-schedd.sh +28 -0
- package/pool/install/unit-hardening.sh +34 -0
- package/scripts/acceptance-red-green.sh +19 -2
- package/scripts/acceptance-red-green.test.sh +2 -0
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/bootstrap-create.mjs +3 -2
- package/scripts/bootstrap-validate.mjs +31 -4
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +286 -77
- package/scripts/file-deferred-followups.test.sh +117 -2
- package/scripts/hotspots.sh +23 -2
- package/scripts/hotspots.test.sh +9 -0
- package/scripts/plan-validate-live.mjs +111 -0
- package/scripts/readiness.mjs +141 -0
- package/scripts/review-prepare.sh +17 -1
- package/scripts/review-prepare.test.sh +17 -0
- package/scripts/settle-scope.mjs +74 -0
- package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
- package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
- package/scripts/wait-ci-green.sh +14 -3
- package/scripts/wait-ci-green.test.sh +3 -1
- package/templates/bootstrap/needs-human-issue.md +4 -1
- package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
import { describe, it } from "node:test";
|
|
4
4
|
import assert from "node:assert/strict";
|
|
5
|
-
import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE } from "./conformance.mjs";
|
|
5
|
+
import { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified } from "./conformance.mjs";
|
|
6
6
|
|
|
7
7
|
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
8
8
|
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
@@ -60,6 +60,21 @@ describe("amendments", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("#4655 multi-requirement amendment references", () => {
|
|
64
|
+
it("R1/R2, 'R1, R2' and an en-dash range split into one applied row per id", () => {
|
|
65
|
+
for (const requirement of ["R1/R2", "R1, R2", "R1\u2013R2", "R1 and R2"]) {
|
|
66
|
+
const a = applyAmendments(C, [{ kind: "defer", requirement, why: "w" }]);
|
|
67
|
+
assert.deepEqual(a.problems, [], requirement);
|
|
68
|
+
assert.deepEqual(a.toTrack.map((r) => r.id).sort(), ["R1", "R2"], requirement);
|
|
69
|
+
assert.deepEqual(a.applied.map((x) => x.target), ["R1", "R2"], requirement);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
it("an unknown id inside a list is still a problem; scope-note stays an unknown kind", () => {
|
|
73
|
+
assert.match(applyAmendments(C, [{ kind: "defer", requirement: "R1/R9", why: "w" }]).problems[0], /unknown requirement R9/);
|
|
74
|
+
assert.match(applyAmendments(C, [{ kind: "scope-note", requirement: "R1", why: "w" }]).problems[0], /unknown kind 'scope-note'; valid kinds are/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
describe("#4574 amendment slips", () => {
|
|
64
79
|
it("type for kind, amend-requirement and limitation apply as defer; an R# id fills requirement", () => {
|
|
65
80
|
const a = applyAmendments(C, [{ kind: "amend-requirement", id: "R2", why: "stored hashed" }, { id: "A1", requirement: "R1", type: "limitation", why: "plugin limit" }]);
|
|
@@ -115,6 +130,11 @@ describe("precheck (deterministic, no reviewer)", () => {
|
|
|
115
130
|
const failed = precheck({ contract: C2, result: { ...res, validation: [{ id: "V1", exit: 1 }, { id: "V2", ci: true }, { id: "V3", exit: 0 }] }, changedFiles: files, ciVerified: true });
|
|
116
131
|
assert.deepEqual(failed.failures.map((f) => f.kind), ["validate_failed"]); assert.deepEqual(failed.ciCovered, ["V2"]);
|
|
117
132
|
});
|
|
133
|
+
it("#4708 a validate row carried forward across a CI fix round counts as recorded; a row absent outside a CI-verified head still fails", () => {
|
|
134
|
+
const carried = precheck({ contract: C, result: { ...res, validation: [{ id: "V1", exit: 0, carried: true }] }, changedFiles: files });
|
|
135
|
+
assert.deepEqual(codes(carried), []);
|
|
136
|
+
assert.deepEqual(precheck({ contract: C, result: { ...res, validation: [] }, changedFiles: files }).failures.map((f) => f.kind), ["validate_missing"]);
|
|
137
|
+
});
|
|
118
138
|
it("#4323 missingTool: commands, python and node modules; paths, relative and repo-tracked names are not the environment", () => {
|
|
119
139
|
assert.deepEqual(missingTool({ rc: 127, output: "sh: 1: jq: not found" }), { tool: "jq", kind: "command" });
|
|
120
140
|
assert.deepEqual(missingTool({ rc: 127, output: "" }, { command: "uv run pytest -q" }), { tool: "uv", kind: "command" });
|
|
@@ -670,3 +690,130 @@ describe("#4594 amend-requirement", () => {
|
|
|
670
690
|
assert.match(explainFailures([{ code: "x", detail: "R1 partial" }], c)[0].detail, /amended to "regenerate and reveal/);
|
|
671
691
|
});
|
|
672
692
|
});
|
|
693
|
+
|
|
694
|
+
describe("post-deploy check ids (#4626)", () => {
|
|
695
|
+
const pd = (extra = {}) => ({ ...C, requirements: [{ id: "R1", quote: Q1, disposition: "post-deploy", why: "needs prod", ...extra }, C.requirements[1]] });
|
|
696
|
+
it("validateContract: a post-deploy requirement needs a well-formed check id", () => {
|
|
697
|
+
assert.ok(validateContract(pd(), ISSUE).problems.some((p) => /post-deploy needs a check id/.test(p)));
|
|
698
|
+
assert.ok(validateContract(pd({ check: "Bad Id" }), ISSUE).problems.some((p) => /post-deploy needs a check id/.test(p)));
|
|
699
|
+
assert.ok(!validateContract(pd({ check: "login-smoke" }), ISSUE).problems.some((p) => /check id/.test(p)));
|
|
700
|
+
});
|
|
701
|
+
it("applyAmendments: a post-deploy amendment needs a check id", () => {
|
|
702
|
+
const bad = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w" }], ISSUE);
|
|
703
|
+
assert.ok(bad.problems.some((p) => /needs a check id/.test(p)));
|
|
704
|
+
const ok = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "login-smoke" }], ISSUE);
|
|
705
|
+
assert.equal(ok.contract.requirements[0].check, "login-smoke");
|
|
706
|
+
});
|
|
707
|
+
it("precheck: a check id on neither branch is unimplemented; null checkIds skips the rule", () => {
|
|
708
|
+
const res = { requirements: [{ id: "R1", status: "post-deploy" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
709
|
+
const c = pd({ check: "login-smoke" });
|
|
710
|
+
assert.ok(precheck({ contract: c, result: res, changedFiles: files, checkIds: new Set() }).failures.some((f) => f.kind === "check_unknown"));
|
|
711
|
+
assert.ok(!precheck({ contract: c, result: res, changedFiles: files, checkIds: new Set(["login-smoke"]) }).failures.some((f) => f.kind === "check_unknown"));
|
|
712
|
+
assert.ok(!precheck({ contract: c, result: res, changedFiles: files }).failures.some((f) => f.kind === "check_unknown"));
|
|
713
|
+
});
|
|
714
|
+
it("#4664 followupItems carries the follow-up class; validateContract rejects an unknown class", () => {
|
|
715
|
+
const cls = (r) => followupItems([{ id: "R1", quote: Q1, why: "w", ...r }])[0].class;
|
|
716
|
+
assert.equal(cls({ disposition: "defer" }), "engineering");
|
|
717
|
+
assert.equal(cls({ disposition: "post-deploy", check: "x" }), "post-deploy");
|
|
718
|
+
assert.equal(cls({ disposition: "defer", class: "external" }), "external");
|
|
719
|
+
assert.equal(cls({ disposition: "defer", class: "bogus" }), "engineering");
|
|
720
|
+
assert.deepEqual(FOLLOWUP_CLASSES, ["engineering", "post-deploy", "external", "ops"]);
|
|
721
|
+
assert.equal(followupClass({ disposition: "post-deploy" }), "post-deploy"); assert.equal(followupClass({ class: "ops" }), "ops");
|
|
722
|
+
const v = validateContract({ requirements: [{ id: "R1", quote: Q1, disposition: "defer", why: "w", class: "bogus" }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] }, Q1);
|
|
723
|
+
assert.ok(v.problems.some((x) => /R1: class must be one of/.test(x)), v.problems.join(";"));
|
|
724
|
+
});
|
|
725
|
+
it("followupItems names the check in a post-deploy item", () => {
|
|
726
|
+
const [i] = followupItems([{ id: "R1", quote: Q1, disposition: "post-deploy", why: "w", check: "login-smoke" }]);
|
|
727
|
+
assert.match(i.why, /^post-deploy: \[check:login-smoke\] w$/);
|
|
728
|
+
});
|
|
729
|
+
it("#4724 a deferred row needs exactly one valid bucket; owner actions are never post-deploy; dates are absolute", () => {
|
|
730
|
+
const contract = (r) => ({ requirements: [{ id: "R1", quote: Q1, why: "w", ...r }], deliverables: [], preserve: [], validate: [{ id: "V1", command: "true" }] });
|
|
731
|
+
const probs = (r) => validateContract(contract(r), Q1).problems.filter((x) => /^R1: .*(bucket|never post-deploy|owner)/.test(x));
|
|
732
|
+
assert.deepEqual(BUCKETS, ["post-deploy", "verify-now", "date", "owner", "new-issue"]);
|
|
733
|
+
assert.equal(probs({ disposition: "defer" }).length, 1); // missing bucket
|
|
734
|
+
for (const bucket of ["post-deploy", "verify-now", "new-issue", "date:2026-12-01", "owner:rotate the key"]) assert.deepEqual(probs({ disposition: "defer", bucket }), [], bucket);
|
|
735
|
+
for (const bucket of ["date:5 weeks after deploy", "date:2026-02-30", "date", "owner:", "owner", "later", "post-deploy:x"]) assert.equal(probs({ disposition: "defer", bucket }).length, 1, bucket);
|
|
736
|
+
assert.match(probs({ disposition: "post-deploy", check: "a-smoke", bucket: "owner:spend" })[0], /never post-deploy/);
|
|
737
|
+
assert.equal(probs({ disposition: "deliver", bucket: "post-deploy" }).length, 1);
|
|
738
|
+
assert.deepEqual(probs({ disposition: "deliver" }), []);
|
|
739
|
+
assert.deepEqual(bucketProblems({ disposition: "defer", bucket: "owner:x", class: "post-deploy" }).length, 1);
|
|
740
|
+
assert.equal(bucketKind("date:2026-12-01"), "date"); assert.equal(bucketKind("nope"), null);
|
|
741
|
+
});
|
|
742
|
+
it("#4724 the bucket decides the follow-up class and tags the item for the script and the gate", () => {
|
|
743
|
+
const one = (r) => followupItems([{ id: "R1", quote: Q1, disposition: "defer", why: "w", ...r }], { now: Date.UTC(2026, 9, 27) })[0];
|
|
744
|
+
assert.equal(followupClass({ disposition: "defer", bucket: "owner:spend" }), "external");
|
|
745
|
+
assert.equal(followupClass({ disposition: "defer", bucket: "owner:spend", class: "ops" }), "ops");
|
|
746
|
+
assert.equal(followupClass({ disposition: "defer", bucket: "owner:x", class: "post-deploy" }), "external");
|
|
747
|
+
assert.equal(followupClass({ disposition: "defer", bucket: "new-issue", class: "post-deploy" }), "engineering");
|
|
748
|
+
for (const bucket of ["post-deploy", "verify-now", "date:2026-12-01"]) assert.equal(followupClass({ disposition: "defer", bucket }), "post-deploy");
|
|
749
|
+
assert.equal(one({ bucket: "owner:rotate the key" }).why, "owner: rotate the key. [bucket:owner] w");
|
|
750
|
+
assert.equal(one({ bucket: "verify-now" }).why, "post-deploy: [bucket:verify-now] w");
|
|
751
|
+
assert.equal(one({ bucket: "date:2026-12-01" }).why, "post-deploy: [bucket:date] due:2026-12-01 from:2026-10-27 w");
|
|
752
|
+
assert.equal(one({ bucket: "new-issue" }).why, "defer: [bucket:new-issue] w");
|
|
753
|
+
assert.equal(one({}).why, "defer: w"); // an unbucketed (older) row is unchanged
|
|
754
|
+
});
|
|
755
|
+
it("#4724 bucketTally counts deferred items per bucket kind; an amendment may carry the bucket", () => {
|
|
756
|
+
assert.deepEqual(bucketTally([{ bucket: "date:2026-12-01" }, { bucket: "date:2026-12-02" }, { bucket: "owner:x" }, {}]), { date: 2, owner: 1, none: 1 });
|
|
757
|
+
const c = { requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }], deliverables: [], preserve: [], validate: [] };
|
|
758
|
+
const r = applyAmendments(c, [{ kind: "defer", requirement: "R1", why: "w", bucket: "verify-now" }]);
|
|
759
|
+
assert.equal(r.contract.requirements[0].bucket, "verify-now"); assert.equal(r.toTrack.length, 1);
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
describe("#4726 a [type:e2e] requirement needs a real end-to-end validate", () => {
|
|
764
|
+
const Q = "Users can sign up and sign in with an emailed code alone [type:e2e]";
|
|
765
|
+
const mk = (cmd, quote = Q, extra = {}) => {
|
|
766
|
+
const contract = { ...C, requirements: [{ id: "R1", quote, disposition: "deliver", ...extra }, C.requirements[1]], validate: [{ id: "V1", command: cmd }] };
|
|
767
|
+
return { contract, ctx: { ...ctx, contract } };
|
|
768
|
+
};
|
|
769
|
+
const rv = (contract) => ({ ...good, issue_coverage: [{ quote: contract.requirements[0].quote, status: "covered" }, ...good.issue_coverage.slice(1)] });
|
|
770
|
+
const run = (cmd, quote, extra, e2e) => { const m = mk(cmd, quote, extra); return decide(rv(m.contract), { ...m.ctx, issueText: `${ISSUE}\n${m.contract.requirements[0].quote}`, e2e }); };
|
|
771
|
+
it("e2e requirement verified only by a unit test fails conformance (the #36414 shape)", () => {
|
|
772
|
+
const d = run("pytest tests/test_email_code_auth.py");
|
|
773
|
+
assert.equal(d.state, "nonconforming");
|
|
774
|
+
assert.deepEqual(codes(d), ["conformance.e2e_unverified"]);
|
|
775
|
+
});
|
|
776
|
+
it("a Playwright, configured e2e check or e2e spec file validate passes", () => {
|
|
777
|
+
assert.equal(run("npx playwright test e2e/signup.spec.ts").state, "conforms");
|
|
778
|
+
assert.equal(run("make smoke-live", Q, {}, { checks: ["make smoke-live"] }).state, "conforms");
|
|
779
|
+
assert.equal(run("node --test tests/signup.js", Q, {}, { isSpec: (f) => f === "tests/signup.js" }).state, "conforms");
|
|
780
|
+
});
|
|
781
|
+
it("isE2eCommand and e2eVerified resolve a V-ref to its contract command", () => {
|
|
782
|
+
assert.equal(isE2eCommand("npx playwright test"), true);
|
|
783
|
+
assert.equal(isE2eCommand("pytest tests/test_x.py"), false);
|
|
784
|
+
const contract = { validate: [{ id: "V1", command: "pytest tests/a.py" }, { id: "V2", command: "npx cypress run" }] };
|
|
785
|
+
assert.equal(e2eVerified({ validated_by: "V1" }, contract), false);
|
|
786
|
+
assert.equal(e2eVerified({ validated_by: "V1, V2" }, contract), true);
|
|
787
|
+
});
|
|
788
|
+
it("[type:unit] and [type:api] requirements keep their evidence rules; not-applicable is untouched", () => {
|
|
789
|
+
assert.equal(run("pytest tests/test_x.py", "Users can sign up with an emailed code [type:unit]").state, "conforms");
|
|
790
|
+
assert.equal(run("pytest tests/test_x.py", "Users can sign up with an emailed code [type:api]").state, "conforms");
|
|
791
|
+
const na = { ...C.requirements[0], quote: Q, disposition: "not-applicable", evidence: "src/a.js:1" };
|
|
792
|
+
const contract = { ...C, requirements: [na, C.requirements[1]] };
|
|
793
|
+
const r = { ...good, issue_coverage: [{ quote: Q, status: "covered" }, ...good.issue_coverage.slice(1)], requirements: [{ id: "R1", status: "not-applicable" }, good.requirements[1]] };
|
|
794
|
+
assert.notEqual(decide(r, { ...ctx, contract, issueText: `${ISSUE}\n${Q}` }).failures.some((f) => f.code === "conformance.e2e_unverified"), true);
|
|
795
|
+
});
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
describe("decision amendments (#4695)", () => {
|
|
799
|
+
const withRow = { ...C, preserve: [...C.preserve, { id: "H1", what: "use A", source: "decision", decision: "d9-1" }] };
|
|
800
|
+
const amend = (extra, opts = { baseFiles: ["src/scrape.js", "src/search.js"] }) => applyAmendments(withRow, [{ kind: "decision", why: "w", decision: { choice: "use B", anchors: ["src/**"], ...extra } }], null, opts);
|
|
801
|
+
it("applies a decision whose anchors exist on the base and returns the record", () => {
|
|
802
|
+
const a = amend({});
|
|
803
|
+
assert.deepEqual(a.problems, []);
|
|
804
|
+
assert.equal(a.decisions[0].choice, "use B");
|
|
805
|
+
assert.ok(a.contract.preserve.some((p) => p.id === "H1"));
|
|
806
|
+
});
|
|
807
|
+
it("rejects an anchor that matches no base file", () => {
|
|
808
|
+
const a = amend({ anchors: ["nope/**"] });
|
|
809
|
+
assert.match(a.problems[0], /match no file on the base/);
|
|
810
|
+
assert.equal(a.decisions.length, 0);
|
|
811
|
+
});
|
|
812
|
+
it("supersedes drops the engine-authored preserve row; without it the row stays", () => {
|
|
813
|
+
assert.ok(!amend({ supersedes: "d9-1" }).contract.preserve.some((p) => p.id === "H1"));
|
|
814
|
+
assert.ok(amend({}).contract.preserve.some((p) => p.id === "H1"));
|
|
815
|
+
});
|
|
816
|
+
it("a decision without choice or anchors is an incomplete amendment", () => {
|
|
817
|
+
assert.match(amend({ choice: "" }).problems[0], /needs decision/);
|
|
818
|
+
});
|
|
819
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* #4695: anchored decisions. A settled choice carries the paths/globs (and optional symbols) it governs and an optional
|
|
5
|
+
* `supersedes` link. The record of truth is a `<!-- FORGE:DECISION {json} -->` issue comment (the JSON sits on the
|
|
6
|
+
* marker line; prose FORGE:DECISION comments carry none and are ignored); `~/.forge/decisions/<repo>.json` indexes it.
|
|
7
|
+
* Supersession appends a fresh record of the old decision (status superseded, superseded_by) on the old issue: the
|
|
8
|
+
* latest record per id wins, nothing is edited. When a lane's deliverables hit an anchor the decision becomes an
|
|
9
|
+
* engine-authored preserve row `{id:"H<n>", what, source:"decision"}` (same shape as #4694), capped at HISTORY_ROWS_MAX.
|
|
10
|
+
* Pure except the index read/write; no LLM, gh or git here.
|
|
11
|
+
*/
|
|
12
|
+
import * as nodeFs from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { noComments } from "./render.mjs";
|
|
16
|
+
|
|
17
|
+
export const DECISION_MARKER = "FORGE:DECISION";
|
|
18
|
+
/** Preserve rows the engine adds from history per lane; guards (#4694) share this cap. */
|
|
19
|
+
export const HISTORY_ROWS_MAX = 5;
|
|
20
|
+
const TEXT_MAX = 200;
|
|
21
|
+
const ANCHORS_MAX = 10;
|
|
22
|
+
const MARKER_RE = /^<!--\s*FORGE:DECISION\s+(\{.*\})\s*-->/;
|
|
23
|
+
|
|
24
|
+
const one = (v, max = TEXT_MAX) => {
|
|
25
|
+
const t = typeof v === "string" ? noComments(v, " ").replace(/\s+/g, " ").trim() : "";
|
|
26
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
27
|
+
};
|
|
28
|
+
const strs = (v, max = ANCHORS_MAX, len = 200) => [...new Set((Array.isArray(v) ? v : []).map((x) => one(x, len)).filter(Boolean))].slice(0, max);
|
|
29
|
+
const cleanAnchor = (a) => String(a).replace(/^\.\//, "").replace(/^\/+/, "");
|
|
30
|
+
|
|
31
|
+
/** A bounded decision record from raw input, or null when it has no choice or no anchor. `issue` is the register issue. */
|
|
32
|
+
export function normaliseDecision(raw, { issue = null, n = 1 } = {}) {
|
|
33
|
+
const r = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
|
|
34
|
+
const choice = one(r.choice), anchors = strs(r.anchors).map(cleanAnchor);
|
|
35
|
+
if (!choice || !anchors.length) return null;
|
|
36
|
+
const iss = Number.isInteger(r.issue) ? r.issue : issue;
|
|
37
|
+
const status = r.status === "superseded" ? "superseded" : "active";
|
|
38
|
+
const rec = { v: 1, id: one(r.id, 60) || `d${iss ?? 0}-${n}`, choice, why: one(r.why), anchors, issue: iss, status };
|
|
39
|
+
const symbols = strs(r.symbols, ANCHORS_MAX, 80);
|
|
40
|
+
if (symbols.length) rec.symbols = symbols;
|
|
41
|
+
if (one(r.supersedes, 60)) rec.supersedes = one(r.supersedes, 60);
|
|
42
|
+
if (status === "superseded" && one(r.superseded_by, 60)) rec.superseded_by = one(r.superseded_by, 60);
|
|
43
|
+
return rec;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The marker (for post(): `<!-- FORGE:DECISION {json} -->`) and readable body of a decision record. */
|
|
47
|
+
export function renderDecision(rec) {
|
|
48
|
+
const json = JSON.stringify(rec).replace(/>/g, "\\u003e").replace(/</g, "\\u003c"); // can never close the marker comment
|
|
49
|
+
const tail = [rec.supersedes ? `Supersedes \`${rec.supersedes}\`.` : "", rec.status === "superseded" ? `Superseded by \`${rec.superseded_by}\`.` : ""].filter(Boolean).join(" ");
|
|
50
|
+
return {
|
|
51
|
+
marker: `${DECISION_MARKER} ${json}`,
|
|
52
|
+
body: `## Decision \`${rec.id}\`${rec.status === "superseded" ? " (superseded)" : ""}\n**What**: ${rec.choice}\n**Why**: ${rec.why || "-"}\n**Governs**: ${rec.anchors.map((a) => `\`${a}\``).join(", ")}${tail ? `\n${tail}` : ""}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The record from a comment body whose first line is `<!-- FORGE:DECISION {json} -->`; null for prose or malformed. */
|
|
57
|
+
export function parseDecisionComment(body, { issue = null } = {}) {
|
|
58
|
+
const m = MARKER_RE.exec(String(body ?? "").split("\n", 1)[0]);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
try {
|
|
61
|
+
const raw = JSON.parse(m[1]);
|
|
62
|
+
return normaliseDecision({ ...raw, issue: Number.isInteger(raw?.issue) ? raw.issue : issue });
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One record per id, the latest wins (input in posting order); ids ordered by first appearance. */
|
|
67
|
+
export function foldDecisions(records) {
|
|
68
|
+
const by = new Map();
|
|
69
|
+
for (const r of records ?? []) if (r?.id) by.set(r.id, { ...(by.get(r.id) ?? {}), ...r });
|
|
70
|
+
return [...by.values()];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const globRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\/?/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replace(/\u0000/g, ".*")}$`);
|
|
74
|
+
/** True when `anchor` (a file, a `dir/` prefix or a glob) covers `path`. */
|
|
75
|
+
export function anchorMatches(anchor, path) {
|
|
76
|
+
const a = cleanAnchor(anchor), p = cleanAnchor(path);
|
|
77
|
+
if (!a || !p) return false;
|
|
78
|
+
if (a.endsWith("/")) return p.startsWith(a);
|
|
79
|
+
return a === p || globRe(a).test(p) || p.startsWith(`${a}/`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The anchors that match no file of `files`. */
|
|
83
|
+
export const deadAnchors = (anchors, files) => (anchors ?? []).filter((a) => !(files ?? []).some((f) => anchorMatches(a, f)));
|
|
84
|
+
|
|
85
|
+
/** Active decisions with at least one dead anchor on the base file list: [{decision, dead}]. Never applied. */
|
|
86
|
+
export function staleDecisions(records, baseFiles) {
|
|
87
|
+
const out = [];
|
|
88
|
+
for (const d of foldDecisions(records)) {
|
|
89
|
+
if (d.status === "superseded") continue;
|
|
90
|
+
const dead = deadAnchors(d.anchors, baseFiles);
|
|
91
|
+
if (dead.length) out.push({ decision: d, dead });
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Preserve rows for the active decisions an anchor of which covers a deliverable path: most specific anchor first, then
|
|
98
|
+
* newest issue, then id; at most `cap` rows, ids H<start>… Deterministic: the same inputs give the same rows.
|
|
99
|
+
*/
|
|
100
|
+
export function historyRows(records, deliverablePaths, { cap = HISTORY_ROWS_MAX, start = 1 } = {}) {
|
|
101
|
+
const paths = (deliverablePaths ?? []).filter(Boolean);
|
|
102
|
+
if (!paths.length || cap <= 0) return [];
|
|
103
|
+
const hits = [];
|
|
104
|
+
for (const d of foldDecisions(records)) {
|
|
105
|
+
if (d.status === "superseded") continue;
|
|
106
|
+
const spec = Math.max(-1, ...d.anchors.filter((a) => paths.some((p) => anchorMatches(a, p))).map((a) => cleanAnchor(a).length));
|
|
107
|
+
if (spec >= 0) hits.push({ d, spec });
|
|
108
|
+
}
|
|
109
|
+
hits.sort((x, y) => y.spec - x.spec || (y.d.issue ?? 0) - (x.d.issue ?? 0) || (x.d.id < y.d.id ? -1 : x.d.id > y.d.id ? 1 : 0));
|
|
110
|
+
return hits.slice(0, cap).map(({ d }, i) => ({
|
|
111
|
+
id: `H${start + i}`,
|
|
112
|
+
what: `${d.choice}${d.why ? ` (${d.why})` : ""} [decision ${d.id}${d.issue ? `, #${d.issue}` : ""}; to change it, amend {kind:"decision", decision:{choice, anchors, supersedes:"${d.id}"}, why}]`,
|
|
113
|
+
source: "decision",
|
|
114
|
+
decision: d.id,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The contract with history rows appended to preserve; the same object when none apply (the hash stays byte-identical). */
|
|
119
|
+
export function withDecisionRows(contract, records) {
|
|
120
|
+
const have = new Set((contract?.preserve ?? []).map((p) => p.id));
|
|
121
|
+
let start = 1;
|
|
122
|
+
while (have.has(`H${start}`)) start++;
|
|
123
|
+
const room = HISTORY_ROWS_MAX - (contract?.preserve ?? []).filter((p) => /^H\d+$/.test(String(p.id))).length;
|
|
124
|
+
const rows = historyRows(records, (contract?.deliverables ?? []).map((d) => d.path), { cap: room, start });
|
|
125
|
+
return rows.length ? { ...contract, preserve: [...(contract.preserve ?? []), ...rows] } : contract;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** {old, next}: the old record marked superseded by the new id, the new one linked back with `supersedes`. */
|
|
129
|
+
export function supersede(old, next) {
|
|
130
|
+
return { old: { ...old, status: "superseded", superseded_by: next.id }, next: { ...next, supersedes: old.id } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const decisionsDir = () => process.env.FORGEDOCK_DECISIONS_DIR || join(homedir(), ".forge", "decisions");
|
|
134
|
+
export const indexFile = (repo) => join(decisionsDir(), `${String(repo ?? "repo").replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
|
|
135
|
+
|
|
136
|
+
/** The indexed records (posting order); [] when the index is absent or unreadable. One file read. */
|
|
137
|
+
export function readDecisionIndex(repo, { fs = nodeFs } = {}) {
|
|
138
|
+
try {
|
|
139
|
+
const j = JSON.parse(fs.readFileSync(indexFile(repo), "utf8"));
|
|
140
|
+
return (Array.isArray(j?.records) ? j.records : []).map((r) => normaliseDecision(r)).filter(Boolean);
|
|
141
|
+
} catch { return []; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Replaces the index (written atomically); best effort, the register of record is GitHub. */
|
|
145
|
+
export function writeDecisionIndex(repo, records, { fs = nodeFs } = {}) {
|
|
146
|
+
try {
|
|
147
|
+
const file = indexFile(repo);
|
|
148
|
+
fs.mkdirSync(decisionsDir(), { recursive: true });
|
|
149
|
+
fs.writeFileSync(`${file}.tmp`, JSON.stringify({ v: 1, records: foldDecisions(records) }));
|
|
150
|
+
fs.renameSync(`${file}.tmp`, file);
|
|
151
|
+
return true;
|
|
152
|
+
} catch { return false; }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Posts the lane's decision records as FORGE:DECISION on `issue` and, for a `supersedes`, the old record marked
|
|
157
|
+
* superseded on its own issue (both records updated); then updates the index. `post(marker, body, onIssue)` posts a
|
|
158
|
+
* comment. Returns the records posted. Best effort per record: a failed post is skipped, never fails the lane.
|
|
159
|
+
*/
|
|
160
|
+
export async function recordDecisions(list, { issue, repo, post, fs = nodeFs }) {
|
|
161
|
+
const index = foldDecisions(readDecisionIndex(repo, { fs })), out = [];
|
|
162
|
+
let n = 0;
|
|
163
|
+
for (const raw of list ?? []) {
|
|
164
|
+
n++;
|
|
165
|
+
let next = normaliseDecision({ ...raw, id: undefined, issue }, { issue, n });
|
|
166
|
+
if (!next) continue;
|
|
167
|
+
const old = raw.supersedes ? index.find((d) => d.id === raw.supersedes) : null;
|
|
168
|
+
let oldRec = null;
|
|
169
|
+
if (old) ({ old: oldRec, next } = supersede(old, next));
|
|
170
|
+
else if (raw.supersedes) next = { ...next, supersedes: String(raw.supersedes) };
|
|
171
|
+
try {
|
|
172
|
+
const m = renderDecision(next);
|
|
173
|
+
await post(m.marker, m.body, issue);
|
|
174
|
+
if (oldRec?.issue) { const o = renderDecision(oldRec); await post(o.marker, o.body, oldRec.issue); }
|
|
175
|
+
} catch { continue; }
|
|
176
|
+
index.push(...[oldRec, next].filter(Boolean));
|
|
177
|
+
out.push(next);
|
|
178
|
+
}
|
|
179
|
+
if (out.length) writeDecisionIndex(repo, index, { fs });
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { tmpDir } from "../tests/_tmp.mjs";
|
|
6
|
+
import { normaliseDecision, renderDecision, parseDecisionComment, foldDecisions, anchorMatches, deadAnchors, staleDecisions, historyRows, withDecisionRows, supersede, readDecisionIndex, recordDecisions, HISTORY_ROWS_MAX } from "./decisions.mjs";
|
|
7
|
+
|
|
8
|
+
const D = (o) => normaliseDecision({ choice: "use A not B", why: "B leaks", anchors: ["src/a/**"], ...o }, { issue: 10, n: 1 });
|
|
9
|
+
|
|
10
|
+
describe("decisions (#4695)", () => {
|
|
11
|
+
it("round-trips through the marker line and ignores prose FORGE:DECISION comments", () => {
|
|
12
|
+
const d = D({ symbols: ["foo"], anchors: ["src/a/x.js", "lib/-->evil"] });
|
|
13
|
+
const m = renderDecision(d);
|
|
14
|
+
const body = `<!-- ${m.marker} -->\n${m.body}`;
|
|
15
|
+
assert.deepEqual(parseDecisionComment(body), d);
|
|
16
|
+
assert.equal(parseDecisionComment("<!-- FORGE:DECISION -->\n## Decision (batch 1)\n**What**: x"), null);
|
|
17
|
+
assert.equal(parseDecisionComment("<!-- FORGE:DECISION {nope} -->"), null);
|
|
18
|
+
assert.equal(normaliseDecision({ choice: "x", anchors: [] }), null);
|
|
19
|
+
});
|
|
20
|
+
it("matches files, directory prefixes and globs", () => {
|
|
21
|
+
assert.ok(anchorMatches("src/a.js", "src/a.js"));
|
|
22
|
+
assert.ok(anchorMatches("src/a/", "src/a/b/c.js"));
|
|
23
|
+
assert.ok(anchorMatches("src/**", "src/a/b.js"));
|
|
24
|
+
assert.ok(anchorMatches("src/*.js", "src/a.js"));
|
|
25
|
+
assert.ok(!anchorMatches("src/*.js", "src/a/b.js"));
|
|
26
|
+
assert.deepEqual(deadAnchors(["src/a.js", "gone/**"], ["src/a.js"]), ["gone/**"]);
|
|
27
|
+
});
|
|
28
|
+
it("history rows: only anchored deliverables, capped at 5, deterministic", () => {
|
|
29
|
+
const recs = Array.from({ length: 8 }, (_, i) => normaliseDecision({ id: `d${i}`, choice: `c${i}`, anchors: ["src/**"], issue: i + 1 }));
|
|
30
|
+
const a = historyRows(recs, ["src/x.js"]), b = historyRows([...recs].reverse(), ["src/x.js"]);
|
|
31
|
+
assert.equal(a.length, HISTORY_ROWS_MAX);
|
|
32
|
+
assert.deepEqual(a, b);
|
|
33
|
+
assert.deepEqual(a.map((r) => r.id), ["H1", "H2", "H3", "H4", "H5"]);
|
|
34
|
+
assert.equal(a[0].decision, "d7");
|
|
35
|
+
assert.ok(a.every((r) => r.source === "decision" && r.what.includes("supersedes")));
|
|
36
|
+
assert.deepEqual(historyRows(recs, ["docs/x.md"]), []);
|
|
37
|
+
assert.deepEqual(historyRows([], ["src/x.js"]), []);
|
|
38
|
+
});
|
|
39
|
+
it("withDecisionRows leaves a contract with no hit untouched (same object)", () => {
|
|
40
|
+
const c = { preserve: [{ id: "P1", what: "w" }], deliverables: [{ id: "D1", path: "docs/x.md" }] };
|
|
41
|
+
assert.equal(withDecisionRows(c, [D()]), c);
|
|
42
|
+
const c2 = withDecisionRows({ ...c, deliverables: [{ id: "D1", path: "src/a/x.js" }] }, [D()]);
|
|
43
|
+
assert.deepEqual(c2.preserve.map((p) => p.id), ["P1", "H1"]);
|
|
44
|
+
});
|
|
45
|
+
it("supersession links both records and the old one stops binding", () => {
|
|
46
|
+
const old = D({ id: "d10-1" }), next = D({ id: "d11-1", choice: "use B" });
|
|
47
|
+
const r = supersede(old, next);
|
|
48
|
+
assert.equal(r.old.status, "superseded");
|
|
49
|
+
assert.equal(r.old.superseded_by, "d11-1");
|
|
50
|
+
assert.equal(r.next.supersedes, "d10-1");
|
|
51
|
+
assert.deepEqual(historyRows([old, r.old], ["src/a/x.js"]), []);
|
|
52
|
+
assert.equal(foldDecisions([old, r.old]).length, 1);
|
|
53
|
+
});
|
|
54
|
+
it("lists stale decisions with dead anchors, never superseded ones", () => {
|
|
55
|
+
const live = D({ id: "a" }), dead = D({ id: "b", anchors: ["src/a/**", "old/x.js"] }), gone = D({ id: "c", anchors: ["old/y.js"], status: "superseded", superseded_by: "z" });
|
|
56
|
+
const s = staleDecisions([live, dead, gone], ["src/a/q.js"]);
|
|
57
|
+
assert.deepEqual(s.map((x) => [x.decision.id, x.dead]), [["b", ["old/x.js"]]]);
|
|
58
|
+
});
|
|
59
|
+
it("recordDecisions posts the new and the superseded old record and updates the index", async () => {
|
|
60
|
+
process.env.FORGEDOCK_DECISIONS_DIR = tmpDir("dec-");
|
|
61
|
+
const posts = [], post = async (marker, body, on) => { posts.push({ marker, on }); };
|
|
62
|
+
const first = await recordDecisions([{ choice: "use A", anchors: ["src/**"], why: "w" }], { issue: 10, repo: "o/r", post });
|
|
63
|
+
assert.equal(first[0].id, "d10-1");
|
|
64
|
+
const second = await recordDecisions([{ choice: "use B", anchors: ["src/**"], why: "w2", supersedes: "d10-1" }], { issue: 11, repo: "o/r", post });
|
|
65
|
+
assert.equal(second[0].supersedes, "d10-1");
|
|
66
|
+
assert.deepEqual(posts.map((p) => p.on), [10, 11, 10]);
|
|
67
|
+
const idx = foldDecisions(readDecisionIndex("o/r"));
|
|
68
|
+
assert.equal(idx.find((d) => d.id === "d10-1").status, "superseded");
|
|
69
|
+
assert.equal(idx.find((d) => d.id === "d11-1").supersedes, "d10-1");
|
|
70
|
+
});
|
|
71
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -18,8 +18,9 @@ import { gatherContextFacts } from "./context.mjs";
|
|
|
18
18
|
import { makePitfallStore } from "./pitfalls.mjs";
|
|
19
19
|
import { enumeratingTests, missingEntries } from "./enumerating.mjs";
|
|
20
20
|
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
21
|
-
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
|
|
21
|
+
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems, journalOrderProblems } from "./sequence.mjs";
|
|
22
22
|
import { duplicateExports } from "./postmerge.mjs";
|
|
23
|
+
import { englishCopies } from "./i18n.mjs";
|
|
23
24
|
import { scopedChecks } from "./checks.mjs";
|
|
24
25
|
import { makeSequenceLeaseIo } from "./seqlease.mjs";
|
|
25
26
|
import { followupMap } from "./conformance.mjs";
|
|
@@ -29,9 +30,9 @@ import { detectApiShape } from "./apishape.mjs";
|
|
|
29
30
|
const pexec = promisify(execFile);
|
|
30
31
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
31
32
|
/** Argv-only child with stdin input; resolves stdout, rejects on a non-zero exit with stderr attached. */
|
|
32
|
-
export function runWithInput(bin, args, input, timeout = 120000) {
|
|
33
|
+
export function runWithInput(bin, args, input, timeout = 120000, env = undefined) {
|
|
33
34
|
return new Promise((res, rej) => {
|
|
34
|
-
const child = execFile(bin, args, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
35
|
+
const child = execFile(bin, args, { timeout, maxBuffer: 10 * 1024 * 1024, ...(env ? { env } : {}) }, (err, stdout, stderr) => {
|
|
35
36
|
if (err) { err.stdout = stdout; err.stderr = stderr; rej(err); } else res(String(stdout));
|
|
36
37
|
});
|
|
37
38
|
// A child that exits before reading stdin closes the pipe: EPIPE must reject via the exit callback, never crash the lane.
|
|
@@ -49,14 +50,25 @@ export function systemdRunAvailable() {
|
|
|
49
50
|
}
|
|
50
51
|
return systemdProbe;
|
|
51
52
|
}
|
|
52
|
-
const MEM_RE = /^\d+(\.\d+)?[KMGT]?$/i, CPU_RE = /^\d
|
|
53
|
-
/**
|
|
54
|
-
export
|
|
53
|
+
const MEM_RE = /^\d+(\.\d+)?[KMGT]?$/i, CPU_RE = /^\d+%$/, TASKS_RE = /^\d+$/;
|
|
54
|
+
/** #4690: default `TasksMax` of a lane scope; `tasks_max: 0|off` (or any non-count) turns the cap off. */
|
|
55
|
+
export const DEFAULT_LANE_TASKS_MAX = 1024;
|
|
56
|
+
let scopeSeq = 0;
|
|
57
|
+
/**
|
|
58
|
+
* Cap the whole lane process tree: spawn under `systemd-run --user --scope --unit=.. -p TasksMax=.. [-p MemoryMax=..] [-p CPUQuota=..]`.
|
|
59
|
+
* #4690: `TasksMax` is on by default (one runaway lane cannot fork-bomb the host); unset limits use the default, invalid ones are dropped.
|
|
60
|
+
* Returns `unit` (the scope name) when wrapped, so the runner can read the scope's limit events.
|
|
61
|
+
*/
|
|
62
|
+
export function wrapLaneLimits(bin, args, limits, unit = `forgedock-lane-${process.pid}-${++scopeSeq}`) {
|
|
55
63
|
const mem = limits?.memory_max && MEM_RE.test(String(limits.memory_max)) ? String(limits.memory_max) : null;
|
|
56
64
|
const cpu = limits?.cpu_quota && CPU_RE.test(String(limits.cpu_quota)) ? String(limits.cpu_quota) : null;
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
const raw = limits?.tasks_max === undefined || limits?.tasks_max === null || limits?.tasks_max === "" ? DEFAULT_LANE_TASKS_MAX : String(limits.tasks_max).trim();
|
|
66
|
+
const tasks = TASKS_RE.test(raw) && Number(raw) > 0 ? Number(raw) : null;
|
|
67
|
+
if (!mem && !cpu && !tasks) return { bin, args };
|
|
68
|
+
return { bin: "systemd-run", unit, args: ["--user", "--scope", "-q", `--unit=${unit}`, ...(tasks ? ["-p", `TasksMax=${tasks}`] : []), ...(mem ? ["-p", `MemoryMax=${mem}`] : []), ...(cpu ? ["-p", `CPUQuota=${cpu}`] : []), "--", bin, ...args] };
|
|
59
69
|
}
|
|
70
|
+
/** #4690: the one line a run logs when lanes get no scope (systemd-run --user --scope unavailable); null when they are capped. */
|
|
71
|
+
export const laneCapNotice = (probe = systemdRunAvailable) => (probe() ? null : "lanes are uncapped: `systemd-run --user --scope` is unavailable here, so no TasksMax/MemoryMax/CPUQuota applies to lane process trees");
|
|
60
72
|
|
|
61
73
|
/** #4211: a judgment call over data in the prompt only (the refactor:split grouping call): no tool can read a file. */
|
|
62
74
|
export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
|
|
@@ -80,7 +92,7 @@ export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRun
|
|
|
80
92
|
const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
81
93
|
args: [String(issue)], cwd, bin, model, maxTurns: caps?.maxTurns ? maxTurns : null, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
82
94
|
logger: { log() {} },
|
|
83
|
-
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap:
|
|
95
|
+
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, busy, busyMs, onIdleExtend, ...(contextMax > 0 ? { contextMax } : {}), ...(turnMax > 0 ? { turnMax } : {}), wrap: probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
84
96
|
// #4395: a resumed session's first turn carries the prior history, so only a fresh session reports a baseline.
|
|
85
97
|
return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default",
|
|
86
98
|
baselineTokens: resume ? null : (r.baselineTokens ?? null), tools: toolStats(r.toolUses) }; // #4410: tool calls per turn, re-reads
|
|
@@ -327,11 +339,14 @@ export function makeLeanIo({ repo }) {
|
|
|
327
339
|
async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
|
|
328
340
|
/**
|
|
329
341
|
* File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
|
|
330
|
-
* append, create, claim marker, duplicate re-check). items: [{key, why}]
|
|
342
|
+
* append, create, claim marker, duplicate re-check; one issue per class, #4664). items: [{key, why, class?}]; the key is the
|
|
343
|
+
* item's identity (column 5, column 1 is `-`). milestone / defaultMilestone: the parent's and forge.yaml followups.default_milestone; lane: the release lane of the post-deploy rollup (#4724).
|
|
344
|
+
* Returns {key: issueNumber}; throws on exit 2/3/4.
|
|
331
345
|
*/
|
|
332
|
-
async fileFollowups({ parent, items }) {
|
|
333
|
-
const tsv = items.map((i) =>
|
|
334
|
-
const
|
|
346
|
+
async fileFollowups({ parent, items, milestone = null, defaultMilestone = null, lane = null }) {
|
|
347
|
+
const tsv = items.map((i) => `-\t${i.why}\t\t${i.class ?? ""}\t${i.key}`).join("\n") + "\n";
|
|
348
|
+
const env = { ...process.env, ...(milestone ? { FORGE_FOLLOWUP_MILESTONE: String(milestone) } : {}), ...(defaultMilestone ? { FORGE_FOLLOWUPS_DEFAULT_MILESTONE: String(defaultMilestone) } : {}), ...(lane ? { FORGE_RELEASE_LANE: String(lane) } : {}) }; // #4724: the post-deploy rollup is one per release lane
|
|
349
|
+
const out = await runWithInput("bash", [script("file-deferred-followups.sh"), "--parent", String(parent), "--repo", repo], tsv, 180000, env);
|
|
335
350
|
return followupMap(out, items);
|
|
336
351
|
},
|
|
337
352
|
/**
|
|
@@ -380,10 +395,13 @@ export function makeLeanIo({ repo }) {
|
|
|
380
395
|
conflictResolve: ({ cwd, files, cfg }) => resolveBaseConflicts({ git: (a) => run("git")(a, { cwd }), cwd, files, cfg }),
|
|
381
396
|
sequenceLease: makeSequenceLeaseIo(),
|
|
382
397
|
/** #4591: lane-added migrations the repo's generator did not produce (no journal entry or snapshot); [] on failure. */
|
|
383
|
-
|
|
398
|
+
/** #4661: plus journal `when` stamps not above the base max (newest-only migrators skip them). */
|
|
399
|
+
migrationGenerator: async ({ cwd, base }) => { const git = (a) => run("git")(a, { cwd }); return [...await generatorProblems({ git, base }), ...await journalOrderProblems({ git, base })]; },
|
|
384
400
|
/** #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). */
|
|
385
401
|
postMergeChecks: async ({ cwd, base }) => { try { return scopedChecks({ repo: cwd, base }); } catch { return []; } },
|
|
386
402
|
duplicateExports: ({ cwd, base }) => duplicateExports({ git: (a) => run("git")(a, { cwd }), cwd, base }),
|
|
403
|
+
/** #4596: keys added to a non-en locale catalog as English copies (bin/lean/i18n.mjs). */
|
|
404
|
+
englishCopies: ({ cwd, base }) => englishCopies({ git: (a) => run("git")(a, { cwd }), base }),
|
|
387
405
|
/**
|
|
388
406
|
* #4271: scripts/acceptance-red-green.sh for the frozen repro_test in cwd; {base, head} words, or null on any failure.
|
|
389
407
|
* `env` (#4325): the lane env (run TMPDIR, toolchain), merged over process.env, so the command sees what the builder saw.
|