forgedock 1.18.0 → 1.20.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/README.md +2 -0
- 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 +187 -10
- package/bin/runner.mjs +89 -13
- 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/orchestrate/phase-4-execution.md +1 -1
- package/commands/review-pr-agents/protocols.md +19 -0
- package/commands/review-pr-staging.md +4 -4
- package/commands/review-pr.md +31 -20
- package/commands/work-on/build/architect.md +54 -1
- package/commands/work-on/build/implement.md +28 -1
- package/commands/work-on/build/validate.md +321 -29
- package/commands/work-on/build.md +184 -17
- package/commands/work-on/close.md +9 -4
- package/commands/work-on/investigate.md +7 -0
- package/commands/work-on/remediate.md +43 -17
- package/commands/work-on/review.md +43 -22
- package/commands/work-on.md +4 -4
- package/docs/CONFIG.md +93 -2
- package/docs/OUTCOME-CODES.md +5 -1
- package/docs/RELEASE-NOTES.md +15 -0
- package/docs/STATS.md +11 -4
- package/docs/TELEMETRY.md +5 -2
- package/docs/WORK-ON-RUNTIME.md +29 -3
- 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/architect-resume-match.test.sh +12 -3
- package/scripts/build-knowledge-index.mjs +8 -5
- package/scripts/builder-resume-match.test.sh +12 -3
- package/scripts/calibration.mjs +5 -2
- 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 +4 -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 +379 -0
- package/scripts/ci-local/plan.mjs +186 -0
- package/scripts/ci-local/run.mjs +451 -0
- package/scripts/ci-local/scoping.mjs +531 -0
- package/scripts/ci-local.sh +98 -0
- package/scripts/ci-local.test.sh +1108 -0
- package/scripts/classify-finding.sh +33 -7
- 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/file-deferred-followups.sh +91 -30
- package/scripts/file-deferred-followups.test.sh +101 -8
- 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 +3 -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 +294 -53
- package/scripts/review-finalize.test.sh +347 -9
- 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/review-roster.sh +18 -5
- package/scripts/review-roster.test.sh +57 -1
- package/scripts/run-shell-tests.sh +5 -0
- package/scripts/selfreview-plan.sh +86 -0
- package/scripts/selfreview-plan.test.sh +90 -0
- package/scripts/spawn-depth-check.sh +5 -4
- package/scripts/spawn-depth-check.test.sh +3 -0
- package/scripts/telemetry.test.sh +10 -0
- package/scripts/transition-label.sh +26 -0
- package/scripts/trusted-comments.test.sh +2 -1
- package/scripts/verify-phase-trail.test.sh +14 -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,23 @@
|
|
|
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
|
-
# [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--dry-run]
|
|
52
|
+
# [--purpose-findings-file F | --purpose-findings TEXT] [--result stale|clean|blocking] [--agents-file F | --agents TEXT] [--findings-count N] [--blocking-count N]
|
|
53
|
+
# [--intensity-tier T] [--table-cell-key K] [--trust-shadow-mode B] [--other-blocking true|false] [--out-dir D] [--dry-run]
|
|
54
|
+
# (--other-blocking: the reviewer's criterion 1/2/3/5 result; resolved and persisted to OUT_DIR/other-blocking.txt, which merge-gate ORs in)
|
|
53
55
|
# 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
56
|
# 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]
|
|
57
|
+
# --base-conflict B --other-blocking B [--calibration-note-file F | --calibration-note T] [--table-cell-key K] [--base B] [--issue N]
|
|
56
58
|
# (every B is true|false; an empty or other value aborts the merge: the guard state must be bound)
|
|
59
|
+
# Free text (purpose findings, agents, calibration note) derives from PR content: pass it through the *-file flags (a file written with the
|
|
60
|
+
# file-write tool, read here as data: control characters stripped, length capped, unreadable file = empty). The inline TEXT flags are for
|
|
61
|
+
# trusted callers only; the file flag wins when both are given. Never place free text in a shell word.
|
|
57
62
|
# demilestone: --is-milestone true|false [--merge-state S] (8B passes MERGED; 9A omits it and the state is read from the PR)
|
|
58
63
|
# Env: FORGE_CONFIG (forge.yaml for review.max_filed_findings; default ./forge.yaml).
|
|
59
64
|
# Exit: 0 ok | 3 BLOCKED (REVIEW_RESULT: status: BLOCKED printed; the caller must stop the review, never skip) | 4 merge-gate only:
|
|
60
65
|
# 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
66
|
# | 1 file-verify found no issue number | 2 usage error
|
|
67
|
+
# merge-gate: `blocked degraded-gate-unreviewed` (exit 3, needs-human added) when the linked issue's newest trusted FORGE:QUALITY_GATE `**Degraded**:` line or
|
|
68
|
+
# FORGE:ACCEPTANCE_GATE:DEGRADED marker lists checks that no trusted `### Degraded Paths Reviewed` PR section names (forge#3628).
|
|
62
69
|
# merge-gate reports every non-merging routed outcome as MERGE_OUTCOME=<merged|merge-failed|parked-awaiting-merge|base-conflict|needs-human|
|
|
63
70
|
# protection-blocked|stale-review|ci-not-green>; everything except merged / merge-failed / parked-awaiting-merge also exits 3 with a blocker.
|
|
64
71
|
#
|
|
@@ -76,9 +83,10 @@ PR_NUMBER=""; REPO=""; CONTEXT=""; OUT_DIR=""; IN_FILE=""; OUT_FILE=""; AUTO_MER
|
|
|
76
83
|
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
84
|
BASE_CONFLICT=""; OTHER_BLOCKING=""; INTENSITY_TIER=""; TABLE_CELL_KEY=""; TRUST_SHADOW_MODE=""; MERGE_HEALTH=""; MERGE_HEALTH_STATE=""; RESULT=""
|
|
78
85
|
AGENTS_TEXT=""; FINDINGS_COUNT=""; BLOCKING_COUNT=""; PURPOSE_FINDINGS=""; IS_MILESTONE="false"; MERGE_STATE_IN=""
|
|
86
|
+
CALIBRATION_NOTE_FILE=""; AGENTS_FILE=""; PURPOSE_FINDINGS_FILE=""
|
|
79
87
|
while [ $# -gt 0 ]; do
|
|
80
88
|
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)
|
|
89
|
+
--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
90
|
[ $# -ge 2 ] || { echo "review-finalize: $1 requires a value" >&2; exit 2; } ;;
|
|
83
91
|
esac
|
|
84
92
|
case "$1" in
|
|
@@ -101,6 +109,9 @@ while [ $# -gt 0 ]; do
|
|
|
101
109
|
--has-purpose-regression) HAS_PURPOSE_REGRESSION="${2:-}"; shift 2 ;;
|
|
102
110
|
--calibration-needs-human) CALIBRATION_NEEDS_HUMAN="${2:-}"; shift 2 ;;
|
|
103
111
|
--calibration-note) CALIBRATION_NOTE="${2:-}"; shift 2 ;;
|
|
112
|
+
--calibration-note-file) CALIBRATION_NOTE_FILE="${2:-}"; shift 2 ;;
|
|
113
|
+
--agents-file) AGENTS_FILE="${2:-}"; shift 2 ;;
|
|
114
|
+
--purpose-findings-file) PURPOSE_FINDINGS_FILE="${2:-}"; shift 2 ;;
|
|
104
115
|
--trust-needs-human) TRUST_NEEDS_HUMAN="${2:-}"; shift 2 ;;
|
|
105
116
|
--base-conflict) BASE_CONFLICT="${2:-}"; shift 2 ;;
|
|
106
117
|
--other-blocking) OTHER_BLOCKING="${2:-}"; shift 2 ;;
|
|
@@ -123,6 +134,16 @@ while [ $# -gt 0 ]; do
|
|
|
123
134
|
esac
|
|
124
135
|
done
|
|
125
136
|
|
|
137
|
+
# 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.
|
|
138
|
+
# Control characters are dropped, newlines fold to spaces, output is capped at 2000 bytes; an unreadable or empty path prints nothing.
|
|
139
|
+
read_text_file() {
|
|
140
|
+
[ -n "${1:-}" ] && [ -f "$1" ] && [ -r "$1" ] || return 0
|
|
141
|
+
head -c 2000 "$1" 2>/dev/null | tr '\r\n\t' ' ' | tr -d '\000-\037\177'
|
|
142
|
+
}
|
|
143
|
+
[ -z "$PURPOSE_FINDINGS_FILE" ] || PURPOSE_FINDINGS=$(read_text_file "$PURPOSE_FINDINGS_FILE")
|
|
144
|
+
[ -z "$AGENTS_FILE" ] || AGENTS_TEXT=$(read_text_file "$AGENTS_FILE")
|
|
145
|
+
[ -z "$CALIBRATION_NOTE_FILE" ] || CALIBRATION_NOTE=$(read_text_file "$CALIBRATION_NOTE_FILE")
|
|
146
|
+
|
|
126
147
|
# --- file-verify needs nothing else: it only parses the issue-creation result text on stdin ---
|
|
127
148
|
if [ "$SUB" = "file-verify" ]; then
|
|
128
149
|
ISSUE_SKILL_OUTPUT=$(cat)
|
|
@@ -252,12 +273,20 @@ do_extract() {
|
|
|
252
273
|
# The panel guard guarantees at least one trusted reviewer body for this head; none means a misread, never "clean".
|
|
253
274
|
[ -n "$SOURCE_BODIES" ] || { echo "REVIEW_RESULT: status: BLOCKED, blocker: no trusted reviewer comments for head ${PRIMARY_SHA} (fail closed)"; exit 3; }
|
|
254
275
|
CARRIER_BODIES=$(agent_bodies_at "$PRIMARY_SHA")
|
|
276
|
+
CARRIER_BODIES_PRIMARY="$CARRIER_BODIES"
|
|
255
277
|
if [ -n "$EARLIER_SHA" ]; then
|
|
256
278
|
EARLIER_BODIES=$(bodies_at "$EARLIER_SHA") && [ -n "$EARLIER_BODIES" ] \
|
|
257
279
|
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: earlier reviewed head ${EARLIER_SHA} findings unreadable (fail closed)"; exit 3; }
|
|
258
280
|
SOURCE_BODIES=$(printf '%s\n%s' "$SOURCE_BODIES" "$EARLIER_BODIES")
|
|
259
281
|
CARRIER_BODIES=$(printf '%s\n%s' "$CARRIER_BODIES" "$(agent_bodies_at "$EARLIER_SHA")")
|
|
260
282
|
fi
|
|
283
|
+
# Panel integrity: a trusted agent comment for the primary head counts as delivered only with a findings block (it may be empty).
|
|
284
|
+
# Computed from the RAW agent bodies, never from bodies_at, so a synthesized block cannot mask a missing agent block.
|
|
285
|
+
# Per domain: a later (re-dispatched) comment with a complete block clears an earlier block-less one.
|
|
286
|
+
AGENTS_WITHOUT_BLOCK=$(printf '%s\n' "$CARRIER_BODIES_PRIMARY" | jq -rs '[.[] | select(type == "string")
|
|
287
|
+
| {d: (capture("^<!-- FORGE:REVIEW-AGENT:(?<d>[a-z-]+) -->").d // empty),
|
|
288
|
+
ok: (contains("<!-- REVIEW-FINDINGS-START -->") and contains("<!-- REVIEW-FINDINGS-END -->"))}]
|
|
289
|
+
| group_by(.d) | map(select(any(.[]; .ok) | not) | .[0].d) | .[]' 2>/dev/null | sort -u | tr '\n' ' ' | sed 's/ $//')
|
|
261
290
|
# Match to the closing ` -->`, never `[^>]+`: a `>` or `->` inside a title must not drop the finding (#3897).
|
|
262
291
|
FINDINGS=$(printf '%s\n' "$SOURCE_BODIES" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' 2>/dev/null)
|
|
263
292
|
# Fail closed when a raw marker did not parse: a lost finding must never read as a clean review.
|
|
@@ -267,7 +296,8 @@ do_extract() {
|
|
|
267
296
|
|| { echo "REVIEW_RESULT: status: BLOCKED, blocker: ${N_RAW_MARKERS} FINDING markers but ${N_PARSED_MARKERS} parsed (malformed marker, fail closed)"; exit 3; }
|
|
268
297
|
|
|
269
298
|
# 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"
|
|
299
|
+
printf '%s\n%s\n' "$CARRIER_BODIES" "$SOURCE_BODIES" | grep -v '^$' | awk '!seen[$0]++' > "$WORKDIR/carriers.jsonl" || : > "$WORKDIR/carriers.jsonl"
|
|
300
|
+
: > "$WORKDIR/seen-markers"
|
|
271
301
|
|
|
272
302
|
RAW="$WORKDIR/raw.jsonl"; : > "$RAW"; N_EXTRACTED=0
|
|
273
303
|
while IFS='|' read -r F_ID F_CONF F_SEV F_LOC F_TITLE; do
|
|
@@ -280,11 +310,24 @@ do_extract() {
|
|
|
280
310
|
case "$_r" in *[!0-9-]*|"") ;; *) F_FILE="${F_LOC%:*}"; L1="${_r%%-*}"; L2="${_r#*-}"; [ "$L2" != "$_r" ] || L2="$L1" ;; esac ;;
|
|
281
311
|
esac
|
|
282
312
|
case "$L1" in *[!0-9]*) L1=""; L2="" ;; esac; case "$L2" in *[!0-9]*) L2="$L1" ;; esac
|
|
283
|
-
# The carrying comment: the
|
|
313
|
+
# The carrying comment: bound by the finding's FULL marker line, not its id. A finding repeated across heads, slices or agents
|
|
314
|
+
# 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
|
|
315
|
+
# prose. Falls back to the first body holding the id-prefix only when no body holds the exact line (reworded synthesis marker).
|
|
284
316
|
BODY_FILE="$WORKDIR/body.$N_EXTRACTED"
|
|
285
|
-
|
|
317
|
+
F_TITLE="${F_TITLE%$'\r'}" # a CRLF-ending parse (Windows jq) must not leak a CR into the exact marker
|
|
318
|
+
F_MARKER="<!-- FINDING:${F_ID}|${F_CONF}|${F_SEV}|${F_LOC}|${F_TITLE} -->"
|
|
319
|
+
SEEN_K=$(grep -cxF -- "$F_MARKER" "$WORKDIR/seen-markers" 2>/dev/null || true); case "$SEEN_K" in ''|*[!0-9]*) SEEN_K=0 ;; esac
|
|
320
|
+
printf '%s\n' "$F_MARKER" >> "$WORKDIR/seen-markers"
|
|
321
|
+
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"
|
|
322
|
+
# 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.
|
|
323
|
+
# 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
|
|
324
|
+
if ! tr -d '\r\n' < "$BODY_FILE" | grep -q .; then
|
|
325
|
+
jq -rs --arg p "<!-- FINDING:${F_ID}|" '[.[] | select(contains($p))] | .[0] // ""' "$WORKDIR/carriers.jsonl" > "$BODY_FILE" 2>/dev/null || : > "$BODY_FILE"
|
|
326
|
+
fi
|
|
286
327
|
AGENT=$(sed -n 's/^<!-- FORGE:REVIEW-AGENT:\([a-z-]*\) -->.*/\1/p' "$BODY_FILE" | head -n 1)
|
|
287
|
-
# The finding's own prose section
|
|
328
|
+
# The finding's own prose section. Start anchor, in order: a heading naming the id; a non-list line naming the id (a summary
|
|
329
|
+
# bullet `- ID: ...` is never the section); the first mention; the prose after the finding's own marker. It runs to the next
|
|
330
|
+
# heading / marker / other finding id.
|
|
288
331
|
SECTION_FILE="$WORKDIR/section.$N_EXTRACTED"
|
|
289
332
|
awk -v id="$F_ID" '
|
|
290
333
|
function hasid(l, p, pre, post, rest, off) {
|
|
@@ -297,12 +340,29 @@ do_extract() {
|
|
|
297
340
|
}
|
|
298
341
|
return 0
|
|
299
342
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
343
|
+
{ line[NR] = $0 }
|
|
344
|
+
END {
|
|
345
|
+
start = 0; nonlist = 0; mention = 0; mk = 0
|
|
346
|
+
for (i = 1; i <= NR; i++) {
|
|
347
|
+
l = line[i]
|
|
348
|
+
if (l ~ /<!-- (FINDING:|REVIEW-FINDINGS)/) { if (!mk && index(l, "<!-- FINDING:" id "|")) mk = i; continue }
|
|
349
|
+
if (!hasid(l)) continue
|
|
350
|
+
if (!mention) mention = i
|
|
351
|
+
if (!nonlist && l !~ /^[ \t]*([-*+]|[0-9]+[.)])[ \t]/) nonlist = i
|
|
352
|
+
if (l ~ /^#/) { start = i; break }
|
|
353
|
+
}
|
|
354
|
+
if (!start) start = nonlist
|
|
355
|
+
if (!start) start = mention
|
|
356
|
+
if (!start && mk) start = mk + 1
|
|
357
|
+
if (!start) exit
|
|
358
|
+
for (i = start; i <= NR; i++) {
|
|
359
|
+
l = line[i]
|
|
360
|
+
if (l ~ /<!-- (FINDING:|REVIEW-FINDINGS)/) break
|
|
361
|
+
if (i > start && l ~ /^#/) break
|
|
362
|
+
if (i > start && match(l, /^[-*#> \t]*\**[A-Z][A-Z0-9]*-[0-9]+/) && !hasid(l)) break
|
|
363
|
+
print l
|
|
364
|
+
}
|
|
365
|
+
}' "$BODY_FILE" > "$SECTION_FILE" 2>/dev/null || : > "$SECTION_FILE"
|
|
306
366
|
# Failure scenario: present / absent when the section was found, unknown when the carrying comment or section is unreadable.
|
|
307
367
|
SCENARIO="unknown"
|
|
308
368
|
if [ -s "$SECTION_FILE" ]; then
|
|
@@ -348,13 +408,15 @@ EOF
|
|
|
348
408
|
gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate > "$FILES_JSON" 2>/dev/null || : > "$FILES_JSON"
|
|
349
409
|
# "path:line" for every added line; an empty file means the diff was unreadable (gates then keep every finding).
|
|
350
410
|
ADDED_LINES_FILE="$WORKDIR/added-lines"
|
|
351
|
-
|
|
411
|
+
# Second stream from the same pass: "path<TAB>text" per added line, for the Gate 2 symbol fallback.
|
|
412
|
+
ADDED_TEXT_FILE="$WORKDIR/added-text"; : > "$ADDED_TEXT_FILE"
|
|
413
|
+
jq -r '.[] | select(.patch != null) | "FILE\t\(.filename)", .patch' "$FILES_JSON" 2>/dev/null | awk -v tf="$ADDED_TEXT_FILE" '
|
|
352
414
|
/^FILE\t/{f=substr($(0),6);next}
|
|
353
415
|
/^@@/{s=$(0);sub(/^@@ -[0-9,]+ \+/,"",s);sub(/[ ,].*/,"",s);n=s+0;next}
|
|
354
416
|
/^\\/{next}
|
|
355
|
-
/^\+/{print f":"n;n++;next}
|
|
417
|
+
/^\+/{print f":"n; print f "\t" substr($(0),2) > tf; n++;next}
|
|
356
418
|
/^-/{next}
|
|
357
|
-
{n++}' > "$ADDED_LINES_FILE" || : > "$ADDED_LINES_FILE"
|
|
419
|
+
{n++}' > "$ADDED_LINES_FILE" || { : > "$ADDED_LINES_FILE"; : > "$ADDED_TEXT_FILE"; }
|
|
358
420
|
# Files the PR deleted or renamed (the old name too): findings on them always intersect.
|
|
359
421
|
GONE_FILES_FILE="$WORKDIR/gone-files"
|
|
360
422
|
jq -r '.[] | select(.status=="removed" or .status=="renamed") | .filename, (.previous_filename // empty)' "$FILES_JSON" > "$GONE_FILES_FILE" 2>/dev/null || : > "$GONE_FILES_FILE"
|
|
@@ -376,8 +438,54 @@ EOF
|
|
|
376
438
|
if gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${REVIEW_SHA}" > "$t" 2>/dev/null; then awk 'END{print NR}' "$t"; else echo UNKNOWN; fi
|
|
377
439
|
}
|
|
378
440
|
|
|
441
|
+
# Gate 2 symbol fallback: 0 when an identifier the finding names occurs (whole word, fixed string) in the added lines of FILE
|
|
442
|
+
# and nowhere in the base version of FILE (a symbol the PR introduced).
|
|
443
|
+
# Candidates: backticked tokens, plus identifier-shaped tokens on "Root cause" lines; each must match an anchored identifier
|
|
444
|
+
# regex of 4+ chars and not be a common word. Reviewer text is never used in arithmetic, a glob or a regex. Any failure returns 1.
|
|
445
|
+
# A symbol counts only when it is NEW: present in the added lines AND absent from the base version of the file (#4068 review
|
|
446
|
+
# DSEC-1/DBH-1: `self`, `return`, `result` in an added line must not re-scope a pre-existing finding). The base text comes from
|
|
447
|
+
# `git show <base>:<file>` (local commit) or the raw contents API; a file the PR adds has an empty base; an unreadable base
|
|
448
|
+
# disables the fallback (the line-window result stands).
|
|
449
|
+
SYM_BASE_SHA=""
|
|
450
|
+
base_text_for() { # base_text_for <file> <out>; 0 = out holds the base text (possibly empty), 1 = unknown
|
|
451
|
+
local f="$1" o="$2"
|
|
452
|
+
if jq -e --arg f "$f" 'any(.[]; .filename == $f and .status == "added")' "$FILES_JSON" >/dev/null 2>&1; then : > "$o"; return 0; fi
|
|
453
|
+
[ -n "$SYM_BASE_SHA" ] || SYM_BASE_SHA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .base.sha 2>/dev/null || true)
|
|
454
|
+
case "$SYM_BASE_SHA" in *[!0-9a-f]*|"") SYM_BASE_SHA="-"; return 1 ;; -) return 1 ;; esac
|
|
455
|
+
if git rev-parse -q --verify "${SYM_BASE_SHA}^{commit}" >/dev/null 2>&1; then
|
|
456
|
+
git show "${SYM_BASE_SHA}:${f}" > "$o" 2>/dev/null && return 0
|
|
457
|
+
git cat-file -e "${SYM_BASE_SHA}:${f}" 2>/dev/null && return 1
|
|
458
|
+
: > "$o"; return 0
|
|
459
|
+
fi
|
|
460
|
+
case "$f" in *[!A-Za-z0-9._/@+-]*|"") return 1 ;; esac
|
|
461
|
+
gh api -H "Accept: application/vnd.github.raw" "repos/${REPO}/contents/${f}?ref=${SYM_BASE_SHA}" > "$o" 2>/dev/null && return 0
|
|
462
|
+
return 1
|
|
463
|
+
}
|
|
464
|
+
symbol_in_added() {
|
|
465
|
+
local f="$1" text="$2" t="$WORKDIR/sym-added" b="$WORKDIR/sym-base" c n=0
|
|
466
|
+
[ -s "$ADDED_TEXT_FILE" ] || return 1
|
|
467
|
+
awk -v f="$f" 'index($0, f "\t") == 1 { print substr($0, length(f) + 2) }' "$ADDED_TEXT_FILE" > "$t" 2>/dev/null || return 1
|
|
468
|
+
[ -s "$t" ] || return 1
|
|
469
|
+
base_text_for "$f" "$b" || return 1
|
|
470
|
+
while IFS= read -r c; do
|
|
471
|
+
case "$c" in ""|[!A-Za-z_]*) continue ;; esac
|
|
472
|
+
[ "${#c}" -ge 4 ] || continue
|
|
473
|
+
case "$(printf '%s' "$c" | tr '[:upper:]' '[:lower:]')" in
|
|
474
|
+
this|that|with|from|true|false|null|none|file|line|code|test|case|value|root|cause|when|then|which|where|there|their|error|data|name|path|type|call|only|also|into|have|does|used|uses) continue ;;
|
|
475
|
+
esac
|
|
476
|
+
n=$((n + 1)); [ "$n" -le 8 ] || break
|
|
477
|
+
if grep -qwF -- "$c" "$t" 2>/dev/null && ! grep -qwF -- "$c" "$b" 2>/dev/null; then return 0; fi
|
|
478
|
+
done <<SYMEOF
|
|
479
|
+
$({
|
|
480
|
+
printf '%s\n' "$text" | grep -oE '`[^`]+`' 2>/dev/null | tr -c 'A-Za-z0-9_\n' '\n'
|
|
481
|
+
printf '%s\n' "$text" | grep -iE 'root cause' 2>/dev/null | grep -oE '[A-Za-z_][A-Za-z0-9_]*' | grep -E '_|[a-z][A-Z]|[0-9]'
|
|
482
|
+
} 2>/dev/null | grep -E '^[A-Za-z_][A-Za-z0-9_]{3,}$' | awk '!seen[$0]++')
|
|
483
|
+
SYMEOF
|
|
484
|
+
return 1
|
|
485
|
+
}
|
|
486
|
+
|
|
379
487
|
FINAL="$WORKDIR/final.jsonl"; : > "$FINAL"
|
|
380
|
-
N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_KEPT=0
|
|
488
|
+
N_STALE=0; N_PREEXISTING=0; N_NO_SCENARIO=0; N_INTRODUCED=0; N_KEPT=0
|
|
381
489
|
I=0; TOTAL=$(jq 'length' "$DEDUPED")
|
|
382
490
|
while [ "$I" -lt "$TOTAL" ]; do
|
|
383
491
|
F=$(jq -c ".[$I]" "$DEDUPED"); I=$((I + 1))
|
|
@@ -401,17 +509,24 @@ EOF
|
|
|
401
509
|
esac
|
|
402
510
|
if [ "$STALE" -eq 1 ]; then N_STALE=$((N_STALE + 1)); continue; fi
|
|
403
511
|
fi
|
|
404
|
-
# Gate 2 (introduced-line):
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
512
|
+
# 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;
|
|
513
|
+
# 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.
|
|
514
|
+
# CRITICAL/HIGH and unknown severities never are (their scope is still recorded).
|
|
515
|
+
SCOPE="unknown"
|
|
516
|
+
if [ "$GONE" -eq 1 ]; then
|
|
517
|
+
SCOPE="introduced"
|
|
518
|
+
elif [ -n "$LINE" ] && [ "$NOPATCH" -eq 0 ] && [ -s "$ADDED_LINES_FILE" ]; then
|
|
519
|
+
LO=$((LINE > 5 ? LINE - 5 : 1)); INTRODUCED=0; L="$LO"
|
|
520
|
+
while [ "$L" -le $((LINE + 5)) ]; do
|
|
521
|
+
if grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE"; then INTRODUCED=1; break; fi
|
|
522
|
+
L=$((L + 1))
|
|
523
|
+
done
|
|
524
|
+
# The cited line is the reviewer's estimate (stale or at the call site): symbol fallback before routing pre-existing.
|
|
525
|
+
if [ "$INTRODUCED" -eq 0 ] && symbol_in_added "$FILE" "$(printf '%s' "$F" | jq -r '.text // ""')"; then INTRODUCED=1; fi
|
|
526
|
+
if [ "$INTRODUCED" -eq 1 ]; then SCOPE="introduced"; else
|
|
527
|
+
SCOPE="pre-existing"
|
|
528
|
+
case "$SEV" in MEDIUM|LOW|INFO) ROUTE="pre-existing" ;; esac
|
|
529
|
+
fi
|
|
415
530
|
fi
|
|
416
531
|
# Gate 3 (concrete failure): MEDIUM or lower without a **Failure scenario** is speculative hardening, demoted to a NOTE.
|
|
417
532
|
# CRITICAL/HIGH are exempt; an unreadable carrying comment keeps the finding.
|
|
@@ -420,13 +535,14 @@ EOF
|
|
|
420
535
|
fi
|
|
421
536
|
case "$ROUTE" in pre-existing) N_PREEXISTING=$((N_PREEXISTING + 1)) ;; demoted-no-scenario) N_NO_SCENARIO=$((N_NO_SCENARIO + 1)) ;; esac
|
|
422
537
|
N_KEPT=$((N_KEPT + 1))
|
|
423
|
-
|
|
538
|
+
[ "$SCOPE" != "introduced" ] || N_INTRODUCED=$((N_INTRODUCED + 1))
|
|
539
|
+
printf '%s' "$F" | jq -c --arg r "$ROUTE" --arg sc "$SCOPE" '. + {route: $r, scope: $sc}' >> "$FINAL"
|
|
424
540
|
done
|
|
425
541
|
|
|
426
542
|
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" \
|
|
543
|
+
--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
544
|
'{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},
|
|
545
|
+
counts: {findings_extracted: $ex, findings_deduped: $dd, findings_dropped_stale: $st, findings_preexisting: $pe, findings_introduced: $ni, notes_demoted_no_scenario: $ns},
|
|
430
546
|
findings: .}' "$FINAL" > "$OUT_FILE" || { echo "review-finalize: cannot write $OUT_FILE" >&2; exit 2; }
|
|
431
547
|
echo "FINDINGS_OUT=$OUT_FILE"
|
|
432
548
|
echo "STRUCTURED_FINDINGS=$N_EXTRACTED"
|
|
@@ -434,7 +550,8 @@ EOF
|
|
|
434
550
|
echo "FINDINGS_DROPPED_STALE=$N_STALE"
|
|
435
551
|
echo "FINDINGS_PREEXISTING=$N_PREEXISTING"
|
|
436
552
|
echo "NOTES_DEMOTED_NO_SCENARIO=$N_NO_SCENARIO"
|
|
437
|
-
|
|
553
|
+
echo "AGENTS_WITHOUT_BLOCK=$AGENTS_WITHOUT_BLOCK" # undelivered agents (no findings block): re-dispatch once, else degraded panel; never read free text
|
|
554
|
+
{ [ "$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
555
|
exit 0
|
|
439
556
|
}
|
|
440
557
|
|
|
@@ -495,7 +612,7 @@ do_classify() {
|
|
|
495
612
|
&& [ -s "$CONTRACT_SCOPE_FILE" ] \
|
|
496
613
|
&& gh pr diff "$PR_NUMBER" -R "$REPO" --name-only > "$PR_FILES_FILE" 2>/dev/null; then
|
|
497
614
|
# A deferred row counts only if issue N is OPEN, is an issue (not a PR), is not MERGE_ISSUE, and its body
|
|
498
|
-
# carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus the row's path, and is authored by a trusted author
|
|
615
|
+
# carries `FORGE:DEFERRED_FROM: #<MERGE_ISSUE>` plus a checklist line for the row's exact path (the list script strips a trailing slash, so `p` and `p/` both match; `- [ ] \`path\``, [xX] accepted, the producer format of file-deferred-followups.sh; a body that only mentions the path does not count), and is authored by a trusted author
|
|
499
616
|
# (OWNER/MEMBER/COLLABORATOR or Bot; same predicate as trusted-comments.sh; missing fields fail closed).
|
|
500
617
|
# Unverified rows are dropped.
|
|
501
618
|
_verified="$_scratch/contract-verified"; : > "$_verified"
|
|
@@ -505,7 +622,7 @@ do_classify() {
|
|
|
505
622
|
[ "$_n" = "${MERGE_ISSUE#\#}" ] && continue
|
|
506
623
|
gh api "repos/${REPO}/issues/$_n" 2>/dev/null \
|
|
507
624
|
| jq -e --arg m "FORGE:DEFERRED_FROM: #${MERGE_ISSUE#\#} -->" --arg p "$_ipath" \
|
|
508
|
-
'.state == "open" and .pull_request == null and ((.body // "") | contains($m) and
|
|
625
|
+
'.state == "open" and .pull_request == null and ((.body // "") | (contains($m) and (split("\n") | any(rtrimstr("\r") as $l | ("- [ ] `","- [x] `","- [X] `") as $pre | ($p, $p + "/") as $q | ($pre + $q + "`") as $w | $l == $w or ($l | startswith($w + " ")))))) and (((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")) or ((.user.type // "") == "Bot"))' >/dev/null 2>&1 \
|
|
509
626
|
|| continue
|
|
510
627
|
grep -Fxq -- "$_n" "$CONTRACT_OPEN_FILE" || echo "$_n" >> "$CONTRACT_OPEN_FILE"
|
|
511
628
|
fi
|
|
@@ -603,7 +720,7 @@ do_classify() {
|
|
|
603
720
|
TOTAL=$(jq '.findings | length' "$IN_FILE"); I=0
|
|
604
721
|
while [ "$I" -lt "$TOTAL" ]; do
|
|
605
722
|
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)
|
|
723
|
+
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
724
|
if [ "$F_ROUTE" = "demoted-no-scenario" ]; then printf '%s\t%s\n' "$I" "NOTE no-scenario" >> "$DISP_FILE"; I=$((I + 1)); continue; fi
|
|
608
725
|
FINDING_TEXT_FILE="$WORKDIR/finding-text.$I"; jq -r ".findings[$I].text // \"\"" "$IN_FILE" > "$FINDING_TEXT_FILE" 2>/dev/null
|
|
609
726
|
set -- --severity "$F_SEV" --confidence "$F_CONF" --agent "$F_AGENT" --lineage "$FINDING_LINEAGE" --text-file "$FINDING_TEXT_FILE"
|
|
@@ -611,6 +728,7 @@ do_classify() {
|
|
|
611
728
|
[ -n "$INPR_DIFF_FILE" ] && [ "$F_ROUTE" != "pre-existing" ] && set -- "$@" --inpr-diff "$INPR_DIFF_FILE"
|
|
612
729
|
[ -n "$CONTRACT_SCOPE_FILE" ] && set -- "$@" --contract-scope "$CONTRACT_SCOPE_FILE" --contract-open "$CONTRACT_OPEN_FILE" --pr-files "$PR_FILES_FILE"
|
|
613
730
|
[ -n "$MERGE_ISSUE" ] && set -- "$@" --fixes-issue "$MERGE_ISSUE"
|
|
731
|
+
case "$F_SCOPE" in introduced|pre-existing|unknown) set -- "$@" --scope "$F_SCOPE" ;; esac
|
|
614
732
|
case "$F_FIX" in fails|regresses) set -- "$@" --fix-efficacy "$F_FIX" ;; esac
|
|
615
733
|
[ -n "$PRIOR_FILE" ] && { set -- "$@" --prior-findings "$PRIOR_FILE" --title "$F_TITLE"; [ -n "$DIFF_MAP_FILE" ] && set -- "$@" --diff-map "$DIFF_MAP_FILE"; }
|
|
616
734
|
[ -n "$F_L1" ] && set -- "$@" --line-start "$F_L1" --line-end "${F_L2:-$F_L1}"
|
|
@@ -629,11 +747,32 @@ do_classify() {
|
|
|
629
747
|
fi
|
|
630
748
|
case "$DISPOSITION" in
|
|
631
749
|
"NOTE contract-deferred #"*)
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
750
|
+
# The demotion counts only after the comment on #N succeeded and #N is still OPEN; any failure files the finding (fails toward filing).
|
|
751
|
+
_dn="${DISPOSITION##*#}"; _dok=1
|
|
752
|
+
case "$_dn" in ''|*[!0-9]*) _dok=0 ;; *)
|
|
753
|
+
if [ "$DRY_RUN" != "true" ]; then
|
|
754
|
+
_dst=$(gh issue view "$_dn" -R "$REPO" --json state --jq .state 2>/dev/null) || _dst=""
|
|
755
|
+
if [ "$_dst" != "OPEN" ]; then _dok=0
|
|
756
|
+
elif ! gh issue comment "$_dn" -R "$REPO" --body "Review of #${PR_NUMBER} also found this on a path declared deferred here: ${F_LOC} ${F_TITLE}" >/dev/null 2>&1; then _dok=0 # allowlist:check-command-side-effects
|
|
757
|
+
fi
|
|
758
|
+
fi
|
|
635
759
|
;; esac
|
|
636
|
-
CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1))
|
|
760
|
+
if [ "$_dok" = "1" ]; then CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)); else
|
|
761
|
+
# Fall back to what the finding would have been absent the contract (a LOW/POSSIBLE or lineage-damped finding stays a NOTE),
|
|
762
|
+
# not a blanket ISSUE: re-run the classifier without the contract flags. An INPR_FIX or unusable result files (fails toward filing).
|
|
763
|
+
DISPOSITION="ISSUE contract-comment-failed"
|
|
764
|
+
_n=$#; _skip=0
|
|
765
|
+
while [ "$_n" -gt 0 ]; do
|
|
766
|
+
_a="$1"; shift; _n=$((_n - 1))
|
|
767
|
+
if [ "$_skip" = "1" ]; then _skip=0; continue; fi
|
|
768
|
+
case "$_a" in --contract-scope|--contract-open|--pr-files) _skip=1 ;; *) set -- "$@" "$_a" ;; esac
|
|
769
|
+
done
|
|
770
|
+
_fb=$(bash "$CLASSIFY_SCRIPT" "$@" 2>/dev/null | head -n 1) || _fb=""
|
|
771
|
+
case "$_fb" in
|
|
772
|
+
"NOTE reflag"*) DISPOSITION="$_fb"; NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
|
|
773
|
+
NOTE*|ISSUE*) DISPOSITION="$_fb" ;;
|
|
774
|
+
esac
|
|
775
|
+
fi ;;
|
|
637
776
|
"NOTE contract-accepted-risk"*) CONTRACT_DEMOTED=$((CONTRACT_DEMOTED + 1)) ;;
|
|
638
777
|
"NOTE reflag-"*) NOTES_REFLAG=$((NOTES_REFLAG + 1)) ;;
|
|
639
778
|
INPR_FIX*)
|
|
@@ -683,7 +822,7 @@ do_classify() {
|
|
|
683
822
|
FINDINGS_FILED=$(jq '[.findings[] | select(.class == "ISSUE" and .route != "file-pre-existing" and .route != "pre-existing")] | length' "$OUT_FILE")
|
|
684
823
|
PLAIN_NOTES=$(jq '[.findings[] | select(.class == "NOTE" and .route != "pre-existing" and (.disposition | test("^NOTE (contract-|reflag-|capped)") | not))] | length' "$OUT_FILE")
|
|
685
824
|
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")
|
|
825
|
+
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
826
|
N_EXTRACTED=$(jq -r '.counts.findings_extracted // (.findings | length)' "$OUT_FILE")
|
|
688
827
|
CLASSIFIER_MODE=script # the guard above blocks the review when the classifier is unresolvable, so this is always script
|
|
689
828
|
|
|
@@ -705,7 +844,7 @@ do_classify() {
|
|
|
705
844
|
# Disposition marker (MANDATORY whenever Phase 6A extracted at least one finding): Phase 8 refuses to auto-merge a PR that has FINDING:
|
|
706
845
|
# 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
846
|
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} -->
|
|
847
|
+
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
848
|
$(cat "$NOTE_LIST_FILE" 2>/dev/null)"
|
|
710
849
|
printf '%s\n' "$NOTE_DISPOSITION_BODY" > "$OUT_DIR/note-disposition.md"
|
|
711
850
|
POSTED_DISPOSITION=false; POSTED_INPR=false
|
|
@@ -782,7 +921,8 @@ do_file_prepare() {
|
|
|
782
921
|
while [ "$I" -lt "$TOTAL" ]; do
|
|
783
922
|
F_ROUTE=$(fj .route); F_CLASS=$(fj .class)
|
|
784
923
|
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)
|
|
924
|
+
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)
|
|
925
|
+
case "$F_SCOPE" in introduced|pre-existing|unknown) ;; *) F_SCOPE="unknown" ;; esac
|
|
786
926
|
case "$F_LINE" in ''|*[!0-9]*) F_LINE=0 ;; esac
|
|
787
927
|
ACTION="file"; DEDUP_OF=""; REGRESSION=""; NOTE=""; CONSOLIDATE="false"; PRIOR_NUMS=""; SUBSYSTEM=""; RECURRENCE=""
|
|
788
928
|
# Title (defense in depth: strip command substitution so the raw title stays readable if it round-trips through an eval-based consumer).
|
|
@@ -881,8 +1021,8 @@ do_file_prepare() {
|
|
|
881
1021
|
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
1022
|
--arg prio "$FINDING_PRIORITY" --argjson labels "$LABELS" --arg ms "$MILESTONE_TITLE" --arg batch "$BATCHABLE" --arg dedup "$DEDUP_OF" \
|
|
883
1023
|
--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,
|
|
1024
|
+
--arg rec "$RECURRENCE" --arg route "$F_ROUTE" --arg agent "$F_AGENT" --arg scope "$F_SCOPE" \
|
|
1025
|
+
'{id: $id, action: $action, route: $route, title: $title, file: $file, line: ($line | tonumber), severity: $sev, scope: $scope, agent: $agent, priority: $prio,
|
|
886
1026
|
labels: $labels, milestone: $ms, batchable: ($batch == "true"), dedup_of: ($dedup | tonumber? // null), regression_of: ($reg | tonumber? // null),
|
|
887
1027
|
note: $note, pattern: {slug: $slug, subsystem: $sub, consolidate: ($cons == "true"), prior: ($prior | split(" ") | map(select(length > 0) | tonumber)), recurrence: $rec}}' >> "$OUT_ITEMS"
|
|
888
1028
|
I=$((I + 1))
|
|
@@ -953,6 +1093,20 @@ do_verdict() {
|
|
|
953
1093
|
if [ "$RESULT" = "clean" ] && { [ "$BASE_CONFLICT" = "true" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ]; }; then
|
|
954
1094
|
RESULT="blocking"; VERDICT_COERCED=true
|
|
955
1095
|
fi
|
|
1096
|
+
# OTHER_BLOCKING (criteria 1, 2, 3 and 5) is reviewer-judged, so it is resolved here and persisted for merge-gate to OR in: a blocking
|
|
1097
|
+
# result that BASE_CONFLICT and HAS_PURPOSE_REGRESSION do not explain was caused by another criterion, whatever the reviewer passed.
|
|
1098
|
+
OTHER_BLOCKING_RESOLVED=""
|
|
1099
|
+
case "$OTHER_BLOCKING" in true|false) OTHER_BLOCKING_RESOLVED="$OTHER_BLOCKING" ;; esac
|
|
1100
|
+
if [ "$RESULT" = "blocking" ] && [ "$BASE_CONFLICT" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ]; then
|
|
1101
|
+
OTHER_BLOCKING_RESOLVED=true
|
|
1102
|
+
elif [ "$RESULT" = "clean" ]; then
|
|
1103
|
+
OTHER_BLOCKING_RESOLVED=false
|
|
1104
|
+
fi
|
|
1105
|
+
# Rewritten (or removed) on every run so a stale value from a prior head can never survive a re-review.
|
|
1106
|
+
rm -f "$OUT_DIR/other-blocking.txt" 2>/dev/null || true
|
|
1107
|
+
if { [ "$RESULT" = "clean" ] || [ "$RESULT" = "blocking" ]; } && [ -n "$OTHER_BLOCKING_RESOLVED" ] && [ "$DRY_RUN" != "true" ]; then
|
|
1108
|
+
printf '%s\n' "$OTHER_BLOCKING_RESOLVED" > "$OUT_DIR/other-blocking.txt" 2>/dev/null || true
|
|
1109
|
+
fi
|
|
956
1110
|
VERDICT_OUT=""
|
|
957
1111
|
case "$RESULT" in
|
|
958
1112
|
stale) VERDICT_OUT="STALE" ;;
|
|
@@ -987,6 +1141,7 @@ ${TRUST_ANNOTATION}${CONFLICT_LINE}${PROTECTION_LINE}${PURPOSE_LINE}${ATTRIBUTIO
|
|
|
987
1141
|
echo "HAS_PURPOSE_REGRESSION=${HAS_PURPOSE_REGRESSION}"
|
|
988
1142
|
echo "HAS_MERGE_CONFLICT=${HAS_MERGE_CONFLICT}"
|
|
989
1143
|
echo "BASE_CONFLICT=${BASE_CONFLICT}"
|
|
1144
|
+
echo "OTHER_BLOCKING=${OTHER_BLOCKING_RESOLVED}"
|
|
990
1145
|
echo "MERGE_CONFLICT_MSG=${MERGE_CONFLICT_MSG}"
|
|
991
1146
|
echo "MERGE_PROTECTION_WARNING=${MERGE_PROTECTION_WARNING}"
|
|
992
1147
|
exit 0
|
|
@@ -1061,9 +1216,10 @@ do_calibrate() {
|
|
|
1061
1216
|
fi
|
|
1062
1217
|
echo "CALIBRATION_NEEDS_HUMAN=${CALIBRATION_NEEDS_HUMAN}"
|
|
1063
1218
|
echo "CALIBRATION_CELL=${CALIBRATION_CELL}"
|
|
1064
|
-
#
|
|
1065
|
-
#
|
|
1219
|
+
# Defense in depth: strip quote, backtick, dollar sign, backslash and newline from the echoed line (#3918 class). Phase 8 reads the
|
|
1220
|
+
# note from the file below, not from this line, so it never passes through a shell word.
|
|
1066
1221
|
CALIBRATION_NOTE=$(printf '%s' "$CALIBRATION_NOTE" | tr -d "'\`\$\\\\" | tr '\r\n' ' ')
|
|
1222
|
+
printf '%s\n' "$CALIBRATION_NOTE" > "$OUT_DIR/calibration-note.txt" 2>/dev/null || true
|
|
1067
1223
|
echo "CALIBRATION_NOTE=${CALIBRATION_NOTE}"
|
|
1068
1224
|
exit 0
|
|
1069
1225
|
}
|
|
@@ -1184,6 +1340,86 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
|
|
|
1184
1340
|
fi
|
|
1185
1341
|
[ "${TRAIL_RC:-1}" -eq 0 ] || blocked "phase trail unreadable (rc=${TRAIL_RC:-1})" # hard guard: nothing below runs unless the trail verified
|
|
1186
1342
|
|
|
1343
|
+
# --- degraded-gate gate (forge#3628): a build whose tests ran nothing is not a verified build ---
|
|
1344
|
+
# The newest trusted FORGE:QUALITY_GATE (`**Degraded**: name — reason; ...`) and FORGE:ACCEPTANCE_GATE (`:DEGRADED` marker) on the linked
|
|
1345
|
+
# issue name the degraded checks. Each one must be named in a trusted `### Degraded Paths Reviewed` section on the PR; a generic section
|
|
1346
|
+
# does not satisfy it. Unreadable comments fail closed. Only trusted-comments.sh ever reads these comments, so an untrusted comment can
|
|
1347
|
+
# neither forge a DEGRADED marker nor clear one.
|
|
1348
|
+
DEG_ISSUE_JSON=$(gh api --paginate "repos/${REPO}/issues/${MERGE_ISSUE}/comments" 2>/dev/null) || DEG_ISSUE_JSON=""
|
|
1349
|
+
if [ -z "$DEG_ISSUE_JSON" ] || [ -z "$TRUSTED_SCRIPT" ]; then
|
|
1350
|
+
echo "DEGRADED GATE: issue comments or trusted-comments.sh unreadable — refusing to merge (fail closed)" >&2
|
|
1351
|
+
blocked "note disposition unreadable"
|
|
1352
|
+
fi
|
|
1353
|
+
DEG_NAMES=""; DEG_ANY=false
|
|
1354
|
+
DEG_QG=$(printf '%s' "$DEG_ISSUE_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:QUALITY_GATE -->') || DEG_QG="__ERR__"
|
|
1355
|
+
DEG_AG=$(printf '%s' "$DEG_ISSUE_JSON" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:ACCEPTANCE_GATE -->') || DEG_AG="__ERR__"
|
|
1356
|
+
if [ "$DEG_QG" = "__ERR__" ] || [ "$DEG_AG" = "__ERR__" ]; then
|
|
1357
|
+
echo "DEGRADED GATE: trust filter failed — refusing to merge (fail closed)" >&2
|
|
1358
|
+
blocked "note disposition unreadable"
|
|
1359
|
+
fi
|
|
1360
|
+
# newest body of each gate (bodies are JSON strings, one per line, oldest first)
|
|
1361
|
+
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')
|
|
1362
|
+
DEG_AG_BODY=$(printf '%s\n' "$DEG_AG" | tail -n 1 | jq -r 'select(type == "string")' 2>/dev/null)
|
|
1363
|
+
case "$DEG_QG_LINE" in ''|none|None|NONE) ;; *) DEG_ANY=true; DEG_NAMES="$DEG_QG_LINE" ;; esac
|
|
1364
|
+
if printf '%s\n' "$DEG_AG_BODY" | grep -qE '^[[:space:]]*<!-- FORGE:ACCEPTANCE_GATE:DEGRADED -->[[:space:]]*$'; then
|
|
1365
|
+
DEG_ANY=true
|
|
1366
|
+
DEG_AG_LINE=$(printf '%s\n' "$DEG_AG_BODY" | sed -n 's/^\*\*Degraded checks[^*]*\*\*: *//p' | sed -n '1p')
|
|
1367
|
+
[ -n "$DEG_AG_LINE" ] && DEG_NAMES="${DEG_NAMES:+$DEG_NAMES; }${DEG_AG_LINE}"
|
|
1368
|
+
fi
|
|
1369
|
+
if [ "$DEG_ANY" = "true" ]; then
|
|
1370
|
+
# Reviewed section (forge#3995): only a trusted review comment (FORGE:REVIEW-AGENT / FORGE:REVIEW / synthesis marker as the
|
|
1371
|
+
# LEADING text) carrying `Reviewed-SHA:` for the CURRENT head counts, so a stale section from an older head or a plain comment
|
|
1372
|
+
# cannot clear the gate. An unreadable head fails closed.
|
|
1373
|
+
DEG_SECTIONS=""
|
|
1374
|
+
DEG_HEAD=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq .head.sha 2>/dev/null) || DEG_HEAD=""
|
|
1375
|
+
if ! [[ "$DEG_HEAD" =~ ^([0-9a-fA-F]{40}|[0-9a-fA-F]{64})$ ]]; then
|
|
1376
|
+
echo "DEGRADED GATE: PR head unreadable — refusing to merge (fail closed)" >&2
|
|
1377
|
+
blocked "note disposition unreadable"
|
|
1378
|
+
fi
|
|
1379
|
+
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__"
|
|
1380
|
+
if [ "$DEG_PR_BODIES" = "__ERR__" ]; then
|
|
1381
|
+
echo "DEGRADED GATE: trust filter failed on PR comments — refusing to merge (fail closed)" >&2
|
|
1382
|
+
blocked "note disposition unreadable"
|
|
1383
|
+
fi
|
|
1384
|
+
if [ -n "$DEG_PR_BODIES" ]; then
|
|
1385
|
+
DEG_SECTIONS=$(printf '%s\n' "$DEG_PR_BODIES" | while IFS= read -r _db; do
|
|
1386
|
+
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 \
|
|
1387
|
+
| awk '/^### Degraded Paths Reviewed[[:space:]]*$/ {f=1; next} f && /^(#|##|###) / {f=0} f {print}'
|
|
1388
|
+
done)
|
|
1389
|
+
fi
|
|
1390
|
+
DEG_MISSING=""
|
|
1391
|
+
if [ -z "$(printf '%s' "$DEG_SECTIONS" | tr -d '[:space:]')" ]; then
|
|
1392
|
+
DEG_MISSING="no \`### Degraded Paths Reviewed\` section on a review comment for the current head"
|
|
1393
|
+
else
|
|
1394
|
+
# per-check match (forge#3995): the name is everything before " — " in each "; "-separated entry. It must be a whole list
|
|
1395
|
+
# item `- name: <how it was reviewed>` whose explanation does not negate it; a substring or a "NOT reviewed" item is not coverage.
|
|
1396
|
+
DEG_NAME_LIST=$(printf '%s\n' "$DEG_NAMES" | tr ';' '\n' | sed 's/ — .*$//; s/^[[:space:]]*//; s/[[:space:]]*$//' | sed '/^$/d' | sort -u)
|
|
1397
|
+
while IFS= read -r _dn; do
|
|
1398
|
+
[ -n "$_dn" ] || continue
|
|
1399
|
+
_hit=$(printf '%s\n' "$DEG_SECTIONS" | awk -v n="$_dn" '
|
|
1400
|
+
{ l=$0; sub(/^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+/, "", l); gsub(/`/, "", l)
|
|
1401
|
+
if (index(l, n) != 1) next
|
|
1402
|
+
r=substr(l, length(n)+1)
|
|
1403
|
+
if (r !~ /^[[:space:]]*[:—-][[:space:]]*[^[:space:]]/) next
|
|
1404
|
+
sub(/^[[:space:]]*[:—-][[:space:]]*/, "", r)
|
|
1405
|
+
if (tolower(r) ~ /^(not|never|no|n\/a|none|unreviewed|skipped)([^a-z]|$)/) next
|
|
1406
|
+
found=1 }
|
|
1407
|
+
END { print found+0 }')
|
|
1408
|
+
[ "$_hit" = "1" ] || DEG_MISSING="${DEG_MISSING:+$DEG_MISSING, }${_dn}"
|
|
1409
|
+
done <<< "$DEG_NAME_LIST"
|
|
1410
|
+
[ -z "$DEG_MISSING" ] || DEG_MISSING="not covered: ${DEG_MISSING}"
|
|
1411
|
+
fi
|
|
1412
|
+
if [ -n "$DEG_MISSING" ]; then
|
|
1413
|
+
echo "DEGRADED GATE: the build's gate is DEGRADED (${DEG_NAMES}) and the review does not cover it (${DEG_MISSING})" >&2
|
|
1414
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "<!-- FORGE:DEGRADED_GATE_UNREVIEWED -->
|
|
1415
|
+
Auto-merge refused for PR #${PR_NUMBER}: the build's verification was DEGRADED (a check ran nothing or did not complete): ${DEG_NAMES}.
|
|
1416
|
+
|
|
1417
|
+
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 -->
|
|
1418
|
+
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
|
|
1419
|
+
blocked "degraded-gate-unreviewed"
|
|
1420
|
+
fi
|
|
1421
|
+
fi
|
|
1422
|
+
|
|
1187
1423
|
# --- merge guard state: §7B verdict + purpose-regression + calibration + trust-escalation ---
|
|
1188
1424
|
# These are decided in earlier steps (earlier shells), so they arrive as flags. An empty or unbound value would read as
|
|
1189
1425
|
# "not blocking" and fall through to the merge path, so any value outside the expected set stops here instead of merging.
|
|
@@ -1198,6 +1434,8 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
|
|
|
1198
1434
|
gh issue edit "$MERGE_ISSUE" -R "$REPO" --add-label "needs-human" 2>/dev/null || true # <!-- allowlist:check-command-side-effects -->
|
|
1199
1435
|
blocked "merge guard state unbound"
|
|
1200
1436
|
fi
|
|
1437
|
+
# OR in the value `verdict` persisted: a passed `false` never overrides a persisted `true`. Anything else in the file is ignored.
|
|
1438
|
+
if [ "$(head -n 1 "$OUT_DIR/other-blocking.txt" 2>/dev/null | tr -d '[:space:]')" = "true" ]; then OTHER_BLOCKING=true; fi
|
|
1201
1439
|
# ONLY_BASE_CONFLICT: the base conflict is the ONLY reason for CHANGES REQUESTED, evaluated after every human gate is set.
|
|
1202
1440
|
ONLY_BASE_CONFLICT=false
|
|
1203
1441
|
if [ "$BASE_CONFLICT" = "true" ] && [ "$OTHER_BLOCKING" != "true" ] && [ "$HAS_PURPOSE_REGRESSION" != "true" ] && [ "$CALIBRATION_NEEDS_HUMAN" != "true" ] && [ "$TRUST_NEEDS_HUMAN" != "true" ]; then
|
|
@@ -1205,7 +1443,7 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
|
|
|
1205
1443
|
fi
|
|
1206
1444
|
if [ "$ONLY_BASE_CONFLICT" = "true" ] && [ "$VERDICT" = "CHANGES REQUESTED" ]; then
|
|
1207
1445
|
# Pure base conflict: route to the base-sync path, NOT needs-human. A standalone /review-pr caller just sees the blocker.
|
|
1208
|
-
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason).
|
|
1446
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict with \`${BASE}\` (the only blocking reason). Base sync needed: the work-on pipeline performs the base sync (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push); a standalone \`/review-pr --auto-merge\` caller with no work-on router must run \`/work-on ${MERGE_ISSUE} --remediate\` to do it. The sync re-runs the quality gate and then re-reviews the new head. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
|
|
1209
1447
|
echo "MERGE_OUTCOME=base-conflict"
|
|
1210
1448
|
blocked "base-conflict"
|
|
1211
1449
|
elif [ "$VERDICT" = "CHANGES REQUESTED" ] || [ "$HAS_PURPOSE_REGRESSION" = "true" ] || [ "$CALIBRATION_NEEDS_HUMAN" = "true" ] || [ "$TRUST_NEEDS_HUMAN" = "true" ]; then
|
|
@@ -1233,7 +1471,7 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
|
|
|
1233
1471
|
fi
|
|
1234
1472
|
if [ "$PRE_MERGE_BASE_CONFLICT" = "true" ]; then
|
|
1235
1473
|
# Base moved during review: route to the base-sync path, NOT needs-human.
|
|
1236
|
-
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`).
|
|
1474
|
+
gh issue comment "$MERGE_ISSUE" -R "$REPO" --body "⛔ Auto-merge aborted for PR #${PR_NUMBER}: base conflict (\`mergeable=${PRE_MERGE_HEALTH}\`, \`mergeStateStatus=${PRE_MERGE_HEALTH_STATE}\`). Base sync needed: the work-on pipeline performs the base sync (merging \`origin/${BASE}\` into the PR branch, merge only, no rebase, no force-push); a standalone \`/review-pr --auto-merge\` caller with no work-on router must run \`/work-on ${MERGE_ISSUE} --remediate\` to do it. The sync is followed by a re-review. A human is pulled in only if the conflict cannot be resolved automatically." # <!-- allowlist:check-command-side-effects -->
|
|
1237
1475
|
echo "MERGE_OUTCOME=base-conflict"
|
|
1238
1476
|
blocked "base-conflict"
|
|
1239
1477
|
elif [ "$PRE_MERGE_HEALTH_STATE" = "BLOCKED" ]; then
|
|
@@ -1277,6 +1515,9 @@ Re-run each missing phase via its Skill (see the \`->\` action on each MISSING l
|
|
|
1277
1515
|
else CI_GATE_OUT="CI_GATE: ERROR — scripts/wait-ci-green.sh not resolvable (fail closed)"; CI_GATE_RC=2; fi
|
|
1278
1516
|
echo "$CI_GATE_OUT"
|
|
1279
1517
|
GATED_HEAD=$(printf '%s\n' "$CI_GATE_OUT" | sed -n 's/^CI_GATE_HEAD: //p' | head -1)
|
|
1518
|
+
# rc 4 RATE_LIMITED (GitHub throttled or briefly unavailable for the whole window) is a wait like TIMEOUT: map it to the
|
|
1519
|
+
# pending path BEFORE rc 4 is reused below as the stale-review sentinel, so it is never a merge and never a re-review.
|
|
1520
|
+
[ "$CI_GATE_RC" -eq 4 ] && CI_GATE_RC=3
|
|
1280
1521
|
# rc 2 "head moved" is a stale review, not a human problem; rc 3 TIMEOUT means CI is still running.
|
|
1281
1522
|
printf '%s\n' "$CI_GATE_OUT" | grep -q 'PR head moved during the wait' && CI_GATE_RC=4
|
|
1282
1523
|
if [ "$CI_GATE_RC" -eq 3 ]; then
|
|
@@ -1399,7 +1640,7 @@ do_integrity() {
|
|
|
1399
1640
|
ACTUAL_AGENT_DOMAINS=""
|
|
1400
1641
|
if [ -n "$TRUSTED_SCRIPT" ]; then
|
|
1401
1642
|
ACTUAL_AGENT_DOMAINS=$(printf '%s' "$COMMENTS_JSON" \
|
|
1402
|
-
| bash "$TRUSTED_SCRIPT" bodies "
|
|
1643
|
+
| bash "$TRUSTED_SCRIPT" bodies "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->" 2>/dev/null \
|
|
1403
1644
|
| jq -rs '[.[] | scan("^<!-- FORGE:REVIEW-AGENT:([a-z-]+) -->") | .[0]] | unique | join(", ")' 2>/dev/null || echo "") # unresolvable => empty => count 0 (degraded, fail closed)
|
|
1404
1645
|
fi
|
|
1405
1646
|
# 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".
|