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
|
@@ -18,15 +18,16 @@
|
|
|
18
18
|
# file-verify 6C post-filing parse: reads the issue-creation result text on stdin, prints ISSUE_NUM=N, exits 1 when no number.
|
|
19
19
|
# verdict 7A gate log + 7B base-conflict computation; posts the verdict comment when --result stale|clean|blocking is given.
|
|
20
20
|
# calibrate 7B.5 calibration-table consultation (can only tighten); records FORGE:CALIBRATION_CHECK on the linked issue.
|
|
21
|
-
# merge-gate Phase 8: note-disposition, in-PR fix, --issue, phase-trail, guard-state, base-conflict / calibration / trust routing,
|
|
21
|
+
# merge-gate Phase 8: note-disposition, in-PR fix, --issue, phase-trail, degraded-gate review, guard-state, base-conflict / calibration / trust routing,
|
|
22
22
|
# pre-merge mergeability, previously-escalated parking, CI gate, reviewed-head guard, merge. Never skips a gate.
|
|
23
23
|
# demilestone 8B / 9A: clears the milestone from open review-finding issues of a merged milestone PR.
|
|
24
24
|
# integrity Phase 9: stale-head check and the verifiable trusted REVIEW-AGENT count (carry-aware).
|
|
25
25
|
#
|
|
26
26
|
# Boundary: the filing call itself (the /issue skill with its programmatic contract) and the LLM-authored finding body stay in the
|
|
27
27
|
# spec: a shell script cannot invoke a skill. The script ends at the work list (file-prepare) and resumes at file-verify.
|
|
28
|
-
#
|
|
29
|
-
#
|
|
28
|
+
# `extract` never reads free text: findings come only from `<!-- FINDING:... -->` markers. A trusted current-head agent comment
|
|
29
|
+
# without a REVIEW-FINDINGS-START/END block is listed in AGENTS_WITHOUT_BLOCK (undelivered) and withholds CLEAN_STRUCTURED.
|
|
30
|
+
# Also left to the caller: INTEG-N findings from Phase 2.5, pattern-class consolidation issue creation, project-board add, 6D, and the choice of which NOTEs are fixed or dropped
|
|
30
31
|
# (pass --notes-fixed / --notes-dropped; every other plain NOTE is counted as listed).
|
|
31
32
|
#
|
|
32
33
|
# Options:
|
|
@@ -48,17 +49,22 @@
|
|
|
48
49
|
# contract.issue is ignored (no single fixing issue, lineage none) and the disposition record carries out_of_scope= and scope=
|
|
49
50
|
# Phase 7-9 subcommands: --context FILE is optional; without it pass --review-sha SHA, --review-mode FULL|DELTA|BASE_SYNC_ONLY, --issue N, --base B.
|
|
50
51
|
# verdict: --merge-health S --merge-health-state S [--base B] [--head-branch H] [--is-milestone true|false] [--has-purpose-regression true|false]
|
|
51
|
-
# [--purpose-findings TEXT] [--result stale|clean|blocking] [--agents TEXT] [--findings-count N] [--blocking-count N]
|
|
52
|
+
# [--purpose-findings-file F | --purpose-findings TEXT] [--result stale|clean|blocking] [--agents-file F | --agents TEXT] [--findings-count N] [--blocking-count N]
|
|
52
53
|
# [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--dry-run]
|
|
53
54
|
# calibrate: [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--trust-needs-human B] [--dry-run]; the issue is context contract.issue or --issue N
|
|
54
55
|
# merge-gate: --verdict "APPROVED|CHANGES REQUESTED" --has-purpose-regression B --calibration-needs-human B --trust-needs-human B
|
|
55
|
-
# --base-conflict B --other-blocking B [--calibration-note T] [--table-cell-key K] [--base B] [--issue N]
|
|
56
|
+
# --base-conflict B --other-blocking B [--calibration-note-file F | --calibration-note T] [--table-cell-key K] [--base B] [--issue N]
|
|
56
57
|
# (every B is true|false; an empty or other value aborts the merge: the guard state must be bound)
|
|
58
|
+
# Free text (purpose findings, agents, calibration note) derives from PR content: pass it through the *-file flags (a file written with the
|
|
59
|
+
# file-write tool, read here as data: control characters stripped, length capped, unreadable file = empty). The inline TEXT flags are for
|
|
60
|
+
# trusted callers only; the file flag wins when both are given. Never place free text in a shell word.
|
|
57
61
|
# demilestone: --is-milestone true|false [--merge-state S] (8B passes MERGED; 9A omits it and the state is read from the PR)
|
|
58
62
|
# Env: FORGE_CONFIG (forge.yaml for review.max_filed_findings; default ./forge.yaml).
|
|
59
63
|
# Exit: 0 ok | 3 BLOCKED (REVIEW_RESULT: status: BLOCKED printed; the caller must stop the review, never skip) | 4 merge-gate only:
|
|
60
64
|
# REVIEW_RESULT: status: PHASE_TRAIL_FAILED printed, followed by the verifier's MISSING lines (re-dispatch is owned by work-on/review.md R4)
|
|
61
65
|
# | 1 file-verify found no issue number | 2 usage error
|
|
66
|
+
# merge-gate: `blocked degraded-gate-unreviewed` (exit 3, needs-human added) when the linked issue's newest trusted FORGE:QUALITY_GATE `**Degraded**:` line or
|
|
67
|
+
# FORGE:ACCEPTANCE_GATE:DEGRADED marker lists checks that no trusted `### Degraded Paths Reviewed` PR section names (forge#3628).
|
|
62
68
|
# merge-gate reports every non-merging routed outcome as MERGE_OUTCOME=<merged|merge-failed|parked-awaiting-merge|base-conflict|needs-human|
|
|
63
69
|
# protection-blocked|stale-review|ci-not-green>; everything except merged / merge-failed / parked-awaiting-merge also exits 3 with a blocker.
|
|
64
70
|
#
|
|
@@ -76,9 +82,10 @@ PR_NUMBER=""; REPO=""; CONTEXT=""; OUT_DIR=""; IN_FILE=""; OUT_FILE=""; AUTO_MER
|
|
|
76
82
|
REVIEW_SHA_OPT=""; REVIEW_MODE_OPT=""; BASE_OPT=""; HEAD_BRANCH=""; ISSUE_OPT=""; VERDICT=""; HAS_PURPOSE_REGRESSION=""; CALIBRATION_NEEDS_HUMAN=""; CALIBRATION_NOTE=""; TRUST_NEEDS_HUMAN=""
|
|
77
83
|
BASE_CONFLICT=""; OTHER_BLOCKING=""; INTENSITY_TIER=""; TABLE_CELL_KEY=""; TRUST_SHADOW_MODE=""; MERGE_HEALTH=""; MERGE_HEALTH_STATE=""; RESULT=""
|
|
78
84
|
AGENTS_TEXT=""; FINDINGS_COUNT=""; BLOCKING_COUNT=""; PURPOSE_FINDINGS=""; IS_MILESTONE="false"; MERGE_STATE_IN=""
|
|
85
|
+
CALIBRATION_NOTE_FILE=""; AGENTS_FILE=""; PURPOSE_FINDINGS_FILE=""
|
|
79
86
|
while [ $# -gt 0 ]; do
|
|
80
87
|
case "$1" in
|
|
81
|
-
--pr|--repo|--context|--out-dir|--findings|--out|--auto-merge|--notes-fixed|--notes-dropped|--patterns|--base|--head-branch|--issue|--verdict|--has-purpose-regression|--calibration-needs-human|--calibration-note|--trust-needs-human|--base-conflict|--other-blocking|--intensity-tier|--table-cell-key|--trust-shadow-mode|--merge-health|--merge-health-state|--result|--agents|--findings-count|--blocking-count|--purpose-findings|--is-milestone|--merge-state|--review-sha|--review-mode|--scope-files|--prior-prs)
|
|
88
|
+
--pr|--repo|--context|--out-dir|--findings|--out|--auto-merge|--notes-fixed|--notes-dropped|--patterns|--base|--head-branch|--issue|--verdict|--has-purpose-regression|--calibration-needs-human|--calibration-note|--calibration-note-file|--agents-file|--purpose-findings-file|--trust-needs-human|--base-conflict|--other-blocking|--intensity-tier|--table-cell-key|--trust-shadow-mode|--merge-health|--merge-health-state|--result|--agents|--findings-count|--blocking-count|--purpose-findings|--is-milestone|--merge-state|--review-sha|--review-mode|--scope-files|--prior-prs)
|
|
82
89
|
[ $# -ge 2 ] || { echo "review-finalize: $1 requires a value" >&2; exit 2; } ;;
|
|
83
90
|
esac
|
|
84
91
|
case "$1" in
|
|
@@ -101,6 +108,9 @@ while [ $# -gt 0 ]; do
|
|
|
101
108
|
--has-purpose-regression) HAS_PURPOSE_REGRESSION="${2:-}"; shift 2 ;;
|
|
102
109
|
--calibration-needs-human) CALIBRATION_NEEDS_HUMAN="${2:-}"; shift 2 ;;
|
|
103
110
|
--calibration-note) CALIBRATION_NOTE="${2:-}"; shift 2 ;;
|
|
111
|
+
--calibration-note-file) CALIBRATION_NOTE_FILE="${2:-}"; shift 2 ;;
|
|
112
|
+
--agents-file) AGENTS_FILE="${2:-}"; shift 2 ;;
|
|
113
|
+
--purpose-findings-file) PURPOSE_FINDINGS_FILE="${2:-}"; shift 2 ;;
|
|
104
114
|
--trust-needs-human) TRUST_NEEDS_HUMAN="${2:-}"; shift 2 ;;
|
|
105
115
|
--base-conflict) BASE_CONFLICT="${2:-}"; shift 2 ;;
|
|
106
116
|
--other-blocking) OTHER_BLOCKING="${2:-}"; shift 2 ;;
|
|
@@ -123,6 +133,16 @@ while [ $# -gt 0 ]; do
|
|
|
123
133
|
esac
|
|
124
134
|
done
|
|
125
135
|
|
|
136
|
+
# read_text_file FILE: print FILE as one line of data (#3918). Free text from PR content reaches this script through a file, never a shell word.
|
|
137
|
+
# Control characters are dropped, newlines fold to spaces, output is capped at 2000 bytes; an unreadable or empty path prints nothing.
|
|
138
|
+
read_text_file() {
|
|
139
|
+
[ -n "${1:-}" ] && [ -f "$1" ] && [ -r "$1" ] || return 0
|
|
140
|
+
head -c 2000 "$1" 2>/dev/null | tr '\r\n\t' ' ' | tr -d '\000-\037\177'
|
|
141
|
+
}
|
|
142
|
+
[ -z "$PURPOSE_FINDINGS_FILE" ] || PURPOSE_FINDINGS=$(read_text_file "$PURPOSE_FINDINGS_FILE")
|
|
143
|
+
[ -z "$AGENTS_FILE" ] || AGENTS_TEXT=$(read_text_file "$AGENTS_FILE")
|
|
144
|
+
[ -z "$CALIBRATION_NOTE_FILE" ] || CALIBRATION_NOTE=$(read_text_file "$CALIBRATION_NOTE_FILE")
|
|
145
|
+
|
|
126
146
|
# --- file-verify needs nothing else: it only parses the issue-creation result text on stdin ---
|
|
127
147
|
if [ "$SUB" = "file-verify" ]; then
|
|
128
148
|
ISSUE_SKILL_OUTPUT=$(cat)
|
|
@@ -252,12 +272,20 @@ do_extract() {
|
|
|
252
272
|
# The panel guard guarantees at least one trusted reviewer body for this head; none means a misread, never "clean".
|
|
253
273
|
[ -n "$SOURCE_BODIES" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: no trusted reviewer comments for head ${PRIMARY_SHA} (fail closed)"; exit 3; }
|
|
254
274
|
CARRIER_BODIES=$(agent_bodies_at "$PRIMARY_SHA")
|
|
275
|
+
CARRIER_BODIES_PRIMARY="$CARRIER_BODIES"
|
|
255
276
|
if [ -n "$EARLIER_SHA" ]; then
|
|
256
277
|
EARLIER_BODIES=$(bodies_at "$EARLIER_SHA") && [ -n "$EARLIER_BODIES" ] \
|
|
257
278
|
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: earlier reviewed head ${EARLIER_SHA} findings unreadable (fail closed)"; exit 3; }
|
|
258
279
|
SOURCE_BODIES=$(printf '%s\n%s' "$SOURCE_BODIES" "$EARLIER_BODIES")
|
|
259
280
|
CARRIER_BODIES=$(printf '%s\n%s' "$CARRIER_BODIES" "$(agent_bodies_at "$EARLIER_SHA")")
|
|
260
281
|
fi
|
|
282
|
+
# Panel integrity: a trusted agent comment for the primary head counts as delivered only with a findings block (it may be empty).
|
|
283
|
+
# Computed from the RAW agent bodies, never from bodies_at, so a synthesized block cannot mask a missing agent block.
|
|
284
|
+
# Per domain: a later (re-dispatched) comment with a complete block clears an earlier block-less one.
|
|
285
|
+
AGENTS_WITHOUT_BLOCK=$(printf '%s\n' "$CARRIER_BODIES_PRIMARY" | jq -rs '[.[] | select(type == "string")
|
|
286
|
+
| {d: (capture("^<!-- FORGE:REVIEW-AGENT:(?<d>[a-z-]+) -->").d // empty),
|
|
287
|
+
ok: (contains("<!-- REVIEW-FINDINGS-START -->") and contains("<!-- REVIEW-FINDINGS-END -->"))}]
|
|
288
|
+
| group_by(.d) | map(select(any(.[]; .ok) | not) | .[0].d) | .[]' 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//')
|
|
261
289
|
# Match to the closing ` -->`, never `[^>]+`: a `>` or `->` inside a title must not drop the finding (#3897).
|
|
262
290
|
FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null)
|
|
263
291
|
# Fail closed when a raw marker did not parse: a lost finding must never read as a clean review.
|
|
@@ -267,7 +295,8 @@ do_extract() {
|
|
|
267
295
|
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: ${N_RAW_MARKERS} FINDING markers but ${N_PARSED_MARKERS} parsed (malformed marker, fail closed)"; exit 3; }
|
|
268
296
|
|
|
269
297
|
# Carrier lookup order: raw agent bodies first (they carry the agent marker and the per-finding prose), then the source bodies.
|
|
270
|
-
printf '%s\n%s\n' "$CARRIER_BODIES" "$SOURCE_BODIES" | grep -v '^$' > "$WORKDIR/carriers.jsonl" || : > "$WORKDIR/carriers.jsonl"
|
|
298
|
+
printf '%s\n%s\n' "$CARRIER_BODIES" "$SOURCE_BODIES" | grep -v '^$' | awk '!seen[$0]++' > "$WORKDIR/carriers.jsonl" || : > "$WORKDIR/carriers.jsonl"
|
|
299
|
+
: > "$WORKDIR/seen-markers"
|
|
271
300
|
|
|
272
301
|
RAW="$WORKDIR/raw.jsonl"; : > "$RAW"; N_EXTRACTED=0
|
|
273
302
|
while IFS='|' read -r F_ID F_CONF F_SEV F_LOC F_TITLE; do
|
|
@@ -280,11 +309,24 @@ do_extract() {
|
|
|
280
309
|
case "$_r" in *[!0-9-]*|"") ;; *) F_FILE="${F_LOC%:*}"; L1="${_r%%-*}"; L2="${_r#*-}"; [ "$L2" != "$_r" ] || L2="$L1" ;; esac ;;
|
|
281
310
|
esac
|
|
282
311
|
case "$L1" in *[!0-9]*) L1=""; L2="" ;; esac; case "$L2" in *[!0-9]*) L2="$L1" ;; esac
|
|
283
|
-
# The carrying comment: the
|
|
312
|
+
# The carrying comment: bound by the finding's FULL marker line, not its id. A finding repeated across heads, slices or agents
|
|
313
|
+
# binds the k-th body holding that exact marker (k = how often this exact marker was already seen), so each keeps its own agent and
|
|
314
|
+
# prose. Falls back to the first body holding the id-prefix only when no body holds the exact line (reworded synthesis marker).
|
|
284
315
|
BODY_FILE="$WORKDIR/body.$N_EXTRACTED"
|
|
285
|
-
|
|
316
|
+
F_TITLE="${F_TITLE%$'\r'}" # a CRLF-ending parse (Windows jq) must not leak a CR into the exact marker
|
|
317
|
+
F_MARKER="<!-- FINDING:${F_ID}|${F_CONF}|${F_SEV}|${F_LOC}|${F_TITLE} -->"
|
|
318
|
+
SEEN_K=$(grep -cxF -- "$F_MARKER" "$WORKDIR/seen-markers" 2>/dev/null || true); case "$SEEN_K" in ''|*[!0-9]*) SEEN_K=0 ;; esac
|
|
319
|
+
printf '%s\n' "$F_MARKER" >> "$WORKDIR/seen-markers"
|
|
320
|
+
jq -rs --arg m "$F_MARKER" --argjson k "$SEEN_K" '[.[] | select(contains($m))] | (.[$k] // .[0]) // ""' "$WORKDIR/carriers.jsonl" > "$BODY_FILE" 2>/dev/null || : > "$BODY_FILE"
|
|
321
|
+
# Emptiness is judged on content, not size: jq writes a newline for the empty string, so -s is true for "no body". contains(), not index(): jq index() on strings is unreliable across builds.
|
|
322
|
+
# WIRE:PROVEN — manual: jq -rs on an empty result writes a lone newline; -s reports it non-empty, the tr/grep content check reports it empty, so the fallback runs
|
|
323
|
+
if ! tr -d '\r\n' < "$BODY_FILE" | grep -q .; then
|
|
324
|
+
jq -rs --arg p "<!-- FINDING:${F_ID}|" '[.[] | select(contains($p))] | .[0] // ""' "$WORKDIR/carriers.jsonl" > "$BODY_FILE" 2>/dev/null || : > "$BODY_FILE"
|
|
325
|
+
fi
|
|
286
326
|
AGENT=$(sed -n 's/^<!-- FORGE:REVIEW-AGENT:\([a-z-]*\) -->.*/\1/p' "$BODY_FILE" | head -n 1)
|
|
287
|
-
# The finding's own prose section
|
|
327
|
+
# The finding's own prose section. Start anchor, in order: a heading naming the id; a non-list line naming the id (a summary
|
|
328
|
+
# bullet `- ID: ...` is never the section); the first mention; the prose after the finding's own marker. It runs to the next
|
|
329
|
+
# heading / marker / other finding id.
|
|
288
330
|
SECTION_FILE="$WORKDIR/section.$N_EXTRACTED"
|
|
289
331
|
awk -v id="$F_ID" '
|
|
290
332
|
function hasid(l, p, pre, post, rest, off) {
|
|
@@ -297,12 +339,29 @@ do_extract() {
|
|
|
297
339
|
}
|
|
298
340
|
return 0
|
|
299
341
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
342
|
+
{ line[NR] = $0 }
|
|
343
|
+
END {
|
|
344
|
+
start = 0; nonlist = 0; mention = 0; mk = 0
|
|
345
|
+
for (i = 1; i <= NR; i++) {
|
|
346
|
+
l = line[i]
|
|
347
|
+
if (l ~ /<!-- (FINDING:|REVIEW-FINDINGS)/) { if (!mk && index(l, "<!-- FINDING:" id "|")) mk = i; continue }
|
|
348
|
+
if (!hasid(l)) continue
|
|
349
|
+
if (!mention) mention = i
|
|
350
|
+
if (!nonlist && l !~ /^[ \t]*([-*+]|[0-9]+[.)])[ \t]/) nonlist = i
|
|
351
|
+
if (l ~ /^#/) { start = i; break }
|
|
352
|
+
}
|
|
353
|
+
if (!start) start = nonlist
|
|
354
|
+
if (!start) start = mention
|
|
355
|
+
if (!start && mk) start = mk + 1
|
|
356
|
+
if (!start) exit
|
|
357
|
+
for (i = start; i <= NR; i++) {
|
|
358
|
+
l = line[i]
|
|
359
|
+
if (l ~ /<!-- (FINDING:|REVIEW-FINDINGS)/) break
|
|
360
|
+
if (i > start && l ~ /^#/) break
|
|
361
|
+
if (i > start && match(l, /^[-*#> \t]*\**[A-Z][A-Z0-9]*-[0-9]+/) && !hasid(l)) break
|
|
362
|
+
print l
|
|
363
|
+
}
|
|
364
|
+
}' "$BODY_FILE" > "$SECTION_FILE" 2>/dev/null || : > "$SECTION_FILE"
|
|
306
365
|
# Failure scenario: present / absent when the section was found, unknown when the carrying comment or section is unreadable.
|
|
307
366
|
SCENARIO="unknown"
|
|
308
367
|
if [ -s "$SECTION_FILE" ]; then
|
|
@@ -377,7 +436,7 @@ EOF
|
|
|
377
436
|
}
|
|
378
437
|
|
|
379
438
|
FINAL="$WORKDIR/final.jsonl"; : > "$FINAL"
|
|
380
|
-
N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_KEPT=0
|
|
439
|
+
N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_INTRODUCED=0; N_KEPT=0
|
|
381
440
|
I=0; TOTAL=$(jq 'length' "$DEDUPED")
|
|
382
441
|
while [ "$I" -lt "$TOTAL" ]; do
|
|
383
442
|
F=$(jq -c ".[$I]" "$DEDUPED"); I=$((I + 1))
|
|
@@ -401,17 +460,22 @@ EOF
|
|
|
401
460
|
esac
|
|
402
461
|
if [ "$STALE" -eq 1 ]; then N_STALE=$((N_STALE + 1)); continue; fi
|
|
403
462
|
fi
|
|
404
|
-
# Gate 2 (introduced-line):
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
463
|
+
# Gate 2 (introduced-line): scope is computed for every finding (any severity) from the added-line map. A file the PR deleted or renamed is introduced;
|
|
464
|
+
# a patch-less file, an empty map or a missing line is unknown. MEDIUM or lower with no added line within +-5 is routed pre-existing.
|
|
465
|
+
# CRITICAL/HIGH and unknown severities never are (their scope is still recorded).
|
|
466
|
+
SCOPE="unknown"
|
|
467
|
+
if [ "$GONE" -eq 1 ]; then
|
|
468
|
+
SCOPE="introduced"
|
|
469
|
+
elif [ -n "$LINE" ] && [ "$NOPATCH" -eq 0 ] && [ -s "$ADDED_LINES_FILE" ]; then
|
|
470
|
+
LO=$((LINE > 5 ? LINE - 5 : 1)); INTRODUCED=0; L="$LO"
|
|
471
|
+
while [ "$L" -le $((LINE + 5)) ]; do
|
|
472
|
+
if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
|
|
473
|
+
L=$((L + 1))
|
|
474
|
+
done
|
|
475
|
+
if [ "$INTRODUCED" -eq 1 ]; then SCOPE="introduced"; else
|
|
476
|
+
SCOPE="pre-existing"
|
|
477
|
+
case "$SEV" in MEDIUM|LOW|INFO) ROUTE="pre-existing" ;; esac
|
|
478
|
+
fi
|
|
415
479
|
fi
|
|
416
480
|
# Gate 3 (concrete failure): MEDIUM or lower without a **Failure scenario** is speculative hardening, demoted to a NOTE.
|
|
417
481
|
# CRITICAL/HIGH are exempt; an unreadable carrying comment keeps the finding.
|
|
@@ -420,13 +484,14 @@ EOF
|
|
|
420
484
|
fi
|
|
421
485
|
case "$ROUTE" in pre-existing) N_PREEXISTING=$((N_PREEXISTING + 1)) ;; demoted-no-scenario) N_NO_SCENARIO=$((N_NO_SCENARIO + 1)) ;; esac
|
|
422
486
|
N_KEPT=$((N_KEPT + 1))
|
|
423
|
-
|
|
487
|
+
[ "$SCOPE" != "introduced" ] || N_INTRODUCED=$((N_INTRODUCED + 1))
|
|
488
|
+
printf '%s' "$F" | jq -c --arg r "$ROUTE" --arg sc "$SCOPE" '. + {route: $r, scope: $sc}' >> "$FINAL"
|
|
424
489
|
done
|
|
425
490
|
|
|
426
491
|
jq -s --arg sha "$REVIEW_SHA" --arg mode "$REVIEW_MODE" --arg mi "$MERGE_ISSUE" \
|
|
427
|
-
--argjson ex "$N_EXTRACTED" --argjson dd "$N_DEDUPED" --argjson st "$N_STALE" --argjson pe "$N_PREEXISTING" --argjson ns "$N_NO_SCENARIO" \
|
|
492
|
+
--argjson ex "$N_EXTRACTED" --argjson dd "$N_DEDUPED" --argjson st "$N_STALE" --argjson pe "$N_PREEXISTING" --argjson ns "$N_NO_SCENARIO" --argjson ni "$N_INTRODUCED" \
|
|
428
493
|
'{schema_version: 1, review_sha: $sha, review_mode: $mode, merge_issue: ($mi | tonumber? // null),
|
|
429
|
-
counts: {findings_extracted: $ex, findings_deduped: $dd, findings_dropped_stale: $st, findings_preexisting: $pe, notes_demoted_no_scenario: $ns},
|
|
494
|
+
counts: {findings_extracted: $ex, findings_deduped: $dd, findings_dropped_stale: $st, findings_preexisting: $pe, findings_introduced: $ni, notes_demoted_no_scenario: $ns},
|
|
430
495
|
findings: .}' "$FINAL" > "$OUT_FILE" || { echo "review-finalize: cannot write $OUT_FILE" >&2; exit 2; }
|
|
431
496
|
echo "FINDINGS_OUT=$OUT_FILE"
|
|
432
497
|
echo "STRUCTURED_FINDINGS=$N_EXTRACTED"
|
|
@@ -434,7 +499,8 @@ EOF
|
|
|
434
499
|
echo "FINDINGS_DROPPED_STALE=$N_STALE"
|
|
435
500
|
echo "FINDINGS_PREEXISTING=$N_PREEXISTING"
|
|
436
501
|
echo "NOTES_DEMOTED_NO_SCENARIO=$N_NO_SCENARIO"
|
|
437
|
-
|
|
502
|
+
echo "AGENTS_WITHOUT_BLOCK=$AGENTS_WITHOUT_BLOCK" # undelivered agents (no findings block): re-dispatch once, else degraded panel; never read free text
|
|
503
|
+
{ [ "$N_EXTRACTED" -gt 0 ] || [ -n "$AGENTS_WITHOUT_BLOCK" ]; } || echo "CLEAN_STRUCTURED=true" # 0 structured findings and every agent delivered a block: skip to Phase 7
|
|
438
504
|
exit 0
|
|
439
505
|
}
|
|
440
506
|
|
|
@@ -603,7 +669,7 @@ do_classify() {
|
|
|
603
669
|
TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0
|
|
604
670
|
while [ "$I" -lt "$TOTAL" ]; do
|
|
605
671
|
F_ID=$(fj .id); F_SEV=$(fj .severity); F_CONF=$(fj .confidence); F_AGENT=$(fj .agent); F_FILE=$(fj .file); F_TITLE=$(fj .title)
|
|
606
|
-
F_ROUTE=$(fj .route); F_FIX=$(fj .fix_efficacy); F_L1=$(fj .line_start); F_L2=$(fj .line_end); F_LOC=$(fj .loc)
|
|
672
|
+
F_ROUTE=$(fj .route); F_SCOPE=$(fj .scope); F_FIX=$(fj .fix_efficacy); F_L1=$(fj .line_start); F_L2=$(fj .line_end); F_LOC=$(fj .loc)
|
|
607
673
|
if [ "$F_ROUTE" = "demoted-no-scenario" ]; then printf '%s\t%s\n' "$I" "NOTE no-scenario" >> "$DISP_FILE"; I=$((I + 1)); continue; fi
|
|
608
674
|
FINDING_TEXT_FILE="$WORKDIR/finding-text.$I"; jq -r ".findings[$I].text // \"\"" "$IN_FILE" > "$FINDING_TEXT_FILE" 2>/dev/null
|
|
609
675
|
set -- --severity "$F_SEV" --confidence "$F_CONF" --agent "$F_AGENT" --lineage "$FINDING_LINEAGE" --text-file "$FINDING_TEXT_FILE"
|
|
@@ -611,6 +677,7 @@ do_classify() {
|
|
|
611
677
|
[ -n "$INPR_DIFF_FILE" ] && [ "$F_ROUTE" != "pre-existing" ] && set -- "$@" --inpr-diff "$INPR_DIFF_FILE"
|
|
612
678
|
[ -n "$CONTRACT_SCOPE_FILE" ] && set -- "$@" --contract-scope "$CONTRACT_SCOPE_FILE" --contract-open "$CONTRACT_OPEN_FILE" --pr-files "$PR_FILES_FILE"
|
|
613
679
|
[ -n "$MERGE_ISSUE" ] && set -- "$@" --fixes-issue "$MERGE_ISSUE"
|
|
680
|
+
case "$F_SCOPE" in introduced|pre-existing|unknown) set -- "$@" --scope "$F_SCOPE" ;; esac
|
|
614
681
|
case "$F_FIX" in fails|regresses) set -- "$@" --fix-efficacy "$F_FIX" ;; esac
|
|
615
682
|
[ -n "$PRIOR_FILE" ] && { set -- "$@" --prior-findings "$PRIOR_FILE" --title "$F_TITLE"; [ -n "$DIFF_MAP_FILE" ] && set -- "$@" --diff-map "$DIFF_MAP_FILE"; }
|
|
616
683
|
[ -n "$F_L1" ] && set -- "$@" --line-start "$F_L1" --line-end "${F_L2:-$F_L1}"
|
|
@@ -683,7 +750,7 @@ do_classify() {
|
|
|
683
750
|
FINDINGS_FILED=$(jq '[.findings[] | select(.class == "ISSUE" and .route != "file-pre-existing" and .route != "pre-existing")] | length' "$OUT_FILE")
|
|
684
751
|
PLAIN_NOTES=$(jq '[.findings[] | select(.class == "NOTE" and .route != "pre-existing" and (.disposition | test("^NOTE (contract-|reflag-|capped)") | not))] | length' "$OUT_FILE")
|
|
685
752
|
NOTES_LISTED=$((PLAIN_NOTES - NOTES_FIXED - NOTES_DROPPED)); [ "$NOTES_LISTED" -ge 0 ] || NOTES_LISTED=0
|
|
686
|
-
N_STALE=$(jq -r '.counts.findings_dropped_stale // 0' "$OUT_FILE"); N_PRE=$(jq -r '.counts.findings_preexisting // 0' "$OUT_FILE"); N_NOSCEN=$(jq -r '.counts.notes_demoted_no_scenario // 0' "$OUT_FILE")
|
|
753
|
+
N_STALE=$(jq -r '.counts.findings_dropped_stale // 0' "$OUT_FILE"); N_PRE=$(jq -r '.counts.findings_preexisting // 0' "$OUT_FILE"); N_INTRO=$(jq -r '[.findings[] | select(.scope == "introduced")] | length' "$OUT_FILE"); N_NOSCEN=$(jq -r '.counts.notes_demoted_no_scenario // 0' "$OUT_FILE")
|
|
687
754
|
N_EXTRACTED=$(jq -r '.counts.findings_extracted // (.findings | length)' "$OUT_FILE")
|
|
688
755
|
CLASSIFIER_MODE=script # the guard above blocks the review when the classifier is unresolvable, so this is always script
|
|
689
756
|
|
|
@@ -705,7 +772,7 @@ do_classify() {
|
|
|
705
772
|
# Disposition marker (MANDATORY whenever Phase 6A extracted at least one finding): Phase 8 refuses to auto-merge a PR that has FINDING:
|
|
706
773
|
# markers but no FORGE:NOTE_DISPOSITION comment. `reviewed_sha` is the head this record covers (a later review of ANOTHER head reads it as a prior list).
|
|
707
774
|
BUNDLE_MARKER=""; if [ "$BUNDLE" = "true" ]; then _sm=full; [ -z "$SCOPE_FILES" ] || _sm=scoped; BUNDLE_MARKER=" out_of_scope=${OUT_OF_SCOPE} scope=${_sm}"; fi
|
|
708
|
-
NOTE_DISPOSITION_BODY="<!-- FORGE:NOTE_DISPOSITION: notes_fixed=${NOTES_FIXED:-0} notes_listed=${NOTES_LISTED:-0} notes_dropped=${NOTES_DROPPED:-0} findings_filed=${FINDINGS_FILED:-0} inpr_fix=${INPR_FIX_COUNT:-0} contract_demoted=${CONTRACT_DEMOTED:-0} findings_dropped_stale=${N_STALE:-0} findings_preexisting=${N_PRE:-0} notes_demoted_no_scenario=${N_NOSCEN:-0} lineage=${FINDING_LINEAGE} classifier=${CLASSIFIER_MODE} notes_reflag=${NOTES_REFLAG:-0} notes_capped=${NOTES_CAPPED:-0} reviewed_sha=${REVIEW_SHA}${BUNDLE_MARKER} -->
|
|
775
|
+
NOTE_DISPOSITION_BODY="<!-- FORGE:NOTE_DISPOSITION: notes_fixed=${NOTES_FIXED:-0} notes_listed=${NOTES_LISTED:-0} notes_dropped=${NOTES_DROPPED:-0} findings_filed=${FINDINGS_FILED:-0} inpr_fix=${INPR_FIX_COUNT:-0} contract_demoted=${CONTRACT_DEMOTED:-0} findings_dropped_stale=${N_STALE:-0} findings_preexisting=${N_PRE:-0} introduced=${N_INTRO:-0} notes_demoted_no_scenario=${N_NOSCEN:-0} lineage=${FINDING_LINEAGE} classifier=${CLASSIFIER_MODE} notes_reflag=${NOTES_REFLAG:-0} notes_capped=${NOTES_CAPPED:-0} reviewed_sha=${REVIEW_SHA}${BUNDLE_MARKER} -->
|
|
709
776
|
$(cat "$NOTE_LIST_FILE" 2>/dev/null)"
|
|
710
777
|
printf '%s\n' "$NOTE_DISPOSITION_BODY" > "$OUT_DIR/note-disposition.md"
|
|
711
778
|
POSTED_DISPOSITION=false; POSTED_INPR=false
|
|
@@ -782,7 +849,8 @@ do_file_prepare() {
|
|
|
782
849
|
while [ "$I" -lt "$TOTAL" ]; do
|
|
783
850
|
F_ROUTE=$(fj .route); F_CLASS=$(fj .class)
|
|
784
851
|
case "$F_CLASS:$F_ROUTE" in ISSUE:file|ISSUE:file-pre-existing) ;; *) I=$((I + 1)); continue ;; esac
|
|
785
|
-
F_ID=$(fj .id); F_FILE=$(fj .file); F_TITLE=$(fj .title); F_SEV=$(fj .severity); F_AGENT=$(fj .agent); F_LINE=$(fj .line_start)
|
|
852
|
+
F_ID=$(fj .id); F_FILE=$(fj .file); F_TITLE=$(fj .title); F_SEV=$(fj .severity); F_AGENT=$(fj .agent); F_LINE=$(fj .line_start); F_SCOPE=$(fj .scope)
|
|
853
|
+
case "$F_SCOPE" in introduced|pre-existing|unknown) ;; *) F_SCOPE="unknown" ;; esac
|
|
786
854
|
case "$F_LINE" in ''|*[!0-9]*) F_LINE=0 ;; esac
|
|
787
855
|
ACTION="file"; DEDUP_OF=""; REGRESSION=""; NOTE=""; CONSOLIDATE="false"; PRIOR_NUMS=""; SUBSYSTEM=""; RECURRENCE=""
|
|
788
856
|
# Title (defense in depth: strip command substitution so the raw title stays readable if it round-trips through an eval-based consumer).
|
|
@@ -881,8 +949,8 @@ do_file_prepare() {
|
|
|
881
949
|
jq -n -c --arg id "$F_ID" --arg action "$ACTION" --arg title "$ISSUE_TITLE" --arg file "$F_FILE" --arg line "$F_LINE" --arg sev "$(upper "$F_SEV")" \
|
|
882
950
|
--arg prio "$FINDING_PRIORITY" --argjson labels "$LABELS" --arg ms "$MILESTONE_TITLE" --arg batch "$BATCHABLE" --arg dedup "$DEDUP_OF" \
|
|
883
951
|
--arg reg "$REGRESSION" --arg note "$NOTE" --arg cons "$CONSOLIDATE" --arg prior "$PRIOR_NUMS" --arg sub "$SUBSYSTEM" --arg slug "$PATTERN_SLUG" \
|
|
884
|
-
--arg rec "$RECURRENCE" --arg route "$F_ROUTE" --arg agent "$F_AGENT" \
|
|
885
|
-
'{id: $id, action: $action, route: $route, title: $title, file: $file, line: ($line | tonumber), severity: $sev, agent: $agent, priority: $prio,
|
|
952
|
+
--arg rec "$RECURRENCE" --arg route "$F_ROUTE" --arg agent "$F_AGENT" --arg scope "$F_SCOPE" \
|
|
953
|
+
'{id: $id, action: $action, route: $route, title: $title, file: $file, line: ($line | tonumber), severity: $sev, scope: $scope, agent: $agent, priority: $prio,
|
|
886
954
|
labels: $labels, milestone: $ms, batchable: ($batch == "true"), dedup_of: ($dedup | tonumber? // null), regression_of: ($reg | tonumber? // null),
|
|
887
955
|
note: $note, pattern: {slug: $slug, subsystem: $sub, consolidate: ($cons == "true"), prior: ($prior | split(" ") | map(select(length > 0) | tonumber)), recurrence: $rec}}' >> "$OUT_ITEMS"
|
|
888
956
|
I=$((I + 1))
|
|
@@ -1061,9 +1129,10 @@ do_calibrate() {
|
|
|
1061
1129
|
fi
|
|
1062
1130
|
echo "CALIBRATION_NEEDS_HUMAN=${CALIBRATION_NEEDS_HUMAN}"
|
|
1063
1131
|
echo "CALIBRATION_CELL=${CALIBRATION_CELL}"
|
|
1064
|
-
#
|
|
1065
|
-
#
|
|
1132
|
+
# Defense in depth: strip quote, backtick, dollar sign, backslash and newline from the echoed line (#3918 class). Phase 8 reads the
|
|
1133
|
+
# note from the file below, not from this line, so it never passes through a shell word.
|
|
1066
1134
|
CALIBRATION_NOTE=$(printf '%s' "$CALIBRATION_NOTE" | tr -d "'\`\$\\\\" | tr '\r\n' ' ')
|
|
1135
|
+
printf '%s\n' "$CALIBRATION_NOTE" > "$OUT_DIR/calibration-note.txt" 2>/dev/null || true
|
|
1067
1136
|
echo "CALIBRATION_NOTE=${CALIBRATION_NOTE}"
|
|
1068
1137
|
exit 0
|
|
1069
1138
|
}
|
|
@@ -1184,6 +1253,86 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
|
|
|
1184
1253
|
fi
|
|
1185
1254
|
[ "${TRAIL_RC:-1}" -eq 0 ] || blocked "phase trail unreadable (rc=${TRAIL_RC:-1})" # hard guard: nothing below runs unless the trail verified
|
|
1186
1255
|
|
|
1256
|
+
# --- degraded-gate gate (forge#3628): a build whose tests ran nothing is not a verified build ---
|
|
1257
|
+
# The newest trusted FORGE:QUALITY_GATE (`**Degraded**: name — reason; ...`) and FORGE:ACCEPTANCE_GATE (`:DEGRADED` marker) on the linked
|
|
1258
|
+
# issue name the degraded checks. Each one must be named in a trusted `### Degraded Paths Reviewed` section on the PR; a generic section
|
|
1259
|
+
# does not satisfy it. Unreadable comments fail closed. Only trusted-comments.sh ever reads these comments, so an untrusted comment can
|
|
1260
|
+
# neither forge a DEGRADED marker nor clear one.
|
|
1261
|
+
DEG_ISSUE_JSON=$(gh api --paginate "repos/${REPO}/issues/${MERGE_ISSUE}/comments" 2>/dev/null) || DEG_ISSUE_JSON=""
|
|
1262
|
+
if [ -z "$DEG_ISSUE_JSON" ] || [ -z "$TRUSTED_SCRIPT" ]; then
|
|
1263
|
+
echo "DEGRADED GATE: issue comments or trusted-comments.sh unreadable — refusing to merge (fail closed)" >&2
|
|
1264
|
+
blocked "note disposition unreadable"
|
|
1265
|
+
fi
|
|
1266
|
+
DEG_NAMES=""; DEG_ANY=false
|
|
1267
|
+
DEG_QG=$(printf '%s' "$DEG_ISSUE_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:QUALITY_GATE -->') || DEG_QG="__ERR__"
|
|
1268
|
+
DEG_AG=$(printf '%s' "$DEG_ISSUE_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:ACCEPTANCE_GATE -->') || DEG_AG="__ERR__"
|
|
1269
|
+
if [ "$DEG_QG" = "__ERR__" ] || [ "$DEG_AG" = "__ERR__" ]; then
|
|
1270
|
+
echo "DEGRADED GATE: trust filter failed — refusing to merge (fail closed)" >&2
|
|
1271
|
+
blocked "note disposition unreadable"
|
|
1272
|
+
fi
|
|
1273
|
+
# newest body of each gate (bodies are JSON strings, one per line, oldest first)
|
|
1274
|
+
DEG_QG_LINE=$(printf '%s\n' "$DEG_QG" | tail -n 1 | jq -r 'select(type == "string")' 2>/dev/null | sed -n 's/^\*\*Degraded\*\*: *//p' | sed -n '1p')
|
|
1275
|
+
DEG_AG_BODY=$(printf '%s\n' "$DEG_AG" | tail -n 1 | jq -r 'select(type == "string")' 2>/dev/null)
|
|
1276
|
+
case "$DEG_QG_LINE" in ''|none|None|NONE) ;; *) DEG_ANY=true; DEG_NAMES="$DEG_QG_LINE" ;; esac
|
|
1277
|
+
if printf '%s\n' "$DEG_AG_BODY" | grep -qE '^[[:space:]]*<!-- FORGE:ACCEPTANCE_GATE:DEGRADED -->[[:space:]]*$'; then
|
|
1278
|
+
DEG_ANY=true
|
|
1279
|
+
DEG_AG_LINE=$(printf '%s\n' "$DEG_AG_BODY" | sed -n 's/^\*\*Degraded checks[^*]*\*\*: *//p' | sed -n '1p')
|
|
1280
|
+
[ -n "$DEG_AG_LINE" ] && DEG_NAMES="${DEG_NAMES:+$DEG_NAMES; }${DEG_AG_LINE}"
|
|
1281
|
+
fi
|
|
1282
|
+
if [ "$DEG_ANY" = "true" ]; then
|
|
1283
|
+
# Reviewed section (forge#3995): only a trusted review comment (FORGE:REVIEW-AGENT / FORGE:REVIEW / synthesis marker as the
|
|
1284
|
+
# LEADING text) carrying `Reviewed-SHA:` for the CURRENT head counts, so a stale section from an older head or a plain comment
|
|
1285
|
+
# cannot clear the gate. An unreadable head fails closed.
|
|
1286
|
+
DEG_SECTIONS=""
|
|
1287
|
+
DEG_HEAD=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .head.sha 2>/dev/null) || DEG_HEAD=""
|
|
1288
|
+
if ! [[ "$DEG_HEAD" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then
|
|
1289
|
+
echo "DEGRADED GATE: PR head unreadable — refusing to merge (fail closed)" >&2
|
|
1290
|
+
blocked "note disposition unreadable"
|
|
1291
|
+
fi
|
|
1292
|
+
DEG_PR_BODIES=$(printf '%s' "$DISPO_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- (FORGE:REVIEW-AGENT:[a-z-]+|FORGE:REVIEW|REVIEW-FINDINGS-SYNTHESIZED-START) -->[\s\S]*(^|\n)### Degraded Paths Reviewed') || DEG_PR_BODIES="__ERR__"
|
|
1293
|
+
if [ "$DEG_PR_BODIES" = "__ERR__" ]; then
|
|
1294
|
+
echo "DEGRADED GATE: trust filter failed on PR comments — refusing to merge (fail closed)" >&2
|
|
1295
|
+
blocked "note disposition unreadable"
|
|
1296
|
+
fi
|
|
1297
|
+
if [ -n "$DEG_PR_BODIES" ]; then
|
|
1298
|
+
DEG_SECTIONS=$(printf '%s\n' "$DEG_PR_BODIES" | while IFS= read -r _db; do
|
|
1299
|
+
printf '%s' "$_db" | jq -r --arg head "$DEG_HEAD" 'select(type == "string") | select(split("\n") | map(rtrimstr("\r")) | index("Reviewed-SHA: " + $head))' 2>/dev/null \
|
|
1300
|
+
| awk '/^### Degraded Paths Reviewed[[:space:]]*$/ {f=1; next} f && /^(#|##|###) / {f=0} f {print}'
|
|
1301
|
+
done)
|
|
1302
|
+
fi
|
|
1303
|
+
DEG_MISSING=""
|
|
1304
|
+
if [ -z "$(printf '%s' "$DEG_SECTIONS" | tr -d '[:space:]')" ]; then
|
|
1305
|
+
DEG_MISSING="no \`### Degraded Paths Reviewed\` section on a review comment for the current head"
|
|
1306
|
+
else
|
|
1307
|
+
# per-check match (forge#3995): the name is everything before " — " in each "; "-separated entry. It must be a whole list
|
|
1308
|
+
# item `- name: <how it was reviewed>` whose explanation does not negate it; a substring or a "NOT reviewed" item is not coverage.
|
|
1309
|
+
DEG_NAME_LIST=$(printf '%s\n' "$DEG_NAMES" | tr ';' '\n' | sed 's/ — .*$//; s/^[[:space:]]*//; s/[[:space:]]*$//' | sed '/^$/d' | sort -u)
|
|
1310
|
+
while IFS= read -r _dn; do
|
|
1311
|
+
[ -n "$_dn" ] || continue
|
|
1312
|
+
_hit=$(printf '%s\n' "$DEG_SECTIONS" | awk -v n="$_dn" '
|
|
1313
|
+
{ l=$0; sub(/^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+/, "", l); gsub(/`/, "", l)
|
|
1314
|
+
if (index(l, n) != 1) next
|
|
1315
|
+
r=substr(l, length(n)+1)
|
|
1316
|
+
if (r !~ /^[[:space:]]*[:—-][[:space:]]*[^[:space:]]/) next
|
|
1317
|
+
sub(/^[[:space:]]*[:—-][[:space:]]*/, "", r)
|
|
1318
|
+
if (tolower(r) ~ /^(not|never|no|n\/a|none|unreviewed|skipped)([^a-z]|$)/) next
|
|
1319
|
+
found=1 }
|
|
1320
|
+
END { print found+0 }')
|
|
1321
|
+
[ "$_hit" = "1" ] || DEG_MISSING="${DEG_MISSING:+$DEG_MISSING, }${_dn}"
|
|
1322
|
+
done <<< "$DEG_NAME_LIST"
|
|
1323
|
+
[ -z "$DEG_MISSING" ] || DEG_MISSING="not covered: ${DEG_MISSING}"
|
|
1324
|
+
fi
|
|
1325
|
+
if [ -n "$DEG_MISSING" ]; then
|
|
1326
|
+
echo "DEGRADED GATE: the build's gate is DEGRADED (${DEG_NAMES}) and the review does not cover it (${DEG_MISSING})" >&2
|
|
1327
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "<!-- FORGE:DEGRADED_GATE_UNREVIEWED -->
|
|
1328
|
+
Auto-merge refused for PR #${PR_NUMBER}: the build's verification was DEGRADED (a check ran nothing or did not complete): ${DEG_NAMES}.
|
|
1329
|
+
|
|
1330
|
+
The review must add a \`### Degraded Paths Reviewed\` section that names each degraded check and states how its path was reviewed (${DEG_MISSING}). Then re-run /review-pr." 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
|
|
1331
|
+
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
|
|
1332
|
+
blocked "degraded-gate-unreviewed"
|
|
1333
|
+
fi
|
|
1334
|
+
fi
|
|
1335
|
+
|
|
1187
1336
|
# --- merge guard state: §7B verdict + purpose-regression + calibration + trust-escalation ---
|
|
1188
1337
|
# These are decided in earlier steps (earlier shells), so they arrive as flags. An empty or unbound value would read as
|
|
1189
1338
|
# "not blocking" and fall through to the merge path, so any value outside the expected set stops here instead of merging.
|
|
@@ -1277,6 +1426,9 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
|
|
|
1277
1426
|
else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
|
|
1278
1427
|
echo "$CI_GATE_OUT"
|
|
1279
1428
|
GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
|
|
1429
|
+
# rc 4 RATE_LIMITED (GitHub throttled or briefly unavailable for the whole window) is a wait like TIMEOUT: map it to the
|
|
1430
|
+
# pending path BEFORE rc 4 is reused below as the stale-review sentinel, so it is never a merge and never a re-review.
|
|
1431
|
+
[ "$CI_GATE_RC" -eq 4 ] && CI_GATE_RC=3
|
|
1280
1432
|
# rc 2 "head moved" is a stale review, not a human problem; rc 3 TIMEOUT means CI is still running.
|
|
1281
1433
|
printf '%s\n' "$CI_GATE_OUT" | grep -q 'PR head moved during the wait' && CI_GATE_RC=4
|
|
1282
1434
|
if [ "$CI_GATE_RC" -eq 3 ]; then
|
|
@@ -1399,7 +1551,7 @@ do_integrity() {
|
|
|
1399
1551
|
ACTUAL_AGENT_DOMAINS=""
|
|
1400
1552
|
if [ -n "$TRUSTED_SCRIPT" ]; then
|
|
1401
1553
|
ACTUAL_AGENT_DOMAINS=$(printf '%s' "$COMMENTS_JSON" \
|
|
1402
|
-
| bash "$TRUSTED_SCRIPT" bodies "
|
|
1554
|
+
| bash "$TRUSTED_SCRIPT" bodies "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
|
|
1403
1555
|
| jq -rs '[.[] | scan("^<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "") # unresolvable => empty => count 0 (degraded, fail closed)
|
|
1404
1556
|
fi
|
|
1405
1557
|
# No `|| echo 0` fallback after grep -c: it always prints a count (0 on no match) even though it exits 1, so a fallback would double-count to "0\n0".
|