forgedock 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -5,11 +5,15 @@
5
5
  * (each segment's bare executable, Python modules a bare python imports via -c/-m, Node packages `node -e` code imports)
6
6
  * and a read-only bash probe for each. The engine runs the probes (lean.mjs) before the implement session, so a validate
7
7
  * command the host cannot run is corrected in the plan instead of found after the build (#4323 env.tool_missing).
8
+ * #4393: text alone misses wrapped interpreters (`uv run python -m x`) and transitive imports, so `runPreflight` also runs
9
+ * each command once on the base worktree and classifies its output (`runProbeMiss`); `unsetEnvVars` flags `$NAME`s the lane
10
+ * env leaves empty (#4325).
8
11
  */
9
12
  import { builtinModules } from "node:module";
13
+ import { missingTool } from "./conformance.mjs";
10
14
 
11
15
  export const PREFLIGHT_CAP = 20;
12
- const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
16
+ const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package", path: "path", "env-var": "env var" };
13
17
  const PY_RE = /^python(\d+(\.\d+)?)?$/;
14
18
  const PY_ARG_FLAGS = new Set(["-W", "-X", "-Q"]);
15
19
  const sq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
@@ -127,7 +131,108 @@ export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIG
127
131
  return { probes: all.slice(0, cap), dropped: Math.max(0, all.length - cap) };
128
132
  }
129
133
 
130
- /** One contract problem per missed probe: names the validate ids and the tool, and how to fix the plan (never a global install). */
134
+ const FIX_HOW = "run the check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts) or use a check that does not need it, never a global install";
135
+ /** One contract problem per miss: names the validate ids and the tool (or path, or env var), and how to fix the plan (never a global install). */
131
136
  export function preflightProblems(misses) {
132
- return (misses ?? []).map((m) => `${m.ids.join(", ")}: needs ${KIND_NAME[m.kind] ?? m.kind} '${m.tool}'${m.interp ? ` (for ${m.interp})` : ""}, which this host lacks, so the validate command cannot run here; run the check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts) or use a check that does not need it, never a global install`);
137
+ return (misses ?? []).map((m) => {
138
+ const ids = m.ids.join(", ");
139
+ if (m.kind === "env-var") return `${ids}: uses $${m.tool}, which is unset or empty in the lane environment, so the validate command cannot run as written; set it inside the command or ${FIX_HOW}`;
140
+ if (m.source === "run") return `${ids}: running it on the base worktree stopped before any test ran: needs ${KIND_NAME[m.kind] ?? m.kind} '${m.tool}'${m.line ? ` (\`${m.line}\`)` : ""}, so the validate command cannot run here; ${m.kind === "path" ? "point it at a path the repo or a deliverable has, or " : ""}${FIX_HOW}`;
141
+ return `${ids}: needs ${KIND_NAME[m.kind] ?? m.kind} '${m.tool}'${m.interp ? ` (for ${m.interp})` : ""}, which this host lacks, so the validate command cannot run here; ${FIX_HOW}`;
142
+ });
143
+ }
144
+
145
+ /** #4393: per-command timeout and total budget of the freeze-time runs; a command cut by either is no evidence. */
146
+ export const RUN_PROBE_MS = 30000, RUN_BUDGET_MS = 120000;
147
+ const TIMEOUT_RC = 124;
148
+ // bash sets these itself; they are never "missing from the lane env"
149
+ const SHELL_VARS = new Set(["RANDOM", "SECONDS", "LINENO", "PWD", "OLDPWD", "HOME", "UID", "EUID", "PPID", "BASHPID", "IFS", "HOSTNAME", "SHELL", "USER", "PATH", "BASH", "BASH_SOURCE", "BASH_VERSION", "BASH_REMATCH", "PIPESTATUS", "FUNCNAME", "EPOCHSECONDS", "EPOCHREALTIME", "SHLVL", "OPTARG", "OPTIND", "REPLY", "HOSTTYPE", "OSTYPE", "MACHTYPE", "GROUPS"]);
150
+
151
+ /**
152
+ * `$NAME` / `${NAME}` references (outside single quotes, not escaped) that are unset or empty in `env` and that the command
153
+ * does not assign itself (`NAME=`, `export NAME=`, `for NAME in`). `${NAME:-x}`-style defaults, `${#NAME}` and specials are not flagged.
154
+ */
155
+ export function unsetEnvVars(command, env = {}) {
156
+ const s = String(command ?? ""), refs = [];
157
+ let q = null;
158
+ for (let i = 0; i < s.length; i++) {
159
+ const ch = s[i];
160
+ if (q === "'") { if (ch === "'") q = null; continue; }
161
+ if (ch === "\\") { i++; continue; }
162
+ if (ch === '"') { q = q ? null : '"'; continue; }
163
+ if (ch === "'" && !q) { q = "'"; continue; }
164
+ if (ch !== "$") continue;
165
+ const m = /^\{([A-Za-z_]\w*)(\}|:?[-=+?])|^([A-Za-z_]\w*)/.exec(s.slice(i + 1));
166
+ if (m && (m[3] || m[2] === "}")) refs.push(m[1] ?? m[3]);
167
+ }
168
+ const assigned = (n) => new RegExp(`(^|[\\s;&|(])((export|local|readonly|declare)\\s+)?${n}=|\\bfor\\s+${n}\\s+in\\b`).test(s);
169
+ return [...new Set(refs)].filter((n) => !SHELL_VARS.has(n) && (env[n] === undefined || env[n] === null || env[n] === "") && !assigned(n));
170
+ }
171
+
172
+ // [rc, pattern]: bash could not exec a path (127), python could not open its script, pytest / node --test could not find a file
173
+ const PATH_MISSING_RES = [[127, /(?:^|\s)([^\s:'"`]+): No such file or directory/m], [null, /can't open file '([^']+)'/], [null, /file or directory not found: (\S+)/], [null, /Could not find '([^']+)'/]];
174
+ const TESTISH_RE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[^/]+$|(^|\/)test_[^/]*\.py$|_test\.(py|go)$/;
175
+
176
+ /**
177
+ * #4393: why one freeze-time run of a validate command shows it cannot run on this host, or null when it ran (passed, or
178
+ * failed on test content: RED on base is expected), timed out (no evidence) or gave no recognisable reason.
179
+ * A path counts as missing unless `knownPath(path)` (repo-tracked, a deliverable, the repro test) or it looks like a test the
180
+ * builder adds; a module goes through conformance.mjs `missingTool` with `repoHas`, as the post-build gate does.
181
+ * Returns {tool, kind: command|python-module|node-module|path, line} with the output line that showed it.
182
+ */
183
+ export function runProbeMiss(r, { repoHas = () => false, command = "", knownPath = () => false } = {}) {
184
+ const rc = r?.rc, out = String(r?.output ?? "");
185
+ if (!Number.isInteger(rc) || rc === 0 || rc === TIMEOUT_RC) return null;
186
+ const lineOf = (needle) => (out.split("\n").find((l) => l.includes(needle)) ?? "").trim().slice(0, 160);
187
+ for (const [onlyRc, re] of PATH_MISSING_RES) {
188
+ const p = onlyRc === null || onlyRc === rc ? re.exec(out)?.[1] : null;
189
+ if (!p) continue;
190
+ if (TESTISH_RE.test(p) || knownPath(p)) return null;
191
+ return { tool: p, kind: "path", line: lineOf(p) };
192
+ }
193
+ const miss = missingTool(r, { repoHas, command });
194
+ return miss ? { ...miss, line: lineOf(miss.tool) } : null;
195
+ }
196
+
197
+ /**
198
+ * #4393: what a freeze-time run may legitimately not find yet: `repoHas` (a name the repo tracks or a deliverable's change
199
+ * text names) and `knownPath` (a tracked file, a deliverable or the repro test, matched as a suffix of an absolute path).
200
+ */
201
+ export function knownToContract(contract, files = []) {
202
+ const changes = (Array.isArray(contract?.deliverables) ? contract.deliverables : []).map((d) => String(d?.change ?? "")).join("\n");
203
+ const paths = [...files, ...(contract?.deliverables ?? []).map((d) => d?.path), contract?.repro_test?.path].filter((x) => typeof x === "string" && x).map((x) => x.replace(/^\.\//, "").replace(/\/+$/, ""));
204
+ const segs = new Set(paths.flatMap((x) => x.split("/")));
205
+ const repoHas = (name) => segs.has(name) || segs.has(`${name}.py`) || new RegExp(`(^|[^\\w@/.-])${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\w/-])`).test(changes);
206
+ const knownPath = (p) => { const rel = String(p).replace(/^\.\//, ""); return paths.some((k) => rel === k || rel.endsWith(`/${k}`) || rel.startsWith(`${k}/`) || rel.includes(`/${k}/`)); };
207
+ return { repoHas, knownPath };
208
+ }
209
+
210
+ const porcelain = (out) => new Map(String(out ?? "").split("\n").filter((l) => l.length > 3).map((l) => [l.slice(3), l.slice(0, 2)]));
211
+
212
+ /**
213
+ * #4393: run each `contract.validate[]` command (ids in `skipIds` excepted) once through `run({command, timeoutMs, env})`,
214
+ * 30 s each inside a 120 s budget, and classify each with `runProbeMiss`. `git(args)` runs in the worktree: whatever the runs
215
+ * newly created or modified (`git status --porcelain`) is reverted; a path dirty before the runs is never touched.
216
+ * Returns {misses: [{ids, tool, kind, line, source: "run"}], skipped: ids not run (budget), reverted: paths}.
217
+ */
218
+ export async function runPreflight({ contract, run, git, env = {}, repoHas = () => false, knownPath = () => false, skipIds = [], now = () => Date.now(), perCommandMs = RUN_PROBE_MS, budgetMs = RUN_BUDGET_MS }) {
219
+ const misses = [], skipped = [], reverted = [];
220
+ const todo = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v?.id && typeof v.command === "string" && !skipIds.includes(v.id));
221
+ if (!todo.length) return { misses, skipped, reverted };
222
+ const status = async () => porcelain(await git(["status", "--porcelain", "--untracked-files=all"]).catch(() => ""));
223
+ const before = await status(), deadline = now() + budgetMs;
224
+ for (const v of todo) {
225
+ const left = deadline - now();
226
+ if (left <= 0) { skipped.push(v.id); continue; }
227
+ let r;
228
+ try { r = await run({ command: v.command, timeoutMs: Math.min(perCommandMs, left), env }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
229
+ const miss = runProbeMiss(r, { repoHas, command: v.command, knownPath });
230
+ if (miss) misses.push({ ids: [v.id], ...miss, source: "run" });
231
+ }
232
+ const after = await status();
233
+ for (const [path, xy] of after) {
234
+ if (before.has(path) || path.includes(" -> ")) continue;
235
+ try { await git(xy === "??" ? ["clean", "-fdq", "--", path] : ["checkout", "--", path]); reverted.push(path); } catch { /* left as is; the scope gate still sees it */ }
236
+ }
237
+ return { misses, skipped, reverted };
133
238
  }
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
- import { shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems } from "./preflight.mjs";
7
+ import { shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
8
8
  import { runCheckCommand } from "./effects.mjs";
9
9
  import { repoTracks } from "./conformance.mjs";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
@@ -59,3 +59,50 @@ describe("#4328 validate preflight: what a command needs", () => {
59
59
  }
60
60
  });
61
61
  });
62
+
63
+ describe("#4393 validate preflight: running the command on base", () => {
64
+ const known = knownToContract({ deliverables: [{ id: "D1", path: "tools/new_check.py", change: "add module newlib" }], repro_test: { path: "test/r.test.mjs" } }, ["shared/detection/__init__.py", "scripts/run.sh"]);
65
+ it("unrunnable: a missing module, command or path; a repo-tracked or deliverable-named module is not the host", () => {
66
+ assert.deepEqual(runProbeMiss({ rc: 1, output: "Traceback (most recent call last):\nModuleNotFoundError: No module named 'httpx'\n" }, known),
67
+ { tool: "httpx", kind: "python-module", line: "ModuleNotFoundError: No module named 'httpx'" });
68
+ assert.equal(runProbeMiss({ rc: 1, output: "ModuleNotFoundError: No module named 'shared'" }, known), null);
69
+ assert.equal(runProbeMiss({ rc: 1, output: "ModuleNotFoundError: No module named 'newlib'" }, known), null);
70
+ assert.equal(runProbeMiss({ rc: 127, output: "bash: line 1: jq: command not found" }, known).tool, "jq");
71
+ assert.deepEqual(runProbeMiss({ rc: 127, output: "bash: line 1: ./scripts/gone.sh: No such file or directory" }, known), { tool: "./scripts/gone.sh", kind: "path", line: "bash: line 1: ./scripts/gone.sh: No such file or directory" });
72
+ assert.equal(runProbeMiss({ rc: 2, output: "python3: can't open file '/wt/x/tools/old.py': [Errno 2] No such file or directory" }, known).kind, "path");
73
+ });
74
+ it("runs: passes, RED test failures, timeouts and paths the change adds are no miss", () => {
75
+ for (const r of [{ rc: 0, output: "" }, { rc: 1, output: "AssertionError: expected 1 to equal 2" }, { rc: 124, output: "ModuleNotFoundError: No module named 'httpx'\n(timed out after 30s)" },
76
+ { rc: 2, output: "python3: can't open file '/wt/x/tools/new_check.py': [Errno 2] No such file or directory" }, { rc: 4, output: "ERROR: file or directory not found: tests/test_new.py" },
77
+ { rc: 1, output: "Could not find '/wt/x/test/r.test.mjs'" }, { rc: 1, output: "cat: data.json: No such file or directory" }]) assert.equal(runProbeMiss(r, known), null, r.output);
78
+ });
79
+ it("unset env vars: empty or unset, outside single quotes, not assigned or defaulted by the command", () => {
80
+ assert.deepEqual(unsetEnvVars(`cat "$TMPDIR/x" && echo \${OUT} '$SINGLE' \\$ESC \${D:-1} $? $HOME`, { TMPDIR: "", HOME: "/h" }), ["TMPDIR", "OUT"]);
81
+ assert.deepEqual(unsetEnvVars(`X=1; echo $X; export Y=2 && echo "$Y"; for F in a b; do echo $F; done; echo $SET`, { SET: "s" }), []);
82
+ });
83
+ it("runPreflight runs every command with the env and a capped timeout, skips ids, honours the budget and reverts only what the runs changed", async () => {
84
+ const ran = [], gits = [];
85
+ let clock = 0, st = " M kept.js\n";
86
+ const run = async (a) => { ran.push([a.command, a.timeoutMs, a.env.K]); clock += 70000; st += "?? new.log\n M src/a.js\n"; return { rc: 1, output: a.command === "c1" ? "ModuleNotFoundError: No module named 'httpx'" : "" }; };
87
+ const git = async (args) => { gits.push(args.join(" ")); return args[0] === "status" ? st : ""; };
88
+ const contract = { validate: ["c1", "c2", "c3", "c4"].map((command, i) => ({ id: `V${i + 1}`, command })) };
89
+ const r = await runPreflight({ contract, run, git, env: { K: "v" }, skipIds: ["V2"], now: () => clock });
90
+ assert.deepEqual(ran, [["c1", 30000, "v"], ["c3", 30000, "v"]]);
91
+ assert.deepEqual(r.misses.map((m) => [m.ids, m.tool, m.kind, m.source]), [[["V1"], "httpx", "python-module", "run"]]);
92
+ assert.deepEqual(r.skipped, ["V4"]);
93
+ assert.deepEqual(r.reverted, ["new.log", "src/a.js"]);
94
+ assert.deepEqual(gits.filter((g) => !g.startsWith("status")), ["clean -fdq -- new.log", "checkout -- src/a.js"], "kept.js was dirty before: untouched");
95
+ });
96
+ it("problem lines name the id, the tool and the observed line, or the env var", () => {
97
+ const [run, env] = preflightProblems([{ ids: ["V4"], tool: "httpx", kind: "python-module", source: "run", line: "ModuleNotFoundError: No module named 'httpx'" }, { ids: ["V2"], tool: "TMPDIR", kind: "env-var", source: "env" }]);
98
+ assert.match(run, /^V4: running it on the base worktree stopped before any test ran: needs Python module 'httpx' \(`ModuleNotFoundError[^`]*`\)[\s\S]*repo's own toolchain .*never a global install/);
99
+ assert.match(env, /^V2: uses \$TMPDIR, which is unset or empty in the lane environment/);
100
+ });
101
+ it("a real run: a missing module is a miss, a failing test is not", async () => {
102
+ const dir = tmpDir("preflight-run-");
103
+ const git = async () => "";
104
+ const contract = C(`node -e "require('absent-pkg-4393')"`, `node -e "process.exit(1)"`);
105
+ const r = await runPreflight({ contract, run: (a) => runCheckCommand({ ...a, cwd: dir }), git });
106
+ assert.deepEqual(r.misses.map((m) => [m.ids[0], m.tool]), [["V1", "absent-pkg-4393"]]);
107
+ });
108
+ });
@@ -0,0 +1,297 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Outcome-based acceptance (#4399): an issue may declare an acceptance probe, a command plus its pass condition. Once the
5
+ * issue's fix is on the default (production) branch, the probe runs deterministically (no model call, no lane), the
6
+ * result is recorded on the issue as a `FORGE:PROBE` comment, and a fail reopens the issue with the output attached.
7
+ *
8
+ * Declaration: a top-level fenced block in the issue body or a comment by a declarer (the latest comment wins):
9
+ * ```forge-probe
10
+ * run: curl -fsS https://example.com/health
11
+ * pass: exit 0 # default; or `pass: /regex/flags` matched against stdout
12
+ * timeout: 120 # seconds, capped at 900
13
+ * ```
14
+ * Only declarers' commands run (`isProbeDeclarer`, fail closed): a human trusted association or trusted login, never a
15
+ * Bot/App, the engine included, since bot-filed text quotes untrusted content (#4468). A block nested in another fence
16
+ * or indented as code is quoted content, not a declaration. The fix commit is the latest
17
+ * trusted FORGE:TRAJECTORY `merged at <sha>`, else the merge commit of the latest merged PR that closes the issue; it is
18
+ * promoted when `git merge-base --is-ancestor <fix> origin/<default>` holds. A recorded result is never re-run for the
19
+ * same fix. The probe gets a minimal env (PATH, HOME, LANG, TMPDIR) plus forge.yaml `acceptance_probe.env` (the repo's
20
+ * designated test identity); those values are masked in the posted output.
21
+ * Triggers: `forgedock probe [issue...]` and a sweep at the start of every `forgedock orchestrate` batch (`--no-probes`).
22
+ */
23
+ import { spawn as nodeSpawn } from "node:child_process";
24
+ import { readFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+ import { parseForgeYaml } from "../forge-utils.mjs";
27
+ import { isTrustedComment } from "../engine/phases.mjs";
28
+ import { noComments } from "./render.mjs";
29
+
30
+ export const PROBE_MARKER = "FORGE:PROBE";
31
+ export const DEFAULT_TIMEOUT_S = 120;
32
+ export const MAX_TIMEOUT_S = 900;
33
+ export const OUTPUT_TAIL = 4000;
34
+ const BASE_ENV = ["PATH", "HOME", "LANG", "TMPDIR"];
35
+
36
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
37
+ const isForgeMarker = (body) => /^\s*<!--\s*FORGE:/.test(String(body ?? ""));
38
+
39
+ /**
40
+ * #4468: the contents of the top-level CommonMark fences of `text` whose info string is `forge-probe`. A fence closes on
41
+ * a line of its own character at least as long; anything inside another fence (a quoted sample) or indented 4+ columns
42
+ * (an indented code block) is content, never a declaration. An unclosed fence yields nothing.
43
+ */
44
+ function probeBlocks(text) {
45
+ const blocks = [];
46
+ let open = null;
47
+ for (const line of text.split("\n")) {
48
+ const m = FENCE_RE.exec(line);
49
+ if (!open) {
50
+ if (m && !(m[1][0] === "`" && m[2].includes("`"))) open = { ch: m[1][0], len: m[1].length, lines: m[2].trim() === "forge-probe" ? [] : null };
51
+ } else if (m && m[1][0] === open.ch && m[1].length >= open.len && !m[2].trim()) {
52
+ if (open.lines) blocks.push(open.lines.join("\n"));
53
+ open = null;
54
+ } else open.lines?.push(line);
55
+ }
56
+ return blocks;
57
+ }
58
+
59
+ /** The last top-level ```forge-probe block of `text`: {run, pass, timeoutS}, or null when absent or malformed. */
60
+ export function parseProbe(text) {
61
+ const blocks = probeBlocks(noComments(String(text ?? ""), " ").replace(/\r\n?/g, "\n"));
62
+ if (!blocks.length) return null;
63
+ const fields = {};
64
+ for (const line of blocks[blocks.length - 1].split("\n")) {
65
+ const m = /^\s*(run|pass|timeout)\s*:\s*(.*?)\s*$/i.exec(line);
66
+ if (m && !(m[1].toLowerCase() in fields)) fields[m[1].toLowerCase()] = m[2];
67
+ }
68
+ if (!fields.run) return null;
69
+ let pass = { kind: "exit" };
70
+ const p = (fields.pass ?? "").replace(/\s+#.*$/, "");
71
+ if (p && !/^exit\s+0$/i.test(p)) {
72
+ const r = /^\/(.+)\/([a-z]*)$/.exec(p);
73
+ if (!r) return null;
74
+ try { new RegExp(r[1], r[2]); } catch { return null; }
75
+ pass = { kind: "regex", re: r[1], flags: r[2] };
76
+ }
77
+ let timeoutS = null;
78
+ if (fields.timeout) {
79
+ const t = parseInt(fields.timeout, 10);
80
+ if (!Number.isFinite(t) || t <= 0) return null;
81
+ timeoutS = Math.min(t, MAX_TIMEOUT_S);
82
+ }
83
+ return { run: fields.run, pass, timeoutS };
84
+ }
85
+
86
+ /** REST issue/comment author fields as the trust predicate reads them. */
87
+ const actorOf = (o) => ({ association: o?.author_association, type: o?.user?.type, login: o?.user?.login });
88
+
89
+ /**
90
+ * #4468: who may declare a probe, a command the host runs. Unlike `isTrustedActor` (which trusts every Bot so engine
91
+ * markers count), only a human: association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS (default OWNER,MEMBER,COLLABORATOR;
92
+ * explicitly empty trusts none) or login in FORGE_TRAIL_TRUSTED_LOGINS, never a Bot/App or `[bot]` login, the engine
93
+ * included: the issues it files quote untrusted PR content. Missing author data fails closed.
94
+ */
95
+ export function isProbeDeclarer({ association, type, login } = {}) {
96
+ if (!type || type === "Bot" || /\[bot\]$/i.test(String(login ?? ""))) return false;
97
+ const list = (v) => String(v).split(",").map((x) => x.trim()).filter(Boolean);
98
+ return list(process.env.FORGE_TRAIL_TRUSTED_ASSOCIATIONS ?? "OWNER,MEMBER,COLLABORATOR").includes(association ?? "")
99
+ || list(process.env.FORGE_TRAIL_TRUSTED_LOGINS ?? "").includes(login ?? "");
100
+ }
101
+
102
+ /**
103
+ * The probe the issue declares: the latest declarer comment's block, else the body's when its author is a declarer.
104
+ * {probe, source} | {untrusted: true} (a block exists but only from non-declarers) | null.
105
+ */
106
+ export function declaredProbe(iss) {
107
+ let untrusted = false;
108
+ for (const c of [...(iss?.comments ?? [])].reverse()) {
109
+ if (isForgeMarker(c?.body)) continue; // the engine's own comments (a FORGE:PROBE output tail) never declare a probe
110
+ const probe = parseProbe(c?.body);
111
+ if (!probe) continue;
112
+ if (isProbeDeclarer(actorOf(c))) return { probe, source: "comment" };
113
+ untrusted = true;
114
+ }
115
+ const probe = parseProbe(iss?.body);
116
+ if (probe) {
117
+ if (isProbeDeclarer(actorOf(iss))) return { probe, source: "body" };
118
+ untrusted = true;
119
+ }
120
+ return untrusted ? { untrusted: true } : null;
121
+ }
122
+
123
+ /** {fix, result} of a FORGE:PROBE comment body, or null. */
124
+ export function probeResultOf(body) {
125
+ const s = String(body ?? "");
126
+ if (!s.trimStart().startsWith(`<!-- ${PROBE_MARKER} -->`)) return null;
127
+ const line = s.split("\n").find((l) => /^\s*`?\{.*"result"/.test(l));
128
+ if (!line) return null;
129
+ try {
130
+ const j = JSON.parse(line.trim().replace(/^`|`$/g, ""));
131
+ return j && (j.result === "pass" || j.result === "fail") ? { fix: String(j.fix ?? ""), result: j.result } : null;
132
+ } catch { return null; }
133
+ }
134
+
135
+ const sameSha = (a, b) => !!a && !!b && (a.startsWith(b) || b.startsWith(a));
136
+ const closesRe = (n) => new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b:?\\s+(?:[\\w.-]+/[\\w.-]+)?#${n}(?!\\d)`, "i");
137
+
138
+ /** The fix commit: latest trusted FORGE:TRAJECTORY `merged at <sha>`, else the latest merged PR closing #N's merge commit. */
139
+ export async function fixSha(iss, io, repo) {
140
+ for (const c of [...(iss?.comments ?? [])].reverse()) {
141
+ if (!String(c?.body ?? "").includes("FORGE:TRAJECTORY") || !isTrustedComment(c)) continue;
142
+ const m = /merged at ([0-9a-f]{7,40})\b/.exec(c.body);
143
+ if (m) return m[1];
144
+ }
145
+ try {
146
+ const prs = JSON.parse(String(await io.gh(["pr", "list", "-R", repo, "--state", "merged", "--search", `#${iss.number}`, "--json", "number,title,body,mergeCommit,mergedAt", "--limit", "20"])));
147
+ const hit = (Array.isArray(prs) ? prs : []).filter((p) => p?.mergeCommit?.oid && closesRe(iss.number).test(`${p.title ?? ""}\n${p.body ?? ""}`))
148
+ .sort((a, b) => String(b.mergedAt ?? "").localeCompare(String(a.mergedAt ?? "")))[0];
149
+ return hit?.mergeCommit?.oid ?? null;
150
+ } catch { return null; }
151
+ }
152
+
153
+ /** True when `sha` is an ancestor of origin/<defaultBranch>; any git failure (unknown object) is false. */
154
+ export async function onDefault({ sha, defaultBranch, io, cwd }) {
155
+ try { await io.git(["merge-base", "--is-ancestor", sha, `origin/${defaultBranch}`], { cwd }); return true; } catch { return false; }
156
+ }
157
+
158
+ /** forge.yaml `acceptance_probe`: {env: [names], timeoutS, sweepDays, enabled}. */
159
+ export function readProbeConfig(cwd = process.cwd()) {
160
+ let c = {};
161
+ try { c = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.acceptance_probe ?? {}; } catch { c = {}; }
162
+ if (typeof c !== "object" || !c) c = {};
163
+ const list = Array.isArray(c.env) ? c.env : String(c.env ?? "").replace(/^\[|\]$/g, "").split(/[\s,]+/);
164
+ const num = (v, d) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
165
+ return {
166
+ env: list.map((s) => String(s).trim().replace(/^["']|["']$/g, "")).filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s)),
167
+ timeoutS: Math.min(num(c.timeout_s, DEFAULT_TIMEOUT_S), MAX_TIMEOUT_S),
168
+ sweepDays: num(c.sweep_days, 30),
169
+ enabled: !/^(false|no|off|0)$/i.test(String(c.enabled ?? "true").trim()),
170
+ };
171
+ }
172
+
173
+ /** The probe's environment: the base names plus the allowlisted test identity, nothing else. */
174
+ export function probeEnv(allow = [], env = process.env) {
175
+ return Object.fromEntries([...BASE_ENV, ...allow].filter((k) => typeof env[k] === "string").map((k) => [k, env[k]]));
176
+ }
177
+
178
+ /** Run `sh -c <run>` in its own process group; a timeout kills the group. Resolves {code, stdout, stderr, timedOut}. */
179
+ export function spawnProbe({ run, cwd, env, timeoutMs }) {
180
+ return new Promise((resolve) => {
181
+ let stdout = "", stderr = "", timedOut = false;
182
+ const child = nodeSpawn("sh", ["-c", run], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
183
+ const cap = (s, d) => (s + d).slice(-OUTPUT_TAIL * 4);
184
+ child.stdout.on("data", (d) => { stdout = cap(stdout, d); });
185
+ child.stderr.on("data", (d) => { stderr = cap(stderr, d); });
186
+ const timer = setTimeout(() => { timedOut = true; try { process.kill(-child.pid, "SIGKILL"); } catch { /* gone */ } }, timeoutMs);
187
+ child.on("error", (e) => { clearTimeout(timer); resolve({ code: 127, stdout, stderr: `${stderr}${e.message}`, timedOut }); });
188
+ child.on("close", (code) => { clearTimeout(timer); resolve({ code: code ?? 1, stdout, stderr, timedOut }); });
189
+ });
190
+ }
191
+
192
+ /** Run a probe: {result: pass|fail, exit, ms, output}. The output tail masks the allowlisted env values. */
193
+ export async function runProbe(probe, { cwd, env = {}, mask = [], timeoutS = DEFAULT_TIMEOUT_S, spawn = spawnProbe, now = () => Date.now() } = {}) {
194
+ const t0 = now();
195
+ const r = await spawn({ run: probe.run, cwd, env, timeoutMs: (probe.timeoutS ?? timeoutS) * 1000 });
196
+ const ms = now() - t0;
197
+ let ok = !r.timedOut && r.code === 0;
198
+ if (!r.timedOut && probe.pass?.kind === "regex") ok = new RegExp(probe.pass.re, probe.pass.flags).test(String(r.stdout ?? ""));
199
+ let output = `${r.stdout ?? ""}${r.stderr ? `\n${r.stderr}` : ""}${r.timedOut ? `\n[timed out after ${probe.timeoutS ?? timeoutS}s]` : ""}`;
200
+ for (const v of mask) if (typeof v === "string" && v.length >= 4) output = output.split(v).join("***");
201
+ return { result: ok ? "pass" : "fail", exit: r.timedOut ? null : r.code, ms, output: output.slice(-OUTPUT_TAIL) };
202
+ }
203
+
204
+ /** The FORGE:PROBE comment. */
205
+ export function renderProbeComment({ probe, fix, defaultBranch, defaultSha, r }) {
206
+ const fence = "`".repeat(Math.max(3, ...[...String(r.output).matchAll(/`+/g)].map((m) => m[0].length + 1)));
207
+ const pass = probe.pass?.kind === "regex" ? `stdout matches \`/${probe.pass.re}/${probe.pass.flags}\`` : "exit 0";
208
+ return [
209
+ `<!-- ${PROBE_MARKER} -->`,
210
+ `## Acceptance probe: ${r.result}`,
211
+ JSON.stringify({ fix, default_sha: defaultSha, result: r.result, exit: r.exit, ms: r.ms }),
212
+ "",
213
+ `Ran after the fix \`${String(fix).slice(0, 12)}\` reached \`${defaultBranch}\`. Pass condition: ${pass}.${r.result === "fail" ? " The issue is reopened." : ""}`,
214
+ "",
215
+ fence, r.output.trim() || "(no output)", fence,
216
+ ].join("\n");
217
+ }
218
+
219
+ /** Read the issue (REST, for author_association) and its comments. */
220
+ async function readIssue(issue, repo, io) {
221
+ const iss = JSON.parse(String(await io.gh(["api", `repos/${repo}/issues/${issue}`])));
222
+ const raw = String(await io.gh(["api", `repos/${repo}/issues/${issue}/comments`, "--paginate", "--jq", ".[]"]));
223
+ const comments = raw.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
224
+ return { ...iss, number: issue, comments };
225
+ }
226
+
227
+ /**
228
+ * Probe one issue. Outcomes: no-probe | untrusted | unresolved | already-recorded | awaiting-promotion | pass | fail.
229
+ * Writes (comment, reopen) only for pass/fail and never with `dryRun`.
230
+ */
231
+ export async function probeIssue({ issue, repo, cwd = process.cwd(), io, defaultBranch, dryRun = false, config = readProbeConfig(cwd), env = process.env, spawn }) {
232
+ const row = (outcome, detail = "", extra = {}) => ({ issue, outcome, detail, ...extra });
233
+ const iss = await readIssue(issue, repo, io);
234
+ const d = declaredProbe(iss);
235
+ if (!d) return row("no-probe");
236
+ if (d.untrusted) return row("untrusted", "forge-probe declared only by untrusted actors; not run");
237
+ const fix = await fixSha(iss, io, repo);
238
+ if (!fix) return row("unresolved", "no fix commit found (FORGE:TRAJECTORY or a merged closing PR)");
239
+ const recorded = iss.comments.filter((c) => isTrustedComment(c)).map((c) => probeResultOf(c.body)).filter(Boolean).reverse()
240
+ .find((p) => sameSha(p.fix, fix));
241
+ if (recorded) return row("already-recorded", `${recorded.result} for ${fix.slice(0, 12)}`, { fix });
242
+ if (!(await onDefault({ sha: fix, defaultBranch, io, cwd }))) return row("awaiting-promotion", `fix ${fix.slice(0, 12)} is not on ${defaultBranch}`, { fix });
243
+ let defaultSha = null;
244
+ try { defaultSha = String(await io.git(["rev-parse", `origin/${defaultBranch}`], { cwd })).trim() || null; } catch { /* recorded as null */ }
245
+ const r = await runProbe(d.probe, { cwd, env: probeEnv(config.env, env), mask: config.env.map((k) => env[k]), timeoutS: config.timeoutS, ...(spawn ? { spawn } : {}) });
246
+ const detail = `exit ${r.exit ?? "timeout"} in ${r.ms}ms`;
247
+ if (dryRun) return row(r.result, `${detail} (dry run: not recorded)`, { fix, output: r.output });
248
+ const body = renderProbeComment({ probe: d.probe, fix, defaultBranch, defaultSha, r });
249
+ let note = "";
250
+ if (r.result === "fail") {
251
+ try { await io.gh(["issue", "reopen", String(issue), "-R", repo]); } catch (e) { note = `; reopen failed: ${String(e?.message ?? e).slice(0, 120)}`; }
252
+ }
253
+ await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", body]);
254
+ return row(r.result, detail + note, { fix });
255
+ }
256
+
257
+ /** The default branch: forge.yaml `branches.default`, else GitHub's. */
258
+ export async function resolveDefaultBranch({ repo, io, cwd = process.cwd() }) {
259
+ try {
260
+ const b = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.branches?.default;
261
+ if (typeof b === "string" && b.trim()) return b.trim();
262
+ } catch { /* GitHub's */ }
263
+ return String(await io.gh(["repo", "view", repo, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"])).trim() || null;
264
+ }
265
+
266
+ /**
267
+ * Probe `issues`, or (none given) the issues closed within `sweepDays` that mention `forge-probe`, capped at `limit`.
268
+ * Sequential: probes hit production. Never throws per issue: an error is an `error` row.
269
+ */
270
+ export async function sweepProbes({ repo, cwd = process.cwd(), io, issues = null, limit = 50, sinceDays = null, dryRun = false, config = readProbeConfig(cwd), now = () => Date.now(), env = process.env, spawn } = {}) {
271
+ if (!config.enabled && !issues?.length) return [];
272
+ const defaultBranch = await resolveDefaultBranch({ repo, io, cwd });
273
+ if (!defaultBranch) throw new Error("no default branch");
274
+ let list = issues;
275
+ if (!list?.length) {
276
+ const since = new Date(now() - (sinceDays ?? config.sweepDays) * 86400000).toISOString().slice(0, 10);
277
+ const found = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "closed", "--search", `forge-probe in:body,comments closed:>=${since}`, "--limit", String(limit), "--json", "number"])));
278
+ list = (Array.isArray(found) ? found : []).map((i) => i.number).filter(Number.isInteger);
279
+ }
280
+ if (!list.length) return [];
281
+ try { await io.git(["fetch", "origin", defaultBranch], { cwd }); } catch { /* offline: the local ref */ }
282
+ const rows = [];
283
+ for (const issue of list) {
284
+ try { rows.push(await probeIssue({ issue, repo, cwd, io, defaultBranch, dryRun, config, env, spawn })); } catch (e) {
285
+ rows.push({ issue, outcome: "error", detail: String(e?.message ?? e).slice(0, 160) });
286
+ }
287
+ }
288
+ return rows;
289
+ }
290
+
291
+ const GROUPS = [["pass", "pass"], ["fail", "fail (reopened)"], ["awaiting-promotion", "awaiting promotion"], ["unresolved", "unresolved fix"], ["untrusted", "untrusted (not run)"], ["error", "error"]];
292
+ /** One line, e.g. `probes: pass #A; fail (reopened) #B; awaiting promotion #C`; "" when nothing ran or waits. */
293
+ export function formatProbeSummary(rows) {
294
+ const parts = GROUPS.map(([k, label]) => [label, (rows ?? []).filter((r) => r.outcome === k).map((r) => `#${r.issue}`)])
295
+ .filter(([, l]) => l.length).map(([label, l]) => `${label} ${l.join(" ")}`);
296
+ return parts.length ? `probes: ${parts.join("; ")}` : "";
297
+ }