forgedock 1.29.1 → 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.
Files changed (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +356 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,90 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Decomposition gate (#4396): a builder `split-required` block is admitted only when its sub-issues touch disjoint
5
+ * areas (each declares `paths`) or the block's `size` estimate exceeds the large-lane budget; otherwise the issue stays
6
+ * one lane with an ordered checklist. Also the batch report's decomposed vs single-lane $/parent line. Pure: no I/O.
7
+ */
8
+
9
+ /** The large-lane size budget: a split of overlapping parts is admitted only above it. */
10
+ export const SPLIT_MIN_FILES = 25;
11
+ export const SPLIT_MIN_LINES = 1500;
12
+
13
+ const norm = (p) => String(p).trim().replace(/^(\.?\/+)+/, "").replace(/\/+$/, "");
14
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
15
+ const count = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null);
16
+
17
+ /** The block's `size` as {files, lines} (null fields when not a finite non-negative number), or null when absent. */
18
+ function sizeOf(block) {
19
+ const s = block?.size;
20
+ if (!s || typeof s !== "object" || Array.isArray(s)) return null;
21
+ const files = count(s.files), lines = count(s.lines);
22
+ return files == null && lines == null ? null : { files, lines };
23
+ }
24
+
25
+ /**
26
+ * @param {object} block result.json `block` of kind split-required (sub-issues already checked for title and body)
27
+ * @returns {{ok:true, rationale:{independent:boolean, oversize:boolean, overlaps:Array<[number,number,string,string]>, missing:number[], size:?{files:?number, lines:?number}}} | {ok:false, problem:string}}
28
+ */
29
+ export function splitGate(block) {
30
+ const subs = Array.isArray(block?.subissues) ? block.subissues : [];
31
+ // `paths` or the #4385 `files` (the files each sub-issue edits): either declares the area a part touches.
32
+ const declared = (s) => [...(Array.isArray(s?.paths) ? s.paths : []), ...(Array.isArray(s?.files) ? s.files : [])];
33
+ const paths = subs.map((s) => [...new Set(declared(s).filter((p) => typeof p === "string" && norm(p)).map(norm))]);
34
+ const missing = paths.flatMap((p, i) => (p.length ? [] : [i]));
35
+ const found = [];
36
+ for (let i = 0; i < paths.length; i++) {
37
+ for (let j = i + 1; j < paths.length; j++) {
38
+ for (const a of paths[i]) for (const b of paths[j]) if (overlaps(a, b)) found.push([i, j, a, b]);
39
+ }
40
+ }
41
+ const independent = subs.length > 0 && !missing.length && !found.length;
42
+ const size = sizeOf(block);
43
+ const oversize = !!size && ((size.files ?? 0) > SPLIT_MIN_FILES || (size.lines ?? 0) > SPLIT_MIN_LINES);
44
+ if (independent || oversize) return { ok: true, rationale: { independent, oversize, overlaps: found, missing, size } };
45
+ const why = [];
46
+ if (missing.length) why.push(`sub-issue(s) ${missing.join(", ")} declare no "paths" or "files"`);
47
+ if (found.length) why.push(`the parts overlap (${found.slice(0, 5).map(([i, j, a, b]) => `${i}:${a} ~ ${j}:${b}`).join("; ")})`);
48
+ why.push(size ? `the size estimate (${size.files ?? "?"} files, ${size.lines ?? "?"} lines) is within the ${SPLIT_MIN_FILES}-file / ${SPLIT_MIN_LINES}-line budget` : "the block has no \"size\" estimate");
49
+ return { ok: false, problem: `the split is not justified: ${why.join("; ")}. Split only when every sub-issue lists "paths" (or "files") disjoint from the others, or when "size" exceeds ${SPLIT_MIN_FILES} files or ${SPLIT_MIN_LINES} lines. Otherwise keep one lane: write the contract as an ordered checklist, one requirement or deliverable per item, each with its own validate command` };
50
+ }
51
+
52
+ /** One line for the FORGE:DECOMPOSED comment and the decision log. */
53
+ export function rationaleLine(r) {
54
+ if (!r) return "";
55
+ const ov = r.overlaps?.length ? ` (overlaps ${r.overlaps.slice(0, 3).map(([i, j, a, b]) => `${i}:${a} ~ ${j}:${b}`).join("; ")})` : r.missing?.length ? ` (no paths: ${r.missing.join(", ")})` : "";
56
+ const sz = r.size ? `${r.size.files ?? "?"} files, ${r.size.lines ?? "?"} lines` : "no estimate";
57
+ return `independent: ${r.independent ? "yes" : "no"}${ov}; size: ${sz} vs budget ${SPLIT_MIN_FILES} files / ${SPLIT_MIN_LINES} lines -> oversize ${r.oversize ? "yes" : "no"}`;
58
+ }
59
+
60
+ /**
61
+ * Batch-report line: cost per decomposed parent (its row plus its sub-issue rows, recursively) vs per single-lane
62
+ * parent (a merged row that is no row's sub-issue). Null when no row decomposed.
63
+ */
64
+ export function formatDecompositionSummary(rows) {
65
+ const list = (rows ?? []).filter((r) => r && Number.isSafeInteger(r.issue));
66
+ const subsOf = (r) => (r?.outcome === "decomposed" && Array.isArray(r.subissues) ? r.subissues.filter((n) => Number.isSafeInteger(n) && n !== r.issue) : []);
67
+ const byIssue = new Map(list.map((r) => [r.issue, r]));
68
+ const isSub = new Set(list.flatMap(subsOf));
69
+ const parents = list.filter((r) => subsOf(r).length && !isSub.has(r.issue));
70
+ if (!list.some((r) => subsOf(r).length)) return null;
71
+ let lanes = 0, outside = 0;
72
+ const total = (r, seen) => {
73
+ let c = Number(r.cost_usd) || 0;
74
+ for (const n of subsOf(r)) {
75
+ if (seen.has(n)) continue;
76
+ seen.add(n);
77
+ const s = byIssue.get(n);
78
+ if (!s) { outside++; continue; }
79
+ lanes++;
80
+ c += total(s, seen);
81
+ }
82
+ return c;
83
+ };
84
+ const dCost = parents.reduce((a, r) => a + total(r, new Set([r.issue])), 0);
85
+ const single = list.filter((r) => r.outcome === "merged" && !isSub.has(r.issue));
86
+ const sCost = single.reduce((a, r) => a + (Number(r.cost_usd) || 0), 0);
87
+ const per = (c, n) => `$${(n ? c / n : 0).toFixed(2)}/parent`;
88
+ return `decomposition: ${parents.length} decomposed parent${parents.length === 1 ? "" : "s"} ${per(dCost, parents.length)} (${lanes} sub-lane${lanes === 1 ? "" : "s"}${outside ? `, ${outside} outside this batch` : ""}); `
89
+ + (single.length ? `${single.length} single-lane ${per(sCost, single.length)}` : "single-lane none");
90
+ }
@@ -0,0 +1,105 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // #4396: decomposition gate (independence and size) and the batch report's decomposed vs single-lane $/parent line.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { splitGate, rationaleLine, formatDecompositionSummary, SPLIT_MIN_FILES, SPLIT_MIN_LINES } from "./decompose.mjs";
7
+ import { classifyBlock } from "./lean.mjs";
8
+ import { orchestrate } from "./orchestrate.mjs";
9
+
10
+ const sub = (paths) => ({ title: "t", body: "b", ...(paths ? { paths } : {}) });
11
+
12
+ describe("splitGate (#4396)", () => {
13
+ it("admits parts with disjoint paths and records independent", () => {
14
+ const g = splitGate({ subissues: [sub(["src/a", "docs/a.md"]), sub(["./src/b/"]), sub(["src/ab"])] });
15
+ assert.equal(g.ok, true);
16
+ assert.deepEqual(g.rationale, { independent: true, oversize: false, overlaps: [], missing: [], size: null });
17
+ });
18
+
19
+ it("rejects overlapping parts (same file, or a file under another part's directory) within the size budget", () => {
20
+ for (const [a, b] of [["src/alerts", "src/alerts/routing.ts"], ["src/x.ts", "./src/x.ts"]]) {
21
+ const g = splitGate({ subissues: [sub([a]), sub([b])], size: { files: 3, lines: 200 } });
22
+ assert.equal(g.ok, false, `${a} ~ ${b}`);
23
+ assert.match(g.problem, /parts overlap/);
24
+ assert.match(g.problem, /ordered checklist/);
25
+ assert.match(g.problem, /within the 25-file \/ 1500-line budget/);
26
+ }
27
+ });
28
+
29
+ it("reads the #4385 per-sub-issue files as the declared area, alone or with paths", () => {
30
+ const f = (files, paths) => ({ title: "t", body: "b", files, ...(paths ? { paths } : {}) });
31
+ assert.equal(splitGate({ subissues: [f(["src/a.ts"]), f(["/src/b.ts"])] }).ok, true);
32
+ const g = splitGate({ subissues: [f(["src/a.ts"]), f(["src/b.ts"], ["src/a.ts"])] });
33
+ assert.equal(g.ok, false);
34
+ assert.match(g.problem, /0:src\/a\.ts ~ 1:src\/a\.ts/);
35
+ });
36
+
37
+ it("rejects a split whose parts declare no paths and that has no size estimate", () => {
38
+ const g = splitGate({ subissues: [sub(), sub(["src/b"])] });
39
+ assert.equal(g.ok, false);
40
+ assert.match(g.problem, /sub-issue\(s\) 0 declare no "paths"/);
41
+ assert.match(g.problem, /no "size" estimate/);
42
+ });
43
+
44
+ it("admits overlapping parts when the size estimate exceeds the budget (files or lines); ignores a non-numeric size", () => {
45
+ const parts = [sub(["src/a"]), sub(["src/a/x.ts"])];
46
+ assert.equal(splitGate({ subissues: parts, size: { files: SPLIT_MIN_FILES + 1 } }).rationale.oversize, true);
47
+ const g = splitGate({ subissues: parts, size: { lines: SPLIT_MIN_LINES + 1 } });
48
+ assert.deepEqual({ ok: g.ok, independent: g.rationale.independent, oversize: g.rationale.oversize }, { ok: true, independent: false, oversize: true });
49
+ assert.equal(splitGate({ subissues: parts, size: { files: SPLIT_MIN_FILES, lines: SPLIT_MIN_LINES } }).ok, false, "at the budget is not over it");
50
+ assert.equal(splitGate({ subissues: parts, size: { files: "99" } }).ok, false);
51
+ });
52
+
53
+ it("rationaleLine names independence, overlaps and size against the budget", () => {
54
+ const g = splitGate({ subissues: [sub(["src/a"]), sub(["src/a/x.ts"])], size: { files: 40, lines: 3000 } });
55
+ assert.equal(rationaleLine(g.rationale), "independent: no (overlaps 0:src/a ~ 1:src/a/x.ts); size: 40 files, 3000 lines vs budget 25 files / 1500 lines -> oversize yes");
56
+ assert.equal(rationaleLine(null), "");
57
+ });
58
+
59
+ it("classifyBlock turns an unjustified split into an untyped block and carries the rationale of an admitted one", () => {
60
+ const bad = classifyBlock({ block: { kind: "split-required", subissues: [sub(["src/a"]), sub(["src/a"])] } }, 7);
61
+ assert.equal(bad.kind, "untyped");
62
+ assert.match(bad.problem, /split is not justified/);
63
+ const ok = classifyBlock({ block: { kind: "split-required", subissues: [sub(["src/a"]), { ...sub(["src/b"]), depends_on: [0] }] } }, 7);
64
+ assert.equal(ok.kind, "split");
65
+ assert.equal(ok.rationale.independent, true);
66
+ assert.deepEqual(ok.subissues.map((s) => s.dependsOn), [[], [0]]);
67
+ });
68
+ });
69
+
70
+ describe("formatDecompositionSummary (#4396)", () => {
71
+ it("is null when no row decomposed", () => {
72
+ assert.equal(formatDecompositionSummary([{ issue: 1, outcome: "merged", cost_usd: 3 }]), null);
73
+ assert.equal(formatDecompositionSummary(undefined), null);
74
+ assert.equal(formatDecompositionSummary([{ issue: 1, outcome: "decomposed", subissues: [] }]), null);
75
+ });
76
+
77
+ it("sums a parent with its sub-lanes (recursively) and compares with merged single-lane parents", () => {
78
+ const rows = [
79
+ { issue: 1, outcome: "decomposed", cost_usd: 2, subissues: [11, 12, 13] },
80
+ { issue: 11, outcome: "merged", cost_usd: 4 },
81
+ { issue: 12, outcome: "decomposed", cost_usd: 1, subissues: [121] },
82
+ { issue: 121, outcome: "merged", cost_usd: 3 },
83
+ { issue: 2, outcome: "merged", cost_usd: 5 },
84
+ { issue: 3, outcome: "merged", cost_usd: 7 },
85
+ { issue: 4, outcome: "needs-human", cost_usd: 9 },
86
+ ];
87
+ assert.equal(formatDecompositionSummary(rows), "decomposition: 1 decomposed parent $10.00/parent (3 sub-lanes, 1 outside this batch); 2 single-lane $6.00/parent");
88
+ });
89
+
90
+ it("reports no single-lane parents as none and guards a cycle", () => {
91
+ const rows = [{ issue: 1, outcome: "decomposed", cost_usd: 1, subissues: [2] }, { issue: 2, outcome: "decomposed", cost_usd: 1, subissues: [1] }];
92
+ assert.match(formatDecompositionSummary([{ issue: 1, outcome: "decomposed", cost_usd: 2, subissues: [5] }]), /^decomposition: 1 decomposed parent \$2\.00\/parent \(0 sub-lanes, 1 outside this batch\); single-lane none$/);
93
+ assert.ok(formatDecompositionSummary(rows));
94
+ });
95
+
96
+ it("the batch row keeps the lane's subissues and decomposition, so the report links a parent to its sub-lanes", async () => {
97
+ const rationale = { independent: true, oversize: false, overlaps: [], missing: [], size: null };
98
+ const script = { 1: { terminalReason: "decomposed", cost_usd: 2, subissues: [8], decomposition: rationale }, 8: { terminalReason: "merged", cost_usd: 4 }, 2: { terminalReason: "merged", cost_usd: 1 } };
99
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "", runOne: async (n) => script[n] });
100
+ const r1 = rows.find((r) => r.issue === 1);
101
+ assert.deepEqual(r1.subissues, [8]);
102
+ assert.deepEqual(r1.decomposition, rationale);
103
+ assert.equal(formatDecompositionSummary(rows), "decomposition: 1 decomposed parent $6.00/parent (1 sub-lane); 1 single-lane $1.00/parent");
104
+ });
105
+ });
@@ -80,7 +80,8 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
80
80
  return { npm_config_store_dir: store("pnpm-store"), npm_config_cache: store("npm-cache"), UV_CACHE_DIR: store("uv-cache"), UV_LINK_MODE: "hardlink",
81
81
  POETRY_CACHE_DIR: store("poetry-cache"), PIP_CACHE_DIR: store("pip-cache"), POETRY_VIRTUALENVS_IN_PROJECT: "true" };
82
82
  }
83
- const run = (cmd, args, cwd) => exec(cmd, args, { cwd, env: env(), timeoutMs: installTimeoutMs });
83
+ /** Install/repair command; `laneEnv` (the lane's toolchain env, #4347) sits under the store env, so store keys win. */
84
+ const run = (cmd, args, cwd, laneEnv) => exec(cmd, args, { cwd, env: laneEnv ? { ...laneEnv, ...env() } : env(), timeoutMs: installTimeoutMs });
84
85
  const runtime = async (cmd) => { try { return String(await exec(cmd, ["--version"], { timeoutMs: 30000 })).trim(); } catch { return "unknown"; } };
85
86
 
86
87
  /** Tracked package.json files (workspace members), relative; root only when git cannot list them. */
@@ -190,7 +191,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
190
191
  }
191
192
  }
192
193
 
193
- async function prepareOne(wt, keyRoot, d) {
194
+ async function prepareOne(wt, keyRoot, d, laneEnv) {
194
195
  const t0 = now();
195
196
  const done = (status, extra = {}) => ({ status, eco: d.eco, key: extra.key ?? null, ms: now() - t0, reason: extra.reason ?? null, ...(extra.published === undefined ? {} : { published: extra.published }) });
196
197
  if (d.skip) return done("skipped", { reason: d.skip });
@@ -209,7 +210,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
209
210
  const created = [];
210
211
  try {
211
212
  for (const t of man.trees ?? []) { created.push(t); linkTree(join(snap, t), join(wt, t), { from: man.prefix, to: wt, binDir: binDirFor(d) }); }
212
- for (const [c, a] of cmds.repair ?? []) await run(c, a, wt);
213
+ for (const [c, a] of cmds.repair ?? []) await run(c, a, wt, laneEnv);
213
214
  try { fs.writeFileSync(join(snap, "last-used"), String(now())); } catch { /* best effort */ }
214
215
  return done("hit", { key });
215
216
  } catch (e) {
@@ -218,7 +219,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
218
219
  }
219
220
  };
220
221
  const install = async () => {
221
- try { for (const [c, a] of cmds.install) await run(c, a, wt); } catch (e) {
222
+ try { for (const [c, a] of cmds.install) await run(c, a, wt, laneEnv); } catch (e) {
222
223
  rmTrees(wt, expected);
223
224
  return { failed: done("failed", { key, reason: `${e?.code === "ENOENT" ? "tool not found" : "install failed"}: ${msg(e)}` }) };
224
225
  }
@@ -240,8 +241,11 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
240
241
  return ins.failed ?? done("miss-installed", { key, published: false, reason: "cache lock busy" });
241
242
  }
242
243
 
243
- /** Materialize or build node_modules/.venv for `wt`; one result per detected ecosystem. Never throws. */
244
- async function prepare({ wt, repoKey } = {}) {
244
+ /**
245
+ * Materialize or build node_modules/.venv for `wt`; one result per detected ecosystem. Never throws.
246
+ * `env` (#4347): the lane's toolchain env (PATH with the pinned pm shims) for install/repair; absent means the host PATH.
247
+ */
248
+ async function prepare({ wt, repoKey, env: laneEnv } = {}) {
245
249
  if (disabled()) return [{ status: "skipped", eco: null, key: null, ms: 0, reason: "FORGE_DEPCACHE=0" }];
246
250
  let found;
247
251
  try { found = detect(fs, wt); } catch (e) { return [{ status: "failed", eco: null, key: null, ms: 0, reason: msg(e) }]; }
@@ -249,7 +253,7 @@ export function makeDepCache({ root = depcacheRoot(), exec, fs = nodeFs, now = (
249
253
  const keyRoot = join(root, safeSegment(repoKey ?? "repo"));
250
254
  const out = [];
251
255
  for (const d of found) {
252
- try { out.push(await prepareOne(wt, keyRoot, d)); } catch (e) { out.push({ status: "failed", eco: d.eco, key: null, ms: 0, reason: msg(e) }); }
256
+ try { out.push(await prepareOne(wt, keyRoot, d, laneEnv && typeof laneEnv === "object" ? laneEnv : undefined)); } catch (e) { out.push({ status: "failed", eco: d.eco, key: null, ms: 0, reason: msg(e) }); }
253
257
  }
254
258
  return out;
255
259
  }
@@ -8,6 +8,7 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, lstatSync
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { join, dirname, relative } from "node:path";
10
10
  import { makeDepCache } from "./depcache.mjs";
11
+ import { prepareToolchain } from "./toolchain.mjs";
11
12
 
12
13
  const scratch = () => tmpDir("fd-depcache-");
13
14
  const put = (p, text, mode) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text, mode ? { mode } : undefined); };
@@ -81,6 +82,10 @@ function fakeExec({ failInstall = false, installDelayMs = 0 } = {}) {
81
82
  symlinkSync("../left-pad/cli.js", join(cwd, "node_modules/.bin/left-pad"));
82
83
  return "";
83
84
  }
85
+ if (cmd === "yarn") {
86
+ put(join(cwd, "node_modules/left-pad/index.js"), `// ${readFileSync(join(cwd, "yarn.lock"), "utf8").length}\n`);
87
+ return "";
88
+ }
84
89
  if (cmd === "uv" && args[0] === "venv") { put(join(cwd, ".venv/pyvenv.cfg"), "home = /usr/bin\nrelocatable = true\n"); return ""; }
85
90
  if (cmd === "uv" || cmd === "poetry") { venv(cwd, readFileSync(join(cwd, cmd === "uv" ? "uv.lock" : "poetry.lock"), "utf8")); return ""; }
86
91
  throw Object.assign(new Error(`spawn ${cmd} ENOENT`), { code: "ENOENT" });
@@ -296,3 +301,79 @@ describe("depcache (#4220): shared dependency installs keyed by lock hash", () =
296
301
  assert.ok(!existsSync(fresh));
297
302
  });
298
303
  });
304
+
305
+ describe("depcache (#4347): installs and repairs run with the lane toolchain env", () => {
306
+ const PMS = new Set(["pnpm", "pnpx", "yarn", "npm", "npx"]);
307
+ /** A host with no global package manager: pm commands resolve only when PATH starts with the toolchain shim dir. */
308
+ const shimOnlyExec = (f, binDir) => {
309
+ const seen = [];
310
+ const exec = async (cmd, args, o = {}) => {
311
+ if (PMS.has(cmd)) {
312
+ seen.push({ cmd, args: [...args], env: o.env });
313
+ if (!String(o.env?.PATH ?? "").startsWith(binDir)) throw Object.assign(new Error(`spawn ${cmd} ENOENT`), { code: "ENOENT" });
314
+ }
315
+ return f.exec(cmd, args, o);
316
+ };
317
+ return { exec, seen };
318
+ };
319
+ /** The real toolchain step: corepack on the host PATH, no global pm; shims recorded, not written. */
320
+ const laneToolchain = async (wt, binDir) => {
321
+ const tc = await prepareToolchain({ dir: wt, binDir, basePath: "/host/bin", find: (n) => (n === "corepack" ? "/host/bin/corepack" : null),
322
+ writeShim: async () => {}, mkdirp: async () => {}, hostNode: () => "v22.1.0" });
323
+ assert.ok(tc.env, "a packageManager pin yields a toolchain env");
324
+ return tc.env;
325
+ };
326
+
327
+ it("#4347 a packageManager-pinned pnpm repo with no host pnpm installs and repairs through the lane toolchain env", async () => {
328
+ const base = scratch(), root = join(base, "cache"), binDir = join(base, "toolchain", "bin");
329
+ const f = fakeExec(), x = shimOnlyExec(f, binDir);
330
+ const cache = makeDepCache({ root, exec: x.exec, env: {}, pollMs: 5 });
331
+ const lane = (name) => { const wt = join(base, "wt", name); FIXTURES.pnpm(wt); put(join(wt, "package.json"), '{"name":"web","private":true,"packageManager":"pnpm@9.12.0"}'); return wt; };
332
+ const a = lane("a");
333
+ const env = await laneToolchain(a, binDir);
334
+ const [r1] = await cache.prepare({ wt: a, repoKey: "r", env });
335
+ assert.equal(r1.status, "miss-installed", r1.reason);
336
+ assert.ok(existsSync(join(a, "node_modules/lodash/index.js")));
337
+ const [r2] = await cache.prepare({ wt: lane("b"), repoKey: "r", env });
338
+ assert.equal(r2.status, "hit", r2.reason);
339
+ assert.deepEqual(x.seen.map((c) => [c.cmd, c.args.includes("--offline")]), [["pnpm", false], ["pnpm", true]], "install then repair");
340
+ for (const c of x.seen) {
341
+ assert.ok(c.env.PATH.startsWith(`${binDir}:`), "the shim dir leads PATH");
342
+ assert.equal(c.env.COREPACK_ENABLE_DOWNLOAD_PROMPT, "0");
343
+ assert.equal(c.env.npm_config_store_dir, join(root, "stores", "pnpm-store"), "the store env stays");
344
+ }
345
+ // Without the toolchain env (the toolchain step failed or wrote no shim) it is the host PATH, as before.
346
+ const [r3] = await makeDepCache({ root: join(base, "cache2"), exec: x.exec, env: {} }).prepare({ wt: lane("c"), repoKey: "r" });
347
+ assert.equal(r3.status, "failed");
348
+ assert.match(r3.reason, /tool not found: spawn pnpm ENOENT/);
349
+ });
350
+
351
+ it("#4347 a pinned yarn (node-modules linker) repo installs through the shim env", async () => {
352
+ const base = scratch(), binDir = join(base, "toolchain", "bin");
353
+ const x = shimOnlyExec(fakeExec(), binDir);
354
+ const wt = join(base, "wt", "y");
355
+ put(join(wt, "package.json"), '{"name":"app","packageManager":"yarn@4.5.0"}');
356
+ put(join(wt, "yarn.lock"), "__metadata:\n version: 8\n");
357
+ put(join(wt, ".yarnrc.yml"), "nodeLinker: node-modules\n");
358
+ const env = await laneToolchain(wt, binDir);
359
+ const [r] = await makeDepCache({ root: join(base, "cache"), exec: x.exec, env: {} }).prepare({ wt, repoKey: "r", env });
360
+ assert.equal(r.status, "miss-installed", r.reason);
361
+ assert.deepEqual(x.seen.map((c) => [c.cmd, c.args]), [["yarn", ["install", "--immutable"]]]);
362
+ assert.ok(x.seen[0].env.PATH.startsWith(`${binDir}:`));
363
+ });
364
+
365
+ it("#4347 a plain npm repo without pins passes exactly the store env, as before; store keys win over the lane env", async () => {
366
+ const s = setup("npm");
367
+ const [r] = await s.cache.prepare({ wt: s.lane("plain"), repoKey: "r" });
368
+ assert.equal(r.status, "miss-installed", r.reason);
369
+ const inst = s.f.calls.filter((c) => c.cmd === "npm");
370
+ assert.equal(inst.length, 1);
371
+ const seen = [];
372
+ const cache = makeDepCache({ root: join(s.base, "cache2"), exec: async (cmd, args, o = {}) => { if (cmd === "npm") seen.push(o); return s.f.exec(cmd, args, o); }, env: {} });
373
+ await cache.prepare({ wt: s.lane("plain2"), repoKey: "r" });
374
+ assert.deepEqual(seen[0].env, cache.env(), "no lane env: only the store env");
375
+ await cache.prepare({ wt: s.lane("pinned"), repoKey: "r2", env: { PATH: "/shim:/host", npm_config_cache: "/lane/should-not-win" } });
376
+ assert.equal(seen[1].env.PATH, "/shim:/host");
377
+ assert.equal(seen[1].env.npm_config_cache, cache.env().npm_config_cache);
378
+ });
379
+ });
@@ -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
+ });