forgedock 1.46.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/bin/engine/outcome-codes.mjs +2 -1
  2. package/bin/engine-cli.mjs +22 -8
  3. package/bin/lean/conformance.e2e.test.mjs +20 -0
  4. package/bin/lean/conformance.mjs +99 -9
  5. package/bin/lean/conformance.test.mjs +121 -4
  6. package/bin/lean/context.mjs +108 -6
  7. package/bin/lean/context.test.mjs +84 -2
  8. package/bin/lean/coupled.mjs +38 -0
  9. package/bin/lean/coupled.test.mjs +57 -0
  10. package/bin/lean/decision-engine.mjs +5 -1
  11. package/bin/lean/decision-engine.test.mjs +21 -0
  12. package/bin/lean/drift.mjs +3 -2
  13. package/bin/lean/drift.test.mjs +23 -1
  14. package/bin/lean/effects.mjs +3 -2
  15. package/bin/lean/execution.mjs +41 -8
  16. package/bin/lean/execution.test.mjs +72 -2
  17. package/bin/lean/integration.mjs +2 -2
  18. package/bin/lean/integration.test.mjs +22 -1
  19. package/bin/lean/lane.mjs +13 -0
  20. package/bin/lean/lane.test.mjs +28 -1
  21. package/bin/lean/lean.mjs +60 -12
  22. package/bin/lean/lean.test.mjs +80 -0
  23. package/bin/lean/limits.mjs +6 -2
  24. package/bin/lean/limits.test.mjs +86 -0
  25. package/bin/lean/milestonesync.mjs +2 -2
  26. package/bin/lean/milestonesync.test.mjs +22 -0
  27. package/bin/lean/orchestrate.mjs +79 -9
  28. package/bin/lean/probe.mjs +4 -2
  29. package/bin/lean/probe.test.mjs +4 -0
  30. package/bin/lean/prompts.mjs +18 -11
  31. package/bin/lean/rotator.mjs +96 -0
  32. package/bin/lean/rotator.test.mjs +143 -0
  33. package/bin/lean/routecheck.mjs +48 -0
  34. package/bin/lean/routecheck.test.mjs +69 -0
  35. package/bin/lean/sandbox-provider.contract.test.mjs +9 -0
  36. package/bin/lean/sandbox-remote.mjs +4 -2
  37. package/bin/lean/verify.mjs +2 -1
  38. package/bin/review-staging.mjs +20 -3
  39. package/commands/deploy-info.md +4 -4
  40. package/commands/issue.md +6 -1
  41. package/commands/review-pr-agents/frontend.md +1 -0
  42. package/commands/review-pr-agents/infra.md +2 -0
  43. package/commands/review-pr-staging.md +2 -0
  44. package/commands/work-on/build/context.md +7 -1
  45. package/commands/work-on/investigate.md +15 -3
  46. package/docs/CONFIG.md +19 -2
  47. package/docs/OUTCOME-CODES.md +2 -1
  48. package/package.json +1 -1
  49. package/scripts/file-deferred-followups.sh +125 -28
  50. package/scripts/file-deferred-followups.test.sh +49 -0
  51. package/scripts/issue-create.sh +7 -0
  52. package/scripts/issue-create.test.sh +13 -0
  53. package/scripts/issue-dedup.sh +5 -4
  54. package/scripts/issue-dedup.test.sh +15 -0
  55. package/scripts/owner-route-parity.test.sh +32 -0
  56. package/scripts/prior-attempts.mjs +72 -0
  57. package/scripts/prior-attempts.test.mjs +52 -0
  58. package/scripts/review-finalize.sh +35 -4
  59. package/scripts/review-finalize.test.sh +10 -0
  60. package/scripts/review-prepare.sh +16 -4
  61. package/scripts/review-prepare.test.sh +8 -0
  62. package/scripts/review-roster.sh +3 -1
  63. package/scripts/review-roster.test.sh +30 -2
  64. package/scripts/review-staging-pre.sh +8 -6
  65. package/scripts/review-staging-pre.test.sh +11 -1
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ /**
5
+ * scripts/prior-attempts.mjs — prior attempts on an issue's subject (#5085). Deterministic, no LLM.
6
+ *
7
+ * Extracts the subject terms (error/outcome codes, symbols, title words) from a title and body, searches closed issues
8
+ * and merged PRs by those terms (GitHub search) plus the whole git history (`git log --grep`), whatever files they
9
+ * touched, and prints a `## Prior Attempts` table. Same search as the context pack's subject rows (bin/lean/context.mjs).
10
+ * Used by /issue (filing time) and as the live fallback when the Forge Ledger recall index is absent.
11
+ *
12
+ * Usage: node scripts/prior-attempts.mjs --repo OWNER/NAME --title T [--body-file F] [--issue N] [--base REF] [--json]
13
+ * Exit: 0 table printed (also when nothing is found), 1 a search failed (stderr says which), 2 usage error.
14
+ */
15
+ import { execFile } from "node:child_process";
16
+ import { readFileSync } from "node:fs";
17
+ import { fileURLToPath } from "node:url";
18
+ import { subjectHistory } from "../bin/lean/context.mjs";
19
+
20
+ const USAGE = "usage: prior-attempts.mjs --repo OWNER/NAME --title T [--body-file F] [--issue N] [--base REF] [--json]";
21
+
22
+ const KEYS = { "--repo": "repo", "--title": "title", "--body-file": "bodyFile", "--issue": "issue", "--base": "base" };
23
+
24
+ export function parseArgs(argv) {
25
+ const o = { json: false };
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === "--json") o.json = true;
29
+ else if (a === "-h" || a === "--help") o.help = true;
30
+ else if (Object.hasOwn(KEYS, a)) {
31
+ if (i + 1 >= argv.length) throw new Error(`${a} needs a value`);
32
+ o[KEYS[a]] = argv[++i];
33
+ } else throw new Error(`unknown argument ${a}`);
34
+ }
35
+ return o;
36
+ }
37
+
38
+ // Backslashes first, then pipes: a title ending in "\" must not turn the cell's "\|" back into a column break (CodeQL js/incomplete-sanitization).
39
+ const cell = (s) => String(s ?? "").replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
40
+ const shipped = (r) => (r.status === "merged" ? "yes" : r.status === "not_planned" || r.status === "closed unmerged" ? "no" : "closed");
41
+
42
+ /** The `## Prior Attempts` markdown for a subjectHistory result; columns 2 and 4 are left for the drafter. */
43
+ export function renderPriorAttempts({ terms = [], rows = [] } = {}) {
44
+ const out = ["## Prior Attempts", ""];
45
+ if (!rows.length) out.push(`None found (terms: ${terms.length ? terms.join(", ") : "none"}).`);
46
+ else {
47
+ out.push("| Issue/PR | What it changed | Shipped? | Why it didn't hold |", "|---|---|---|---|");
48
+ for (const r of rows) out.push(`| #${r.number} ${cell(r.title)} (${r.kind}, ${r.status}) | ? | ${shipped(r)} | ? |`);
49
+ out.push("", `Search terms: ${terms.join(", ")}`);
50
+ }
51
+ return out.join("\n");
52
+ }
53
+
54
+ const run = (cmd) => (args, opts = {}) => new Promise((resolve, reject) =>
55
+ execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024, ...opts }, (e, stdout, stderr) => (e ? reject(new Error(String(stderr || e.message).trim())) : resolve(stdout))));
56
+
57
+ /** CLI body; `io` {gh, git, read, out, err} is injectable for tests. Returns the exit code. */
58
+ export async function main(argv, io = {}) {
59
+ const out = io.out ?? ((s) => process.stdout.write(`${s}\n`)), err = io.err ?? ((s) => process.stderr.write(`${s}\n`));
60
+ let o;
61
+ try { o = parseArgs(argv); } catch (e) { err(`${e.message}\n${USAGE}`); return 2; }
62
+ if (o.help) { out(USAGE); return 0; }
63
+ if (!o.repo || !/^[\w.-]+\/[\w.-]+$/.test(o.repo) || !o.title) { err(USAGE); return 2; }
64
+ let body = "";
65
+ if (o.bodyFile) { try { body = (io.read ?? ((p) => readFileSync(p, "utf8")))(o.bodyFile); } catch (e) { err(`cannot read --body-file: ${e.message}`); return 2; } }
66
+ const r = await subjectHistory({ io: { gh: io.gh ?? run("gh"), git: io.git ?? run("git") }, repo: o.repo, issue: o.issue ? Number(o.issue) : null, title: o.title, body, base: o.base ?? "HEAD" });
67
+ out(o.json ? JSON.stringify(r) : renderPriorAttempts(r));
68
+ for (const e of r.errors) err(`WARNING: ${e}`);
69
+ return r.errors.length ? 1 : 0;
70
+ }
71
+
72
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,52 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { main, parseArgs, renderPriorAttempts } from "./prior-attempts.mjs";
6
+
7
+ const cap = () => { const o = { out: [], err: [] }; return { o, io: { out: (s) => o.out.push(s), err: (s) => o.err.push(s) } }; };
8
+ const item = (n, title, extra = {}) => JSON.stringify({ number: n, title, state_reason: "completed", pr: false, merged: false, ...extra });
9
+
10
+ describe("#5085: prior-attempts.mjs", () => {
11
+ it("prints a ## Prior Attempts table from closed issues and merged PRs found by subject terms, not files", async () => {
12
+ const { o, io } = cap();
13
+ const queries = [];
14
+ const gh = async (a) => { const q = a.find((x) => String(x).startsWith("q=")); queries.push(q);
15
+ return q.includes('"tls.bypass_misclassified"') ? [item(34683, "TLS bypass counted as block"), item(35204, "Retry TLS | bypass", { pr: true, merged: true }), item(34935, "won't fix", { state_reason: "not_planned" })].join("\n") : ""; };
16
+ const git = async () => `\x1e${"a".repeat(40)}\x1fMerge pull request #34735 from o/x\x1fClassify tls.bypass_misclassified as success\n`;
17
+ const code = await main(["--repo", "o/r", "--title", "fix: TLS bypass misclassified", "--body-file", "b.md", "--issue", "38355"], { ...io, gh, git, read: () => "Ends as `tls.bypass_misclassified` in src/a.ts" });
18
+ assert.equal(code, 0, o.err.join("\n"));
19
+ const t = o.out.join("\n");
20
+ assert.match(t, /^## Prior Attempts\n\n\| Issue\/PR \| What it changed \| Shipped\? \| Why it didn't hold \|/);
21
+ assert.ok(t.includes("| #35204 Retry TLS \\| bypass (PR, merged) | ? | yes | ? |"), t);
22
+ assert.ok(t.includes("| #34935 won't fix (issue, not_planned) | ? | no | ? |"), t);
23
+ assert.ok(t.includes("| #34683 TLS bypass counted as block (issue, completed) | ? | closed | ? |"), t);
24
+ assert.ok(t.includes("| #34735 Classify tls.bypass_misclassified as success (PR, merged) | ? | yes | ? |"), t);
25
+ assert.ok(t.includes("Search terms: tls.bypass_misclassified, bypass, misclassified"), t);
26
+ assert.ok(queries.every((q) => q.startsWith("q=repo:o/r is:closed ")) && !queries.some((q) => q.includes("src/a.ts")));
27
+ });
28
+
29
+ it("nothing found prints the heading and the terms; a failed search exits 1 with a warning", async () => {
30
+ assert.equal(renderPriorAttempts({ terms: ["x.y"], rows: [] }), "## Prior Attempts\n\nNone found (terms: x.y).");
31
+ const { o, io } = cap();
32
+ assert.equal(await main(["--repo", "o/r", "--title", "Thin content billing"], { ...io, gh: async () => { throw new Error("HTTP 403"); }, git: async () => "" }), 1);
33
+ assert.ok(o.out[0].startsWith("## Prior Attempts") && o.err.some((e) => e.startsWith("WARNING: attempts: subject search")));
34
+ });
35
+
36
+ it("a title with a backslash or pipe stays inside its table cell (CodeQL js/incomplete-sanitization, release review #5103)", () => {
37
+ const t = renderPriorAttempts({ terms: ["x"], rows: [{ number: 7, title: "path a\\|b ends in \\", kind: "issue", status: "merged" }] });
38
+ const row = t.split("\n").find((l) => l.startsWith("| #7"));
39
+ assert.ok(row.includes("a\\\\\\|b ends in \\\\ "), row);
40
+ assert.equal(row.split(/(?<!\\)\|/).length, 6, row);
41
+ });
42
+
43
+ it("usage errors exit 2; --json prints the raw rows", async () => {
44
+ const { io } = cap();
45
+ assert.equal(await main(["--title", "x"], io), 2);
46
+ assert.equal(await main(["--repo", "o/r", "--bogus"], io), 2);
47
+ assert.throws(() => parseArgs(["--repo"]), /needs a value/);
48
+ const c = cap();
49
+ assert.equal(await main(["--repo", "o/r", "--title", "Thin content billing", "--json"], { ...c.io, gh: async () => item(9, "thin"), git: async () => "" }), 0);
50
+ assert.deepEqual(JSON.parse(c.o.out[0]).rows.map((r) => r.number), [9]);
51
+ });
52
+ });
@@ -266,7 +266,7 @@ do_extract() {
266
266
  # carried head's, and only a VERIFIED carry record (trusted, same head, resolver agrees) selects that SHA. DELTA: reviewers
267
267
  # saw only the delta, so the earlier fully reviewed head's findings are read too (they must still reach the Phase 8 gates);
268
268
  # the 6B.4 head-SHA and provenance gates then drop any whose cited line is gone from the current head.
269
- PRIMARY_SHA="$REVIEW_SHA"; EARLIER_SHA=""
269
+ PRIMARY_SHA="$REVIEW_SHA"; EARLIER_SHA=""; PRIMARY_FINDINGS=""
270
270
  if [ "$REVIEW_MODE" = "BASE_SYNC_ONLY" ]; then
271
271
  CARRIED_FROM=$(carry_verified_from); [ -n "$CARRIED_FROM" ] && PRIMARY_SHA="$CARRIED_FROM"
272
272
  fi
@@ -280,6 +280,8 @@ do_extract() {
280
280
  if [ -n "$EARLIER_SHA" ]; then
281
281
  EARLIER_BODIES=$(bodies_at "$EARLIER_SHA") && [ -n "$EARLIER_BODIES" ] \
282
282
  || { echo "REVIEW_RESULT: status: BLOCKED, blocker: earlier reviewed head ${EARLIER_SHA} findings unreadable (fail closed)"; exit 3; }
283
+ # forge#5081: the findings the delta-head reviewers themselves raised; an earlier-pass finding absent from them is "earlier only".
284
+ PRIMARY_FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null)
283
285
  SOURCE_BODIES=$(printf '%s\n%s' "$SOURCE_BODIES" "$EARLIER_BODIES")
284
286
  CARRIER_BODIES=$(printf '%s\n%s' "$CARRIER_BODIES" "$(agent_bodies_at "$EARLIER_SHA")")
285
287
  fi
@@ -385,10 +387,18 @@ do_extract() {
385
387
  | tr '[:upper:]' '[:lower:]' | sed -E 's/.*(fails|regresses).*/\1/')
386
388
  TEXT_FILE="$WORKDIR/text.$N_EXTRACTED"
387
389
  { printf '%s\n%s\n' "$F_TITLE" "$F_FILE"; cat "$SECTION_FILE" 2>/dev/null; } > "$TEXT_FILE"
390
+ # DELTA: earlier_only = raised only at the earlier head (not by the delta reviewers, not re-raised on the same file within 5 lines).
391
+ EARLIER_ONLY=false
392
+ if [ -n "$EARLIER_SHA" ] && ! printf '%s\n' "$PRIMARY_FINDINGS" | grep -qxF -- "${F_ID}|${F_CONF}|${F_SEV}|${F_LOC}|${F_TITLE}" \
393
+ && ! printf '%s\n' "$PRIMARY_FINDINGS" | awk -F'|' -v f="$F_FILE" -v l="${L1:-0}" '{ loc = $4; i = match(loc, /:[0-9]+(-[0-9]+)?$/)
394
+ if (i) { file = substr(loc, 1, i - 1); split(substr(loc, i + 1), b, "-"); st = b[1] + 0 } else { file = loc; st = 0 }
395
+ if (file == f && (l + 0 == 0 || st == 0 || (st >= l - 5 && st <= l + 5))) found = 1 } END { exit !found }'; then
396
+ EARLIER_ONLY=true
397
+ fi
388
398
  jq -n -c --arg id "$F_ID" --arg c "$F_CONF" --arg s "$F_SEV" --arg loc "$F_LOC" --arg file "$F_FILE" --arg l1 "$L1" --arg l2 "$L2" \
389
- --arg title "$F_TITLE" --arg agent "$AGENT" --arg scen "$SCENARIO" --arg fe "$FIX_EFF" --rawfile text "$TEXT_FILE" \
399
+ --arg title "$F_TITLE" --arg agent "$AGENT" --arg scen "$SCENARIO" --arg fe "$FIX_EFF" --rawfile text "$TEXT_FILE" --argjson eo "$EARLIER_ONLY" \
390
400
  '{id: $id, confidence: $c, severity: $s, loc: $loc, file: $file, line_start: ($l1 | tonumber? // null), line_end: ($l2 | tonumber? // null),
391
- title: $title, agent: $agent, scenario: $scen, fix_efficacy: $fe, text: $text}' >> "$RAW"
401
+ title: $title, agent: $agent, scenario: $scen, fix_efficacy: $fe, text: $text} + (if $eo then {earlier_only: true} else {} end)' >> "$RAW"
392
402
  done <<EOF
393
403
  $FINDINGS
394
404
  EOF
@@ -934,6 +944,21 @@ do_file_prepare() {
934
944
  }
935
945
 
936
946
  OUT_ITEMS="$WORKDIR/items.jsonl"; : > "$OUT_ITEMS"
947
+ # forge#5081: DELTA: the old-side line ranges (file, start, count) the delta rewrote or deleted; an earlier-only finding inside one was fixed there.
948
+ DELTA_OLD_RANGES="$WORKDIR/delta-old-ranges"; : > "$DELTA_OLD_RANGES"
949
+ if [ "$REVIEW_MODE" = "DELTA" ] && is_sha "$DELTA_FROM" && is_sha "$REVIEW_SHA"; then
950
+ _dtop=$(git rev-parse --show-toplevel 2>/dev/null) || _dtop=""
951
+ if [ -n "$_dtop" ]; then
952
+ { git -C "$_dtop" diff -U0 --no-color --no-renames "$DELTA_FROM" "$REVIEW_SHA" 2>/dev/null || true; } | while IFS= read -r _l; do
953
+ case "$_l" in
954
+ "--- a/"*) _f="${_l#--- a/}" ;;
955
+ "@@ "*) _h="${_l#@@ -}"; _o="${_h%% *}"
956
+ case "$_o" in *,*) _oc="${_o#*,}" ;; *) _oc=1 ;; esac
957
+ printf '%s\t%s\t%s\n' "$_f" "${_o%%,*}" "$_oc" ;;
958
+ esac
959
+ done > "$DELTA_OLD_RANGES" || : > "$DELTA_OLD_RANGES"
960
+ fi
961
+ fi
937
962
  TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0; N_FILE=0
938
963
  while [ "$I" -lt "$TOTAL" ]; do
939
964
  F_ROUTE=$(fj .route); F_CLASS=$(fj .class)
@@ -954,7 +979,13 @@ do_file_prepare() {
954
979
  else
955
980
  DEDUP_RESULT=""; DEDUP_EXIT=0
956
981
  fi
957
- if [ "$DEDUP_EXIT" -eq 1 ]; then
982
+ F_EARLIER_ONLY=$(fj .earlier_only)
983
+ if [ "$F_EARLIER_ONLY" = "true" ] && [ "$F_LINE" -gt 0 ] && [ -s "$DELTA_OLD_RANGES" ] \
984
+ && awk -F'\t' -v f="$F_FILE" -v l="$F_LINE" '$1 == f && $3 + 0 > 0 && l >= $2 + 0 && l <= $2 + $3 - 1 { found = 1 } END { exit !found }' "$DELTA_OLD_RANGES"; then
985
+ # The delta rewrote the cited lines and its reviewers did not re-raise the finding: fixed in the delta, never re-filed.
986
+ ACTION="skip-fixed-in-delta"; NOTE="fixed in delta ${DELTA_FROM:0:7}..${REVIEW_SHA:0:7}"
987
+ echo "FIXED-IN-DELTA: ${F_FILE}:${F_LINE} was changed by the delta and not re-raised"
988
+ elif [ "$DEDUP_EXIT" -eq 1 ]; then
958
989
  ACTION="skip-dedup"; NOTE="$DEDUP_RESULT"; DEDUP_OF=$(printf '%s' "$DEDUP_RESULT" | sed -n 's/[^0-9]*\([0-9][0-9]*\).*/\1/p' | head -1)
959
990
  echo "DEDUP: Skipping — $DEDUP_RESULT"
960
991
  elif [ "$DEDUP_EXIT" -eq 2 ]; then
@@ -772,6 +772,16 @@ mkfind K-4 CONFIRMED HIGH c.py 5 "Renamed defect"; putfind 1
772
772
  jq '.findings |= map(. + {class: "ISSUE", disposition: "ISSUE x"})' "$WORK/out/findings.json" > "$WORK/out/classified.json"
773
773
  run "$GOOD" file-prepare
774
774
  want "delta mode: Source line marks own closed issue" '.items[0] | [.action, (.dedup_of | tostring)] | join(",")' worklist.json "skip-resolved,63"
775
+ # forge#5081: a finding raised only at the earlier head whose cited lines the delta rewrote is fixed in the delta, never re-filed
776
+ echo "changed" > "$REPO_DIR/c.py"; git -C "$REPO_DIR" commit -qam delta; SHA2=$(git -C "$REPO_DIR" rev-parse HEAD); git -C "$REPO_DIR" reset -q --hard "$SHA"
777
+ fp_setup; SHA_OVERRIDE="$SHA2" mkctx 1 DELTA "$SHA" 12
778
+ echo '[]' > "$FIX/issues-open.json"; echo '[]' > "$FIX/issues-closed.json"
779
+ mkfind K-5 CONFIRMED HIGH c.py 5 "Earlier-only defect"; mkfind K-6 CONFIRMED HIGH c.py 5 "Re-raised defect"; putfind 2
780
+ jq '.findings |= map(. + {class: "ISSUE", disposition: "ISSUE x"} + (if .id == "K-5" then {earlier_only: true} else {} end))' "$WORK/out/findings.json" > "$WORK/out/classified.json"
781
+ run "$GOOD" file-prepare
782
+ want "delta mode: earlier-only finding on rewritten lines is fixed in delta" '.items[] | select(.id == "K-5") | .action' worklist.json "skip-fixed-in-delta"
783
+ want "delta mode: a finding the delta reviewers re-raised is still filed" '.items[] | select(.id == "K-6") | .action' worklist.json "file"
784
+ SHA_OVERRIDE=""
775
785
  # the dedup helper receives the title without the shared suffix
776
786
  DEDUP_LOG="$WORK/dedup-args"; : > "$DEDUP_LOG"
777
787
  printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$1" >> "%s"\nexit 0\n' "$DEDUP_LOG" > "$GOOD/scripts/issue-dedup.sh"
@@ -498,15 +498,15 @@ if [ -n "$DELTA_FROM_OPT" ]; then
498
498
  [ -n "$_dl" ] || _dl="[]"
499
499
  fi
500
500
  if [ -n "$TRUSTED_SCRIPT" ] && [ -n "$COMMENTS_JSON" ]; then
501
- _db=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->|^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->|# PR Review Summary|^<!-- FORGE:GATE_(PASS|FAILURE) -->|<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity|<!-- FORGE:REVIEW_DEGRADED|<!-- FORGE:REVIEW_BLOCKED' 2>/dev/null) \
501
+ _db=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies '^(?:[^\n]*\r?\n){0,4}<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->|^<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->|# PR Review Summary|^<!-- FORGE:GATE_(PASS|FAILURE) -->|<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity|<!-- FORGE:REVIEW_DEGRADED|<!-- FORGE:REVIEW_BLOCKED' 2>/dev/null) \
502
502
  && _dv=$(printf '%s\n' "$_db" | jq -s -r --arg f "$DELTA_FROM_OPT" --argjson L "$_dl" '
503
503
  def shas($t; $re): [ $t | scan($re) | .[0] ];
504
504
  def one($a): if ($a | length) == 1 then $a[0] else null end;
505
505
  def rsha: "(?:^|\\n)Reviewed-SHA: ([0-9a-f]{40}|[0-9a-f]{64})\\r?(?:\\n|$)";
506
506
  to_entries as $E
507
- | [ $E[] | select((.value | test("\\A<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->")) and (.value | test("<!-- REVIEW-FINDINGS-START -->")))
507
+ | [ $E[] | select((.value | test("\\A(?:[^\\n]*\\r?\\n){0,4}<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->")) and (.value | test("<!-- REVIEW-FINDINGS-START -->")))
508
508
  | select(one(shas(.value; rsha)) == $f)
509
- | {i: .key, d: (.value | capture("\\A<!-- FORGE:REVIEW-AGENT:(?<d>[a-z0-9-]+) -->").d)} ] as $AG
509
+ | {i: .key, d: (.value | capture("\\A(?:[^\\n]*\\r?\\n){0,4}<!-- FORGE:REVIEW-AGENT:(?<d>[a-z0-9-]+) -->").d)} ] as $AG
510
510
  | if ($AG | length) == 0 then "no-agent" else
511
511
  ($AG | map(.i) | min) as $first | ($AG | map(.d) | unique | length) as $nd
512
512
  | ([ $E[] | .value as $t | select(
@@ -526,7 +526,19 @@ if [ -n "$DELTA_FROM_OPT" ]; then
526
526
  case "$_dv" in
527
527
  ok) REVIEW_MODE="DELTA"; DELTA_FROM="$DELTA_FROM_OPT" ;;
528
528
  no-agent) block "--delta-from ${DELTA_FROM_OPT}: no trusted FORGE:REVIEW-AGENT comment with a findings block for that head, no earlier pass to credit: run a full review" ;;
529
- no-proof) block "--delta-from ${DELTA_FROM_OPT}: no trusted proof that the earlier panel completed (staging summary, synthesized block or PR Review Summary for that head, nor every launched domain delivered at it): run a full review" ;;
529
+ no-proof)
530
+ # forge#5081: name what lacked a valid delivery (launched domains with none, comments whose marker or findings block is malformed)
531
+ _hint=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" bodies '<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->' 2>/dev/null \
532
+ | jq -s -r --arg f "$DELTA_FROM_OPT" --argjson L "$_dl" '
533
+ def vstart: test("\\A(?:[^\\n]*\\r?\\n){0,4}<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->");
534
+ [ .[] | {d: capture("<!-- FORGE:REVIEW-AGENT:(?<d>[a-z0-9-]+) -->").d, start: vstart, blk: test("<!-- REVIEW-FINDINGS-START -->"), at: test("Reviewed-SHA: " + $f)}
535
+ | .ok = (.start and .blk and .at) ] as $A
536
+ | ([ $A[] | select(.ok) | .d ] | unique) as $ok
537
+ | [ $L[] | select(. as $d | $ok | index($d) | not) ] as $miss
538
+ | [ $A[] | select(.at and (.ok | not)) | "\(.d) (" + (if .start | not then "REVIEW-AGENT marker not within the first 5 lines" elif .blk | not then "no findings block" else "no usable Reviewed-SHA" end) + ")" ] as $mal
539
+ | (if ($miss | length) > 0 then "; launched domains without a valid delivery at that head: " + ($miss | join(", ")) else "" end)
540
+ + (if ($mal | length) > 0 then "; malformed reviewer comment(s): " + ($mal | join(", ")) else "" end)' 2>/dev/null) || _hint=""
541
+ block "--delta-from ${DELTA_FROM_OPT}: no trusted proof that the earlier panel completed (staging summary, synthesized block or PR Review Summary for that head, nor every launched domain delivered at it)${_hint}: run a full review" ;;
530
542
  degraded) block "--delta-from ${DELTA_FROM_OPT}: the earlier pass is degraded (review-panel-integrity / REVIEW_DEGRADED / REVIEW_BLOCKED after its agents): run a full review" ;;
531
543
  *) block "--delta-from ${DELTA_FROM_OPT}: could not read the trusted review comments, no earlier pass to credit: run a full review" ;;
532
544
  esac
@@ -250,6 +250,14 @@ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "untrusted ear
250
250
  eq "untrusted earlier pass blocker" '.blockers | join(" ") | test("no earlier pass to credit")' true
251
251
  earlier_pass "$SHA_B"
252
252
  prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "no earlier pass at the from sha" 3
253
+ # forge#5081: a reviewer body that opens with a heading before the marker still counts as delivered
254
+ { cmt "$(printf '## Security review\n\n%s' "$(agent_body "$SHA_A")")"; cmt "$(staging_summary "$SHA_A")"; } | comments_of
255
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "marker not on line 1 is accepted" 0; eq "marker-not-on-line-1 mode" .delta.mode DELTA
256
+ # ...and a malformed comment (marker buried past the first lines) is named in the blocker
257
+ late_body() { printf 'a\nb\nc\nd\ne\nf\n<!-- FORGE:REVIEW-AGENT:frontend -->\nReviewed-SHA: %s\n<!-- REVIEW-FINDINGS-START -->\n<!-- REVIEW-FINDINGS-END -->' "$1"; }
258
+ { cmt "$(agent_body "$SHA_A")"; cmt "$(late_body "$SHA_A")"; } | comments_of
259
+ prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_A"; rc_is "no proof with a malformed reviewer comment" 3
260
+ eq "blocker names the malformed comment" '.blockers | join(" ") | test("frontend \\(REVIEW-AGENT marker not within the first 5 lines\\)")' true
253
261
  earlier_pass "$SHA_D"
254
262
  prep "$WORK/root-nodelta" --no-delta --delta-from "$SHA_D"; rc_is "non-ancestor from sha" 3
255
263
  eq "non-ancestor blocker" '.blockers | join(" ") | test("not an ancestor")' true
@@ -28,7 +28,7 @@
28
28
  # exact value EXTENDED is STANDARD. An extended roster prints an extra DEPTH=extended line.
29
29
  # Path triggers escalate in EVERY tier except full (which already runs the union): CONTRACT HIGH_RISK, cross-critical
30
30
  # pairs, churn hot-spot, danger zone, bin/engine* (Concurrency + spec-cli), bin/ (spec-cli), auth/session/trust-filter
31
- # files (Auth: auth*, not author*; oauth, jwt, login, password, permission, rbac, acl, token(s), api_key(s), credential, deps.py), CI/docker/deploy/infra files (Infra), migration/.sql files (Database), SDK/OpenAPI files (API).
31
+ # files (Auth: auth*, not author*; oauth, jwt, login, password, permission, rbac, acl, token(s), api_key(s), credential, deps.py), CI/docker/deploy/infra files (Infra), migration/.sql files (Database), SDK/OpenAPI files (API). web/app dirs, .tsx/.jsx, next.config.*, middleware.ts (Frontend; forge#5077).
32
32
  # Auth and Infra are path-gated: a diff-text score alone never selects them. Security is a hard floor in every tier.
33
33
  # spec-cli is added only with --spec-cli true (the persona file exists in the repo under review); in the standard tier it
34
34
  # also covers commands/**. Bash 3.2 portable (no mapfile, associative arrays, grep -P).
@@ -198,8 +198,10 @@ if printf '%s\n' "$AUTH_FILES" | grep -qiE '(^|[/_.-])(auth([^o]|o[^r]|$)|author
198
198
  add_agent Auth; trig auth-path
199
199
  fi
200
200
  hit '^\.github/|(^|/)Dockerfile|docker-compose|(^|/)(docker-)?compose[^/]*\.ya?ml$|(^|/)deploy[^/]*\.sh$|(^|/)docker-entrypoint[^/]*$|(^|/)(deploy|infra|traefik|nginx|k8s|helm|terraform)/|\.tf$' && { add_agent Infra; trig infra-path; }
201
+ hit '(^|/)app/(.*/)?route\.[cm]?[jt]sx?$|(^|/)pages/api/' && { add_agent Infra; trig route-handler; } # forge#5087: a new HTTP route needs its ingress confirmed
201
202
  hit 'migration|\.sql$' && { add_agent Database; trig migration-path; }
202
203
  hit '^sdk/|openapi.*\.json$|openapi-versions/' && { add_agent API; trig sdk-path; }
204
+ hit '(^|/)(web|app)/|\.(tsx|jsx)$|(^|/)next\.config\.[a-z]+$|(^|/)middleware\.(ts|js)$' && { add_agent Frontend; trig frontend-path; }
203
205
  hit '^bin/engine' && { add_agent Concurrency; trig bin-engine; }
204
206
  if [ "$SPEC_CLI" = "true" ]; then
205
207
  hit '^bin/' && { add_agent spec-cli; trig bin; }
@@ -164,6 +164,15 @@ run "$(printf 'README.md\nscripts/deploy.sh')" --scores "$SC_QUIET" --changed-li
164
164
  expect_has "infra-path-multi-file-list" infra
165
165
  run "Scripts/Deploy.SH" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
166
166
  expect_has "infra-path-case-insensitive" infra
167
+ # forge#5087: a route-handler diff pulls the Infra reviewer even when no infra file changed
168
+ for f in "web/src/app/(site)/api/v1/agent/signup/pending/route.ts" "web/src/pages/api/health.ts" "app/route.js"; do
169
+ run "$f" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
170
+ expect_has "route-handler-$f" infra
171
+ done
172
+ for f in web/src/app/page.tsx web/src/app/dashboard/layout.tsx web/src/lib/route.ts; do
173
+ run "$f" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
174
+ expect_not "no-route-handler-$f" infra
175
+ done
167
176
  run "db/migrations/0042_add.sql" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
168
177
  expect_has "migration path" database
169
178
  run "sdk/node/src/index.ts" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
@@ -182,8 +191,8 @@ expect_has "billing score in medium tier" billing; expect_has "billing score in
182
191
  run "web/src/app/a.tsx
183
192
  web/src/b.tsx" --scores "FRONTEND=3 API=2 BILLING=1 DATABASE=1" --changed-lines 300
184
193
  expect_tier "standard" standard; expect_has "standard" frontend; expect_has "standard" api; expect_not "standard top-2 only" billing
185
- run "web/src/app/a.tsx
186
- web/src/b.tsx" --scores "FRONTEND=3 API=2" --changed-lines 100
194
+ run "svc/a.py
195
+ svc/b.py" --scores "FRONTEND=3 API=2" --changed-lines 100
187
196
  expect_sel "medium has no baseline" "security"
188
197
  run "svc/x.py" --scores "FRONTEND=3" --changed-lines ""
189
198
  expect_tier "unknown size" standard; expect_has "unknown size" frontend
@@ -250,5 +259,24 @@ expect_tier "EXTENDED lifts fix-pr to standard" standard
250
259
  run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --thorough true --review-depth EXTENDED
251
260
  expect_tier "full stays full" full
252
261
 
262
+ # forge#5077: Frontend path trigger escalates in every tier except full; a bundle selects every path-triggered domain
263
+ for n in 30 100 300; do
264
+ run "web/src/app/(site)/not-found.tsx" --scores "$SC_QUIET" --changed-lines "$n" --changed-files 1
265
+ expect_has "frontend path trigger at $n lines" frontend
266
+ done
267
+ for f in next.config.mjs middleware.ts app/page.jsx; do
268
+ run "$f" --scores "$SC_QUIET" --changed-lines 30 --changed-files 1; expect_has "frontend path $f" frontend
269
+ done
270
+ run "web/src/app/(site)/not-found.tsx
271
+ svc/auth/login.py
272
+ .github/workflows/ci.yml
273
+ db/migrations/001.sql
274
+ sdk/client.py" --scores "FRONTEND=0 API=0 BILLING=2 DATABASE=2" --changed-lines 700000 --changed-files 5
275
+ expect_tier "staging bundle" standard
276
+ for d in frontend auth infra database api; do expect_has "bundle union" "$d"; done
277
+ run "docs/web-notes.md
278
+ svc/a.py" --scores "$SC_QUIET" --changed-lines 30 --changed-files 2
279
+ expect_not "no frontend path" frontend
280
+
253
281
  echo "review-roster: $pass passed, $fail failed"
254
282
  [ "$fail" -eq 0 ]
@@ -72,7 +72,7 @@ gh_retry() {
72
72
  # One review-finding search fetch; fails (so gh_retry retries) on a gh error or non-array output.
73
73
  fetch_findings() {
74
74
  local raw
75
- raw=$(gh issue list -R "$GH_REPO" --label "review-finding" --state open --search "PR #${1}" --limit 20 --json number,title,body 2>/dev/null) || return 1
75
+ raw=$(gh issue list -R "$GH_REPO" --label "review-finding" --state open --search "PR #${1}" --limit 20 --json number,title,body,labels 2>/dev/null) || return 1
76
76
  printf '%s' "$raw" | jq -e 'type == "array"' >/dev/null 2>&1 || return 1
77
77
  printf '%s' "$raw"
78
78
  }
@@ -156,7 +156,9 @@ for pr_num in $ALL_PR_NUMBERS; do
156
156
  # Search for open review-finding issues that reference this PR. The search is only a coarse
157
157
  # candidate fetch (full-text, tokenized: it also hits hidden markers and longer numbers), so
158
158
  # narrow it with the anchored FINDING_PR_FILTER: title suffix `PR #N)` or a `**Source**: PR #N` line.
159
- FINDING_PR_FILTER='[.[] | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
159
+ # Only a blocking finding gates: labelled `blocking`, priority:P0/P1, or carrying no priority:* label (unknown severity fails closed).
160
+ # A priority:P2/P3 follow-up (NOTE-class, POSSIBLE, over review.max_filed_findings) never blocks the bundle.
161
+ FINDING_PR_FILTER='[.[] | select(([.labels[]?.name] | (index("blocking") != null) or (index("priority:P0") != null) or (index("priority:P1") != null) or (map(select(startswith("priority:"))) | length == 0))) | select((.title | endswith("PR #" + $n + ")")) or ((.body // "") | test("(^|\\n)\\*\\*Source\\*\\*: PR #" + $n + "([^0-9]|$)")))] | .[] | " - #\(.number): \(.title)"' # FINDING_PR_FILTER
160
162
  OPEN_FINDINGS=""
161
163
  if _RAW=$(gh_retry --search fetch_findings "$pr_num"); then
162
164
  OPEN_FINDINGS=$(printf '%s' "$_RAW" | jq -r --arg n "${pr_num}" "$FINDING_PR_FILTER" 2>/dev/null) || READ_FAILURES="$READ_FAILURES #${pr_num}(findings)" # uses FINDING_PR_FILTER
@@ -177,7 +179,7 @@ if [ -n "$READ_FAILURES" ]; then
177
179
  echo "Re-run the gate once GitHub reads succeed."
178
180
  echo "RESULT: BLOCK DEPLOY"
179
181
  if [ -n "$PR_NUMBER" ]; then
180
- gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
182
+ gh_retry gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
181
183
  ## Deploy Gate: BLOCKED
182
184
 
183
185
  **Gate**: open-review-finding (read failure)
@@ -195,7 +197,7 @@ if [ -n "$DEGRADED_REVIEWS" ]; then
195
197
  echo "⛔ DEPLOY BLOCKED — An included PR has an incomplete isolated review panel."
196
198
  echo "$DEGRADED_REVIEWS"
197
199
  if [ -n "$PR_NUMBER" ]; then
198
- gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
200
+ gh_retry gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
199
201
  ## Deploy Gate: BLOCKED
200
202
 
201
203
  **Gate**: review-panel-integrity
@@ -245,7 +247,7 @@ if [ -n "$BLOCKING_FINDINGS" ]; then
245
247
  FINDING_COUNT=$(echo "$BLOCKING_FINDINGS" | grep -c '^\s*- #' || echo "unknown")
246
248
  GATE_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
247
249
  if [ -n "$PR_NUMBER" ]; then
248
- gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
250
+ gh_retry gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_FAILURE -->
249
251
  ## Deploy Gate: BLOCKED
250
252
 
251
253
  **Gate**: open-review-finding
@@ -274,7 +276,7 @@ else
274
276
  echo "✅ Open review-finding gate: PASSED — no open findings for PRs in this bundle."
275
277
  # Post FORGE:GATE_PASS for symmetric observability — bypass is indistinguishable from clean pass without this
276
278
  if [ -n "$PR_NUMBER" ]; then
277
- gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_PASS -->
279
+ gh_retry gh pr comment "$PR_NUMBER" -R $GH_REPO --body "<!-- FORGE:GATE_PASS -->
278
280
  ## Deploy Gate: PASSED
279
281
 
280
282
  **Gate**: open-review-finding check
@@ -39,7 +39,7 @@ case "$a" in "pr view"*"--json labels"*) [ -f "$F/fail-labels" ] && exit 1 ;; "i
39
39
  flaky() { [ -f "$F/flaky-$1" ] || return 0; local c; c=$(cat "$F/count-$1" 2>/dev/null || echo 0); echo $((c+1)) > "$F/count-$1"; [ "$c" -lt "$(cat "$F/flaky-$1")" ] && exit 1; return 0; }
40
40
  case "$a" in "pr view"*"--json labels"*) flaky labels ;; "issue list"*) flaky issues ;; "api repos/o/r/issues/"*) case "$a" in *"/comments"*) ;; *) flaky kind ;; esac ;; esac
41
41
  case "$1 $2" in
42
- "pr comment") while [ $# -gt 0 ]; do [ "$1" = --body ] && { printf '%s\n@@\n' "$2" >> "$F/posted"; }; shift; done; exit 0 ;;
42
+ "pr comment") flaky comment; while [ $# -gt 0 ]; do [ "$1" = --body ] && { printf '%s\n@@\n' "$2" >> "$F/posted"; }; shift; done; exit 0 ;;
43
43
  "pr diff") [ -f "$F/files-$3" ] && { cat "$F/files-$3"; exit 0; }; exit 1 ;;
44
44
  "pr view")
45
45
  case "$a" in *"--json labels"*) [ -f "$F/degraded-$3" ] && echo true || echo false; exit 0 ;; esac
@@ -99,6 +99,16 @@ case "$OUT" in *"RESULT: BLOCK DEPLOY"*) ok ;; *) bad "blocked gate must print R
99
99
  grep -q 'FORGE:GATE_FAILURE:TYPE=open-review-finding|FINDINGS=1' "$FIX/posted" && ok || bad "blocked gate must post the GATE_FAILURE marker with FINDINGS=1"
100
100
  newfix unanchored; jq -n '[{number:41,title:"bug (PR #55)",body:"x"}]' > "$FIX/findings-5.json"
101
101
  run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 0 ] && ok || bad "a finding for another PR (#55) must not block #5"
102
+ # #5081: only blocking findings gate; a priority:P2/P3 follow-up does not, a priority:P1 or `blocking` one does
103
+ newfix nonblocking; jq -n '[{number:41,title:"nit (PR #5)",body:"x",labels:[{name:"review-finding"},{name:"priority:P3"}]}]' > "$FIX/findings-5.json"
104
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 0 ] && ok || bad "a priority:P3 review-finding must not block"
105
+ newfix blockp1; jq -n '[{number:41,title:"bug (PR #5)",body:"x",labels:[{name:"review-finding"},{name:"priority:P1"}]}]' > "$FIX/findings-5.json"
106
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 1 ] && ok || bad "a priority:P1 review-finding must block"
107
+ newfix blocklabel; jq -n '[{number:41,title:"bug (PR #5)",body:"x",labels:[{name:"review-finding"},{name:"priority:P3"},{name:"blocking"}]}]' > "$FIX/findings-5.json"
108
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; [ $? -eq 1 ] && ok || bad "a finding labelled blocking must block"
109
+ # #5081: a transient gh pr comment failure is retried, not dropped
110
+ newfix commentflaky; echo 2 > "$FIX/flaky-comment"
111
+ run gate --repo o/r --pr 9 --prs-file "$TMP/prs" >/dev/null 2>&1; grep -qx '<!-- FORGE:GATE_PASS:TYPE=open-review-finding -->' "$FIX/posted" && ok || bad "a transient gh pr comment failure must be retried until the GATE_PASS lands"
102
112
  newfix override; jq -n '[{number:41,title:"bug (PR #5)",body:"x"}]' > "$FIX/findings-5.json"
103
113
  jq -n '[{body:"OVERRIDE: shipping with open findings — known",author_association:"OWNER",user:{type:"User",login:"o"}}]' > "$FIX/comments-9.json"
104
114
  OUT=$(run gate --repo o/r --pr 9 --prs-file "$TMP/prs" 2>&1); RC=$?