forgedock 1.18.0 → 1.19.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.
- package/bin/engine/outcome-codes.mjs +13 -0
- package/bin/engine/phases.mjs +54 -4
- package/bin/engine/runlog.mjs +10 -2
- package/bin/engine/telemetry-seam.mjs +3 -0
- package/bin/engine/telemetry.test.mjs +3 -2
- package/bin/engine-cli.mjs +32 -2
- package/bin/engine.mjs +139 -7
- package/bin/runner.mjs +69 -9
- package/bin/stats/aggregate.mjs +7 -3
- package/bin/stats/cache.mjs +7 -3
- package/bin/stats/github-source.mjs +136 -28
- package/bin/stats/index.mjs +46 -9
- package/bin/stats/render.mjs +1 -1
- package/bin/stats/stats.test.mjs +201 -40
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +25 -15
- package/commands/work-on/build/architect.md +53 -0
- package/commands/work-on/build/implement.md +19 -0
- package/commands/work-on/build/validate.md +320 -28
- package/commands/work-on/build.md +66 -9
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +32 -13
- package/commands/work-on/review.md +32 -19
- package/docs/CONFIG.md +89 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +18 -0
- package/package.json +2 -2
- package/packages/protocol/src/types.js +1 -1
- package/scripts/acceptance-red-green.sh +90 -0
- package/scripts/acceptance-red-green.test.sh +74 -0
- package/scripts/cap-findings.test.sh +2 -0
- package/scripts/check-contract-scope.test.sh +2 -0
- package/scripts/check-edge-cases.sh +143 -0
- package/scripts/check-edge-cases.test.sh +175 -0
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-test-tmp-cleanup.sh +56 -0
- package/scripts/check-test-tmp-cleanup.test.sh +69 -0
- package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
- package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
- package/scripts/ci-local/adapters/buildkite.mjs +58 -0
- package/scripts/ci-local/adapters/circleci.mjs +101 -0
- package/scripts/ci-local/adapters/common.mjs +115 -0
- package/scripts/ci-local/adapters/explicit.mjs +21 -0
- package/scripts/ci-local/adapters/github-actions.mjs +349 -0
- package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
- package/scripts/ci-local/adapters/portability.mjs +104 -0
- package/scripts/ci-local/adapters/task-runners.mjs +67 -0
- package/scripts/ci-local/cli.mjs +50 -0
- package/scripts/ci-local/lib.mjs +371 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +345 -0
- package/scripts/ci-local/scoping.mjs +508 -0
- package/scripts/ci-local.sh +97 -0
- package/scripts/ci-local.test.sh +919 -0
- package/scripts/classify-finding.sh +32 -6
- package/scripts/classify-finding.test.sh +23 -0
- package/scripts/classify-test-run.sh +69 -0
- package/scripts/classify-test-run.test.sh +90 -0
- package/scripts/close-parent-c3.test.sh +2 -0
- package/scripts/consumer-sweep.sh +569 -0
- package/scripts/consumer-sweep.test.sh +357 -0
- package/scripts/detect-append-registries.sh +106 -0
- package/scripts/detect-append-registries.test.sh +64 -0
- package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
- package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
- package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
- package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
- package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
- package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
- package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
- package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
- package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
- package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
- package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
- package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
- package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
- package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
- package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
- package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
- package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
- package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
- package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
- package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
- package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
- package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/lib/telemetry.sh +1 -0
- package/scripts/remediate-approved-head.test.sh +2 -0
- package/scripts/remediate-basesync.test.sh +1 -1
- package/scripts/review-acceptance-gate.sh +2 -2
- package/scripts/review-acceptance-gate.test.sh +20 -0
- package/scripts/review-delta.sh +8 -1
- package/scripts/review-delta.test.sh +19 -0
- package/scripts/review-finalize.sh +193 -41
- package/scripts/review-finalize.test.sh +211 -4
- package/scripts/review-pr-delta-wiring.test.sh +13 -0
- package/scripts/review-prepare.sh +5 -3
- package/scripts/review-prepare.test.sh +18 -2
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/telemetry.test.sh +2 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +8 -0
- package/scripts/wait-ci-green.sh +61 -9
- package/scripts/wait-ci-green.test.sh +41 -3
- package/scripts/with-run-tmpdir.sh +20 -0
package/bin/stats/stats.test.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
3
4
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
4
5
|
import assert from "node:assert/strict";
|
|
5
|
-
import {
|
|
6
|
+
import { readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
6
7
|
import { tmpdir } from "node:os";
|
|
7
8
|
import { dirname, join } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,7 +11,7 @@ import { spawnSync } from "node:child_process";
|
|
|
10
11
|
import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
|
|
11
12
|
import { cacheDir } from "./cache.mjs";
|
|
12
13
|
import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
|
|
13
|
-
import { buildReport, parseArgs, parseSince, runStats } from "./index.mjs";
|
|
14
|
+
import { buildReport, makeProgressPrinter, parseArgs, parseSince, runStats, sinceToTimestamp } from "./index.mjs";
|
|
14
15
|
import { readLocalRuns } from "./local-source.mjs";
|
|
15
16
|
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
16
17
|
|
|
@@ -29,6 +30,10 @@ function fakeGh() {
|
|
|
29
30
|
const path = args.find((a) => a.startsWith("repos/"));
|
|
30
31
|
const m = /issues\/(\d+)\/comments$/.exec(path);
|
|
31
32
|
if (m) return JSON.stringify(comments[m[1]] ?? []);
|
|
33
|
+
if (/issues\/comments\?/.test(path)) { // repo-wide endpoint: every comment, tagged with its issue (fixtures carry no updated_at)
|
|
34
|
+
const all = Object.entries(comments).flatMap(([n, list]) => list.map((c) => ({ ...c, issue_url: `https://api.github.com/repos/o/r/issues/${n}` })));
|
|
35
|
+
return JSON.stringify(/page=1(&|$)/.test(path) ? all : []);
|
|
36
|
+
}
|
|
32
37
|
const since = /since=([^&]+)/.exec(path)?.[1];
|
|
33
38
|
return JSON.stringify(issues.filter((i) => !since || i.updated_at >= since));
|
|
34
39
|
};
|
|
@@ -36,7 +41,7 @@ function fakeGh() {
|
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
let td;
|
|
39
|
-
beforeEach(() => { td =
|
|
44
|
+
beforeEach(() => { td = tmpDir("stats-"); });
|
|
40
45
|
afterEach(() => rmSync(td, { recursive: true, force: true }));
|
|
41
46
|
|
|
42
47
|
describe("telemetry comment parsing", () => {
|
|
@@ -58,9 +63,9 @@ describe("telemetry comment parsing", () => {
|
|
|
58
63
|
});
|
|
59
64
|
|
|
60
65
|
describe("GitHub-only report (fresh machine)", () => {
|
|
61
|
-
it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", () => {
|
|
66
|
+
it("rebuilds cost, time, flow, funnel, gates and issue flow from GitHub alone", async () => {
|
|
62
67
|
const { gh } = fakeGh();
|
|
63
|
-
const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
68
|
+
const report = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
64
69
|
const g = report.groups.find((x) => x.version === "1.17.0");
|
|
65
70
|
assert.equal(g.n, 1);
|
|
66
71
|
assert.equal(g.cost.cacheReadMean, 3000000);
|
|
@@ -85,6 +90,8 @@ describe("GitHub-only report (fresh machine)", () => {
|
|
|
85
90
|
assert.equal(u.funnel.falsePositive, 1);
|
|
86
91
|
assert.equal(u.issueFlow.net, 2);
|
|
87
92
|
assert.equal(u.issueFlow.findingsPerMerge, null);
|
|
93
|
+
assert.equal(g.funnel.introduced, 2, "only events carrying scope=introduced are counted");
|
|
94
|
+
assert.equal(g.issueFlow.introducedPerMerge, (g.issueFlow.closed.merged ?? 0) > 0 ? 2 / g.issueFlow.closed.merged : null);
|
|
88
95
|
assert.ok(!JSON.stringify(report).includes("999999999"), "untrusted record leaked into the report");
|
|
89
96
|
});
|
|
90
97
|
it("matches the run-record field names written by the engine seam", () => {
|
|
@@ -138,6 +145,15 @@ describe("local runs, joins, version delta and low-n", () => {
|
|
|
138
145
|
assert.equal(legacy.cost.cacheReadMean, 4000000, "legacy PHASE_COMMIT usage counted when no attempt events exist");
|
|
139
146
|
assert.equal(legacy.flow.codesN, 0, "old logs have no outcome codes");
|
|
140
147
|
});
|
|
148
|
+
it("reports introduced findings per merged issue", () => {
|
|
149
|
+
const ev = (eid, scope) => ({ event: "finding_disposition", eid, repo: "o/r", pr: 1, issue: eid === "d" ? 2 : 1, version: "1.18.0", severity: "MEDIUM", disposition: "INPR_FIX", ...(scope ? { scope } : {}) });
|
|
150
|
+
const issue = (number) => ({ number, repo: "o/r", isPr: false, labels: ["workflow:merged"], createdAt: "2026-10-06T00:00:00Z", closedAt: "2026-10-07T00:00:00Z" });
|
|
151
|
+
const r = aggregate({ events: [ev("a", "introduced"), ev("b", "introduced"), ev("c", "pre-existing"), ev("d")], issues: [issue(1), issue(2)] }, { releaseTimes: [] });
|
|
152
|
+
const g = r.groups.find((x) => x.version === "1.18.0");
|
|
153
|
+
assert.equal(g.funnel.introduced, 2);
|
|
154
|
+
assert.equal(g.issueFlow.introducedPerMerge, 1);
|
|
155
|
+
assert.match(renderMarkdown(r), /introduced findings per merged issue/);
|
|
156
|
+
});
|
|
141
157
|
it("computes the delta against the previous version of the same repo", () => {
|
|
142
158
|
const g = agg().groups.find((x) => x.version === "1.17.0");
|
|
143
159
|
assert.equal(g.delta.against, "1.16.0");
|
|
@@ -178,9 +194,9 @@ describe("n/a degradation and rendering", () => {
|
|
|
178
194
|
assert.match(renderMarkdown(aggregate({})), /No data/);
|
|
179
195
|
assert.equal(percentile([], 90), null);
|
|
180
196
|
});
|
|
181
|
-
it("json output is parseable and carries every section", () => {
|
|
197
|
+
it("json output is parseable and carries every section", async () => {
|
|
182
198
|
const { gh } = fakeGh();
|
|
183
|
-
const report = buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
199
|
+
const report = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [] });
|
|
184
200
|
const parsed = JSON.parse(renderJson(report));
|
|
185
201
|
for (const k of ["cost", "time", "flow", "funnel", "gates", "issueFlow"]) assert.ok(k in parsed.groups[0]);
|
|
186
202
|
const md = renderMarkdown(report);
|
|
@@ -193,56 +209,173 @@ describe("n/a degradation and rendering", () => {
|
|
|
193
209
|
});
|
|
194
210
|
|
|
195
211
|
describe("cache", () => {
|
|
196
|
-
it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", () => {
|
|
212
|
+
it("refreshes incrementally from the updated_at watermark and survives deletion or corruption", async () => {
|
|
197
213
|
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache") };
|
|
198
214
|
const a = fakeGh();
|
|
199
|
-
const first = fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
|
|
215
|
+
const first = await fetchGithubRecords({ repo: "o/r", gh: a.gh, env });
|
|
200
216
|
assert.equal(first.fromCache, false);
|
|
201
217
|
assert.ok(!a.calls[0].includes("since="));
|
|
202
218
|
const b = fakeGh();
|
|
203
|
-
const second = fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
|
|
219
|
+
const second = await fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
|
|
204
220
|
assert.equal(second.fromCache, true);
|
|
205
221
|
assert.match(b.calls[0], /since=2026-10-06T12:55:00Z/); // watermark minus the 5 minute overlap window
|
|
206
222
|
assert.deepEqual(second.records.map((r) => r.eid).sort(), first.records.map((r) => r.eid).sort());
|
|
207
223
|
// corrupt cache -> treated as empty -> same result from a full fetch
|
|
208
224
|
writeFileSync(join(cacheDir("o/r", env), "cache.json"), "{not json");
|
|
209
225
|
const c = fakeGh();
|
|
210
|
-
const third = fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
|
|
226
|
+
const third = await fetchGithubRecords({ repo: "o/r", gh: c.gh, env });
|
|
211
227
|
assert.equal(third.fromCache, false);
|
|
212
228
|
assert.equal(third.records.length, first.records.length);
|
|
213
229
|
// --refresh ignores a good cache
|
|
214
230
|
const d = fakeGh();
|
|
215
|
-
fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
|
|
231
|
+
await fetchGithubRecords({ repo: "o/r", gh: d.gh, env, refresh: true });
|
|
216
232
|
assert.ok(!d.calls[0].includes("since="));
|
|
217
233
|
assert.equal(toModel("o/r", third).issues.length, 5);
|
|
218
234
|
});
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
// Page-aware fake of the issues API over a synthetic store; the repo-wide comments endpoint is empty unless `comments` is given.
|
|
236
|
+
const storeGh = (store, { comments = [], failOnListCall = 0 } = {}) => {
|
|
237
|
+
const calls = [];
|
|
238
|
+
let listCalls = 0;
|
|
239
|
+
const gh = (args) => {
|
|
240
|
+
const path = args.find((a) => a.startsWith("repos/"));
|
|
241
|
+
calls.push(path);
|
|
242
|
+
const q = new URLSearchParams(path.split("?")[1] ?? "");
|
|
243
|
+
if (/issues\/comments\?/.test(path)) {
|
|
244
|
+
const since = q.get("since"), page = Number(q.get("page") ?? 1);
|
|
245
|
+
return JSON.stringify(comments.filter((c) => !since || c.updated_at >= since).slice((page - 1) * 100, page * 100));
|
|
246
|
+
}
|
|
247
|
+
const m = /issues\/(\d+)\/comments$/.exec(path);
|
|
248
|
+
if (m) return JSON.stringify(comments.filter((c) => c.issue_url.endsWith(`/issues/${m[1]}`)));
|
|
249
|
+
const one = /issues\/(\d+)$/.exec(path);
|
|
250
|
+
if (one) return JSON.stringify(store.find((i) => i.number === Number(one[1])));
|
|
251
|
+
if (++listCalls === failOnListCall) throw new Error("rate limited");
|
|
252
|
+
const since = q.get("since");
|
|
253
|
+
const sorted = store.filter((i) => !since || i.updated_at >= since).sort((x, y) => x.updated_at.localeCompare(y.updated_at) || x.number - y.number);
|
|
254
|
+
const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
|
|
255
|
+
return JSON.stringify(sorted.slice((page - 1) * per, page * per));
|
|
230
256
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
257
|
+
return { gh, calls };
|
|
258
|
+
};
|
|
259
|
+
const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 3600000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
260
|
+
const mkItems = (n) => Array.from({ length: n }, (_, i) => ({ number: i + 1, updated_at: ts(i + 1), created_at: ts(0), state: "open", comments: 0, labels: [], user: { type: "Bot" } }));
|
|
261
|
+
const telemetryComment = (issue, eid, event, updated, extra = {}) => ({ id: issue * 1000 + Number(eid.replace(/\D/g, "")), updated_at: updated, user: { type: "Bot" },
|
|
262
|
+
issue_url: `https://api.github.com/repos/o/r/issues/${issue}`,
|
|
263
|
+
body: `<!-- FORGE:TELEMETRY v1 -->\n\`\`\`jsonl\n${JSON.stringify({ v: 1, ts: updated, forge_version: "1.18.0", repo: "o/r", issue, run: `r_${issue}`, eid, event, phase: "build", cache_read_tokens: 100, ...extra })}\n\`\`\`` });
|
|
264
|
+
|
|
265
|
+
it("saves a partial checkpoint on a mid-crawl failure and the next run resumes from it", async () => {
|
|
266
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-partial") };
|
|
267
|
+
const store = mkItems(250);
|
|
268
|
+
await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { failOnListCall: 3 }).gh, env }), /rate limited/);
|
|
236
269
|
const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
|
|
237
270
|
assert.ok(saved.watermark, "partial watermark persisted");
|
|
238
|
-
assert.ok(Object.keys(saved.items).length
|
|
239
|
-
const b =
|
|
240
|
-
const resumed = fetchGithubRecords({ repo: "o/r", gh:
|
|
271
|
+
assert.ok(Object.keys(saved.items).length >= 100 && Object.keys(saved.items).length < 250);
|
|
272
|
+
const b = storeGh(store);
|
|
273
|
+
const resumed = await fetchGithubRecords({ repo: "o/r", gh: b.gh, env });
|
|
241
274
|
assert.equal(resumed.fromCache, true);
|
|
242
|
-
assert.
|
|
243
|
-
assert.
|
|
275
|
+
assert.ok(b.calls[0].includes("since="), "resumes from the watermark");
|
|
276
|
+
assert.equal(resumed.items.length, 250);
|
|
244
277
|
});
|
|
245
|
-
it("never
|
|
278
|
+
it("a mid-crawl failure never advances the watermark past unpersisted telemetry (resume still finds it)", async () => {
|
|
279
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-pending-telemetry") };
|
|
280
|
+
const store = mkItems(250);
|
|
281
|
+
// A telemetry comment on the NEWEST issue (listed last), posted before the failing run starts.
|
|
282
|
+
const comments = [telemetryComment(250, "z1", "phase_attempt", ts(250))];
|
|
283
|
+
await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { comments, failOnListCall: 3 }).gh, env }), /rate limited/);
|
|
284
|
+
const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
|
|
285
|
+
assert.ok(!saved.watermark, "watermark must not be persisted while telemetry records are unpersisted");
|
|
286
|
+
// Resume: the comment is older than any watermark a buggy checkpoint would have saved.
|
|
287
|
+
const resumed = await fetchGithubRecords({ repo: "o/r", gh: storeGh(store, { comments }).gh, env });
|
|
288
|
+
assert.equal(resumed.items.length, 250);
|
|
289
|
+
assert.deepEqual(resumed.records.map((r) => r.eid), ["z1"]);
|
|
290
|
+
assert.equal(resumed.items.find((i) => i.number === 250).records.length, 1);
|
|
291
|
+
});
|
|
292
|
+
it("bounds the crawl by the window: API calls track the window, not the repo size", async () => {
|
|
293
|
+
const store = mkItems(3000); // 3000 items, one per hour
|
|
294
|
+
const from = ts(2990);
|
|
295
|
+
const windowItems = store.filter((i) => i.updated_at >= from).length;
|
|
296
|
+
const comments = store.slice(-5).map((i) => telemetryComment(i.number, `x${i.number}`, "phase_attempt", i.updated_at));
|
|
297
|
+
const { gh, calls } = storeGh(store, { comments });
|
|
298
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh, useCache: false, since: from, env: ENV });
|
|
299
|
+
assert.equal(r.items.length, windowItems);
|
|
300
|
+
// 1 comments page + one refetch per telemetry issue + ceil(window / 100) list pages; never one call per repo item.
|
|
301
|
+
const bound = 1 + comments.length + Math.ceil(windowItems / 100) + 1;
|
|
302
|
+
assert.ok(r.apiCalls <= bound, `api calls ${r.apiCalls} exceed window bound ${bound}`);
|
|
303
|
+
assert.equal(calls.length, r.apiCalls);
|
|
304
|
+
assert.ok(calls.length < 30 && store.length > 100 * calls.length, `calls.length ${calls.length} must be far below the repo size`);
|
|
305
|
+
assert.ok(calls.some((c) => /issues\/comments\?.*since=/.test(c)), "comments come from the repo-wide since= endpoint");
|
|
306
|
+
assert.ok(!calls.some((c) => /issues\/\d+\/comments$/.test(c) && !comments.some((x) => x.issue_url.endsWith(c.match(/issues\/(\d+)\//)[1]))), "no comment call for items without telemetry");
|
|
307
|
+
assert.equal(r.records.length, comments.length);
|
|
308
|
+
});
|
|
309
|
+
it("re-reads every part of an issue whose telemetry was touched in the window, and only those", async () => {
|
|
310
|
+
const store = mkItems(20);
|
|
311
|
+
const comments = [telemetryComment(7, "e1", "phase_attempt", ts(1)), telemetryComment(7, "e2", "run_terminal", ts(15), { reason: "merged" }),
|
|
312
|
+
telemetryComment(9, "e1", "phase_attempt", ts(2))];
|
|
313
|
+
const { gh, calls } = storeGh(store, { comments });
|
|
314
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh, useCache: false, since: ts(10), env: ENV });
|
|
315
|
+
assert.deepEqual(r.records.filter((x) => x.issue === 7).map((x) => x.event).sort(), ["phase_attempt", "run_terminal"], "the pre-window part is fetched too");
|
|
316
|
+
assert.equal(calls.filter((c) => /issues\/7\/comments$/.test(c)).length, 1);
|
|
317
|
+
assert.equal(calls.filter((c) => /issues\/9\/comments$/.test(c)).length, 0, "issue 9 has no telemetry in the window");
|
|
318
|
+
});
|
|
319
|
+
it("accepts an async gh and awaits it", async () => {
|
|
320
|
+
const store = mkItems(5);
|
|
321
|
+
const { gh } = storeGh(store);
|
|
322
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh: async (a) => gh(a), useCache: false, env: ENV });
|
|
323
|
+
assert.equal(r.items.length, 5);
|
|
324
|
+
});
|
|
325
|
+
it("records the coverage floor, crawls only the gap for a wider window and resets on --refresh", async () => {
|
|
326
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-floor") };
|
|
327
|
+
const store = mkItems(100);
|
|
328
|
+
const w1 = storeGh(store);
|
|
329
|
+
const narrow = await fetchGithubRecords({ repo: "o/r", gh: w1.gh, env, since: ts(90) });
|
|
330
|
+
assert.equal(narrow.items.length, 11);
|
|
331
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(90));
|
|
332
|
+
// same or narrower window: incremental from the watermark, floor unchanged
|
|
333
|
+
const same = storeGh(store);
|
|
334
|
+
await fetchGithubRecords({ repo: "o/r", gh: same.gh, env, since: ts(95) });
|
|
335
|
+
assert.ok(/since=/.test(same.calls[0]) && !same.calls[0].includes(`since=${ts(95)}`), "resumes from the watermark, not the window start");
|
|
336
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(90));
|
|
337
|
+
// wider window: the uncovered gap is crawled (from the new start), then the floor is lowered
|
|
338
|
+
const wide = storeGh(store);
|
|
339
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh: wide.gh, env, since: ts(50) });
|
|
340
|
+
assert.ok(wide.calls[0].includes(`since=${ts(50)}`));
|
|
341
|
+
assert.equal(r.items.length, 51, "the pre-window gap is not silently treated as covered");
|
|
342
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(50));
|
|
343
|
+
// no --since after a windowed run: full history is crawled and the floor becomes null
|
|
344
|
+
const all = storeGh(store);
|
|
345
|
+
assert.equal((await fetchGithubRecords({ repo: "o/r", gh: all.gh, env })).items.length, 100);
|
|
346
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, null);
|
|
347
|
+
// --refresh ignores the cache and its floor
|
|
348
|
+
const fresh = storeGh(store);
|
|
349
|
+
await fetchGithubRecords({ repo: "o/r", gh: fresh.gh, env, refresh: true, since: ts(80) });
|
|
350
|
+
assert.equal(JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8")).coveredFrom, ts(80));
|
|
351
|
+
});
|
|
352
|
+
it("does not trust a legacy cache (no coveredFrom) for a window and never lowers the floor on failure", async () => {
|
|
353
|
+
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-legacy") };
|
|
354
|
+
const store = mkItems(250);
|
|
355
|
+
mkdirSync(cacheDir("o/r", env), { recursive: true });
|
|
356
|
+
writeFileSync(join(cacheDir("o/r", env), "cache.json"), JSON.stringify({ v: 1, watermark: ts(250), items: {} }));
|
|
357
|
+
const g = storeGh(store, { failOnListCall: 2 });
|
|
358
|
+
await assert.rejects(() => fetchGithubRecords({ repo: "o/r", gh: g.gh, env, since: ts(10) }), /rate limited/);
|
|
359
|
+
const saved = JSON.parse(readFileSync(join(cacheDir("o/r", env), "cache.json"), "utf8"));
|
|
360
|
+
assert.equal(saved.watermark, ts(250), "failed gap crawl keeps the old watermark");
|
|
361
|
+
assert.ok(!("coveredFrom" in saved) || saved.coveredFrom == null || saved.coveredFrom === undefined);
|
|
362
|
+
const h = storeGh(store);
|
|
363
|
+
const r = await fetchGithubRecords({ repo: "o/r", gh: h.gh, env, since: ts(10) });
|
|
364
|
+
assert.ok(h.calls[0].includes(`since=${ts(10)}`));
|
|
365
|
+
assert.equal(r.items.length, 241);
|
|
366
|
+
});
|
|
367
|
+
it("reports progress through onProgress and backs off when the rate limit is low", async () => {
|
|
368
|
+
const store = mkItems(6000);
|
|
369
|
+
const events = [];
|
|
370
|
+
await fetchGithubRecords({ repo: "o/r", gh: storeGh(store).gh, useCache: false, env: ENV, onProgress: (e) => events.push(e) });
|
|
371
|
+
assert.ok(events.some((e) => e.phase === "comments") && events.some((e) => e.phase === "issues" && e.done === 6000));
|
|
372
|
+
const slept = [];
|
|
373
|
+
const base = storeGh(store).gh;
|
|
374
|
+
const gh = (args) => (args.join(" ") === "api rate_limit" ? JSON.stringify({ resources: { core: { remaining: 3, reset: 1_000_100 } } }) : base(args));
|
|
375
|
+
await fetchGithubRecords({ repo: "o/r", gh, useCache: false, env: ENV, rateCheck: true, now: () => 1_000_000_000, sleep: async (ms) => { slept.push(ms); } });
|
|
376
|
+
assert.ok(slept.length > 0 && slept[0] === 101000, `slept ${slept}`);
|
|
377
|
+
});
|
|
378
|
+
it("never skips an item updated mid-crawl (keyset crawl, not page-number pagination)", async () => {
|
|
246
379
|
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-midcrawl") };
|
|
247
380
|
const ts = (n) => new Date(Date.UTC(2026, 9, 1) + n * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
248
381
|
const store = Array.from({ length: 250 }, (_, i) => ({ number: i + 1, updated_at: ts(i + 1), created_at: ts(0), state: "open", comments: 0, labels: [], user: { type: "Bot" } }));
|
|
@@ -252,6 +385,7 @@ describe("cache", () => {
|
|
|
252
385
|
// listing call an already-served item is bumped, which moves it to the end of the ordering.
|
|
253
386
|
const gh = (args) => {
|
|
254
387
|
const path = args.find((a) => a.startsWith("repos/"));
|
|
388
|
+
if (/issues\/comments\?/.test(path)) return "[]";
|
|
255
389
|
const q = new URLSearchParams(path.split("?")[1]);
|
|
256
390
|
listCalls++;
|
|
257
391
|
const since = q.get("since");
|
|
@@ -261,31 +395,32 @@ describe("cache", () => {
|
|
|
261
395
|
if (listCalls === 1) store[4].updated_at = ts(++maxUpdated);
|
|
262
396
|
return out;
|
|
263
397
|
};
|
|
264
|
-
const first = fetchGithubRecords({ repo: "o/r", gh, env });
|
|
398
|
+
const first = await fetchGithubRecords({ repo: "o/r", gh, env });
|
|
265
399
|
assert.equal(first.items.length, 250, "every item is cached after the crawl, none skipped by the shift");
|
|
266
400
|
assert.equal(new Set(first.items.map((i) => i.number)).size, 250);
|
|
267
401
|
// The bumped item moved past the crawl cursor, so the next incremental run (and its overlap window) still sees it.
|
|
268
402
|
store[9].updated_at = ts(++maxUpdated); // another item updated after the first run
|
|
269
|
-
const second = fetchGithubRecords({ repo: "o/r", gh, env });
|
|
403
|
+
const second = await fetchGithubRecords({ repo: "o/r", gh, env });
|
|
270
404
|
assert.equal(second.fromCache, true);
|
|
271
405
|
assert.equal(second.items.length, 250);
|
|
272
406
|
assert.equal(second.items.find((i) => i.number === 10).updatedAt, store[9].updated_at);
|
|
273
407
|
assert.equal(second.items.find((i) => i.number === 5).updatedAt, store[4].updated_at);
|
|
274
408
|
});
|
|
275
|
-
it("terminates and drains a full page whose items all share one updated_at", () => {
|
|
409
|
+
it("terminates and drains a full page whose items all share one updated_at", async () => {
|
|
276
410
|
const env = { ...ENV, FORGE_STATS_CACHE_DIR: join(td, "cache-sameTs") };
|
|
277
411
|
const store = Array.from({ length: 230 }, (_, i) => ({ number: i + 1, updated_at: "2026-10-02T00:00:00Z", created_at: "2026-10-01T00:00:00Z", comments: 0, labels: [], user: { type: "Bot" } }));
|
|
278
412
|
const gh = (args) => {
|
|
413
|
+
if (/issues\/comments\?/.test(args.find((a) => a.startsWith("repos/")))) return "[]";
|
|
279
414
|
const q = new URLSearchParams(args.find((a) => a.startsWith("repos/")).split("?")[1]);
|
|
280
415
|
const per = Number(q.get("per_page")), page = Number(q.get("page") ?? 1);
|
|
281
416
|
const since = q.get("since");
|
|
282
417
|
return JSON.stringify(store.filter((i) => !since || i.updated_at >= since).slice((page - 1) * per, page * per));
|
|
283
418
|
};
|
|
284
|
-
assert.equal(fetchGithubRecords({ repo: "o/r", gh, env }).items.length, 230);
|
|
419
|
+
assert.equal((await fetchGithubRecords({ repo: "o/r", gh, env })).items.length, 230);
|
|
285
420
|
});
|
|
286
|
-
it("rejects an unsafe repo before any gh call", () => {
|
|
421
|
+
it("rejects an unsafe repo before any gh call", async () => {
|
|
287
422
|
const { gh, calls } = fakeGh();
|
|
288
|
-
assert.
|
|
423
|
+
await assert.rejects(() => fetchGithubRecords({ repo: "o/r; rm -rf /", gh }), /invalid repo/);
|
|
289
424
|
assert.equal(calls.length, 0);
|
|
290
425
|
});
|
|
291
426
|
});
|
|
@@ -298,6 +433,32 @@ describe("cli", () => {
|
|
|
298
433
|
assert.throws(() => parseArgs(["--bogus"]), /unknown argument/);
|
|
299
434
|
assert.throws(() => parseSince("yesterday"), /--since must be/);
|
|
300
435
|
});
|
|
436
|
+
it("resolves --since to a crawl lower bound (date as is, version via release time minus a margin)", () => {
|
|
437
|
+
assert.equal(sinceToTimestamp("2026-10-01").since, "2026-10-01T00:00:00Z");
|
|
438
|
+
assert.equal(sinceToTimestamp(undefined).since, null);
|
|
439
|
+
const v = sinceToTimestamp("v1.16.0", [{ v: "1.16.0", t: Date.parse("2026-10-05T00:00:00Z") / 1000 }]);
|
|
440
|
+
assert.equal(v.since, "2026-10-04T00:00:00Z");
|
|
441
|
+
const unknown = sinceToTimestamp("1.99.0", []);
|
|
442
|
+
assert.equal(unknown.since, null);
|
|
443
|
+
assert.match(unknown.note, /full history/);
|
|
444
|
+
});
|
|
445
|
+
it("prints progress to stderr only, throttled, and keeps --json stdout parseable", async () => {
|
|
446
|
+
const out = [];
|
|
447
|
+
let t = 0;
|
|
448
|
+
const print = makeProgressPrinter((x) => out.push(x), () => t);
|
|
449
|
+
print({ phase: "issues", done: 100, total: null });
|
|
450
|
+
t += 500; print({ phase: "issues", done: 200, total: null });
|
|
451
|
+
t += 2000; print({ phase: "issues", done: 300, total: null });
|
|
452
|
+
assert.equal(out.length, 2);
|
|
453
|
+
const { gh } = fakeGh();
|
|
454
|
+
const realOut = process.stdout.write, realErr = process.stderr.write;
|
|
455
|
+
let stdout = "", stderr = "";
|
|
456
|
+
process.stdout.write = (c) => { stdout += c; return true; };
|
|
457
|
+
process.stderr.write = (c) => { stderr += c; return true; };
|
|
458
|
+
try { assert.equal(await runStats(["--repo", "o/r", "--json", "--no-cache", "--no-local"], { gh, env: ENV, releaseTimes: [] }), 0); } finally { process.stdout.write = realOut; process.stderr.write = realErr; }
|
|
459
|
+
assert.ok(JSON.parse(stdout).groups, "stdout is only the JSON report");
|
|
460
|
+
assert.match(stderr, /forgedock stats: (comments|issues) /);
|
|
461
|
+
});
|
|
301
462
|
it("--help exits 0 and importing the module did not run it", () => {
|
|
302
463
|
const r = spawnSync(process.execPath, [join(here, "index.mjs"), "--help"], { encoding: "utf8" });
|
|
303
464
|
assert.equal(r.status, 0);
|
|
@@ -471,7 +471,7 @@ if [ -n "$TRUSTED_SCRIPT" ] && [ -n "${ALL_PR_NUMBERS:-}" ] && [ -n "${PR_NUMBER
|
|
|
471
471
|
# Reviewed = trusted FORGE:REVIEW_ROUTE (a real review mode) whose sha is the PR's FINAL head, plus at least one review-agent comment
|
|
472
472
|
# carrying the full-length `Reviewed-SHA: <head>` line and a findings block (same predicate as review-pr.md Phase 5; a 7-char substring or a comment with no findings block never counts), and no degraded/blocked review marker. A PR pushed to after its review has a stale sha: the exact-head gate gives it no credit, and only the delta branch below (shared resolver `scripts/review-delta.sh`) may credit the commits an earlier trusted complete panel covered or a trusted `FORGE:REVIEW-CARRIED` base sync carried; everything else stays UNREVIEWED.
|
|
473
473
|
_ROUTE=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:REVIEW_ROUTE mode=(?!spec-evolution-blocked)[^ ]+ spec=review-pr.md sha=${_HEAD7} -->") || { SCOPE_MODE=full; break; }
|
|
474
|
-
_AGENT=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${_HEAD}(\\r?\\n|$)") || { SCOPE_MODE=full; break; }
|
|
474
|
+
_AGENT=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${_HEAD}(\\r?\\n|$)") || { SCOPE_MODE=full; break; }
|
|
475
475
|
_DEGRADED=$(printf '%s' "$_CM" | bash "$TRUSTED_SCRIPT" count "^<!-- FORGE:(REVIEW_DEGRADED|REVIEW_BLOCKED)") || { SCOPE_MODE=full; break; }
|
|
476
476
|
case "$_ROUTE$_AGENT$_DEGRADED" in ''|*[!0-9]*) SCOPE_MODE=full; break ;; esac
|
|
477
477
|
if [ "$_ROUTE" -ge 1 ] && [ "$_AGENT" -ge 1 ] && [ "$_DEGRADED" -eq 0 ] && [ "$(printf '%s' "$_J" | jq '.commits|length')" -lt 100 ]; then
|
|
@@ -1044,7 +1044,7 @@ for AGENT_DOMAIN in $LAUNCHED_REVIEW_AGENTS; do
|
|
|
1044
1044
|
AGENT_COMMENT_COUNT=0
|
|
1045
1045
|
if [ -n "$REVIEW_SHA_FULL" ] && [ -n "$TRUSTED_SCRIPT" ]; then
|
|
1046
1046
|
AGENT_COMMENT_COUNT=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
|
|
1047
|
-
| bash "$TRUSTED_SCRIPT" count "
|
|
1047
|
+
| bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA:[ ]${REVIEW_SHA_FULL}(\\r?\\n|$)" 2>/dev/null || echo 0)
|
|
1048
1048
|
fi
|
|
1049
1049
|
case "$AGENT_COMMENT_COUNT" in ''|*[!0-9]*) AGENT_COMMENT_COUNT=0 ;; esac # fail closed: counts as missing
|
|
1050
1050
|
if [ "${AGENT_COMMENT_COUNT:-0}" -lt 1 ]; then
|
|
@@ -1054,7 +1054,7 @@ done
|
|
|
1054
1054
|
|
|
1055
1055
|
if [ -n "$MISSING_AGENT_COMMENTS" ]; then
|
|
1056
1056
|
echo "REVIEW DELIVERY FAILURE: missing findings comment(s) for:${MISSING_AGENT_COMMENTS}"
|
|
1057
|
-
echo "Recover each agent's returned verdict, findings, and durable body path before rerunning review."
|
|
1057
|
+
echo "A comment without a REVIEW-FINDINGS-START block counts as missing. Re-dispatch the missing agent once with the note: structured findings block required. A second miss is a degraded panel. Recover each agent's returned verdict, findings, and durable body path before rerunning review."
|
|
1058
1058
|
exit 1
|
|
1059
1059
|
fi
|
|
1060
1060
|
```
|
|
@@ -1086,7 +1086,7 @@ bash "$RF" classify --pr "$PR_NUMBER" --repo "$GH_REPO" --context "$CTX" --out-d
|
|
|
1086
1086
|
cat "$OUT_DIR/note-list.txt" 2>/dev/null
|
|
1087
1087
|
```
|
|
1088
1088
|
|
|
1089
|
-
Read `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the summary.
|
|
1089
|
+
Read `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the summary. Findings come only from `FINDING:` markers, never from free text. If `AGENTS_WITHOUT_BLOCK` is non-empty those agents are undelivered (no findings block): re-dispatch them once with the note "structured findings block required", else take the degraded-panel path; `CLEAN_STRUCTURED` is withheld meanwhile. With no kept finding and `CLEAN_STRUCTURED=true`, skip to Phase 8. A marker-less finding is classified with `classify-finding.sh --unstructured` (empty or unparseable severity is `NOTE unstructured`, never ISSUE).
|
|
1090
1090
|
|
|
1091
1091
|
### 7B: Note disposition (same damper and classifier as `/review-pr` §6B.5)
|
|
1092
1092
|
|
package/commands/review-pr.md
CHANGED
|
@@ -367,6 +367,8 @@ Reviewer roster tier \`${ROSTER_TIER}\`: ${ROSTER_REASON}. Roster: ${SELECTED_AG
|
|
|
367
367
|
|
|
368
368
|
**Extended review (`REVIEW_DEPTH=EXTENDED`)** <!-- Added: forge#3862 -->: the roster script has already lifted a small, medium or fix-pr roster to standard and widened the standard baseline to the top-3 domains. Additionally dispatch each selected domain agent once per slice of at most `review.depth.slice_files` changed files (default 12; one slice when the PR has no more files), each slice carrying only its files' diff and the shared PR context, and merge the per-slice findings in Phase 5 (the dedup there treats the same `file:line` from two slices as one). The Security agent always reviews the whole diff, never a slice. Extended depth never skips a Phase 2 check, never weakens the Security floor, and never blocks the merge by itself; it is not a finding.
|
|
369
369
|
|
|
370
|
+
**Degraded gate** <!-- Added: forge#3628 -->: when `MERGE_ISSUE` is set, read the `**Degraded**:` line of its newest trusted `FORGE:QUALITY_GATE` and the `FORGE:ACCEPTANCE_GATE:DEGRADED` list (via `scripts/trusted-comments.sh`). A non-`none` list means a check ran nothing (tests skipped or not run, known-slow skip, blast-radius incomplete). Give each domain agent that list as "Degraded checks to review by hand": it must read the code those checks would have exercised. Post the section in a PR comment whose first line is `<!-- FORGE:REVIEW-AGENT:{domain} -->` (or the synthesis marker) and which carries a `Reviewed-SHA: ${REVIEW_SHA}` line for the current head. Under `### Degraded Paths Reviewed`, write one list item per degraded check as `- <exact name>: <how its path was reviewed>`; a generic section, a stale-head comment, or an item saying the check was NOT reviewed does not satisfy Phase 8 (forge#3995).
|
|
371
|
+
|
|
370
372
|
### 3B.5: Provenance-Based Trust Escalation (Conditional)
|
|
371
373
|
|
|
372
374
|
Proven `(task-type × module-set)` combinations may drop optional judgment agents; novel ones widen to the full affected-domain panel (`NOVEL_NEEDS_HUMAN` also sets `needs-human` on merge). **Hard floor**: Security and every Phase 2 check run at every tier. **Application is skipped** (the lookup still runs, recording the calibration cell key) for `THOROUGH`, milestone-to-staging, `BASE_SYNC_ONLY`, and a `DELTA` head past two full rounds. **Default is SHADOW**: any error path (absent table, parse failure, git error) logs and leaves the roster unchanged, never looser or tighter; `review.trust_shadow_mode: "false"` enforces. `PROVEN` narrows only to Security plus hard-signal agents (AUTH, BILLING, DATABASE or INFRA score >= 3) and never under churn escalation or a danger zone. <!-- forge#1745, forge#1804 -->
|
|
@@ -437,7 +439,7 @@ Re-reviewing a head that only moved does not need the whole panel over the whole
|
|
|
437
439
|
|
|
438
440
|
Phase 2, the §6B.4 gates (full PR diff at the current head) and the 7B/8 gates run unchanged in every mode.
|
|
439
441
|
|
|
440
|
-
**Reviewer-comment trust and shape (applies to every check below and in Phase 4)**: a reviewer comment counts only when it is from a trusted author (`scripts/trusted-comments.sh`, the same predicate as the in-PR fix gate and `verify-phase-trail.sh`: trusted association, Bot account, or `FORGE_TRAIL_TRUSTED_LOGINS`), its body **starts with** `<!-- FORGE:REVIEW-AGENT:{domain} -->`,
|
|
442
|
+
**Reviewer-comment trust and shape (applies to every check below and in Phase 4)**: a reviewer comment counts only when it is from a trusted author (`scripts/trusted-comments.sh`, the same predicate as the in-PR fix gate and `verify-phase-trail.sh`: trusted association, Bot account, or `FORGE_TRAIL_TRUSTED_LOGINS`), its body **starts with** `<!-- FORGE:REVIEW-AGENT:{domain} -->`, it carries a line `Reviewed-SHA: ${REVIEW_SHA}` for the current head, and it carries a `<!-- REVIEW-FINDINGS-START -->` ... `<!-- REVIEW-FINDINGS-END -->` block (empty allowed). A verdict without the block ("APPROVE, no findings") is **undelivered**, never read as clean and never scanned for free-text findings. The head SHA is public, so the SHA line alone proves nothing: an untrusted commenter must never suppress dispatch or satisfy the panel guard. An unresolved trust script or a non-integer count means **not posted** (fail closed).
|
|
441
443
|
|
|
442
444
|
**Pre-dispatch fence.** For `BASE_SYNC_ONLY` it dispatches zero reviewers and posts the carry record (then go straight to Phase 4). Otherwise it implements **idempotent re-entry**: a re-invoked review reuses reviewers that already posted for the current head, so it prints `PENDING_AGENTS` (the domains of the effective roster with no trusted current-SHA comment; comments for another SHA never count) and the linked issue's `BLAST_RADIUS_MANIFEST` (advisory; any failure prints it empty).
|
|
443
445
|
|
|
@@ -479,7 +481,7 @@ agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]');
|
|
|
479
481
|
for AGENT in $SELECTED_AGENTS; do
|
|
480
482
|
AGENT_DOMAIN=$(agent_domain "$AGENT"); HAVE=""
|
|
481
483
|
if [ -n "$TRUSTED_SCRIPT" ]; then
|
|
482
|
-
HAVE=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "
|
|
484
|
+
HAVE=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
|
|
483
485
|
fi
|
|
484
486
|
case "$HAVE" in ''|*[!0-9]*) HAVE=0 ;; esac # fail closed: unreadable or untrusted counts as not posted
|
|
485
487
|
[ "$HAVE" -lt 1 ] && PENDING_AGENTS="${PENDING_AGENTS} ${AGENT}"
|
|
@@ -548,7 +550,7 @@ Each agent's `[FILE_LIST]` is `slices.<domain>.paths` from the context: the Secu
|
|
|
548
550
|
|
|
549
551
|
## Phase 4: Wait for Agents
|
|
550
552
|
|
|
551
|
-
Completion is read from GitHub, never from notifications. A reviewer is complete only when a **trusted** comment (3C trust rule) starting with `<!-- FORGE:REVIEW-AGENT:{domain} -->` carries `Reviewed-SHA: <current head
|
|
553
|
+
Completion is read from GitHub, never from notifications. A reviewer is complete only when a **trusted** comment (3C trust rule) starting with `<!-- FORGE:REVIEW-AGENT:{domain} -->` carries `Reviewed-SHA: <current head>` and a findings block (`REVIEW-FINDINGS-START` ... `REVIEW-FINDINGS-END`, empty allowed). A domain whose comment lacks the block is missing: re-dispatch it **once** (3C, with the note "structured findings block required: end your comment with REVIEW-FINDINGS-START/END, empty if no findings"), then re-run this phase; a second miss is a delivery failure and takes the existing degraded-panel hard stop below. Never loop beyond that one re-dispatch.
|
|
552
554
|
|
|
553
555
|
- **Joined tools** (`Task`, `task`): the reviewers already returned, so one pass decides; anything missing is a delivery failure.
|
|
554
556
|
- **Async `Agent`**: bounded wait, **one poll pass per Bash call** (a call is capped at 120s, 600s max, so one long loop would be killed before the hard stop ran). The first pass records an epoch deadline in a private absolute file keyed by PR and head SHA; every exit removes it and 3C removes it before (re)dispatch, so a re-review never inherits an expired window. Re-run while it prints `PANEL_STATUS: WAITING`.
|
|
@@ -590,7 +592,7 @@ agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]');
|
|
|
590
592
|
for AGENT in $SELECTED_AGENTS; do
|
|
591
593
|
AGENT_DOMAIN=$(agent_domain "$AGENT"); N=""
|
|
592
594
|
if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$COMMENTS_JSON" ]; then
|
|
593
|
-
N=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "
|
|
595
|
+
N=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA: ${REVIEW_SHA}(\\r?\\n|$)" 2>/dev/null || echo "")
|
|
594
596
|
fi
|
|
595
597
|
case "$N" in ''|*[!0-9]*) N=0 ;; esac # fail closed
|
|
596
598
|
if [ "$N" -lt 1 ]; then MISSING_AGENT_COMMENTS="${MISSING_AGENT_COMMENTS} ${AGENT_DOMAIN}"; else ACTUAL_AGENT_COUNT=$(( ACTUAL_AGENT_COUNT + 1 )); fi
|
|
@@ -655,7 +657,7 @@ cat "$OUT_DIR/note-list.txt" 2>/dev/null
|
|
|
655
657
|
|
|
656
658
|
`extract` reads the trusted synthesized block for the head, else its trusted agent bodies (`BASE_SYNC_ONLY`: the carried head; `DELTA`: this head plus the earlier reviewed one), parses every `<!-- FINDING:... -->` marker (an unparseable marker blocks the review), dedupes by file + line range ±5 and by title (3+ shared keywords, higher confidence wins), and applies the **§6B.4 gates** in order, each keeping the finding when its input is unreadable: (1) **head-SHA**: a cited line past the end of the file at `REVIEW_SHA` is stale and DROPPED (`findings_dropped_stale`); (2) **introduced-line**: a MEDIUM-or-lower finding whose range (±5) misses the PR's added lines is `pre-existing` (**CRITICAL/HIGH are never routed as pre-existing**, whatever their confidence, `protocols.md` §5; input comes from the diff, never a reviewer's `**Scope**` tag; deleted, renamed and patch-less files always intersect); (3) **concrete-failure**: a MEDIUM-or-lower finding without a `**Failure scenario**` becomes a NOTE (`notes_demoted_no_scenario`).
|
|
657
659
|
|
|
658
|
-
Read `STRUCTURED_FINDINGS`, `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the record and summary. The caller's part of 6A: add the Phase 2.5 `INTEG-N` findings no reviewer covered (CONFIRMED HIGH, so ISSUE, filed in 6C);
|
|
660
|
+
Read `STRUCTURED_FINDINGS`, `FINDINGS_KEPT`, `FINDINGS_DROPPED_STALE`, `FINDINGS_PREEXISTING` and `NOTES_DEMOTED_NO_SCENARIO` for the record and summary. The caller's part of 6A: add the Phase 2.5 `INTEG-N` findings no reviewer covered (CONFIRMED HIGH, so ISSUE, filed in 6C); findings come only from `FINDING:` markers, never from free text. If `AGENTS_WITHOUT_BLOCK` is non-empty those agents are undelivered: re-dispatch them once (Phase 4 rule), else take the degraded-panel path; `CLEAN_STRUCTURED` is withheld while it is non-empty. On `CLEAN_STRUCTURED=true` and no `INTEG-N` the review is clean: skip to Phase 7. A finding that somehow has no marker is classified with `classify-finding.sh --unstructured` (empty or unparseable severity gives `NOTE unstructured`, never ISSUE). **Pre-existing route**: a pre-existing finding (MEDIUM or lower) is never filed as `review-finding`/`needs-validation` nor linked to this PR (it stays out of findings-per-PR and the amplification ratio); it still passes the §6B.5 classifier, is listed once in the summary, and at most 3 per review (highest severity first) are filed with the `pre-existing` label plus `priority:*`, citing this PR (`preexisting_filed`, not `findings_filed`).
|
|
659
661
|
|
|
660
662
|
### 6B.5: Non-blocking note disposition (MANDATORY before 6C)
|
|
661
663
|
|
|
@@ -674,7 +676,7 @@ Each NOTE gets one disposition: (1) **fix in this PR**, only comment, documentat
|
|
|
674
676
|
|
|
675
677
|
### 6B.6: In-PR fix gate
|
|
676
678
|
|
|
677
|
-
A CONFIRMED MEDIUM-or-higher defect in a file this PR changed is cheaper to fix before merge than through a separate `/work-on` cycle: `classify` marks it `INPR_FIX` and posts the `FORGE:INPR_FIX` request after the disposition marker (`INPR_FIX_POSTED=true`), so do NOT file those findings in 6C. Narrow by design: auto-merge reviews with `--issue`, files in the PR diff, one round per PR (any trusted `FORGE:INPR_FIX*` marker ends it). Phase 8 then refuses to merge this head (blocker `in-pr fix required`). If the round fails, `/work-on` waives it for MEDIUM and files the findings (`work-on/review.md` R4); HIGH/CRITICAL are never waived and end at `needs-human`.
|
|
679
|
+
A CONFIRMED MEDIUM-or-higher defect, or a LIKELY MEDIUM defect whose line the PR introduced (`scope=introduced`), in a file this PR changed is cheaper to fix before merge than through a separate `/work-on` cycle: `classify` marks it `INPR_FIX` and posts the `FORGE:INPR_FIX` request after the disposition marker (`INPR_FIX_POSTED=true`), so do NOT file those findings in 6C. `review-finalize.sh extract` computes `scope` (`introduced` | `pre-existing` | `unknown`) per finding from the PR diff (the added-line window; unknown provenance never widens the gate). Narrow by design: auto-merge reviews with `--issue`, files in the PR diff, one round per PR (any trusted `FORGE:INPR_FIX*` marker ends it). Phase 8 then refuses to merge this head (blocker `in-pr fix required`). If the round fails, `/work-on` waives it for MEDIUM and files the findings (`work-on/review.md` R4); HIGH/CRITICAL are never waived and end at `needs-human`.
|
|
678
680
|
|
|
679
681
|
### 6C: Create Issues
|
|
680
682
|
|
|
@@ -718,6 +720,7 @@ cat <<'ISSUE_EOF' > "$FINDING_ISSUE_BODY_FILE"
|
|
|
718
720
|
**Agent**: [name] ([domain])
|
|
719
721
|
**Confidence**: [CONFIRMED/LIKELY/POSSIBLE]
|
|
720
722
|
**Severity**: [CRITICAL/HIGH/MEDIUM/LOW]
|
|
723
|
+
**Scope**: introduced|pre-existing (the worklist item's `scope`; omit the line when it is `unknown`)
|
|
721
724
|
**Review comment**: [permalink to agent comment]
|
|
722
725
|
|
|
723
726
|
## Pattern Metadata
|
|
@@ -799,25 +802,29 @@ Append a `## Review Findings` table (summaries and issue links) to the PR body:
|
|
|
799
802
|
|
|
800
803
|
```bash
|
|
801
804
|
PR_NUMBER="{PR_NUMBER}"; REPO="{GH_REPO}"; MERGE_ISSUE="{MERGE_ISSUE}"
|
|
802
|
-
MERGE_HEALTH="{MERGE_HEALTH}"; MERGE_HEALTH_STATE="{MERGE_HEALTH_STATE}"; BASE="{BASE}"; HEAD="{HEAD}"; REVIEW_SHA="{REVIEW_SHA}"
|
|
805
|
+
MERGE_HEALTH="{MERGE_HEALTH}"; MERGE_HEALTH_STATE="{MERGE_HEALTH_STATE}"; BASE="{BASE}"; HEAD="{HEAD}"; REVIEW_SHA="{REVIEW_SHA}"; CTX="{CONTEXT_FILE}"
|
|
803
806
|
INTENSITY_TIER="{INTENSITY_TIER}"; TABLE_CELL_KEY="{TABLE_CELL_KEY}"; TRUST_SHADOW_MODE="{TRUST_SHADOW_MODE}"; TRUST_NEEDS_HUMAN="{TRUST_NEEDS_HUMAN}"
|
|
804
807
|
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
805
808
|
RF=$(forge_resolve review-finalize.sh) # install roots only, never the PR checkout; unresolvable is a stop (fail closed), never a skipped gate
|
|
806
809
|
[ -n "$RF" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/review-finalize.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install); refusing to continue (fail closed)"; exit 1; } # STOP
|
|
807
|
-
|
|
810
|
+
OUT_DIR="$(dirname "$CTX")/review-finalize-${PR_NUMBER}"; mkdir -p "$OUT_DIR"
|
|
811
|
+
# Free text (purpose-regression findings, agents list) derives from PR content: BEFORE this fence, write it with the file-write tool (never a
|
|
812
|
+
# shell heredoc, echo or printf of the text) to "$OUT_DIR/purpose-findings.txt" (ids and reasons; leave absent when there is no purpose regression)
|
|
813
|
+
# and "$OUT_DIR/agents.txt" ("{N} agents: {names}"). It is passed by path, so it is never re-parsed as shell. Never put free text in a shell word.
|
|
814
|
+
# --result: stale (PR HEAD moved) | clean (no blocking criteria) | blocking (any criterion above).
|
|
808
815
|
VERDICT_OUT=$(bash "$RF" verdict --pr "$PR_NUMBER" --repo "$REPO" --review-sha "$REVIEW_SHA" --base "$BASE" --head-branch "$HEAD" \
|
|
809
816
|
--merge-health "$MERGE_HEALTH" --merge-health-state "$MERGE_HEALTH_STATE" --is-milestone "{IS_MILESTONE_TO_STAGING}" \
|
|
810
|
-
--has-purpose-regression "{HAS_PURPOSE_REGRESSION}" --purpose-findings
|
|
811
|
-
--result "{RESULT}" --agents
|
|
817
|
+
--has-purpose-regression "{HAS_PURPOSE_REGRESSION}" --purpose-findings-file "$OUT_DIR/purpose-findings.txt" \
|
|
818
|
+
--result "{RESULT}" --agents-file "$OUT_DIR/agents.txt" --findings-count "{M}" --blocking-count "{N_BLOCKING}" \
|
|
812
819
|
--intensity-tier "$INTENSITY_TIER" --table-cell-key "$TABLE_CELL_KEY" --trust-shadow-mode "$TRUST_SHADOW_MODE"); VERDICT_RC=$?
|
|
813
820
|
printf '%s\n' "$VERDICT_OUT"
|
|
814
821
|
[ "$VERDICT_RC" -eq 0 ] || exit 1 # STOP: REVIEW_RESULT: status: BLOCKED was printed
|
|
815
822
|
# 7B.5: reads task type and confidence from the linked issue's FORGE:INVESTIGATOR, consults origin/forge-knowledge, records FORGE:CALIBRATION_CHECK.
|
|
816
|
-
bash "$RF" calibrate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" \
|
|
823
|
+
bash "$RF" calibrate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" --out-dir "$OUT_DIR" \
|
|
817
824
|
--intensity-tier "$INTENSITY_TIER" --table-cell-key "$TABLE_CELL_KEY" --trust-shadow-mode "$TRUST_SHADOW_MODE" --trust-needs-human "$TRUST_NEEDS_HUMAN"
|
|
818
825
|
```
|
|
819
826
|
|
|
820
|
-
`verdict` computes `BASE_CONFLICT` (the single source of truth for the Phase 8 guards), posts the verdict with the trust annotation, and never posts a clean verdict over a base conflict or purpose regression (it downgrades to CHANGES REQUESTED, `VERDICT_COERCED=true`). Read `VERDICT=`, `BASE_CONFLICT=`, `HAS_PURPOSE_REGRESSION=` and, from `calibrate`, `CALIBRATION_NEEDS_HUMAN=` / `CALIBRATION_NOTE=`: they are the Phase 8 flags.
|
|
827
|
+
`verdict` computes `BASE_CONFLICT` (the single source of truth for the Phase 8 guards), posts the verdict with the trust annotation, and never posts a clean verdict over a base conflict or purpose regression (it downgrades to CHANGES REQUESTED, `VERDICT_COERCED=true`). Read `VERDICT=`, `BASE_CONFLICT=`, `HAS_PURPOSE_REGRESSION=` and, from `calibrate`, `CALIBRATION_NEEDS_HUMAN=` / `CALIBRATION_NOTE=`: they are the Phase 8 flags. The note itself is also written to `$OUT_DIR/calibration-note.txt`, which Phase 8 passes by path; free text is never placed in a shell word.
|
|
821
828
|
|
|
822
829
|
---
|
|
823
830
|
|
|
@@ -825,18 +832,20 @@ bash "$RF" calibrate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" \
|
|
|
825
832
|
|
|
826
833
|
**Skip if** `AUTO_MERGE=false`.
|
|
827
834
|
|
|
828
|
-
**One script owns every Phase 8 gate (MANDATORY; a gate is never skipped, reordered or hand-run).** `review-finalize.sh merge-gate` runs, in order, and stops at the first refusal: the note-disposition preflight (a trusted `FORGE:NOTE_DISPOSITION` must exist), the in-PR fix gate (an unanswered trusted `FORGE:INPR_FIX` at the current head blocks), the phase-trail preflight (`scripts/verify-phase-trail.sh` on the linked issue; `--issue` is mandatory and never inferred; an infrastructure failure is `BLOCKED` plus `needs-human`) <!-- Added: forge#3061 -->, the guard-state flags below (`VERDICT` is `APPROVED` or `CHANGES REQUESTED`, the others `true`/`false`; an empty or unsubstituted value aborts with `needs-human`; `--other-blocking true` when 7B criterion 1, 2, 3 or 5 fired), routing (a base conflict as the ONLY blocker routes to the base-sync path, `blocker: base-conflict`; any other hold adds `needs-human`), the pre-merge mergeability guard on fresh state, the previously-escalated guard (parks at `workflow:awaiting-merge`, never merges), the CI gate (`scripts/wait-ci-green.sh`) with the reviewed-head guard (only the commit the `FORGE:REVIEW_ROUTE` sha approved; a docs-only delta is allowed), and finally the merge with `--match-head-commit`. Every gate fails closed.
|
|
835
|
+
**One script owns every Phase 8 gate (MANDATORY; a gate is never skipped, reordered or hand-run).** `review-finalize.sh merge-gate` runs, in order, and stops at the first refusal: the note-disposition preflight (a trusted `FORGE:NOTE_DISPOSITION` must exist), the in-PR fix gate (an unanswered trusted `FORGE:INPR_FIX` at the current head blocks), the phase-trail preflight (`scripts/verify-phase-trail.sh` on the linked issue; `--issue` is mandatory and never inferred; an infrastructure failure is `BLOCKED` plus `needs-human`) <!-- Added: forge#3061 -->, the degraded-gate review (`degraded-gate-unreviewed` unless a `### Degraded Paths Reviewed` section covers each degraded check), the guard-state flags below (`VERDICT` is `APPROVED` or `CHANGES REQUESTED`, the others `true`/`false`; an empty or unsubstituted value aborts with `needs-human`; `--other-blocking true` when 7B criterion 1, 2, 3 or 5 fired), routing (a base conflict as the ONLY blocker routes to the base-sync path, `blocker: base-conflict`; any other hold adds `needs-human`), the pre-merge mergeability guard on fresh state, the previously-escalated guard (parks at `workflow:awaiting-merge`, never merges), the CI gate (`scripts/wait-ci-green.sh`) with the reviewed-head guard (only the commit the `FORGE:REVIEW_ROUTE` sha approved; a docs-only delta is allowed), and finally the merge with `--match-head-commit`. Every gate fails closed.
|
|
829
836
|
|
|
830
837
|
```bash
|
|
831
838
|
PR_NUMBER="{PR_NUMBER}"; REPO="{GH_REPO}"
|
|
832
839
|
MERGE_ISSUE="{MERGE_ISSUE}"; MERGE_BASE="{MERGE_BASE}"; IS_MILESTONE_TO_STAGING="{IS_MILESTONE_TO_STAGING}"
|
|
833
|
-
VERDICT="{VERDICT}"; HAS_PURPOSE_REGRESSION="{HAS_PURPOSE_REGRESSION}"; CALIBRATION_NEEDS_HUMAN="{CALIBRATION_NEEDS_HUMAN}";
|
|
840
|
+
VERDICT="{VERDICT}"; HAS_PURPOSE_REGRESSION="{HAS_PURPOSE_REGRESSION}"; CALIBRATION_NEEDS_HUMAN="{CALIBRATION_NEEDS_HUMAN}"; CTX="{CONTEXT_FILE}"
|
|
834
841
|
TRUST_NEEDS_HUMAN="{TRUST_NEEDS_HUMAN}"; BASE_CONFLICT="{BASE_CONFLICT}"; OTHER_BLOCKING="{OTHER_BLOCKING}"; TABLE_CELL_KEY="{TABLE_CELL_KEY}"
|
|
835
842
|
type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; FORGE_SOURCED_ROOT='${CLAUDE_PLUGIN_ROOT}'; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" '${CLAUDE_PLUGIN_ROOT}' "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/*; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
|
|
836
843
|
RF=$(forge_resolve review-finalize.sh) # install roots only, never the PR checkout; unresolvable is a stop (fail closed), never a skipped gate
|
|
837
844
|
[ -n "$RF" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: scripts/review-finalize.sh not resolvable (set FORGEDOCK_HOME to the ForgeDock install); refusing to continue (fail closed)"; exit 1; } # STOP
|
|
845
|
+
# The calibration note derives from issue text: `calibrate` (Phase 7B.5) wrote it to this file; it is passed by path, never substituted into a shell word.
|
|
846
|
+
OUT_DIR="$(dirname "$CTX")/review-finalize-${PR_NUMBER}"
|
|
838
847
|
GATE_OUT=$(bash "$RF" merge-gate --pr "$PR_NUMBER" --repo "$REPO" --issue "$MERGE_ISSUE" --base "$MERGE_BASE" \
|
|
839
|
-
--verdict "$VERDICT" --has-purpose-regression "$HAS_PURPOSE_REGRESSION" --calibration-needs-human "$CALIBRATION_NEEDS_HUMAN" --calibration-note "$
|
|
848
|
+
--verdict "$VERDICT" --has-purpose-regression "$HAS_PURPOSE_REGRESSION" --calibration-needs-human "$CALIBRATION_NEEDS_HUMAN" --calibration-note-file "$OUT_DIR/calibration-note.txt" \
|
|
840
849
|
--trust-needs-human "$TRUST_NEEDS_HUMAN" --base-conflict "$BASE_CONFLICT" --other-blocking "$OTHER_BLOCKING" --table-cell-key "$TABLE_CELL_KEY"); GATE_RC=$?
|
|
841
850
|
printf '%s\n' "$GATE_OUT"
|
|
842
851
|
# 8B: only for a milestone->staging PR whose merge just succeeded: clears the milestone on its open review-finding issues so they flow through the fast lane.
|
|
@@ -849,6 +858,7 @@ exit "$GATE_RC"
|
|
|
849
858
|
**Outcome mapping.** Report the script's output; never re-run its gates by hand and never `gh pr merge` yourself.
|
|
850
859
|
- Exit 0, `MERGE_OUTCOME=merged` (`MERGE_STATE=MERGED`): merged. `merge-failed`: the merge did not land (the script commented on the issue). `parked-awaiting-merge`: parked per the previously-escalated guard; the script performs the transition itself (`transition-label.sh ... awaiting-merge`, prose fallback `gh issue edit --add-label "workflow:awaiting-merge"`).
|
|
851
860
|
- Exit 3: print the `REVIEW_RESULT: status: BLOCKED, blocker: ...` line and stop; `MERGE_OUTCOME=` names the case (`base-conflict`, `needs-human`, `protection-blocked`, `stale-review`, `ci-not-green`). A CI gate TIMEOUT prints `CI_GATE_TIMEOUT`: re-run the same fence up to 3 more times. A phase-trail blocker with no MISSING lines (`phase trail unreadable (rc=N)`, `auto-merge requires --issue`) is an infrastructure or usage failure: callers neither re-run phases nor merge manually.
|
|
861
|
+
- Exit 3 with `blocker: degraded-gate-unreviewed` (`needs-human` added): the linked issue's gate is DEGRADED and no trusted review comment for the current head has a `### Degraded Paths Reviewed` section with a `- name: how reviewed` item for every degraded check. Not a code defect: `work-on:review` (R4) re-runs the review once so the section is added. Never merge by hand. <!-- Added: forge#3628 -->
|
|
852
862
|
- Exit 4: `REVIEW_RESULT: status: PHASE_TRAIL_FAILED` plus the `MISSING:` lines. Return that status and stop: this phase never re-runs the missing phases and never escalates to `needs-human`; `work-on:review` (R4) alone owns re-dispatching them (forge#3102).
|
|
853
863
|
|
|
854
864
|
Phase 8 ONLY merges the PR. It does NOT close the issue, update labels or clean up worktrees: when invoked via `/work-on` those belong to `work-on/close.md`, and doing them here would close the issue with `workflow:merged` before the close phase ran. <!-- Added: forge#815 -->
|