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.
- package/bin/engine/outcome-codes.mjs +12 -3
- package/bin/engine/telemetry-seam.mjs +5 -0
- package/bin/engine-cli.mjs +97 -18
- package/bin/forgedock.mjs +13 -2
- package/bin/lean/activation.mjs +76 -0
- package/bin/lean/activation.test.mjs +75 -0
- package/bin/lean/admission.mjs +17 -1
- package/bin/lean/batch.mjs +51 -21
- package/bin/lean/batch.test.mjs +95 -5
- package/bin/lean/conformance.mjs +53 -4
- package/bin/lean/conformance.test.mjs +24 -1
- package/bin/lean/decision-engine.mjs +217 -0
- package/bin/lean/decision-engine.test.mjs +159 -0
- package/bin/lean/effects.mjs +39 -13
- package/bin/lean/execution.mjs +74 -5
- package/bin/lean/execution.test.mjs +64 -1
- package/bin/lean/explore.test.mjs +1 -1
- package/bin/lean/guards.mjs +3 -2
- package/bin/lean/guards.test.mjs +24 -3
- package/bin/lean/heavy.mjs +15 -2
- package/bin/lean/heavy.test.mjs +31 -2
- package/bin/lean/issuewatch-release.mjs +245 -0
- package/bin/lean/issuewatch-release.test.mjs +244 -0
- package/bin/lean/issuewatch.mjs +386 -0
- package/bin/lean/issuewatch.test.mjs +280 -0
- package/bin/lean/lane.mjs +4 -3
- package/bin/lean/lane.test.mjs +12 -0
- package/bin/lean/lean.mjs +103 -16
- package/bin/lean/lean.test.mjs +127 -5
- package/bin/lean/needs-human-premise.mjs +177 -0
- package/bin/lean/needs-human-premise.test.mjs +136 -0
- package/bin/lean/orchestrate-blocked.test.mjs +71 -0
- package/bin/lean/orchestrate.mjs +93 -21
- package/bin/lean/postdeploy-check.mjs +88 -2
- package/bin/lean/postdeploy-check.test.mjs +78 -0
- package/bin/lean/prodwatch-sources.mjs +165 -0
- package/bin/lean/prodwatch-sources.test.mjs +108 -0
- package/bin/lean/prompts.mjs +9 -3
- package/bin/lean/recovery.test.mjs +14 -0
- package/bin/lean/relay.mjs +182 -0
- package/bin/lean/relay.test.mjs +214 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +13 -1
- package/bin/lean/sandbox-remote.mjs +53 -9
- package/bin/lean/sched.test.mjs +30 -0
- package/bin/lean/schedd-health.mjs +69 -0
- package/bin/lean/schedd-health.test.mjs +51 -0
- package/bin/lean/schedd.mjs +29 -12
- package/bin/lean/schedd.test.mjs +40 -4
- package/bin/lean/sequence.mjs +57 -24
- package/bin/lean/sequence.test.mjs +71 -0
- package/bin/pool/agent.mjs +15 -1
- package/bin/pool/router.mjs +43 -5
- package/bin/runner.mjs +18 -1
- package/commands/deploy-pr.md +12 -0
- package/commands/forgedock-init.md +1 -0
- package/commands/orchestrate/phase-4-execution.md +1 -0
- package/commands/orchestrate.md +1 -0
- package/commands/review-pr.md +17 -13
- package/docs/CONFIG.md +73 -2
- package/docs/DECISIONS.md +32 -0
- package/docs/ISSUE-WATCH.md +95 -0
- package/docs/NORTHSTAR-TEMPLATE.md +21 -0
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/TELEMETRY.md +7 -0
- package/package.json +1 -2
- package/pool/README.md +1 -1
- package/pool/image/Dockerfile +2 -1
- package/pool/image/cs-relay +31 -0
- package/pool/image/entrypoint.sh +10 -0
- package/pool/install/install-schedd.sh +22 -0
- package/pool/install/unit-hardening.sh +6 -0
- package/scripts/postdeploy-rollup-link.sh +40 -0
- package/scripts/postdeploy-rollup-link.test.sh +46 -0
- package/scripts/refresh-bot-token.sh +122 -0
- package/scripts/review-delta.sh +14 -4
- package/scripts/review-delta.test.sh +21 -0
- package/scripts/review-finalize.sh +69 -25
- package/scripts/review-finalize.test.sh +32 -0
- package/scripts/review-pr-arguments.test.sh +10 -0
- package/scripts/review-pr-changed-verification.test.sh +54 -0
- package/scripts/review-pr-delta-wiring.test.sh +1 -1
package/bin/lean/admission.mjs
CHANGED
|
@@ -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
|
/**
|
package/bin/lean/batch.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
132
|
-
if (have.has(n) && st.results?.[n]?.outcome
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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 } = {}) =>
|
|
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) =>
|
|
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] });
|
package/bin/lean/batch.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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'" } } });
|