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
@@ -2,10 +2,14 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { prefilterIssue, flakyReason, makePrefilter, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, DEFAULT_PREFILTER_MODEL } from "./prefilter.mjs";
5
+ import { prefilterIssue, flakyReason, makePrefilter, postDeployGate, postDeployOnly, deferredFrom, formatParkedLine, lazyDefaultBranch, ownLogins, resolveOwnLogins, formatPrefilterSummary, claimsFix, ciFailureRun, namedPaths, isTrustedIssueComment, DEFAULT_PREFILTER_MODEL, parseMinAge, itemWindowsMs, landedAt } from "./prefilter.mjs";
6
+ import { writeFileSync } from "node:fs";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { execFileSync } from "node:child_process";
9
+ import { join } from "node:path";
6
10
  import { parsePrefilterVerdict, prefilterPrompt } from "./prompts.mjs";
7
- import { orchestrate, formatTable } from "./orchestrate.mjs";
8
- import { triageFromCli } from "../engine-cli.mjs";
11
+ import { orchestrate, formatTable, formatOutcomeLists } from "./orchestrate.mjs";
12
+ import { triageFromCli, readPostDeployMinAge } from "../engine-cli.mjs";
9
13
 
10
14
  const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
11
15
 
@@ -13,7 +17,7 @@ const fence = (o) => "```json\n" + JSON.stringify(o) + "\n```";
13
17
  function world(o = {}) {
14
18
  const w = { comments: [], closed: [], labels: [], runner: [], lanes: [], gitCalls: [],
15
19
  issues: o.issues ?? {}, prs: o.prs ?? [], commits: o.commits ?? "", paths: new Set(o.paths ?? []), runs: o.runs ?? {}, workflowRuns: o.workflowRuns ?? {}, shaRuns: o.shaRuns ?? {},
16
- verdict: o.verdict ?? null };
20
+ verdict: o.verdict ?? null, mergedPrs: o.mergedPrs ?? [], ancestry: o.ancestry ?? {}, edits: [] };
17
21
  w.io = {
18
22
  gh: async (a) => {
19
23
  const k = a.slice(0, 2).join(" ");
@@ -30,6 +34,8 @@ function world(o = {}) {
30
34
  }
31
35
  if (k === "issue comment") { w.comments.push([Number(a[2]), a[a.indexOf("--body") + 1]]); return ""; }
32
36
  if (k === "issue close") { w.closed.push([Number(a[2]), a[a.indexOf("--reason") + 1]]); return ""; }
37
+ if (k === "pr list") return JSON.stringify(w.mergedPrs);
38
+ if (k === "issue edit") { w.edits.push([Number(a[2]), a[5], a[6]]); return ""; }
33
39
  throw new Error(`unexpected gh ${a.join(" ")}`);
34
40
  },
35
41
  git: async (a) => {
@@ -37,6 +43,7 @@ function world(o = {}) {
37
43
  if (a[0] === "log") return w.commits;
38
44
  if (a[0] === "cat-file") { if (w.paths.has(a[2].replace(/^origin\/staging:/, ""))) return ""; throw new Error("missing"); }
39
45
  if (a[0] === "fetch") return "";
46
+ if (a[0] === "merge-base") { const c = w.ancestry[a[2]] ?? 128; if (c === 0) return ""; throw Object.assign(new Error(`exit ${c}`), { code: c }); }
40
47
  if (a[0] === "ls-remote") return a.at(-1) === "refs/heads/staging" ? "abc\trefs/heads/staging\n" : "";
41
48
  throw new Error(`unexpected git ${a.join(" ")}`);
42
49
  },
@@ -71,6 +78,20 @@ describe("pre-filter (#4123)", () => {
71
78
  assert.match(w.comments[0][1], /FORGE:PREFILTER/); assert.match(w.comments[0][1], /src\/foo\.js:3 null guard/); assert.match(w.comments[0][1], /PR #90/);
72
79
  });
73
80
 
81
+ it("#4399 an issue reopened by a failed acceptance probe is never re-closed as already-fixed; a pass is no skip", async () => {
82
+ const probe = (result, author = { login: "rapiercraft-forgedock" }, assoc = "CONTRIBUTOR") => ({ author, authorAssociation: assoc,
83
+ body: `<!-- FORGE:PROBE -->\n## Acceptance probe: ${result}\n${JSON.stringify({ fix: "abc1234", default_sha: "d", result, exit: result === "pass" ? 0 : 1, ms: 5 })}` });
84
+ assert.deepEqual(flakyReason({ comments: [probe("fail")] }), { reason: "probe-failed", why: "acceptance probe failed after promotion" });
85
+ assert.equal(flakyReason({ comments: [probe("fail"), probe("pass")] }), null);
86
+ assert.equal(flakyReason({ comments: [probe("fail", { login: "someone" }, "NONE")] }), null);
87
+ const w = world({ issues: { 12: { title: "foo breaks", body: "foo crashes on null", comments: [probe("fail")] } },
88
+ prs: [{ number: 90, title: "fix: guard foo", body: "Fixes #12", url: "https://github.com/o/r/pull/90" }],
89
+ verdict: { verdict: "ALREADY-FIXED", summary: "guard added", evidence: ["src/foo.js:3"] } });
90
+ const r = await pf(w, 12);
91
+ assert.equal(r.closed, false); assert.match(r.detail, /acceptance probe failed after promotion/);
92
+ assert.equal(w.runner.length, 0); assert.deepEqual(w.closed, []); assert.deepEqual(w.comments, []);
93
+ });
94
+
74
95
  it("a candidate the validator CONFIRMS (or answers without evidence) is not closed", async () => {
75
96
  const mk = (verdict) => world({ issues: { 12: { body: "x" } }, prs: [{ number: 90, title: "fix (#12)", body: "" }], verdict });
76
97
  for (const v of [{ verdict: "CONFIRMED", evidence: ["src/foo.js:3 still derefs"] }, { verdict: "ALREADY-FIXED", evidence: [] }, null]) {
@@ -338,3 +359,238 @@ describe("#4318: the engine's own identity under an installation token or a diff
338
359
  assert.equal(lookups, 4, "user + app, twice: one retry, then cached");
339
360
  });
340
361
  });
362
+
363
+ describe("post-deploy precondition gate (#4389)", () => {
364
+ const MERGE = "a".repeat(40);
365
+ const item = (id, disp) => `- [ ] \`${id}: After the next scheduled run, a health row exists\` — ${disp}: only checkable in prod after promotion`;
366
+ const followup = (parent, items) => `## Problem\n\nDeferred from #${parent}: the items below were declared out of scope.\n\n## Acceptance Criteria\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #${parent} -->\n`;
367
+ const parentPr = [{ number: 90, title: "fix(x): health row (#40)", body: "", mergeCommit: { oid: MERGE } }];
368
+ const gated = (w, n, extra = {}) => pf(w, n, { defaultBranch: "main", ...extra });
369
+
370
+ it("parses the marker and requires every checklist item to be post-deploy", () => {
371
+ const body = followup(40, [item("R2", "post-deploy")]);
372
+ assert.equal(deferredFrom(body), 40);
373
+ assert.equal(deferredFrom("no marker"), null);
374
+ assert.ok(postDeployOnly(body));
375
+ assert.ok(!postDeployOnly(followup(40, [item("R2", "post-deploy"), item("R3", "defer")])));
376
+ assert.ok(!postDeployOnly("no checklist"));
377
+ });
378
+
379
+ it("not an ancestor of the default branch: parked awaiting-promotion, labelled and explained once, no validation run, $0", async () => {
380
+ const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
381
+ const r = await gated(w, 41);
382
+ assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-promotion"); assert.equal(r.closed, false); assert.equal(r.cost_usd, 0);
383
+ assert.match(r.detail, /of #40 is not on main/);
384
+ assert.deepEqual(w.edits, [[41, "--add-label", "awaiting-promotion"]]);
385
+ assert.equal(w.comments.length, 1); assert.match(w.comments[0][1], /FORGE:PRECONDITION/);
386
+ assert.equal(w.runner.length, 0); assert.deepEqual(w.closed, []);
387
+ // already labelled: no second comment
388
+ const w2 = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
389
+ assert.equal((await gated(w2, 41)).parked, true);
390
+ assert.equal(w2.comments.length, 0);
391
+ });
392
+
393
+ it("an escalated (needs-human) promotion follow-up is parked too; a dry run changes nothing on GitHub", async () => {
394
+ const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "needs-human" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
395
+ const r = await gated(w, 41, { dryRun: true });
396
+ assert.equal(r.parked, true);
397
+ assert.deepEqual(w.edits, []); assert.deepEqual(w.comments, []);
398
+ });
399
+
400
+ it("an ancestor (after promotion): not parked, the label is removed, and the issue goes on as before", async () => {
401
+ const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "awaiting-promotion" }] } }, mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
402
+ const r = await gated(w, 41);
403
+ assert.ok(!r.parked); assert.equal(r.detail, "no already-fixed signal");
404
+ assert.deepEqual(w.edits, [[41, "--remove-label", "awaiting-promotion"]]);
405
+ const rows = await orchestrate({ issues: [41], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
406
+ runOne: async () => ({ terminalReason: "merged", pr: 7, cost_usd: 1 }) });
407
+ assert.deepEqual(rows.map((x) => x.outcome), ["merged"]);
408
+ });
409
+
410
+ it("an unresolvable parent (no merged PR, no trajectory) or an unknown SHA falls through to the lane", async () => {
411
+ const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) }, 40: { comments: [] } } });
412
+ const r = await gated(w, 41);
413
+ assert.ok(!r.parked); assert.deepEqual(w.edits, []);
414
+ const g = await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo",
415
+ io: world({ mergedPrs: parentPr, ancestry: { [MERGE]: 128 } }).io });
416
+ assert.equal(g.state, "unresolved");
417
+ assert.equal((await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", defaultBranch: async () => null, io: world().io })).state, "unresolved");
418
+ });
419
+
420
+ it("the trusted trajectory 'merged at <sha>' is the fallback when no merged PR names the parent", async () => {
421
+ const w = world({ issues: { 40: { comments: [{ author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR", body: "<!-- FORGE:TRAJECTORY -->\n| Merge | merged at abcdef123456 |" },
422
+ { author: { login: "mallory" }, authorAssociation: "NONE", body: "<!-- FORGE:TRAJECTORY --> merged at 0123456789ab" }] } }, ancestry: { abcdef123456: 1 } });
423
+ const g = await postDeployGate({ iss: { number: 41, body: followup(40, [item("R2", "post-deploy")]) }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo", io: w.io });
424
+ assert.equal(g.state, "awaiting-promotion"); assert.deepEqual(g.shas, ["abcdef123456"]);
425
+ });
426
+
427
+ it("mixed post-deploy + code items, and issues without DEFERRED_FROM, are unaffected (no gate calls)", async () => {
428
+ for (const body of [followup(40, [item("R2", "post-deploy"), item("R3", "defer")]), item("R2", "post-deploy")]) {
429
+ const w = world({ issues: { 41: { body } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
430
+ const r = await gated(w, 41);
431
+ assert.ok(!r.parked); assert.ok(!w.gitCalls.some((a) => a[0] === "merge-base"));
432
+ }
433
+ const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]), labels: [{ name: "needs-human" }] } } });
434
+ assert.equal((await pf(w, 41)).detail, "labelled, left to the lane"); // no default branch: gate off, today's behaviour
435
+ });
436
+
437
+ it("post_deploy.precondition_cmd: exit 0 ready, exit 1 parked awaiting-precondition, other exits fail open", async () => {
438
+ const body = followup(40, [item("R2", "post-deploy")]);
439
+ for (const [code, state] of [[0, "ready"], [1, "awaiting-precondition"], [2, "unresolved"], [-1, "unresolved"]]) {
440
+ const w = world({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 } });
441
+ const calls = [];
442
+ w.io.execCode = async (cmd, args, opts) => { calls.push([cmd, args, opts.env.FORGE_ISSUE]); return code; };
443
+ const g = await postDeployGate({ iss: { number: 41, body }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo", io: w.io, preconditionCmd: "check-rollout" });
444
+ assert.equal(g.state, state);
445
+ assert.deepEqual(calls, [["sh", ["-c", "check-rollout", "precondition", "41"], "41"]]);
446
+ }
447
+ });
448
+
449
+ it("forgedock triage prints a parked issue, one awaiting-promotion line, and leaves it out of the lanes", async () => {
450
+ const w = world({ issues: { 41: { body: followup(40, [item("R2", "post-deploy")]) }, 13: { body: "y" } }, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
451
+ const logs = []; const l = console.log; console.log = (x) => logs.push(String(x));
452
+ let res;
453
+ try { res = await triageFromCli(["41", "13", "--repo", "o/r", "--lane", "staging", "--dry-run"], { leanIo: w.io }); } finally { console.log = l; }
454
+ assert.deepEqual(res.lanes, [13]);
455
+ assert.ok(logs.some((x) => /^#41 → parked \(awaiting-promotion: /.test(x)));
456
+ assert.ok(logs.includes("awaiting promotion: #41 (fixes on staging, not on main)"));
457
+ assert.deepEqual(w.edits, []);
458
+ });
459
+
460
+ it("lazyDefaultBranch resolves GitHub's default and fetches it once, only when asked", async () => {
461
+ const w = world();
462
+ const d = lazyDefaultBranch({ io: w.io, repo: "o/r", cwd: "/repo" });
463
+ assert.equal(w.gitCalls.length, 0);
464
+ assert.equal(await d(), "main"); assert.equal(await d(), "main");
465
+ assert.deepEqual(w.gitCalls, [["fetch", "origin", "main"]]);
466
+ });
467
+
468
+ it("orchestrate: parked issues start no lane and the report has one awaiting-promotion line, no escalations", async () => {
469
+ const issues = {}; for (const n of [41, 42, 43]) issues[n] = { body: followup(40, [item("R2", "post-deploy")]) };
470
+ const w = world({ issues, mergedPrs: parentPr, ancestry: { [MERGE]: 1 } });
471
+ const lanes = [];
472
+ const rows = await orchestrate({ issues: [41, 42, 43], readBody: async () => "", prefilter: makePrefilter({ repo: "o/r", cwd: "/repo", io: w.io, defaultBranch: "main" }),
473
+ resolveLane: async () => ({ ok: true, lane: "staging" }),
474
+ runOne: async (n) => { lanes.push(n); return { terminalReason: "merged", pr: 1, cost_usd: 1 }; } });
475
+ assert.deepEqual(lanes, []);
476
+ assert.deepEqual(rows.map((r) => [r.outcome, r.cost_usd]), [["awaiting-promotion", 0], ["awaiting-promotion", 0], ["awaiting-promotion", 0]]);
477
+ const lists = formatOutcomeLists(rows);
478
+ assert.deepEqual(lists, ["awaiting promotion: #41 #42 #43 (fixes on staging, not on main)"]);
479
+ assert.match(formatTable(rows), /#41\s+awaiting-promotion \(pre-filter\)/);
480
+ assert.equal(formatParkedLine([{ issue: 1, outcome: "merged" }]), "");
481
+ });
482
+ });
483
+
484
+ describe("post_deploy.min_age soak window (#4425)", () => {
485
+ const MERGE = "a".repeat(40), PROMO = "b".repeat(40), MAIN = "c".repeat(40);
486
+ const H = 3600, LANDED = 1_800_000_000; // unix seconds of the promotion merge
487
+ const item = (id, why) => `- [ ] \`${id}: a health row exists\` — post-deploy: ${why}`;
488
+ const followup = (items) => `## Acceptance Criteria\n\n${items.join("\n")}\n\n<!-- FORGE:DEFERRED_FROM: #40 -->\n`;
489
+ const parentPr = [{ number: 90, title: "fix(x): health row (#40)", body: "", mergeCommit: { oid: MERGE } }];
490
+ // the parent fix reached main through promotion merge PROMO (first parent MAIN) at LANDED
491
+ const soakWorld = (o = {}) => {
492
+ const w = world({ mergedPrs: parentPr, ancestry: { [MERGE]: 0 }, ...o });
493
+ const git = w.io.git;
494
+ w.io.git = async (a, opts) => {
495
+ if (a[0] === "log" && a[1] === "--first-parent") { w.gitCalls.push(a); return o.walk ?? `${"d".repeat(40)} ${PROMO} ${LANDED + 9 * H}\n${PROMO} ${MAIN} ${MERGE} ${LANDED}\n${MAIN} ${"e".repeat(40)} ${LANDED - H}\n`; }
496
+ if (a[0] === "rev-list") { w.gitCalls.push(a); return o.below ?? `${"d".repeat(40)}\n${PROMO}\n`; }
497
+ if (a[0] === "log" && a[1] === "-1") { w.gitCalls.push(a); return String(LANDED - 5 * H); }
498
+ return git(a, opts);
499
+ };
500
+ return w;
501
+ };
502
+ const gate = (w, body, extra = {}) => postDeployGate({ iss: { number: 41, body }, repo: "o/r", lane: "staging", defaultBranch: "main", cwd: "/repo", io: w.io, ...extra });
503
+
504
+ it("parses min_age and the windows each post-deploy item's why states (largest wins, none -> null)", () => {
505
+ assert.equal(parseMinAge("24h"), 24 * H * 1000); assert.equal(parseMinAge("7d"), 7 * 24 * H * 1000); assert.equal(parseMinAge("2w"), 14 * 24 * H * 1000);
506
+ assert.equal(parseMinAge("30m"), 30 * 60e3); assert.equal(parseMinAge("0"), 0); assert.equal(parseMinAge(90), 90 * 60e3);
507
+ assert.equal(parseMinAge("soon"), null); assert.equal(parseMinAge(null), null);
508
+ assert.deepEqual(itemWindowsMs(followup([item("R1", "after 24h of traffic, then 7 days of retention"), item("R2", "only in prod")])), [7 * 24 * H * 1000, null]);
509
+ });
510
+
511
+ it("landedAt: the promotion merge time, or the SHA's own time when it is on the mainline", async () => {
512
+ const w = soakWorld();
513
+ assert.equal(await landedAt({ sha: MERGE, def: "main", io: w.io, cwd: "/repo" }), LANDED);
514
+ assert.deepEqual(w.gitCalls.slice(0, 2), [["rev-list", "--ancestry-path", `${MERGE}..origin/main`], ["log", "--first-parent", "--format=%H %P %ct", `${MERGE}..origin/main`]]);
515
+ const own = soakWorld({ walk: `${PROMO} ${MERGE} ${LANDED}\n`, below: `${PROMO}\n` });
516
+ assert.equal(await landedAt({ sha: MERGE, def: "main", io: own.io, cwd: "/repo" }), LANDED - 5 * H);
517
+ const tip = soakWorld({ walk: "", below: "" });
518
+ assert.equal(await landedAt({ sha: MERGE, def: "main", io: tip.io, cwd: "/repo" }), LANDED - 5 * H);
519
+ });
520
+
521
+ it("landedAt on a real repo: the promotion merge that brought a staging fix in through its second parent", async () => {
522
+ const d = tmpDir("landed-");
523
+ const run = (args, t) => execFileSync("git", args, { cwd: d, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_DATE: `@${t} +0000`, GIT_COMMITTER_DATE: `@${t} +0000`,
524
+ GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" } }).trim();
525
+ run(["init", "-q", "-b", "main"], 1);
526
+ run(["commit", "-q", "--allow-empty", "-m", "base"], LANDED - 10 * H);
527
+ run(["checkout", "-q", "-b", "staging"], 1);
528
+ run(["commit", "-q", "--allow-empty", "-m", "fix"], LANDED - 8 * H);
529
+ const fix = run(["rev-parse", "HEAD"], 1);
530
+ run(["checkout", "-q", "main"], 1);
531
+ run(["commit", "-q", "--allow-empty", "-m", "hotfix on main"], LANDED - 2 * H);
532
+ run(["merge", "-q", "--no-ff", "-m", "promote", "staging"], LANDED);
533
+ run(["commit", "-q", "--allow-empty", "-m", "later"], LANDED + 5 * H);
534
+ run(["update-ref", "refs/remotes/origin/main", "HEAD"], 1);
535
+ const io = { git: async (a, o) => run(a, 1) + "\n" };
536
+ assert.equal(await landedAt({ sha: fix, def: "main", io, cwd: d }), LANDED);
537
+ assert.equal(await landedAt({ sha: fix.slice(0, 12), def: "main", io, cwd: d }), LANDED);
538
+ assert.equal(await landedAt({ sha: run(["rev-parse", "HEAD~1^1"], 1), def: "main", io, cwd: d }), LANDED - 2 * H); // on the mainline itself
539
+ assert.equal(await landedAt({ sha: "f".repeat(40), def: "main", io, cwd: d }), null);
540
+ });
541
+
542
+ it("parks awaiting-min-age until max(item window ?? min_age) after landing, then is ready", async () => {
543
+ const body = followup([item("R1", "wait 7d of production data"), item("R2", "only in prod")]);
544
+ const g = await gate(soakWorld(), body, { minAge: parseMinAge("24h"), now: (LANDED + 48 * H) * 1000 });
545
+ assert.equal(g.state, "awaiting-min-age");
546
+ assert.equal(g.eligibleAt, new Date((LANDED + 7 * 24 * H) * 1000).toISOString());
547
+ assert.match(g.detail, /^fix of #40 on main since .*; min age 7d until /);
548
+ // no stated window: the min_age floor applies
549
+ const short = followup([item("R2", "only in prod")]);
550
+ assert.equal((await gate(soakWorld(), short, { minAge: parseMinAge("24h"), now: (LANDED + 23 * H) * 1000 })).state, "awaiting-min-age");
551
+ assert.equal((await gate(soakWorld(), short, { minAge: parseMinAge("24h"), now: (LANDED + 25 * H) * 1000 })).state, "ready");
552
+ assert.equal((await gate(soakWorld(), body, { minAge: 0, now: (LANDED + 8 * 24 * H) * 1000 })).state, "ready");
553
+ });
554
+
555
+ it("unset min_age changes nothing (no landing-time git calls); an unknown landing time fails open", async () => {
556
+ const w = soakWorld();
557
+ assert.equal((await gate(w, followup([item("R1", "wait 7d")]))).state, "ready");
558
+ assert.ok(!w.gitCalls.some((a) => a[0] === "log" || a[0] === "rev-list"));
559
+ const broken = soakWorld();
560
+ const git = broken.io.git;
561
+ broken.io.git = async (a, o) => { if (a[0] === "rev-list") throw Object.assign(new Error("bad"), { code: 128 }); return git(a, o); };
562
+ const g = await gate(broken, followup([item("R1", "wait 7d")]), { minAge: 0 });
563
+ assert.equal(g.state, "unresolved"); assert.match(g.detail, /landing time of aaaaaaaaaaaa on main unknown/);
564
+ });
565
+
566
+ it("prefilterIssue parks it with the awaiting-promotion label, one comment, and an 'awaiting min age:' report line", async () => {
567
+ const w = soakWorld({ issues: { 41: { body: followup([item("R1", "wait 7d")]) } } });
568
+ w.io.now = () => (LANDED + H) * 1000;
569
+ const r = await pf(w, 41, { defaultBranch: "main", minAge: parseMinAge("24h") });
570
+ assert.equal(r.parked, true); assert.equal(r.kind, "awaiting-min-age"); assert.equal(r.cost_usd, 0);
571
+ assert.deepEqual(w.edits, [[41, "--add-label", "awaiting-promotion"]]);
572
+ assert.equal(w.comments.length, 1); assert.match(w.comments[0][1], /Parked: awaiting its post-deploy soak window/);
573
+ assert.equal(formatParkedLine([{ issue: 41, prefilter: r }, { issue: 42, prefilter: { parked: true, kind: "awaiting-promotion", lane: "staging", default: "main" } }]),
574
+ "awaiting promotion: #42 (fixes on staging, not on main)\nawaiting min age: #41 (fix on main, soak window not elapsed)");
575
+ });
576
+
577
+ it("readPostDeployMinAge reads forge.yaml post_deploy.min_age; absent or invalid -> null", async () => {
578
+ const d = tmpDir("minage-");
579
+ assert.equal(await readPostDeployMinAge(d), null);
580
+ writeFileSync(join(d, "forge.yaml"), "post_deploy:\n min_age: \"24h\"\n");
581
+ assert.equal(await readPostDeployMinAge(d), 24 * H * 1000);
582
+ writeFileSync(join(d, "forge.yaml"), "post_deploy:\n min_age: later\n");
583
+ assert.equal(await readPostDeployMinAge(d), null);
584
+ });
585
+ });
586
+
587
+ describe("#4467: isTrustedIssueComment", () => {
588
+ it("trusts the App in gh issue view and REST shapes and trusted associations; not other contributors", () => {
589
+ assert.equal(isTrustedIssueComment({ author: { login: "rapiercraft-forgedock" }, authorAssociation: "CONTRIBUTOR" }), true);
590
+ assert.equal(isTrustedIssueComment({ user: { login: "rapiercraft-forgedock[bot]", type: "Bot" }, author_association: "NONE" }), true);
591
+ assert.equal(isTrustedIssueComment({ author: { login: "app/rapiercraft-forgedock" }, authorAssociation: "NONE" }), true);
592
+ assert.equal(isTrustedIssueComment({ author: { login: "owner" }, authorAssociation: "OWNER" }), true);
593
+ assert.equal(isTrustedIssueComment({ author: { login: "someone" }, authorAssociation: "CONTRIBUTOR" }), false);
594
+ assert.equal(isTrustedIssueComment(null), false);
595
+ });
596
+ });
@@ -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
+ });