forgedock 1.34.0 → 1.36.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/engine/events.mjs +152 -0
- package/bin/engine/events.test.mjs +128 -0
- package/bin/engine/telemetry-seam.mjs +4 -3
- package/bin/engine-cli.mjs +181 -26
- package/bin/experiment.mjs +55 -0
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +44 -5
- package/bin/lean/batch.mjs +66 -3
- package/bin/lean/batch.test.mjs +176 -1
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +30 -9
- package/bin/lean/conformance.test.mjs +51 -0
- package/bin/lean/effects.mjs +14 -3
- package/bin/lean/execution.mjs +2 -0
- package/bin/lean/i18n.mjs +65 -0
- package/bin/lean/i18n.test.mjs +47 -0
- package/bin/lean/lane.mjs +16 -0
- package/bin/lean/lean.mjs +147 -36
- package/bin/lean/lean.test.mjs +319 -8
- package/bin/lean/orchestrate.mjs +130 -13
- package/bin/lean/postdeploy-check.mjs +149 -0
- package/bin/lean/postdeploy-check.test.mjs +146 -0
- package/bin/lean/postdeploy-monitor.mjs +87 -0
- package/bin/lean/postdeploy-monitor.test.mjs +121 -0
- package/bin/lean/precheck.mjs +58 -8
- package/bin/lean/precheck.test.mjs +77 -3
- package/bin/lean/prefilter.mjs +29 -4
- package/bin/lean/prefilter.test.mjs +13 -5
- package/bin/lean/probe.mjs +2 -1
- package/bin/lean/prompts.mjs +36 -6
- package/bin/lean/sandbox-provider.contract.test.mjs +28 -0
- package/bin/lean/sandbox-remote.mjs +7 -0
- package/bin/lean/schedd.mjs +34 -5
- package/bin/lean/schedd.test.mjs +84 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/bin/stats/aggregate.mjs +1 -1
- package/bin/stats/experiment.mjs +44 -0
- package/bin/stats/github-source.mjs +2 -1
- package/bin/stats/index.mjs +42 -2
- package/bin/stats/release.mjs +75 -0
- package/bin/stats/release.test.mjs +68 -0
- package/commands/orchestrate.md +8 -3
- package/docs/CONFIG.md +61 -2
- package/docs/EVENTS.md +68 -0
- package/docs/STATS.md +31 -0
- package/docs/TELEMETRY.md +3 -1
- package/docs/site/install.md +89 -0
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +2 -1
- package/scripts/eval-gate-scorecard.mjs +7 -0
- package/scripts/file-deferred-followups.sh +1 -0
- package/scripts/file-deferred-followups.test.sh +8 -0
package/bin/lean/conformance.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const INCIDENTAL_RE = /(^|\/)(CHANGELOG|CHANGES|HISTORY)(\.[a-z]+)?$|(^|\/)(pack
|
|
|
27
27
|
export const MIN_QUOTE_CHARS = 12;
|
|
28
28
|
/** A contract larger than this cannot be posted whole in one comment (the comment cap is 60k). */
|
|
29
29
|
export const MAX_CONTRACT_CHARS = 40000;
|
|
30
|
+
import { CHECK_ID_RE } from "./postdeploy-check.mjs";
|
|
30
31
|
const C = OUTCOME_CODES;
|
|
31
32
|
|
|
32
33
|
export const norm = (s) => String(s ?? "").replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
@@ -57,11 +58,13 @@ export function scopeText(iss, trustedComments = []) {
|
|
|
57
58
|
/** #4265: failures whose detail starts with a contract requirement id also quote that requirement, so a needs-human names what is unmet. */
|
|
58
59
|
export function explainFailures(failures, contract) {
|
|
59
60
|
const reqs = new Map((contract?.requirements ?? []).map((r) => [String(r?.id ?? ""), String(r?.quote ?? "")]));
|
|
61
|
+
const amended = new Map((contract?.requirements ?? []).filter((r) => r?.amendment?.text).map((r) => [String(r.id), String(r.amendment.text)]));
|
|
60
62
|
return (failures ?? []).map((f) => {
|
|
61
63
|
const id = /^([A-Za-z][\w-]*)\b/.exec(String(f?.detail ?? ""))?.[1];
|
|
62
64
|
const q = id && reqs.get(id);
|
|
63
65
|
if (!q) return f;
|
|
64
|
-
|
|
66
|
+
const am = amended.get(id);
|
|
67
|
+
return { ...f, detail: `${f.detail} ("${q.length > 160 ? `${q.slice(0, 157)}...` : q}"${am ? `, amended to "${am.length > 160 ? `${am.slice(0, 157)}...` : am}"` : ""})` };
|
|
65
68
|
});
|
|
66
69
|
}
|
|
67
70
|
export const isTestPath = (p) => TEST_RE.test(String(p));
|
|
@@ -229,6 +232,7 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
229
232
|
if (!DISPOSITIONS.includes(r?.disposition)) problems.push(`${id}: disposition must be one of ${DISPOSITIONS.join("|")}`);
|
|
230
233
|
if (!inIssue(r?.quote, issueText)) problems.push(`${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)`);
|
|
231
234
|
if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
|
|
235
|
+
if (r?.disposition === "post-deploy" && !CHECK_ID_RE.test(String(r?.check ?? ""))) problems.push(`${id}: post-deploy needs a check id (\`check\`, e.g. "login-smoke") naming an entry of .forge/post-deploy-checks.json on the default branch`);
|
|
232
236
|
if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
|
|
233
237
|
if (r?.verify != null && !VERIFY_MODES.includes(r.verify)) problems.push(`${id}: verify must be one of ${VERIFY_MODES.join("|")}`);
|
|
234
238
|
if (isOutOfBand(r) && r?.disposition !== "deliver") problems.push(`${id}: verify out_of_band applies only to a deliver requirement`);
|
|
@@ -264,7 +268,7 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
264
268
|
/** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
|
|
265
269
|
const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
266
270
|
|
|
267
|
-
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED];
|
|
271
|
+
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement"];
|
|
268
272
|
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
|
|
269
273
|
|
|
270
274
|
/** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
|
|
@@ -272,7 +276,8 @@ function normalizeAmendment(a) {
|
|
|
272
276
|
if (!a || typeof a !== "object" || Array.isArray(a)) return a;
|
|
273
277
|
const n = { ...a };
|
|
274
278
|
if (blank(n.kind) && !blank(n.type)) n.kind = n.type;
|
|
275
|
-
|
|
279
|
+
// #4594: amend-requirement with a revised `text` is a real amendment; without one it stays the slip for defer
|
|
280
|
+
if (Object.hasOwn(AMEND_SLIPS, n.kind) && !(n.kind === "amend-requirement" && !blank(n.text))) n.kind = AMEND_SLIPS[n.kind];
|
|
276
281
|
if (TRACKED.includes(n.kind) && blank(n.requirement) && /^R\d+$/.test(String(n.id ?? ""))) n.requirement = n.id;
|
|
277
282
|
return n;
|
|
278
283
|
}
|
|
@@ -281,7 +286,7 @@ function normalizeAmendment(a) {
|
|
|
281
286
|
function amendmentProblem(a, tag) {
|
|
282
287
|
const kind = a?.kind;
|
|
283
288
|
const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
|
|
284
|
-
: TRACKED.includes(kind) ? "needs requirement: \"R#\"" : null;
|
|
289
|
+
: TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)" : null;
|
|
285
290
|
const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
|
|
286
291
|
return `unknown or incomplete amendment: ${why}: ${tag}`;
|
|
287
292
|
}
|
|
@@ -320,10 +325,19 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
320
325
|
if (tagsOutOfBand(r)) r.verify = "out_of_band"; // #4404: same freeze-time tag as the planned requirements
|
|
321
326
|
c.requirements.push(r);
|
|
322
327
|
if (TRACKED.includes(r.disposition)) toTrack.push(r);
|
|
328
|
+
} else if (a.kind === "amend-requirement" && !blank(a.requirement)) {
|
|
329
|
+
// #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
|
|
330
|
+
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
331
|
+
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
332
|
+
r.amendment = { text: String(a.text).trim(), why: String(a.why).trim() }; r.amended = true;
|
|
323
333
|
} else if (TRACKED.includes(a.kind) && a.requirement) {
|
|
324
334
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
325
335
|
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
326
|
-
if (!TRACKED.includes(r.disposition)) {
|
|
336
|
+
if (!TRACKED.includes(r.disposition)) {
|
|
337
|
+
if (a.kind === "post-deploy" && !CHECK_ID_RE.test(String(a.check ?? r.check ?? ""))) { problems.push(`amendment post-deploy ${a.requirement} needs a check id (check)`); continue; }
|
|
338
|
+
r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r);
|
|
339
|
+
if (a.kind === "post-deploy") r.check = a.check ?? r.check; // #4626
|
|
340
|
+
}
|
|
327
341
|
} else { problems.push(amendmentProblem(a, tag)); continue; }
|
|
328
342
|
applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
|
|
329
343
|
}
|
|
@@ -379,8 +393,12 @@ export function repoTracks(paths) {
|
|
|
379
393
|
* `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
|
|
380
394
|
* tool), for the trajectory; always an array.
|
|
381
395
|
*/
|
|
382
|
-
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null }) {
|
|
396
|
+
export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null }) {
|
|
383
397
|
const failures = [], superseded = [], ciCovered = [];
|
|
398
|
+
// #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
|
|
399
|
+
if (checkIds) for (const r of contract.requirements ?? []) {
|
|
400
|
+
if (r.disposition === "post-deploy" && !(r.check && checkIds.has(r.check))) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "check_unknown", detail: `${r.id} post-deploy check ${r.check ? `'${r.check}'` : "id"} is neither on the default branch nor added by this PR (.forge/post-deploy-checks.json)` });
|
|
401
|
+
}
|
|
384
402
|
const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
|
|
385
403
|
const creep = outside(contract);
|
|
386
404
|
const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
|
|
@@ -625,14 +643,16 @@ export function decide(raw, ctx) {
|
|
|
625
643
|
const comp = checkCompleteness(rv, ctx);
|
|
626
644
|
if (!comp.complete) return { state: "review.incomplete", failures: comp.problems.map((d) => ({ code: C.REVIEW_INCOMPLETE, detail: d })), missing: comp.missing, review: rv };
|
|
627
645
|
const f = [];
|
|
628
|
-
|
|
646
|
+
const amendedOf = (ids) => (ctx.contract.requirements ?? []).find((r) => r.amendment?.text && (ids ?? []).includes(r.id));
|
|
647
|
+
const notAccepted = (r) => (r ? `amendment not accepted (${r.id} amended to "${String(r.amendment.text).slice(0, 200)}"): ` : "");
|
|
648
|
+
for (const c of rv.issue_coverage) if (c.status !== "covered") f.push({ code: C.CONFORMANCE_OMISSION, detail: `${notAccepted(amendedOf(c.contract_ids))}issue requirement ${c.status}: "${c.quote}"${c.note ? ` (${c.note})` : ""}` });
|
|
629
649
|
for (const r of ctx.contract.requirements) {
|
|
630
650
|
const row = rv.requirements.find((x) => x.id === r.id);
|
|
631
651
|
if (r.disposition === "deliver" && isOutOfBand(r)) {
|
|
632
652
|
// #4404: the measured result lives in the named job; only the instrumentation is judged here
|
|
633
653
|
if (row.instrumented !== true) f.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} verified out of band${r.evidence ? ` by ${r.evidence}` : ""}: instrumentation missing${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
634
654
|
} else if (r.disposition === "deliver" && row.status !== "implemented") {
|
|
635
|
-
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} ${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
655
|
+
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.amendment?.text ? `${r.id} amendment not accepted (amended to "${String(r.amendment.text).slice(0, 200)}"): ` : `${r.id} `}${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
636
656
|
}
|
|
637
657
|
if (TRACKED.includes(r.disposition) && row.deferral_ok === false) f.push({ code: C.CONFORMANCE_UNJUSTIFIED_DEFERRAL, detail: `${r.id} ${r.disposition} is not justified${row.note ? `: ${row.note}` : ""}; implement it` });
|
|
638
658
|
}
|
|
@@ -735,7 +755,8 @@ export function formatShadowSummary(rows) {
|
|
|
735
755
|
/** One line per requirement for the follow-up script: a stable key (id + quote) and a why. Tabs and newlines removed. */
|
|
736
756
|
export function followupItems(reqs) {
|
|
737
757
|
const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
|
|
738
|
-
|
|
758
|
+
const check = (r) => (r.disposition === "post-deploy" && r.check ? `[check:${flat(r.check)}] ` : ""); // #4626: the follow-up item names its check
|
|
759
|
+
return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240).trim(), why: flat(`${r.disposition}: ${check(r)}${r.why ?? ""}`).slice(0, 400).trim() }));
|
|
739
760
|
}
|
|
740
761
|
|
|
741
762
|
/** #4388: the follow-up script's `key\tissue` lines as {[item.key]: issue}, matched on trimmed keys (the script trims what it echoes); unmatched items omitted. */
|
|
@@ -645,3 +645,54 @@ describe("#4404 verified out of band: a requirement measured outside the lane is
|
|
|
645
645
|
assert.deepEqual(outOfBandRequirements(tagged), [{ id: "R3", quote: LH, job: "lighthouse-nightly" }]);
|
|
646
646
|
});
|
|
647
647
|
});
|
|
648
|
+
|
|
649
|
+
describe("#4594 amend-requirement", () => {
|
|
650
|
+
const am = { kind: "amend-requirement", requirement: "R1", text: "regenerate and reveal the feed URL once", why: "token stored hash-only" };
|
|
651
|
+
it("with text it keeps deliver, tracks nothing and records the amendment", () => {
|
|
652
|
+
const a = applyAmendments(C, [am]);
|
|
653
|
+
assert.deepEqual(a.problems, []); assert.deepEqual(a.toTrack, []);
|
|
654
|
+
const r = a.contract.requirements.find((x) => x.id === "R1");
|
|
655
|
+
assert.equal(r.disposition, "deliver"); assert.equal(r.amendment.text, am.text); assert.equal(r.amendment.why, am.why); assert.equal(r.quote, C.requirements[0].quote);
|
|
656
|
+
});
|
|
657
|
+
it("an unknown requirement is a problem; the frozen contract is untouched", () => {
|
|
658
|
+
assert.match(applyAmendments(C, [{ ...am, requirement: "R9" }]).problems[0], /unknown requirement R9/);
|
|
659
|
+
assert.equal(C.requirements[0].amendment, undefined);
|
|
660
|
+
});
|
|
661
|
+
it("decide: an amended requirement the reviewer rejects fails as 'amendment not accepted'; accepted conforms", () => {
|
|
662
|
+
const k = { ...ctx, contract: applyAmendments(C, [am]).contract };
|
|
663
|
+
assert.equal(decide(good, k).state, "conforms");
|
|
664
|
+
const d = decide({ ...row("R1", { status: "partial", note: "amendment: still never shows the URL" }), issue_coverage: good.issue_coverage.map((c) => (c.quote === Q1 ? { ...c, contract_ids: ["R1"], status: "narrowed" } : c)) }, k);
|
|
665
|
+
assert.equal(d.state, "nonconforming");
|
|
666
|
+
assert.ok(d.failures.length >= 2 && d.failures.every((f) => /amendment not accepted \(.*amended to "regenerate/.test(f.detail)));
|
|
667
|
+
});
|
|
668
|
+
it("explainFailures shows the amended text", () => {
|
|
669
|
+
const c = applyAmendments(C, [am]).contract;
|
|
670
|
+
assert.match(explainFailures([{ code: "x", detail: "R1 partial" }], c)[0].detail, /amended to "regenerate and reveal/);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
describe("post-deploy check ids (#4626)", () => {
|
|
675
|
+
const pd = (extra = {}) => ({ ...C, requirements: [{ id: "R1", quote: Q1, disposition: "post-deploy", why: "needs prod", ...extra }, C.requirements[1]] });
|
|
676
|
+
it("validateContract: a post-deploy requirement needs a well-formed check id", () => {
|
|
677
|
+
assert.ok(validateContract(pd(), ISSUE).problems.some((p) => /post-deploy needs a check id/.test(p)));
|
|
678
|
+
assert.ok(validateContract(pd({ check: "Bad Id" }), ISSUE).problems.some((p) => /post-deploy needs a check id/.test(p)));
|
|
679
|
+
assert.ok(!validateContract(pd({ check: "login-smoke" }), ISSUE).problems.some((p) => /check id/.test(p)));
|
|
680
|
+
});
|
|
681
|
+
it("applyAmendments: a post-deploy amendment needs a check id", () => {
|
|
682
|
+
const bad = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w" }], ISSUE);
|
|
683
|
+
assert.ok(bad.problems.some((p) => /needs a check id/.test(p)));
|
|
684
|
+
const ok = applyAmendments(C, [{ kind: "post-deploy", requirement: "R1", why: "w", check: "login-smoke" }], ISSUE);
|
|
685
|
+
assert.equal(ok.contract.requirements[0].check, "login-smoke");
|
|
686
|
+
});
|
|
687
|
+
it("precheck: a check id on neither branch is unimplemented; null checkIds skips the rule", () => {
|
|
688
|
+
const res = { requirements: [{ id: "R1", status: "post-deploy" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
689
|
+
const c = pd({ check: "login-smoke" });
|
|
690
|
+
assert.ok(precheck({ contract: c, result: res, changedFiles: files, checkIds: new Set() }).failures.some((f) => f.kind === "check_unknown"));
|
|
691
|
+
assert.ok(!precheck({ contract: c, result: res, changedFiles: files, checkIds: new Set(["login-smoke"]) }).failures.some((f) => f.kind === "check_unknown"));
|
|
692
|
+
assert.ok(!precheck({ contract: c, result: res, changedFiles: files }).failures.some((f) => f.kind === "check_unknown"));
|
|
693
|
+
});
|
|
694
|
+
it("followupItems names the check in a post-deploy item", () => {
|
|
695
|
+
const [i] = followupItems([{ id: "R1", quote: Q1, disposition: "post-deploy", why: "w", check: "login-smoke" }]);
|
|
696
|
+
assert.match(i.why, /^post-deploy: \[check:login-smoke\] w$/);
|
|
697
|
+
});
|
|
698
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -17,8 +17,10 @@ import { makeHeavyIo } from "./heavy.mjs";
|
|
|
17
17
|
import { gatherContextFacts } from "./context.mjs";
|
|
18
18
|
import { makePitfallStore } from "./pitfalls.mjs";
|
|
19
19
|
import { enumeratingTests, missingEntries } from "./enumerating.mjs";
|
|
20
|
-
import {
|
|
20
|
+
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
21
|
+
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
|
|
21
22
|
import { duplicateExports } from "./postmerge.mjs";
|
|
23
|
+
import { englishCopies } from "./i18n.mjs";
|
|
22
24
|
import { scopedChecks } from "./checks.mjs";
|
|
23
25
|
import { makeSequenceLeaseIo } from "./seqlease.mjs";
|
|
24
26
|
import { followupMap } from "./conformance.mjs";
|
|
@@ -63,8 +65,11 @@ export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
|
|
|
63
65
|
|
|
64
66
|
/** #4456: `--settings` registering the re-read nudge (bin/hooks/reread-nudge.mjs) as a PostToolUse hook on Read and Bash. */
|
|
65
67
|
export const REREAD_HOOK = fileURLToPath(new URL("../hooks/reread-nudge.mjs", import.meta.url));
|
|
66
|
-
export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) =>
|
|
67
|
-
hooks
|
|
68
|
+
export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => {
|
|
69
|
+
const hooks = [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }];
|
|
70
|
+
// #4580: the same script denies a full-file Read of an unchanged path already read twice (PreToolUse)
|
|
71
|
+
return ["--settings", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "Read|Bash", hooks }], PreToolUse: [{ matcher: "Read", hooks }] } })];
|
|
72
|
+
};
|
|
68
73
|
|
|
69
74
|
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
|
|
70
75
|
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = 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 }) => {
|
|
@@ -372,10 +377,16 @@ export function makeLeanIo({ repo }) {
|
|
|
372
377
|
/** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
|
|
373
378
|
sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve }),
|
|
374
379
|
sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
|
|
380
|
+
/** #4588: deterministic base-merge resolution of union / JSON-map / generated files (bin/lean/conflictres.mjs). */
|
|
381
|
+
conflictResolve: ({ cwd, files, cfg }) => resolveBaseConflicts({ git: (a) => run("git")(a, { cwd }), cwd, files, cfg }),
|
|
375
382
|
sequenceLease: makeSequenceLeaseIo(),
|
|
383
|
+
/** #4591: lane-added migrations the repo's generator did not produce (no journal entry or snapshot); [] on failure. */
|
|
384
|
+
migrationGenerator: ({ cwd, base }) => generatorProblems({ git: (a) => run("git")(a, { cwd }), base }),
|
|
376
385
|
/** #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). */
|
|
377
386
|
postMergeChecks: async ({ cwd, base }) => { try { return scopedChecks({ repo: cwd, base }); } catch { return []; } },
|
|
378
387
|
duplicateExports: ({ cwd, base }) => duplicateExports({ git: (a) => run("git")(a, { cwd }), cwd, base }),
|
|
388
|
+
/** #4596: keys added to a non-en locale catalog as English copies (bin/lean/i18n.mjs). */
|
|
389
|
+
englishCopies: ({ cwd, base }) => englishCopies({ git: (a) => run("git")(a, { cwd }), base }),
|
|
379
390
|
/**
|
|
380
391
|
* #4271: scripts/acceptance-red-green.sh for the frozen repro_test in cwd; {base, head} words, or null on any failure.
|
|
381
392
|
* `env` (#4325): the lane env (run TMPDIR, toolchain), merged over process.env, so the command sees what the builder saw.
|
package/bin/lean/execution.mjs
CHANGED
|
@@ -480,6 +480,8 @@ export function providerLanes({ provider, laneArgs, ledgerFor, env = process.env
|
|
|
480
480
|
? { terminalReason: "deferred", code: OUTCOME_CODES.SANDBOX_CAPACITY, detail: `sandbox ${id} could not start (capacity): ${errText(e)}`, ...(Number(e.retryInSeconds) > 0 ? { retryInSeconds: Number(e.retryInSeconds) } : {}) }
|
|
481
481
|
: { terminalReason: "needs-human", code: OUTCOME_CODES.SANDBOX_START_FAILED, detail: `sandbox ${id} failed to start: ${errText(e)}` });
|
|
482
482
|
return {
|
|
483
|
+
// #4612: a graceful stop of a live sandbox (the provider's checkpoint stop); false when it has none.
|
|
484
|
+
stopLane: async (n, rec) => (typeof provider.stop === "function" && rec?.handle ? (await provider.stop(rec.handle)) === true : false),
|
|
483
485
|
runOne: async (n, lane, ctx = {}) => {
|
|
484
486
|
const led = ledgerFor(n);
|
|
485
487
|
const base = { provider: provider.name, id: provider.idFor(n), issue: n, startedAt: now() };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
/**
|
|
4
|
+
* English copies in locale catalogs (#4596). A lane adds a key to the source-locale (en) JSON catalog and copies the
|
|
5
|
+
* English text into the other locales; the repo's own i18n check rejects it, but only on CI. Deterministic (git, no LLM):
|
|
6
|
+
* for each changed non-en JSON catalog whose en counterpart exists, the keys the lane added whose string value equals the
|
|
7
|
+
* en value. Counterpart: the path with its locale segment (a directory or the file stem) replaced by `en`.
|
|
8
|
+
*/
|
|
9
|
+
const LOCALE_RE = /^[a-z]{2,3}(?:[-_][A-Za-z]{2,4})?$/;
|
|
10
|
+
const MAX_FINDINGS = 200;
|
|
11
|
+
const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
|
|
12
|
+
const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
|
|
13
|
+
|
|
14
|
+
/** The en counterpart path of a non-en catalog path, or null when no segment looks like a locale code. */
|
|
15
|
+
export function sourcePath(path) {
|
|
16
|
+
const segs = String(path).split("/");
|
|
17
|
+
for (let i = segs.length - 1; i >= 0; i--) {
|
|
18
|
+
const last = i === segs.length - 1;
|
|
19
|
+
const m = last ? /^(.+)\.json$/.exec(segs[i]) : null;
|
|
20
|
+
const name = last ? (m ? m[1] : "") : segs[i];
|
|
21
|
+
if (!LOCALE_RE.test(name) || name === "en") continue;
|
|
22
|
+
const copy = [...segs];
|
|
23
|
+
copy[i] = last ? "en.json" : "en";
|
|
24
|
+
return copy.join("/");
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Flattened string leaves of a JSON catalog as Map(dotted key -> value); an empty map on invalid JSON. */
|
|
30
|
+
export function flatten(text) {
|
|
31
|
+
const res = new Map();
|
|
32
|
+
let doc;
|
|
33
|
+
try { doc = JSON.parse(String(text ?? "")); } catch { return res; }
|
|
34
|
+
const walk = (v, key) => {
|
|
35
|
+
if (typeof v === "string") res.set(key, v);
|
|
36
|
+
else if (v && typeof v === "object") for (const [k, x] of Object.entries(v)) walk(x, key ? `${key}.${k}` : k);
|
|
37
|
+
};
|
|
38
|
+
walk(doc, "");
|
|
39
|
+
return res;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A value worth translating: three letters in a row once placeholders are removed (not "OK", "{{n}}", "12:00"). */
|
|
43
|
+
const translatable = (v) => /\p{L}{3}/u.test(v.replace(/\{\{?[^}]*\}\}?/g, ""));
|
|
44
|
+
|
|
45
|
+
/** @returns {Promise<Array<{path, key, value, source}>>}; never throws ([] on any failure). */
|
|
46
|
+
export async function englishCopies({ git, base }) {
|
|
47
|
+
try {
|
|
48
|
+
const g = out(git);
|
|
49
|
+
const changed = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=AM", `${base}...HEAD`])).filter((f) => f.endsWith(".json"));
|
|
50
|
+
const res = [];
|
|
51
|
+
for (const f of changed) {
|
|
52
|
+
const src = sourcePath(f);
|
|
53
|
+
if (!src) continue;
|
|
54
|
+
const en = flatten(await g(["show", `HEAD:${src}`]));
|
|
55
|
+
if (!en.size) continue;
|
|
56
|
+
const was = flatten(await g(["show", `${base}:${f}`]));
|
|
57
|
+
for (const [key, value] of flatten(await g(["show", `HEAD:${f}`]))) {
|
|
58
|
+
if (was.has(key) || en.get(key) !== value || !translatable(value)) continue;
|
|
59
|
+
res.push({ path: f, key, value, source: src });
|
|
60
|
+
if (res.length >= MAX_FINDINGS) return res;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return res;
|
|
64
|
+
} catch { return []; }
|
|
65
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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 { execFileSync } from "node:child_process";
|
|
6
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
import { englishCopies, sourcePath, flatten } from "./i18n.mjs";
|
|
10
|
+
|
|
11
|
+
const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
|
12
|
+
const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), JSON.stringify(t)); } };
|
|
13
|
+
function repo(base, lane) {
|
|
14
|
+
const d = tmpDir("i18n-");
|
|
15
|
+
sh(d, "init", "-q", "-b", "main");
|
|
16
|
+
put(d, base); sh(d, "add", "-A"); sh(d, "commit", "-qm", "base");
|
|
17
|
+
sh(d, "checkout", "-q", "-b", "lane");
|
|
18
|
+
put(d, lane); sh(d, "add", "-A"); sh(d, "commit", "-qm", "lane");
|
|
19
|
+
return d;
|
|
20
|
+
}
|
|
21
|
+
const copies = (d) => englishCopies({ git: async (a) => sh(d, ...a), base: "main" });
|
|
22
|
+
|
|
23
|
+
describe("i18n english copies (#4596)", () => {
|
|
24
|
+
it("sourcePath maps a locale file or directory to en", () => {
|
|
25
|
+
assert.equal(sourcePath("messages/ta.json"), "messages/en.json");
|
|
26
|
+
assert.equal(sourcePath("locales/kn/common.json"), "locales/en/common.json");
|
|
27
|
+
assert.equal(sourcePath("messages/en.json"), null);
|
|
28
|
+
assert.equal(sourcePath("package.json"), null);
|
|
29
|
+
});
|
|
30
|
+
it("flatten reads nested string leaves", () => {
|
|
31
|
+
assert.deepEqual([...flatten('{"a":{"b":"x"},"n":1}')], [["a.b", "x"]]);
|
|
32
|
+
assert.equal(flatten("not json").size, 0);
|
|
33
|
+
});
|
|
34
|
+
it("flags added keys copied from en; ignores translated, pre-existing, short and placeholder values", async () => {
|
|
35
|
+
const d = repo(
|
|
36
|
+
{ "messages/en.json": { old: "Old text", v: { a: "Calendar view" } }, "messages/ta.json": { old: "Old text" } },
|
|
37
|
+
{ "messages/en.json": { old: "Old text", v: { a: "Calendar view", b: "Go", c: "OK", d: "{{count}}", e: "Week view" } },
|
|
38
|
+
"messages/ta.json": { old: "Old text", v: { a: "Calendar view", b: "Go", c: "OK", d: "{{count}}", e: "வாரக் காட்சி" } } });
|
|
39
|
+
const r = await copies(d);
|
|
40
|
+
assert.deepEqual(r.map((x) => `${x.path}:${x.key}`), ["messages/ta.json:v.a"]);
|
|
41
|
+
});
|
|
42
|
+
it("is empty without a source catalog and never throws", async () => {
|
|
43
|
+
const d = repo({ "messages/ta.json": { a: "Hello there" } }, { "messages/ta.json": { a: "Hello there", b: "Hello there" } });
|
|
44
|
+
assert.deepEqual(await copies(d), []);
|
|
45
|
+
assert.deepEqual(await englishCopies({ git: async () => { throw new Error("x"); }, base: "main" }), []);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/bin/lean/lane.mjs
CHANGED
|
@@ -83,3 +83,19 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
|
|
|
83
83
|
if (!out.trim()) return refuse(OUTCOME_CODES.LANE_BRANCH_MISSING, `lane branch ${lane} (${why}) does not exist on origin; create it first (e.g. /milestone start) or fix the milestone title`);
|
|
84
84
|
return { ok: true, lane, expected, milestone, overridden, defaults };
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* #4593: true when `lane` is a non-staging branch fully merged into `staging` by a merge commit (a shipped milestone branch).
|
|
89
|
+
* Fail closed: any git error (no such ref, offline) is "not shipped".
|
|
90
|
+
*/
|
|
91
|
+
export async function isShippedLane({ lane, staging = DEFAULT_STAGING_BRANCH, io, cwd }) {
|
|
92
|
+
if (!lane || lane === staging) return false;
|
|
93
|
+
try {
|
|
94
|
+
try { await io.git(["fetch", "origin", lane, staging], { cwd }); } catch { /* offline: use the local refs */ }
|
|
95
|
+
await io.git(["merge-base", "--is-ancestor", `origin/${lane}`, `origin/${staging}`], { cwd });
|
|
96
|
+
// #4623: an unused lane cut from staging is trivially an ancestor; shipped also needs a merge commit that took the lane tip.
|
|
97
|
+
const tip = String(await io.git(["rev-parse", `origin/${lane}`], { cwd })).trim();
|
|
98
|
+
const merges = String(await io.git(["rev-list", "--merges", "--parents", `origin/${staging}`, `^origin/${lane}`], { cwd }));
|
|
99
|
+
return !!tip && merges.split("\n").some((l) => l.trim().split(/\s+/).slice(2).includes(tip));
|
|
100
|
+
} catch { return false; }
|
|
101
|
+
}
|