forgedock 1.37.0 → 1.38.1

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 (81) hide show
  1. package/bin/engine/outcome-codes.mjs +12 -3
  2. package/bin/engine/telemetry-seam.mjs +5 -0
  3. package/bin/engine-cli.mjs +97 -18
  4. package/bin/forgedock.mjs +13 -2
  5. package/bin/lean/activation.mjs +76 -0
  6. package/bin/lean/activation.test.mjs +75 -0
  7. package/bin/lean/admission.mjs +17 -1
  8. package/bin/lean/batch.mjs +51 -21
  9. package/bin/lean/batch.test.mjs +95 -5
  10. package/bin/lean/conformance.mjs +53 -4
  11. package/bin/lean/conformance.test.mjs +24 -1
  12. package/bin/lean/decision-engine.mjs +217 -0
  13. package/bin/lean/decision-engine.test.mjs +159 -0
  14. package/bin/lean/effects.mjs +39 -13
  15. package/bin/lean/execution.mjs +74 -5
  16. package/bin/lean/execution.test.mjs +64 -1
  17. package/bin/lean/explore.test.mjs +1 -1
  18. package/bin/lean/guards.mjs +3 -2
  19. package/bin/lean/guards.test.mjs +24 -3
  20. package/bin/lean/heavy.mjs +15 -2
  21. package/bin/lean/heavy.test.mjs +31 -2
  22. package/bin/lean/issuewatch-release.mjs +245 -0
  23. package/bin/lean/issuewatch-release.test.mjs +244 -0
  24. package/bin/lean/issuewatch.mjs +386 -0
  25. package/bin/lean/issuewatch.test.mjs +280 -0
  26. package/bin/lean/lane.mjs +4 -3
  27. package/bin/lean/lane.test.mjs +12 -0
  28. package/bin/lean/lean.mjs +103 -16
  29. package/bin/lean/lean.test.mjs +127 -5
  30. package/bin/lean/needs-human-premise.mjs +177 -0
  31. package/bin/lean/needs-human-premise.test.mjs +136 -0
  32. package/bin/lean/orchestrate-blocked.test.mjs +71 -0
  33. package/bin/lean/orchestrate.mjs +93 -21
  34. package/bin/lean/postdeploy-check.mjs +88 -2
  35. package/bin/lean/postdeploy-check.test.mjs +78 -0
  36. package/bin/lean/prodwatch-sources.mjs +165 -0
  37. package/bin/lean/prodwatch-sources.test.mjs +108 -0
  38. package/bin/lean/prompts.mjs +9 -3
  39. package/bin/lean/recovery.test.mjs +14 -0
  40. package/bin/lean/relay.mjs +182 -0
  41. package/bin/lean/relay.test.mjs +214 -0
  42. package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
  43. package/bin/lean/sandbox-remote.mjs +53 -9
  44. package/bin/lean/sched.test.mjs +30 -0
  45. package/bin/lean/schedd-health.mjs +69 -0
  46. package/bin/lean/schedd-health.test.mjs +51 -0
  47. package/bin/lean/schedd.mjs +29 -12
  48. package/bin/lean/schedd.test.mjs +40 -4
  49. package/bin/lean/sequence.mjs +57 -24
  50. package/bin/lean/sequence.test.mjs +71 -0
  51. package/bin/pool/agent.mjs +15 -1
  52. package/bin/pool/router.mjs +43 -5
  53. package/bin/runner.mjs +18 -1
  54. package/commands/deploy-pr.md +12 -0
  55. package/commands/forgedock-init.md +1 -0
  56. package/commands/orchestrate/phase-4-execution.md +1 -0
  57. package/commands/orchestrate.md +1 -0
  58. package/commands/review-pr.md +17 -13
  59. package/docs/CONFIG.md +73 -2
  60. package/docs/DECISIONS.md +32 -0
  61. package/docs/ISSUE-WATCH.md +95 -0
  62. package/docs/NORTHSTAR-TEMPLATE.md +21 -0
  63. package/docs/OUTCOME-CODES.md +2 -1
  64. package/docs/TELEMETRY.md +7 -0
  65. package/package.json +1 -2
  66. package/pool/README.md +1 -1
  67. package/pool/image/Dockerfile +2 -1
  68. package/pool/image/cs-relay +31 -0
  69. package/pool/image/entrypoint.sh +10 -0
  70. package/pool/install/install-schedd.sh +22 -0
  71. package/pool/install/unit-hardening.sh +6 -0
  72. package/scripts/postdeploy-rollup-link.sh +40 -0
  73. package/scripts/postdeploy-rollup-link.test.sh +46 -0
  74. package/scripts/refresh-bot-token.sh +122 -0
  75. package/scripts/review-delta.sh +14 -4
  76. package/scripts/review-delta.test.sh +21 -0
  77. package/scripts/review-finalize.sh +69 -25
  78. package/scripts/review-finalize.test.sh +32 -0
  79. package/scripts/review-pr-arguments.test.sh +10 -0
  80. package/scripts/review-pr-changed-verification.test.sh +54 -0
  81. package/scripts/review-pr-delta-wiring.test.sh +1 -1
@@ -6,7 +6,7 @@
6
6
  */
7
7
  // #4169: load average lags by minutes and is phase-blind, so it is off unless configured; heavy steps are gated by
8
8
  // the host heavy-step semaphore (heavy.mjs) instead. An explicit `max_load_per_core` is still honoured.
9
- export const ADMISSION_DEFAULTS = Object.freeze({ enabled: true, min_free_mem_mb: 2048, min_free_tmp_mb: 1024, max_load_per_core: null });
9
+ export const ADMISSION_DEFAULTS = Object.freeze({ enabled: true, min_free_mem_mb: 2048, min_free_tmp_mb: 1024, max_load_per_core: null, lane_cpu_reservation: 1, lane_mem_mb: 2048 });
10
10
  export const ADMISSION_RETRY_SECONDS = 60;
11
11
 
12
12
  /** Workers one verification step may use (#4182): max(1, floor(cores / lanes)); bad inputs count as 1. Heavy shims pass live heavy holders (#4169). */
@@ -15,6 +15,22 @@ export function hostParallelism({ cores, lanes } = {}) {
15
15
  return Math.max(1, Math.floor(c / l));
16
16
  }
17
17
 
18
+ /**
19
+ * Local lane cap (#4777): how many lanes this host can run at once, from cores and free memory:
20
+ * max(1, min(floor(cores / lane_cpu_reservation), floor((memAvailableMb - min_free_mem_mb) / lane_mem_mb))). Evaluated per
21
+ * admission attempt, so it follows the host as lanes come and go; an unknown probe leaves that bound off, and with both
22
+ * unknown (or admission disabled) it is null (no derived cap).
23
+ */
24
+ export function localLaneCap({ cfg = {}, cores, memAvailableMb } = {}) {
25
+ const c = { ...ADMISSION_DEFAULTS, ...Object.fromEntries(Object.entries(cfg ?? {}).filter(([, v]) => v !== undefined && v !== null)) };
26
+ if (c.enabled === false) return null;
27
+ const pos = (v, d) => (Number.isFinite(v) && v > 0 ? v : d);
28
+ const bounds = [];
29
+ if (Number.isFinite(cores) && cores > 0) bounds.push(Math.floor(cores / pos(c.lane_cpu_reservation, 1)));
30
+ if (Number.isFinite(memAvailableMb)) bounds.push(Math.floor((memAvailableMb - pos(c.min_free_mem_mb, 0)) / pos(c.lane_mem_mb, 2048)));
31
+ return bounds.length ? Math.max(1, Math.min(...bounds)) : null;
32
+ }
33
+
18
34
  const gb = (mb) => { const g = mb / 1024; return `${Number.isInteger(g) ? g : g.toFixed(1)}G`; };
19
35
 
20
36
  /**
@@ -15,7 +15,7 @@
15
15
  * running; a resumed dispatcher re-attaches to it by pid, checking `/proc/<pid>/cmdline` against its result path. These
16
16
  * detached lanes are the `local` execution (#4253); `execution.mjs` is the sandbox-provider seam for remote lanes.
17
17
  */
18
- import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
18
+ import { appendFileSync, chmodSync, closeSync, existsSync, fchmodSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
19
19
  import { spawn } from "node:child_process";
20
20
  import { homedir } from "node:os";
21
21
  import { dirname, join, resolve, sep } from "node:path";
@@ -54,15 +54,30 @@ export function assertDiskPath(path, mounts = readMounts()) {
54
54
  if (RAM_FS.has(type)) throw new Error(`batch state must be on disk, but ${path} is on ${type}; set FORGEDOCK_BATCH_DIR to a disk path`);
55
55
  }
56
56
 
57
- /** Write `data` to `file` atomically: a reader sees the old or the new content, never a truncated file. */
58
- export function writeAtomic(file, data) {
59
- mkdirSync(dirname(file), { recursive: true });
57
+ /**
58
+ * Write `data` to `file` atomically: a reader sees the old or the new content, never a truncated file.
59
+ * `private` creates the file 0600 and its directory 0700 (batch state can hold pinned secrets, #4751).
60
+ */
61
+ export function writeAtomic(file, data, { private: priv = false } = {}) {
62
+ const dir = dirname(file);
63
+ mkdirSync(dir, priv ? { recursive: true, mode: 0o700 } : { recursive: true });
64
+ if (priv) chmodSync(dir, 0o700);
60
65
  const tmp = `${file}.tmp-${process.pid}`;
61
- const fd = openSync(tmp, "w");
62
- try { writeSync(fd, data); fsyncSync(fd); } finally { closeSync(fd); }
66
+ const fd = priv ? openSync(tmp, "w", 0o600) : openSync(tmp, "w");
67
+ try { if (priv) fchmodSync(fd, 0o600); writeSync(fd, data); fsyncSync(fd); } finally { closeSync(fd); }
63
68
  renameSync(tmp, file);
64
69
  }
65
70
 
71
+ /** Env names whose values never reach batch state (#4787): tokens, keys, secrets and passwords. */
72
+ export const SECRET_ENV = /(TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|API_?KEY|PRIVATE_?KEY|(^|_)KEY$)/i;
73
+
74
+ /** @returns {{env: object, names: string[]}} `env` without secret-named keys, and the dropped names (never values). */
75
+ export function scrubEnv(env) {
76
+ if (!env || typeof env !== "object") return { env, names: [] };
77
+ const names = Object.keys(env).filter((k) => SECRET_ENV.test(k)).sort();
78
+ return { env: names.length ? Object.fromEntries(Object.entries(env).filter(([k]) => !SECRET_ENV.test(k))) : env, names };
79
+ }
80
+
66
81
  const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
67
82
 
68
83
  const readAdded = (file) => {
@@ -71,7 +86,7 @@ const readAdded = (file) => {
71
86
  };
72
87
 
73
88
  /** `outcome`s `--requeue` may move back to pending (#4550). */
74
- export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded"].includes(outcome) || /^dependency-/.test(String(outcome));
89
+ export const REQUEUABLE = (outcome) => ["needs-human", "invalid", "skipped", "ci-pending", "budget-exceeded", "lane-refused"].includes(outcome) || /^dependency-/.test(String(outcome));
75
90
  /** Key of one requeue entry; the runner records the applied keys in state.json `requeueApplied`. */
76
91
  export const requeueKey = (e) => `${e.issue}@${e.at}`;
77
92
 
@@ -99,21 +114,36 @@ export function openBatch(root, id) {
99
114
  if (!ID_RE.test(String(id ?? ""))) throw new Error(`invalid batch id ${JSON.stringify(id)}`);
100
115
  const dir = join(root, id);
101
116
  const f = (name) => join(dir, name);
117
+ const put = (file, data) => writeAtomic(file, data, { private: true });
102
118
  const self = {
103
119
  id, dir,
104
120
  exists: () => existsSync(f("plan.json")),
105
121
  readPlan: () => readJson(f("plan.json")),
106
- writePlan: (plan) => writeAtomic(f("plan.json"), JSON.stringify(plan, null, 2) + "\n"),
122
+ /** Secret-named `plan.env` keys are never written: their names go to `envSecrets`, the value is re-read from the environment on resume (#4787). */
123
+ writePlan: (plan) => {
124
+ const { env, names } = scrubEnv(plan?.env);
125
+ const out = names.length ? { ...plan, env, envSecrets: [...new Set([...(plan.envSecrets ?? []), ...names])].sort() } : plan;
126
+ put(f("plan.json"), JSON.stringify(out, null, 2) + "\n");
127
+ },
128
+ /** Rewrite an existing plan.json that holds secret env values (or is not 0600) as private and scrubbed; @returns the scrubbed names. */
129
+ scrubPlan: () => {
130
+ const plan = readJson(f("plan.json"));
131
+ if (!plan) return [];
132
+ const { names } = scrubEnv(plan.env);
133
+ if (names.length) self.writePlan(plan);
134
+ else { try { chmodSync(f("plan.json"), 0o600); } catch { /* best effort */ } }
135
+ return names;
136
+ },
107
137
  store: {
108
138
  load: () => readJson(f("state.json")),
109
- save: (state) => writeAtomic(f("state.json"), JSON.stringify(state) + "\n"),
110
- log: (row) => { try { appendFileSync(f("log.jsonl"), JSON.stringify({ at: new Date().toISOString(), ...row }) + "\n"); } catch { /* the log is advisory */ } },
139
+ save: (state) => put(f("state.json"), JSON.stringify(state) + "\n"),
140
+ log: (row) => { try { appendFileSync(f("log.jsonl"), JSON.stringify({ at: new Date().toISOString(), ...row }) + "\n", { mode: 0o600 }); } catch { /* the log is advisory */ } },
111
141
  },
112
142
  /** Live K: the `parallel` file, read on every scheduler pass; null when missing or invalid. */
113
143
  readParallel: () => { try { const k = parseInt(readFileSync(f("parallel"), "utf8"), 10); return Number.isInteger(k) && k >= 1 ? k : null; } catch { return null; } },
114
144
  setParallel: (k) => {
115
145
  if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
116
- writeAtomic(f("parallel"), `${k}\n`);
146
+ put(f("parallel"), `${k}\n`);
117
147
  },
118
148
  /** Issues added by `--add` (#4408): the `add` file, sorted unique positive integers; [] when missing or invalid. */
119
149
  readAdded: () => readAdded(f("add")),
@@ -128,12 +158,12 @@ export function openBatch(root, id) {
128
158
  for (const x of Array.isArray(list) ? list : []) {
129
159
  const n = typeof x === "number" ? x : typeof x === "string" && /^#?\d+$/.test(x.trim()) ? parseInt(x.trim().replace("#", ""), 10) : NaN;
130
160
  if (!Number.isSafeInteger(n) || n < 1) { skipped.push({ issue: x, reason: "not an integer" }); continue; }
131
- // #4687: an issue skipped in the batch (a dependency ended) is re-queued, not refused; any other row is "already in batch"
132
- if (have.has(n) && st.results?.[n]?.outcome === "skipped" && !requeued.includes(n) && self.requeue([n], { reason: "--add", by: "operator" }).requeued.length) { requeued.push(n); continue; }
161
+ // #4687/#4791: an issue skipped (a dependency ended) or lane-refused in the batch is re-queued, not refused; any other row is "already in batch"
162
+ if (have.has(n) && ["skipped", "lane-refused"].includes(st.results?.[n]?.outcome) && !requeued.includes(n) && self.requeue([n], { reason: "--add", by: "operator" }).requeued.length) { requeued.push(n); continue; }
133
163
  if (have.has(n)) { skipped.push({ issue: n, reason: "already in batch" }); continue; }
134
164
  have.add(n); added.push(n);
135
165
  }
136
- if (added.length) writeAtomic(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
166
+ if (added.length) put(f("add"), JSON.stringify([...new Set([...readAdded(f("add")), ...added])].sort((a, b) => a - b)) + "\n");
137
167
  return { added, skipped, requeued };
138
168
  },
139
169
  /**
@@ -157,7 +187,7 @@ export function openBatch(root, id) {
157
187
  }
158
188
  if (requeued.length) {
159
189
  const at = new Date().toISOString();
160
- writeAtomic(f("requeue"), JSON.stringify([...entries, ...requeued.map((issue) => ({ issue, reason: String(reason), by: String(by), at }))]) + "\n");
190
+ put(f("requeue"), JSON.stringify([...entries, ...requeued.map((issue) => ({ issue, reason: String(reason), by: String(by), at }))]) + "\n");
161
191
  }
162
192
  return { requeued, refused };
163
193
  },
@@ -173,18 +203,18 @@ export function openBatch(root, id) {
173
203
  holdIssues: (list) => {
174
204
  const have = new Set(readAdded(f("hold")));
175
205
  const added = [...new Set(ints(list))].filter((n) => !have.has(n)).sort((a, b) => a - b);
176
- if (added.length) writeAtomic(f("hold"), JSON.stringify([...have, ...added].sort((a, b) => a - b)) + "\n");
206
+ if (added.length) put(f("hold"), JSON.stringify([...have, ...added].sort((a, b) => a - b)) + "\n");
177
207
  return added;
178
208
  },
179
209
  /** Remove `list` from the `hold` file. @returns {number[]} the issues that were held */
180
210
  releaseIssues: (list) => {
181
211
  const have = new Set(readAdded(f("hold")));
182
212
  const freed = [...new Set(ints(list))].filter((n) => have.has(n)).sort((a, b) => a - b);
183
- if (freed.length) writeAtomic(f("hold"), JSON.stringify([...have].filter((n) => !freed.includes(n)).sort((a, b) => a - b)) + "\n");
213
+ if (freed.length) put(f("hold"), JSON.stringify([...have].filter((n) => !freed.includes(n)).sort((a, b) => a - b)) + "\n");
184
214
  return freed;
185
215
  },
186
216
  /** `--cancel` (#4376): `{at, reason, stopRemote?}` in the `cancel` file; the runner re-reads it every pass. `stopRemote` (#4612) also asks remote lanes to stop at their checkpoint. */
187
- requestCancel: ({ reason = "operator", stopRemote = false } = {}) => writeAtomic(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason), ...(stopRemote === true ? { stopRemote: true } : {}) }) + "\n"),
217
+ requestCancel: ({ reason = "operator", stopRemote = false } = {}) => put(f("cancel"), JSON.stringify({ at: new Date().toISOString(), reason: String(reason), ...(stopRemote === true ? { stopRemote: true } : {}) }) + "\n"),
188
218
  /** The cancel record, or null when missing or invalid. */
189
219
  readCancel: () => { const v = readJson(f("cancel")); return v && typeof v === "object" && !Array.isArray(v) && typeof v.at === "string" ? v : null; },
190
220
  clearCancel: () => rmSync(f("cancel"), { force: true }),
@@ -197,7 +227,7 @@ export function openBatch(root, id) {
197
227
  return text.split("\n").flatMap((l) => { try { return l ? [JSON.parse(l)] : []; } catch { return []; } });
198
228
  },
199
229
  readReport: () => readJson(f("report.json")),
200
- writeReport: (report) => writeAtomic(f("report.json"), JSON.stringify(report) + "\n"),
230
+ writeReport: (report) => put(f("report.json"), JSON.stringify(report) + "\n"),
201
231
  clearReport: () => rmSync(f("report.json"), { force: true }),
202
232
  resultFile: (n) => f(join("lanes", `${n}.json`)),
203
233
  logFile: (n) => f(join("lanes", `${n}.log`)),
@@ -224,9 +254,9 @@ export function isLaneAlive(pid, marker, { kill = process.kill.bind(process), pr
224
254
  */
225
255
  export function launchLane({ batch, issue, args = [], bin = process.execPath, script, cwd = process.cwd(), env = process.env, spawnFn = spawn }) {
226
256
  const rf = batch.resultFile(issue);
227
- mkdirSync(dirname(rf), { recursive: true });
257
+ mkdirSync(dirname(rf), { recursive: true, mode: 0o700 });
228
258
  rmSync(rf, { force: true });
229
- const out = openSync(batch.logFile(issue), "a");
259
+ const out = openSync(batch.logFile(issue), "a", 0o600);
230
260
  try {
231
261
  // FORGEDOCK_BATCH_ID names the lane's resource ledger dir (~/.forge/ledger/<batch>/, #4179).
232
262
  const child = spawnFn(bin, [script, "run-issue", String(issue), ...args, "--result-file", rf], { cwd, env: { ...env, FORGEDOCK_BATCH_ID: batch.id }, detached: true, stdio: ["ignore", out, out] });
@@ -3,7 +3,7 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { tmpDir } from "../tests/_tmp.mjs";
9
9
  import { orchestrate, deferPolicy, DEFER_BACKOFF_S, formatOutcomeLists } from "./orchestrate.mjs";
@@ -261,9 +261,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
261
261
  assert.equal(isLaneAlive(42, "lane", { kill: () => true, procDir: proc }), true);
262
262
  writeFileSync(join(pd, "stat"), "42 (no) de) Z 1 42 42 0 -1\n");
263
263
  assert.equal(isLaneAlive(42, "lane", { kill: () => true, procDir: proc }), false);
264
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
264
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, blockedLabel: "blocked", globalParallel: null, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
265
265
  writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
266
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4, recovery: { maxAttempts: 1 }, baseSync: { enabled: true, unionGlobs: [] } });
266
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, blockedLabel: "blocked", globalParallel: 4, recovery: { maxAttempts: 1, mechanicalMaxAttempts: 2 }, baseSync: { enabled: true, unionGlobs: [] } });
267
267
  });
268
268
 
269
269
  it("run-issue --result-file writes the lane result, also when the run throws", async () => {
@@ -340,12 +340,12 @@ describe("#4318: the batch dispatcher's signal relabel and the needs-human relab
340
340
  it("an expired lease relabels: the label first, then the comment", async () => {
341
341
  const h = hook({ state: "OPEN", body: LIVE(500) });
342
342
  assert.equal(await h.run(), true);
343
- assert.deepEqual(h.calls, ["view", "edit", "comment"]);
343
+ assert.deepEqual(h.calls, ["view", "list", "edit", "comment"]); // #4725: the ask registry lookup
344
344
  });
345
345
  it("a failed label add posts no comment and reports failed; a failed comment keeps the label", async () => {
346
346
  const a = hook({ state: "OPEN", body: "" }, { edit: true });
347
347
  assert.equal(await a.run(), "failed");
348
- assert.deepEqual(a.calls, ["view", "edit"]);
348
+ assert.deepEqual(a.calls, ["view", "list", "edit"]);
349
349
  const b = hook({ state: "OPEN", body: "" }, { comment: true });
350
350
  assert.equal(await b.run(), true);
351
351
  });
@@ -488,6 +488,30 @@ describe("#4408: issues added to a running batch (forgedock orchestrate --add)",
488
488
  assert.deepEqual(plain.map((r) => r.issue), [1]);
489
489
  });
490
490
 
491
+ it("#4791 --requeue and --add accept a lane-refused issue", () => {
492
+ const b = openBatch(tmpDir("batch-lane-refused-"), "b1");
493
+ b.writePlan({ argv: ["1"], repo: "o/r" });
494
+ b.store.save({ order: [1, 2], results: { 1: { outcome: "lane-refused", code: "lane.unresolved" }, 2: { outcome: "lane-refused" } } });
495
+ assert.deepEqual(b.requeue([1]).requeued, [1]);
496
+ assert.deepEqual(b.addIssues([2]).requeued, [2]);
497
+ });
498
+
499
+ it("#4791 a transient lane-resolution failure is retried with backoff, never capped, and not terminal", async () => {
500
+ const { now, sleep } = fakeClock();
501
+ const updates = [];
502
+ let tries = 0;
503
+ const launched = [];
504
+ const rows = await orchestrate({ issues: [1], readBody: async () => "", now, sleep, maxDeferrals: 1, onUpdate: (r) => updates.push(r),
505
+ resolveLane: async () => (++tries <= 3 ? { ok: false, code: "lane.unresolved", detail: "TLS handshake timeout", transient: true } : { ok: true, lane: "staging" }),
506
+ runOne: async (n, lane) => { launched.push(lane); return { terminalReason: "merged", pr: 5 }; } });
507
+ assert.equal(rows[0].outcome, "merged");
508
+ assert.deepEqual(launched, ["staging"]);
509
+ assert.ok(updates.some((u) => /requeued in 120s: lane.unresolved/.test(u.detail ?? "")));
510
+ assert.ok(updates.some((u) => /requeued in 240s: lane.unresolved/.test(u.detail ?? "")));
511
+ const hard = await orchestrate({ issues: [2], readBody: async () => "", now, sleep, resolveLane: async () => ({ ok: false, code: "lane.branch_missing", detail: "x" }), runOne: async () => { throw new Error("no"); } });
512
+ assert.equal(hard[0].outcome, "lane-refused");
513
+ });
514
+
491
515
  it("#4687 --add re-queues an issue that is skipped in the batch; any other ended issue stays 'already in batch'", () => {
492
516
  const root = tmpDir("batch-add-skipped-");
493
517
  const b = openBatch(root, "b1");
@@ -787,6 +811,7 @@ describe("#4528: a full sandbox pool (sandbox.capacity) is never capped", () =>
787
811
  it("sandbox.capacity is uncapped and honours retryInSeconds; other codes still cap at maxDeferrals", () => {
788
812
  assert.deepEqual(deferPolicy({ code: "sandbox.capacity" }, { now: 0, deferrals: 99 }), { delayMs: 60000, host: true });
789
813
  assert.deepEqual(deferPolicy({ code: "sandbox.capacity", retryInSeconds: 30 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 30000, host: true });
814
+ assert.deepEqual(deferPolicy({ code: "sandbox.unreachable", retryInSeconds: 60 }, { now: 0, deferrals: 99, maxDeferrals: 3 }), { delayMs: 60000, host: true }); // #4788
790
815
  for (const code of ["merge.transient", "gh.unavailable", "cli.idle", "rate_limited", "sandbox.start_failed"]) {
791
816
  assert.deepEqual(deferPolicy({ code, retryInSeconds: 60 }, { now: 0, deferrals: 3, maxDeferrals: 3 }), { capped: true }, code);
792
817
  }
@@ -943,6 +968,29 @@ describe("#4547: opt-in local fallback in mode remote when no pool agent is onli
943
968
  });
944
969
  });
945
970
 
971
+ describe("#4788: a pool offline for longer than N minutes is reported once", () => {
972
+ const run = async (onlineAt) => {
973
+ const c = { t: 1_000_000 };
974
+ const sleep = async (ms) => { await new Promise((r) => setImmediate(r)); c.t += ms; };
975
+ const events = [];
976
+ let tries = 0;
977
+ await orchestrate({ issues: [1], parallel: 1, now: () => c.t, sleep, pollMs: 1, readBody: async () => "", onUpdate: (row) => events.push(row),
978
+ remote: { max: 1, isRemote: () => true, poolOfflineMin: 1, capacity: () => ({ free: null, online: onlineAt(c.t), at: 1 }) },
979
+ runOne: async () => { tries++; if (tries < 80) { c.t += 5000; return { terminalReason: "deferred", code: "sandbox.unreachable", retryInSeconds: 1, detail: "HTTP 502" }; } return { terminalReason: "merged", pr: 1 }; } });
980
+ return { events, tries };
981
+ };
982
+ it("zero live agents past the limit: one pool-offline event; unreachable never gives up", async () => {
983
+ const { events, tries } = await run(() => 0);
984
+ assert.equal(events.filter((e) => e.outcome === "pool-offline").length, 1);
985
+ assert.equal(tries, 80);
986
+ assert.equal(events.some((e) => /gave up/.test(e.detail ?? "")), false);
987
+ });
988
+ it("an agent online: no event", async () => {
989
+ const { events } = await run(() => 1);
990
+ assert.equal(events.some((e) => e.outcome === "pool-offline"), false);
991
+ });
992
+ });
993
+
946
994
  describe("#4592: forgedock orchestrate --hold / --release", () => {
947
995
  it("the hold file: holdIssues / readHeld / releaseIssues merge sorted unique positive integers; invalid content reads as []", () => {
948
996
  const b = openBatch(tmpDir("batch-hold-"), "b1");
@@ -1009,3 +1057,45 @@ describe("#4592: forgedock orchestrate --hold / --release", () => {
1009
1057
  assert.deepEqual(b.readAdded(), [], "an issue already in the batch is not added again");
1010
1058
  });
1011
1059
  });
1060
+
1061
+ describe("#4751: batch state is private", () => {
1062
+ it("batch state files are private", () => {
1063
+ const root = tmpDir("batch-private");
1064
+ const old = process.umask(0o022);
1065
+ try {
1066
+ mkdirSync(join(root, "b1"), { mode: 0o755 });
1067
+ const b = openBatch(root, "b1");
1068
+ b.writePlan({ argv: ["1"], env: { TOKEN: "s3cret" } });
1069
+ b.store.save({ v: 1 });
1070
+ b.store.log({ ev: "x" });
1071
+ const mode = (p) => statSync(p).mode & 0o777;
1072
+ assert.equal(mode(b.dir), 0o700);
1073
+ assert.equal(mode(join(b.dir, "plan.json")), 0o600);
1074
+ assert.equal(mode(join(b.dir, "state.json")), 0o600);
1075
+ assert.equal(mode(join(b.dir, "log.jsonl")), 0o600);
1076
+ writeAtomic(join(root, "plain.json"), "{}");
1077
+ assert.equal(mode(join(root, "plain.json")), 0o644);
1078
+ } finally { process.umask(old); }
1079
+ });
1080
+ });
1081
+
1082
+ describe("batch plan secrets (#4787)", () => {
1083
+ it("writePlan never writes secret env values and keeps the plan 0600", () => {
1084
+ const b = openBatch(join(tmpDir("batch-sec-"), "batches"), "b20260101-000000-1");
1085
+ b.writePlan({ v: 1, env: { PATH: "/bin", COMPUTESHARE_TOKEN: "tok-123", AWS_ACCESS_KEY: "k-456" } });
1086
+ const raw = readFileSync(join(b.dir, "plan.json"), "utf8");
1087
+ assert.ok(!raw.includes("tok-123") && !raw.includes("k-456"));
1088
+ assert.deepEqual(b.readPlan().env, { PATH: "/bin" });
1089
+ assert.deepEqual(b.readPlan().envSecrets, ["AWS_ACCESS_KEY", "COMPUTESHARE_TOKEN"]);
1090
+ assert.equal(statSync(join(b.dir, "plan.json")).mode & 0o777, 0o600);
1091
+ });
1092
+ it("scrubPlan rewrites a pre-fix 0644 plan and is a no-op otherwise", () => {
1093
+ const b = openBatch(join(tmpDir("batch-sec-"), "batches"), "b20260101-000000-2");
1094
+ mkdirSync(b.dir, { recursive: true });
1095
+ writeFileSync(join(b.dir, "plan.json"), JSON.stringify({ v: 1, env: { PATH: "/bin", COMPUTESHARE_TOKEN: "tok-123" } }), { mode: 0o644 });
1096
+ assert.deepEqual(b.scrubPlan(), ["COMPUTESHARE_TOKEN"]);
1097
+ assert.ok(!readFileSync(join(b.dir, "plan.json"), "utf8").includes("tok-123"));
1098
+ assert.equal(statSync(join(b.dir, "plan.json")).mode & 0o777, 0o600);
1099
+ assert.deepEqual(b.scrubPlan(), []);
1100
+ });
1101
+ });
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
11
11
  import { noComments } from "./context.mjs";
12
+ import { activationFailures } from "./activation.mjs";
12
13
 
13
14
  export const DISPOSITIONS = Object.freeze(["deliver", "defer", "not-applicable", "post-deploy"]);
14
15
  /** Dispositions the engine files as a tracked follow-up issue before anything merges. */
@@ -31,7 +32,33 @@ import { CHECK_ID_RE } from "./postdeploy-check.mjs";
31
32
  import { normaliseDecision, deadAnchors } from "./decisions.mjs";
32
33
  const C = OUTCOME_CODES;
33
34
 
34
- export const norm = (s) => String(s ?? "").replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
35
+ /** #4789: folds what a builder retyping a quote changes: unicode punctuation, markdown links/markup, escapes, invisible chars. */
36
+ export const norm = (s) => String(s ?? "")
37
+ .normalize("NFKC")
38
+ .replace(/[\u200b-\u200d\u2060\ufeff\u00ad]/g, "")
39
+ .replace(/[\u2018\u2019\u201a\u201b\u2032]/g, "'").replace(/[\u201c\u201d\u201e\u201f\u2033]/g, '"')
40
+ .replace(/[\u2010-\u2015\u2212]/g, "-").replace(/\u2026/g, "...")
41
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
42
+ .replace(/\\([^A-Za-z0-9\s])/g, "$1")
43
+ .replace(/[`*_>~#]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
44
+ const WORD_RE = /[a-z0-9]{3,}/g;
45
+ /** The issue line sharing the most words with a quote that missed (for the correction prompt); "" when none shares any. */
46
+ export function nearestSpan(quote, issueText) {
47
+ const qw = new Set(norm(quote).match(WORD_RE) ?? []);
48
+ let best = "", bestN = 0;
49
+ for (const l of String(issueText ?? "").split("\n")) {
50
+ const n = norm(l);
51
+ if (!n) continue;
52
+ let hit = 0;
53
+ for (const w of new Set(n.match(WORD_RE) ?? [])) if (qw.has(w)) hit++;
54
+ if (hit > bestN) { bestN = hit; best = l.trim(); }
55
+ }
56
+ return bestN >= 2 ? best.slice(0, 120) : "";
57
+ }
58
+ const quoteMiss = (id, quote, issueText) => {
59
+ const near = nearestSpan(quote, issueText);
60
+ return `${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)${near ? `; nearest issue text: "${near}"` : ""}`;
61
+ };
35
62
  const ITEM_PREFIX_RE = /^\s*(?:[-*+]\s+)?(?:\[[ xX]\]\s+)?(?:\d+[.)]\s+)?/;
36
63
  /**
37
64
  * A quote must be verbatim (whitespace and markdown emphasis aside) from the issue text: a substring of at least
@@ -231,7 +258,7 @@ export function validateContract(c, issueText, { repro = false } = {}) {
231
258
  if (!id || ids.has(id)) problems.push(`requirement id missing or duplicated: ${id}`);
232
259
  ids.add(id);
233
260
  if (!DISPOSITIONS.includes(r?.disposition)) problems.push(`${id}: disposition must be one of ${DISPOSITIONS.join("|")}`);
234
- if (!inIssue(r?.quote, issueText)) problems.push(`${id}: quote is not verbatim from the issue (at least ${MIN_QUOTE_CHARS} characters)`);
261
+ if (!inIssue(r?.quote, issueText)) problems.push(quoteMiss(id, r?.quote, issueText));
235
262
  if (TRACKED.includes(r?.disposition) && blank(r?.why)) problems.push(`${id}: ${r.disposition} needs a why`);
236
263
  if (r?.disposition === "post-deploy" && !CHECK_ID_RE.test(String(r?.check ?? ""))) problems.push(`${id}: post-deploy needs a check id (\`check\`, e.g. "login-smoke") naming an entry of .forge/post-deploy-checks.json on the default branch`);
237
264
  if (r?.disposition === "not-applicable" && blank(r?.evidence)) problems.push(`${id}: not-applicable needs file:line evidence`);
@@ -257,12 +284,16 @@ export function validateContract(c, issueText, { repro = false } = {}) {
257
284
  const ref = movingBaseDiff(v.command);
258
285
  if (ref) problems.push(`${v.id}: \`git diff\` against the moving ${ref} tip fails as soon as the base moves on, even when the change is fine; compare against the merge base instead (\`${ref}...HEAD\` or \`$(git merge-base HEAD ${ref})\`)`);
259
286
  for (const p of tmpdirInputs(v.command)) problems.push(tmpInputProblem(v.id, p));
287
+ if (vitestQuietFlag(v.command)) problems.push(`${v.id}: vitest 3 rejects -q/--quiet (usage error before any test runs); use --reporter=dot`);
260
288
  }
261
289
  if (repro) {
262
290
  const t = c.repro_test;
263
291
  if (t === "none") { if (blank(c.repro_test_reason)) problems.push(`repro_test "none" needs a one-line repro_test_reason`); }
264
292
  else if (!t || typeof t !== "object" || Array.isArray(t) || blank(t.path) || blank(t.command)) problems.push(`this is a bug: repro_test must be {"path","id","command"} (a test that fails on the base and passes after the fix) or "none" with a repro_test_reason`);
265
- else for (const p of tmpdirInputs(t.command)) problems.push(tmpInputProblem("repro_test", p));
293
+ else {
294
+ for (const p of tmpdirInputs(t.command)) problems.push(tmpInputProblem("repro_test", p));
295
+ if (vitestQuietFlag(t.command)) problems.push("repro_test: vitest 3 rejects -q/--quiet; use --reporter=dot");
296
+ }
266
297
  }
267
298
  if (JSON.stringify(c).length > MAX_CONTRACT_CHARS) problems.push(`the contract is over ${MAX_CONTRACT_CHARS} characters; keep quotes to the requirement sentence and descriptions short`);
268
299
  return { ok: problems.length === 0, problems };
@@ -393,6 +424,21 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
393
424
  const PY_MISSING_RE = /ModuleNotFoundError: No module named '([^']+)'|ImportError: No module named '?([\w.]+)'?/;
394
425
  const NODE_MISSING_RE = /Cannot find (?:module|package) '([^']+)'/;
395
426
  const CMD_NOT_FOUND_RE = /([^\s:'"`]+): command not found/, SH_NOT_FOUND_RE = /([^\s:'"`]+): not found\b/;
427
+ // #4761: a runner rejecting its own flags is a defect of the frozen command, not of the change
428
+ const CLI_USAGE_RE = /\bUnknown option\b|Unrecognized CLI Parameter|error: unrecognized arguments|unknown (?:shorthand )?flag\b|bad option:/i;
429
+ const VITEST_QUIET_RE = /\bvitest\b[^&|;]*\s(?:-q|--quiet)(?=\s|$)/;
430
+ /** #4761: a validate/repro command that passes vitest `-q`/`--quiet`, which vitest 3 rejects; null when fine. */
431
+ export function vitestQuietFlag(command) {
432
+ return VITEST_QUIET_RE.test(String(command ?? "")) ? "-q" : null;
433
+ }
434
+ /** #4761: `-q`/`--quiet` on a vitest segment becomes `--reporter=dot`; other commands come back unchanged. */
435
+ export function normalizeRunnerCommand(command) {
436
+ return String(command ?? "").split(/(&&|\|\||;|\|)/).map((seg) => (/\bvitest\b/.test(seg) ? seg.replace(/(\s)(?:-q|--quiet)(?=\s|$)/g, "$1--reporter=dot") : seg)).join("");
437
+ }
438
+ /** #4761: true when a failed command's {rc, output} is a runner CLI usage error (vitest `Unknown option`, jest `Unrecognized CLI Parameter`, pytest `unrecognized arguments`), not a test failure. */
439
+ export function cliUsageError({ rc, output } = {}) {
440
+ return rc !== 0 && CLI_USAGE_RE.test(String(output ?? ""));
441
+ }
396
442
  const KIND_NAME = { command: "command", "python-module": "Python module", "node-module": "Node package" };
397
443
  /**
398
444
  * forge#4323: the host tool or module a failed command could not find, from its {rc, output}: bash/sh "X: command not found"
@@ -439,12 +485,14 @@ export function repoTracks(paths) {
439
485
  * `ciCovered` (#4527): the validate ids accepted only because the head is CI-verified (no local result, or a missing host
440
486
  * tool), for the trajectory; always an array.
441
487
  */
442
- export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null }) {
488
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false, hotspots = null, repro = null, validateRuns = null, repoHas = () => false, frozen = null, checkIds = null, guardFailures = null, newOffKeys = null }) {
443
489
  const failures = [], superseded = [], ciCovered = [];
444
490
  // #4626: a post-deploy check id must be on the default branch or added by this PR (`checkIds`: ids of both checks files; null = not resolved)
445
491
  if (checkIds) for (const r of contract.requirements ?? []) {
446
492
  if (r.disposition === "post-deploy" && !(r.check && checkIds.has(r.check))) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "check_unknown", detail: `${r.id} post-deploy check ${r.check ? `'${r.check}'` : "id"} is neither on the default branch nor added by this PR (.forge/post-deploy-checks.json)` });
447
493
  }
494
+ // #4769: a default-off rollout entry added by the diff needs an activation row
495
+ if (newOffKeys?.length) for (const f of activationFailures(contract, newOffKeys)) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, ...f });
448
496
  const outside = (k) => changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(k, f));
449
497
  const creep = outside(contract);
450
498
  const amendedOnly = frozenAmendedFiles(frozen, contract, outside, creep);
@@ -459,6 +507,7 @@ export function precheck({ contract, result, changedFiles, ignore = [], ciVerifi
459
507
  else if (r.exit !== 0) {
460
508
  const rerun = validateRuns?.[v.id];
461
509
  if (rerun?.rc === 0) continue;
510
+ if (rerun && cliUsageError(rerun)) { failures.push({ code: C.CONTRACT_INVALID_COMMAND, kind: "validate_command_invalid", detail: `validate ${v.id} (\`${v.command}\`) is a CLI usage error of the runner, a defect of the frozen command rather than of the change` }); continue; }
462
511
  const miss = rerun ? missingTool(rerun, { repoHas, command: v.command }) : null;
463
512
  if (miss && ciVerified) { ciCovered.push(v.id); continue; }
464
513
  if (miss) failures.push({ code: C.ENV_TOOL_MISSING, kind: "validate_env", tool: miss.tool, detail: `validate ${v.id} (\`${v.command}\`) could not run: the host is missing ${KIND_NAME[miss.kind]} '${miss.tool}' (environment, not the change)` });
@@ -2,7 +2,7 @@
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 { validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified } from "./conformance.mjs";
5
+ import { vitestQuietFlag, normalizeRunnerCommand, cliUsageError, validateContract, applyAmendments, precheck, hotspotGrowth, reproFailures, isBugIssue, parseReview, checkCompleteness, decide, closePlan, isInvestigation, formatShadowSummary, followupItems, inIssue, isTestPath, normalizeEvidencePath, mergeReviewRows, reviewSkeleton, missingIds, resolveEvidencePath, movingBaseDiff, scopeText, evidencePathLine, evidencePathOnly, explainFailures, missingTool, repoTracks, tmpdirInputs, isScopeCheck, isPureScopeCheck, dropScopeValidates, tagOutOfBand, outOfBandRequirements, isOutOfBand, followupMap, VERIFY_MODES, OUT_OF_BAND_RE, FOLLOWUP_CLASSES, followupClass, BUCKETS, bucketKind, bucketProblems, bucketTally, isE2eCommand, e2eVerified } from "./conformance.mjs";
6
6
 
7
7
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
8
8
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -31,6 +31,14 @@ describe("contract", () => {
31
31
  assert.ok(!inIssue("scrape should free money", ISSUE));
32
32
  assert.ok(!inIssue("429.", ISSUE), "too short to identify a requirement");
33
33
  });
34
+ it("#4789: unicode punctuation and markdown markup do not break the verbatim check; a miss shows the nearest span", () => {
35
+ const issue = "## Problem\nThe \u201cscrape\u201d route \u2014 see [the spec](http://x.y) \u2014 must release the spend reservation\u2026 on 429.\n\nUnrelated line about billing.";
36
+ assert.ok(inIssue('The "scrape" route - see the spec - must release the spend reservation... on 429.', issue));
37
+ assert.ok(inIssue("The \u201cscrape\u201d route \\- see the spec", issue.replace("\u2014", "-")));
38
+ assert.ok(!inIssue("the scrape route must hold the reservation forever", issue));
39
+ const v = validateContract({ ...C, requirements: [{ ...C.requirements[0], quote: "the scrape route must hold the spend reservation on 429" }] }, issue);
40
+ assert.match(v.problems[0], /R1: quote is not verbatim from the issue.*nearest issue text: "The \u201cscrape\u201d route/);
41
+ });
34
42
  it("rejects paraphrases, a defer without why, not-applicable without evidence, missing validate, duplicate ids", () => {
35
43
  const v = validateContract({ ...C, validate: [], requirements: [
36
44
  { id: "R1", quote: "make scrape better", disposition: "deliver" }, { id: "R2", quote: Q2, disposition: "defer" },
@@ -108,6 +116,21 @@ describe("precheck (deterministic, no reviewer)", () => {
108
116
  assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [{ id: "V1", exit: 1 }] }, changedFiles: files })), ["conformance.unimplemented"]);
109
117
  assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [] }, changedFiles: files })), ["conformance.unimplemented"]);
110
118
  });
119
+ it("#4761 vitest -q is a contract problem; a runner usage error is contract.invalid_command, not conformance", () => {
120
+ const bad = { ...C, validate: [{ id: "V1", command: "pnpm --filter x exec vitest run a.test.ts -q" }] };
121
+ assert.ok(validateContract(bad, ISSUE).problems.some((p) => /V1: vitest 3 rejects -q/.test(p)));
122
+ assert.ok(!validateContract({ ...bad, validate: [{ id: "V1", command: "pnpm exec vitest run a.test.ts --reporter=dot" }] }, ISSUE).problems.some((p) => /vitest/.test(p)));
123
+ assert.equal(vitestQuietFlag("pytest -q"), null);
124
+ assert.equal(normalizeRunnerCommand("vitest run a -q && pytest -q"), "vitest run a --reporter=dot && pytest -q");
125
+ assert.ok(cliUsageError({ rc: 1, output: "CACError: Unknown option `-q`" }));
126
+ assert.ok(cliUsageError({ rc: 1, output: "Unrecognized CLI Parameter: --foo" }));
127
+ assert.ok(!cliUsageError({ rc: 1, output: "AssertionError: 1 !== 2" }));
128
+ assert.ok(!cliUsageError({ rc: 0, output: "Unknown option" }));
129
+ const failed = { ...res, validation: [{ id: "V1", exit: 1 }] };
130
+ const pc = precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 1, output: "CACError: Unknown option `-q`" } } });
131
+ assert.deepEqual(codes(pc), ["contract.invalid_command"]);
132
+ assert.equal(pc.failures[0].kind, "validate_command_invalid");
133
+ });
111
134
  it("#4323 a failed validate whose rerun could not find a host tool or module is env.tool_missing naming it, not conformance", () => {
112
135
  const failed = { ...res, validation: [{ id: "V1", exit: 1 }] };
113
136
  const pc = precheck({ contract: C, result: failed, changedFiles: files, validateRuns: { V1: { rc: 1, output: "Traceback...\nModuleNotFoundError: No module named 'yaml'" } } });