forgedock 1.16.2 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/bin/engine/outcome-codes.mjs +114 -0
  2. package/bin/engine/phases.mjs +211 -100
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +20 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +17 -1
  10. package/bin/engine.mjs +227 -48
  11. package/bin/forgedock.mjs +8 -2
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +220 -0
  14. package/bin/runner.mjs +70 -4
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +211 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +312 -0
  23. package/commands/cleanup.md +90 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/deploy-pr.md +64 -0
  26. package/commands/fix-ci.md +20 -6
  27. package/commands/milestone.md +34 -17
  28. package/commands/orchestrate/phase-1-resolve.md +4 -1
  29. package/commands/orchestrate/phase-4-execution.md +28 -6
  30. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  31. package/commands/pipeline-health/phase-1-context.md +2 -0
  32. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  33. package/commands/pipeline-status.md +21 -1
  34. package/commands/review-pr-agents/protocols.md +1 -0
  35. package/commands/review-pr-agents.md +10 -2
  36. package/commands/review-pr-staging.md +196 -496
  37. package/commands/review-pr.md +512 -3134
  38. package/commands/test-gate.md +25 -10
  39. package/commands/work-on/build/architect.md +13 -1
  40. package/commands/work-on/build/implement.md +28 -6
  41. package/commands/work-on/build/validate.md +5 -2
  42. package/commands/work-on/build.md +197 -145
  43. package/commands/work-on/close.md +66 -20
  44. package/commands/work-on/decompose.md +113 -34
  45. package/commands/work-on/investigate.md +15 -14
  46. package/commands/work-on/remediate.md +49 -19
  47. package/commands/work-on/review.md +7 -5
  48. package/commands/work-on.md +1 -2
  49. package/docs/CONFIG.md +76 -24
  50. package/docs/OUTCOME-CODES.md +53 -0
  51. package/docs/RELEASE-NOTES.md +24 -0
  52. package/docs/STATS.md +63 -0
  53. package/docs/TELEMETRY.md +65 -0
  54. package/docs/WORK-ON-RUNTIME.md +19 -0
  55. package/docs/site/command-reference.md +1 -1
  56. package/docs/spec/forge-protocol-v1.md +2 -2
  57. package/docs/spec/label-state-machine.md +1 -1
  58. package/package.json +3 -2
  59. package/packages/protocol/src/phases.js +5 -5
  60. package/scripts/builder-resume-match.test.sh +58 -0
  61. package/scripts/cap-findings.sh +110 -0
  62. package/scripts/cap-findings.test.sh +130 -0
  63. package/scripts/check-spec-bash.sh +5 -1
  64. package/scripts/check-spec-bash.test.sh +20 -0
  65. package/scripts/check-spec-markers.sh +2 -0
  66. package/scripts/classify-finding.sh +65 -16
  67. package/scripts/classify-finding.test.sh +43 -1
  68. package/scripts/classify-grep-exit.sh +20 -0
  69. package/scripts/classify-grep-exit.test.sh +46 -0
  70. package/scripts/close-parent-c3.test.sh +123 -0
  71. package/scripts/cost-report.sh +146 -0
  72. package/scripts/cost-report.test.sh +80 -0
  73. package/scripts/diff-size.sh +97 -3
  74. package/scripts/diff-size.test.sh +59 -2
  75. package/scripts/doctor-pipeline-state.sh +43 -0
  76. package/scripts/file-deferred-followups.sh +159 -0
  77. package/scripts/file-deferred-followups.test.sh +106 -0
  78. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  79. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  80. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  81. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  82. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  83. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  84. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  85. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  86. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  87. package/scripts/forge-root.test.sh +18 -11
  88. package/scripts/lib/fence-bootstrap.sh +4 -3
  89. package/scripts/lib/pr-issue-number.sh +18 -0
  90. package/scripts/lib/review-template-resolve.sh +41 -0
  91. package/scripts/lib/telemetry.sh +168 -0
  92. package/scripts/publish-staging-sync.test.sh +48 -0
  93. package/scripts/remediate-liveness.sh +42 -6
  94. package/scripts/resolve-deploy-gate.sh +32 -6
  95. package/scripts/resolve-deploy-gate.test.sh +42 -1
  96. package/scripts/review-acceptance-gate.sh +16 -7
  97. package/scripts/review-acceptance-gate.test.sh +13 -0
  98. package/scripts/review-finalize.sh +1427 -0
  99. package/scripts/review-finalize.test.sh +833 -0
  100. package/scripts/review-pr-delta-wiring.test.sh +19 -72
  101. package/scripts/review-pr-domain-map.test.sh +23 -13
  102. package/scripts/review-pr-gate2.test.sh +24 -65
  103. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  104. package/scripts/review-pr-staging-scope.test.sh +56 -80
  105. package/scripts/review-prepare.sh +553 -0
  106. package/scripts/review-prepare.test.sh +288 -0
  107. package/scripts/review-roster.sh +186 -0
  108. package/scripts/review-roster.test.sh +185 -0
  109. package/scripts/run-shell-tests.sh +3 -0
  110. package/scripts/telemetry.test.sh +106 -0
  111. package/scripts/trusted-comments.test.sh +9 -2
  112. package/scripts/wait-ci-green.sh +8 -1
  113. package/scripts/wait-ci-green.test.sh +3 -3
  114. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Tests for scripts/review-roster.sh (risk-tiered reviewer roster, forge#3599): the tier cases, the path triggers that
6
+ # escalate in every tier, and the regression table of the 13 review-driven holds. Run: bash scripts/review-roster.test.sh
7
+ # (bash 3.2 compatible)
8
+ #
9
+ # Hold table fixture: the changed-line count, file count and file list of each PR, the PR's linked-issue lineage, and
10
+ # the reviewers whose posted findings were CRITICAL or BLOCKER on it (from the FORGE:REVIEW-AGENT comments). The
11
+ # regression gate: at least one catching reviewer stays selected on every hold, and the sole catcher always does.
12
+
13
+ set -u
14
+ DIR=$(cd "$(dirname "$0")" && pwd)
15
+ SCRIPT="$DIR/review-roster.sh"
16
+ pass=0; fail=0
17
+ ok() { pass=$((pass+1)); }
18
+ bad() { fail=$((fail+1)); echo "FAIL: $1"; }
19
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/review-roster.XXXXXX")
20
+ trap 'rm -rf "$WORK"' EXIT
21
+
22
+ SEL=""; TIER=""; REASON=""
23
+ # run <files, newline separated> <review-roster flags...>
24
+ run() {
25
+ printf '%s\n' "$1" > "$WORK/files"; shift
26
+ out=$(bash "$SCRIPT" --files-file "$WORK/files" "$@") || { bad "script exited non-zero for: $*"; return 1; }
27
+ SEL=$(printf '%s\n' "$out" | sed -n 's/^SELECTED_AGENTS=//p')
28
+ TIER=$(printf '%s\n' "$out" | sed -n 's/^TIER=//p')
29
+ REASON=$(printf '%s\n' "$out" | sed -n 's/^REASON=//p')
30
+ }
31
+ has() { case " $SEL " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
32
+ expect_sel() { if [ "$SEL" = "$2" ]; then ok; else bad "$1: roster '$SEL', want '$2' (tier $TIER; $REASON)"; fi; }
33
+ expect_has() { if has "$2"; then ok; else bad "$1: '$2' missing from '$SEL' (tier $TIER; $REASON)"; fi; }
34
+ expect_not() { if has "$2"; then bad "$1: '$2' should not be in '$SEL' (tier $TIER)"; else ok; fi; }
35
+ expect_tier() { if [ "$TIER" = "$2" ]; then ok; else bad "$1: tier '$TIER', want '$2'"; fi; }
36
+
37
+ SC_QUIET="AUTH=0 BILLING=0 CONCURRENCY=0 DATABASE=0 INFRA=0 SECURITY=1 SCRAPING=0 FRONTEND=0 API=0"
38
+ SC_NOISY="AUTH=3 BILLING=1 CONCURRENCY=0 DATABASE=1 INFRA=3 SECURITY=4 SCRAPING=0 FRONTEND=2 API=2"
39
+ SC_BUSY="AUTH=3 BILLING=3 CONCURRENCY=3 DATABASE=3 INFRA=3 SECURITY=1 SCRAPING=0 FRONTEND=2 API=2"
40
+
41
+ # Case 1: a small diff gives Security only, even when diff-text scores are loud
42
+ run "commands/foo.md" --scores "$SC_NOISY" --changed-lines 40 --changed-files 1
43
+ expect_sel "small diff" "security"; expect_tier "small diff" small
44
+ run "scripts/a.sh
45
+ scripts/b.sh" --scores "$SC_NOISY" --changed-lines 60 --changed-files 2
46
+ expect_sel "60-line boundary" "security"
47
+ # a single file is small up to 150 lines, not beyond
48
+ run "scripts/a.sh" --scores "$SC_QUIET" --changed-lines 120 --changed-files 1
49
+ expect_tier "single file" small
50
+ run "scripts/a.sh" --scores "$SC_QUIET" --changed-lines 400 --changed-files 1
51
+ expect_tier "single large file" standard
52
+
53
+ # Case 2: a small diff touching bin/engine/** adds concurrency and spec-cli
54
+ run "bin/engine/phases.mjs" --scores "$SC_QUIET" --changed-lines 30 --changed-files 1 --spec-cli true
55
+ expect_has "small bin/engine" concurrency; expect_has "small bin/engine" spec-cli; expect_has "small bin/engine" security; expect_tier "small bin/engine" small
56
+ run "bin/engine/phases.mjs" --scores "$SC_QUIET" --changed-lines 30 --changed-files 1 --spec-cli false
57
+ expect_has "consumer repo bin/engine" concurrency; expect_not "consumer repo bin/engine" spec-cli
58
+
59
+ # Case 3: a fix PR gives Security only
60
+ run "commands/orchestrate/phase-4-execution.md
61
+ commands/work-on.md" --scores "$SC_NOISY" --changed-lines 200 --changed-files 2 --fix-pr true --source-severity MEDIUM
62
+ expect_sel "fix PR" "security"; expect_tier "fix PR" fix-pr
63
+
64
+ # Case 4: a fix PR whose source finding was HIGH adds the domain agent
65
+ run "commands/work-on.md" --scores "AUTH=0 BILLING=0 CONCURRENCY=2 DATABASE=0 INFRA=0 FRONTEND=0 API=0" --changed-lines 40 --fix-pr true --source-severity HIGH --source-domain Concurrency
66
+ expect_sel "fix PR, HIGH source finding" "security concurrency"
67
+ run "commands/work-on.md" --scores "AUTH=0 BILLING=0 CONCURRENCY=2 DATABASE=0 INFRA=0 FRONTEND=0 API=0" --changed-lines 40 --fix-pr true --source-severity critical
68
+ expect_sel "fix PR, CRITICAL source, top domain" "security concurrency"
69
+
70
+ # Case 5: CONTRACT HIGH_RISK escalates in every tier
71
+ for tier_args in "--changed-lines 30 --changed-files 1" "--changed-lines 100" "--changed-lines 400" "--changed-lines 30 --fix-pr true"; do
72
+ # shellcheck disable=SC2086
73
+ run "svc/x.py" --scores "$SC_QUIET" $tier_args --contract-flags "HIGH_RISK CONTRACT_AUTH CONTRACT_BILLING"
74
+ expect_has "HIGH_RISK ($tier_args)" auth; expect_has "HIGH_RISK ($tier_args)" billing; expect_has "HIGH_RISK ($tier_args)" concurrency
75
+ done
76
+ run "svc/x.py" --scores "$SC_QUIET" --changed-lines 30 --contract-flags "CONTRACT_AUTH"
77
+ expect_not "CONTRACT_AUTH without HIGH_RISK" auth
78
+
79
+ # Case 6: --thorough and milestone give the full union
80
+ run "svc/x.py" --scores "$SC_BUSY" --changed-lines 10 --thorough true
81
+ expect_tier "thorough" full
82
+ for d in security auth billing concurrency database infra frontend api; do expect_has "thorough" "$d"; done
83
+ expect_not "thorough, scraping not configured" scraper
84
+ run "svc/x.py" --scores "$SC_BUSY SCRAPING=3" --changed-lines 10 --milestone true --scraping-configured true
85
+ expect_tier "milestone" full; expect_has "milestone" scraper
86
+
87
+ # Path gates: auth and infra only by path; the other path triggers fire in the small tier
88
+ run "svc/app/routers/session.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
89
+ expect_has "auth path" auth
90
+ run "scripts/trusted-comments.sh" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
91
+ expect_has "trust-filter path" auth
92
+ run "commands/review-pr.md" --scores "AUTH=3 INFRA=3 DATABASE=0 BILLING=0 CONCURRENCY=0" --changed-lines 120
93
+ expect_not "auth from prose score alone" auth; expect_not "infra from prose score alone" infra
94
+ run ".github/workflows/ci.yml" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
95
+ expect_has "infra path" infra
96
+ run "docker-compose.prod.yml" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
97
+ expect_has "docker path" infra
98
+ run "db/migrations/0042_add.sql" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
99
+ expect_has "migration path" database
100
+ run "sdk/node/src/index.ts" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
101
+ expect_has "sdk path" api
102
+ run "svc/x.py" --scores "AUTH=2 BILLING=2" --changed-lines 20 --changed-files 1
103
+ expect_has "cross-critical" auth; expect_has "cross-critical" billing
104
+ run "svc/x.py" --scores "BILLING=3 CONCURRENCY=3" --changed-lines 20 --changed-files 1 --churn true
105
+ expect_has "churn hot-spot top domain" billing
106
+ run "svc/x.py" --scores "BILLING=3" --changed-lines 20 --changed-files 2
107
+ expect_not "billing score in small tier" billing
108
+ run "svc/x.py
109
+ svc/y.py" --scores "BILLING=3" --changed-lines 100
110
+ expect_has "billing score in medium tier" billing; expect_has "billing score in medium tier" concurrency
111
+
112
+ # Tier shape: standard keeps the top-2 baseline, medium does not
113
+ run "web/src/app/a.tsx
114
+ web/src/b.tsx" --scores "FRONTEND=3 API=2 BILLING=1 DATABASE=1" --changed-lines 300
115
+ expect_tier "standard" standard; expect_has "standard" frontend; expect_has "standard" api; expect_not "standard top-2 only" billing
116
+ run "web/src/app/a.tsx
117
+ web/src/b.tsx" --scores "FRONTEND=3 API=2" --changed-lines 100
118
+ expect_sel "medium has no baseline" "security"
119
+ run "svc/x.py" --scores "FRONTEND=3" --changed-lines ""
120
+ expect_tier "unknown size" standard; expect_has "unknown size" frontend
121
+ run "commands/x.md
122
+ commands/y.md" --scores "$SC_QUIET" --changed-lines 300 --spec-cli true
123
+ expect_has "standard commands/** adds spec-cli" spec-cli
124
+ run "commands/x.md
125
+ commands/y.md" --scores "$SC_QUIET" --changed-lines 100 --spec-cli true
126
+ expect_not "medium commands/** has no spec-cli" spec-cli
127
+
128
+ # Roster output is canonical and duplicate free
129
+ run "bin/engine/a.mjs
130
+ .github/workflows/a.yml" --scores "$SC_BUSY" --changed-lines 300 --spec-cli true
131
+ n=$(printf '%s' "$SEL" | tr ' ' '\n' | grep -c .); u=$(printf '%s' "$SEL" | tr ' ' '\n' | sort -u | grep -c .)
132
+ if [ "$n" -eq "$u" ]; then ok; else bad "duplicate roster entries in '$SEL'"; fi
133
+ case "$SEL" in *[A-Z]*) bad "non-canonical roster '$SEL'" ;; *) ok ;; esac
134
+
135
+ # Unknown flag fails (callers fall back to the full union)
136
+ if bash "$SCRIPT" --bogus 1 >/dev/null 2>&1; then bad "unknown flag accepted"; else ok; fi
137
+
138
+ # Regression table: the 13 review-driven holds. Columns: PR | lines | files | fix-PR | catchers (CRITICAL/BLOCKER) | sole catcher | file list
139
+ hold() {
140
+ pr="$1"; lines="$2"; nf="$3"; fixpr="$4"; catchers="$5"; sole="$6"; files="$7"
141
+ run "$(printf '%s' "$files" | tr ' ' '\n')" --scores "$SC_QUIET" --changed-lines "$lines" --changed-files "$nf" --fix-pr "$fixpr" --spec-cli true
142
+ found=""
143
+ for c in $catchers; do has "$c" && found=1; done
144
+ if [ -n "$found" ]; then ok; else bad "hold #$pr: none of [$catchers] selected: '$SEL' (tier $TIER)"; fi
145
+ if [ -n "$sole" ]; then if has "$sole"; then ok; else bad "hold #$pr: sole catcher '$sole' not selected: '$SEL'"; fi; fi
146
+ if has security; then ok; else bad "hold #$pr: security missing"; fi
147
+ }
148
+ hold 3426 126 3 true "security concurrency" "" "commands/orchestrate/phase-4-execution.md commands/work-on.md commands/work-on/remediate.md"
149
+ hold 3438 227 8 false "spec-cli" "spec-cli" "commands/review-pr-agents/protocols.md commands/review-pr.md commands/work-on.md commands/work-on/remediate.md commands/work-on/review.md docs/WORK-ON-RUNTIME.md docs/spec/review-protocol.md scripts/forge-root.test.sh"
150
+ hold 3461 454 12 false "security spec-cli" "" "commands/work-on.md commands/work-on/build.md commands/work-on/build/implement.md commands/work-on/close.md commands/work-on/decompose.md docs/CONFIG.md docs/spec/forge-protocol-v1.md forge.yaml.example packages/protocol/src/types.js scripts/check-spec-markers.sh scripts/diff-size.sh scripts/diff-size.test.sh"
151
+ hold 3464 53 2 true "security concurrency" "" "commands/orchestrate/phase-4-execution.md commands/work-on.md"
152
+ hold 3480 56 3 true "security" "" "commands/orchestrate/phase-4-execution.md commands/work-on.md scripts/check-spec-markers.sh"
153
+ hold 3490 220 3 false "security" "" "commands/review-pr-staging.md commands/review-pr.md scripts/review-pr-arguments.test.sh"
154
+ hold 3500 263 5 false "security" "" ".github/workflows/check-commands.yml commands/work-on/build/validate.md commands/work-on/review.md scripts/check-branch-ancestry.sh scripts/check-branch-ancestry.test.sh"
155
+ hold 3502 813 11 false "concurrency" "concurrency" "bin/engine-cli.mjs bin/engine.mjs bin/engine/phases-args.test.mjs bin/engine/phases.mjs bin/engine/runlog.mjs bin/tests/engine-crash.test.mjs bin/tests/engine-phases.test.mjs bin/tests/engine-runlog.test.mjs bin/tests/engine.test.mjs docs/WORK-ON-RUNTIME.md packages/protocol/src/phases.js"
156
+ hold 3509 274 6 false "security spec-cli" "" "bin/engine.mjs bin/engine/phases.mjs bin/tests/engine-crash.test.mjs bin/tests/engine-phases.test.mjs bin/tests/engine-runlog.test.mjs bin/tests/engine.test.mjs"
157
+ hold 3513 205 7 false "security concurrency infra spec-cli" "" "commands/orchestrate/phase-4-execution.md commands/review-pr.md commands/work-on.md commands/work-on/remediate.md commands/work-on/review.md docs/WORK-ON-RUNTIME.md scripts/check-spec-markers.sh"
158
+ hold 3520 4977 9 false "concurrency" "concurrency" "bin/engine-cli.mjs bin/engine.mjs bin/engine/runlog.mjs bin/forgedock.mjs bin/runner.mjs bin/tests/engine.test.mjs bin/tests/runner.test.mjs commands/orchestrate/phase-4-execution.md devdocs/modules/runner.md"
159
+ hold 3524 156 5 false "spec-cli" "spec-cli" "bin/engine.mjs bin/engine/phases.mjs bin/runner.mjs bin/tests/engine-phases.test.mjs bin/tests/engine.test.mjs"
160
+ hold 3555 137 8 false "security" "" "bin/engine/phases.mjs bin/tests/engine-phases.test.mjs commands/work-on/build.md commands/work-on/build/implement.md commands/work-on/decompose.md commands/work-on/investigate.md packages/protocol/CHANGELOG.md packages/protocol/src/phases.js"
161
+
162
+ # forge#3862: extended review depth (from the build's review_weight / scope flag) only ever adds reviewers
163
+ SC_TOP="AUTH=0 BILLING=1 CONCURRENCY=0 DATABASE=1 INFRA=0 SECURITY=1 SCRAPING=0 FRONTEND=2 API=2"
164
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1
165
+ expect_tier "no depth flag stays small" small; expect_sel "no depth flag stays security-only" "security"
166
+ case "$out" in *DEPTH=*) bad "standard roster prints no DEPTH line" ;; *) ok ;; esac
167
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth STANDARD
168
+ expect_tier "STANDARD depth stays small" small
169
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth extended
170
+ expect_tier "lowercase depth is not EXTENDED" small
171
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 40 --changed-files 1 --review-depth EXTENDED
172
+ expect_tier "EXTENDED lifts small to standard" standard; expect_has "EXTENDED keeps security" security
173
+ expect_has "EXTENDED small adds top domains" frontend; expect_has "EXTENDED small adds top domains" api
174
+ case "$out" in *DEPTH=extended*) ok ;; *) bad "extended roster prints DEPTH=extended" ;; esac
175
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --changed-files 6
176
+ expect_not "standard top-2 omits the third domain" database
177
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --changed-files 6 --review-depth EXTENDED
178
+ expect_has "EXTENDED standard takes a third domain" database; expect_has "EXTENDED standard keeps security" security
179
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 30 --fix-pr true --review-depth EXTENDED
180
+ expect_tier "EXTENDED lifts fix-pr to standard" standard
181
+ run "svc/a.py" --scores "$SC_TOP" --changed-lines 400 --thorough true --review-depth EXTENDED
182
+ expect_tier "full stays full" full
183
+
184
+ echo "review-roster: $pass passed, $fail failed"
185
+ [ "$fail" -eq 0 ]
@@ -19,6 +19,9 @@ set -u
19
19
  SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
20
20
  REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
21
21
  cd "$REPO_ROOT" || exit 1
22
+ # Gate scripts emit best-effort telemetry (forge#3834); suites run with it off so stubbed gh calls never buffer into ~/.forge.
23
+ # scripts/telemetry.test.sh sets its own environment.
24
+ export FORGE_TELEMETRY=off
22
25
 
23
26
  TIMEOUT_SECS="${SHELL_TEST_TIMEOUT:-300}"
24
27
  # WIRE:PROVEN — manual: SHELL_TEST_TIMEOUT=abc and SHELL_TEST_TIMEOUT=0 both print the error and exit 2
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # Tests for scripts/lib/telemetry.sh (forge_emit): record shape per event, upsert, rollover, buffer/flush,
5
+ # kill switch, never-fails guarantee. No network: gh is replaced via FORGE_TELEMETRY_GH.
6
+ set -u
7
+ HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
8
+ LIB="$HERE/lib/telemetry.sh"
9
+ PASS=0; FAIL=0
10
+ ok() { PASS=$((PASS+1)); }
11
+ bad() { FAIL=$((FAIL+1)); echo "FAIL: $1" >&2; }
12
+ check() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got '$2', want '$3')"; fi; }
13
+
14
+ TD=$(mktemp -d); trap 'rm -rf "$TD"' EXIT
15
+ export TD FORGE_REPO=o/r FORGE_ISSUE=5 FORGE_TELEMETRY_BUFFER_DIR="$TD/buf"
16
+ unset FORGE_TELEMETRY FORGE_PR FORGE_RUN FORGE_PHASE FORGE_TELEMETRY_MAX_CHARS
17
+ cat > "$TD/gh" <<'STUB'
18
+ #!/usr/bin/env bash
19
+ echo "$*" >> "$TD/calls"
20
+ case "$*" in
21
+ "api --paginate "*) if [ -f "$TD/comments.json" ]; then cat "$TD/comments.json"; else echo '[]'; fi ;;
22
+ "api -X PATCH"*) b="${@: -1}"; printf '%s' "${b#body=}" > "$TD/patched" ;;
23
+ "api -X POST"*) b="${@: -1}"; printf '%s' "${b#body=}" > "$TD/posted" ;;
24
+ esac
25
+ STUB
26
+ cat > "$TD/gh-fail" <<'STUB'
27
+ #!/usr/bin/env bash
28
+ exit 1
29
+ STUB
30
+ chmod +x "$TD/gh" "$TD/gh-fail"
31
+ export FORGE_TELEMETRY_GH="$TD/gh"
32
+ set_comments() { jq -n --arg b "$1" --arg t "${2:-Bot}" '[{id:77,body:$b,user:{type:$t},author_association:"NONE"}]' > "$TD/comments.json"; }
33
+ nrec() { printf '%s\n' "$1" | grep -c '^{'; }
34
+ reset() { rm -rf "$TD/calls" "$TD/posted" "$TD/patched" "$TD/comments.json" "$TD/buf"; }
35
+
36
+ . "$LIB"
37
+
38
+ # 1. One fixture per event shape: record carries the shared keys plus the payload, typed.
39
+ reset
40
+ forge_emit diff_size lines=10 excluded=2 threshold=0 result=OK
41
+ REC=$(grep '^{' "$TD/posted")
42
+ check "post has marker" "$(head -n 1 "$TD/posted")" "<!-- FORGE:TELEMETRY v1 -->"
43
+ check "shared keys" "$(printf '%s' "$REC" | jq -c 'keys_unsorted[:9]')" '["v","ts","forge_version","repo","issue","pr","run","phase","event"]'
44
+ check "issue number" "$(printf '%s' "$REC" | jq -r '.issue')" "5"
45
+ check "pr null" "$(printf '%s' "$REC" | jq -r '.pr')" "null"
46
+ check "numeric payload" "$(printf '%s' "$REC" | jq -r '.lines + .excluded')" "12"
47
+ check "string payload" "$(printf '%s' "$REC" | jq -r '.result')" "OK"
48
+ for fx in "acceptance_gate phase=review verified=3 failed=0 malformed=1 deferred=0:phase" \
49
+ "finding_disposition severity=HIGH confidence=CONFIRMED agent=auth disposition=ISSUE reason=severity-HIGH:disposition" \
50
+ "review_panel agents_selected=4 agents_completed=4 mode=FULL:mode" \
51
+ "ci_gate result=PASS rc=0 checks=7:checks" \
52
+ "liveness decision=DEFER why=lease:decision"; do
53
+ reset; FORGE_PR=9 forge_emit ${fx%%:*}
54
+ key=${fx##*:}; ev=${fx%% *}
55
+ check "fixture $ev event" "$(grep '^{' "$TD/posted" | jq -r '.event')" "$ev"
56
+ check "fixture $ev key $key" "$(grep '^{' "$TD/posted" | jq -r --arg k "$key" 'has($k)')" "true"
57
+ done
58
+ # review-side events go to the PR, others to the issue
59
+ reset; FORGE_PR=9 forge_emit review_panel mode=FULL; grep -q 'issues/9/comments' "$TD/calls" && ok || bad "review_panel targets PR"
60
+ reset; FORGE_PR=9 forge_emit diff_size lines=1; grep -q 'issues/5/comments' "$TD/calls" && ok || bad "diff_size targets issue"
61
+
62
+ # 2. Sanitising: reserved keys, bad keys, backticks and newlines never reach the record.
63
+ reset
64
+ forge_emit x repo=evil 'a b=1' ok='a`b
65
+ c'
66
+ REC=$(grep '^{' "$TD/posted")
67
+ check "reserved key ignored" "$(printf '%s' "$REC" | jq -r '.repo')" "o/r"
68
+ check "value sanitised" "$(printf '%s' "$REC" | jq -r '.ok')" "a_b c"
69
+ check "bad key dropped" "$(printf '%s' "$REC" | jq 'has("a b")')" "false"
70
+
71
+ # 3. Upsert edits the single trusted comment in place; an untrusted one is ignored.
72
+ reset; forge_emit ci_gate result=PASS; set_comments "$(cat "$TD/posted")"
73
+ forge_emit liveness decision=PROCEED
74
+ check "upsert patches" "$(nrec "$(cat "$TD/patched")")" "2"
75
+ check "no second post" "$(grep -c 'POST' "$TD/calls")" "1"
76
+ reset; forge_emit ci_gate result=PASS; set_comments "$(cat "$TD/posted")" User; rm -f "$TD/posted"
77
+ forge_emit liveness decision=PROCEED
78
+ [ -f "$TD/posted" ] && [ ! -f "$TD/patched" ] && ok || bad "untrusted comment must not be patched"
79
+
80
+ # 4. Rollover to part=N near the size limit.
81
+ reset; forge_emit ci_gate result=PASS; set_comments "$(cat "$TD/posted")"; rm -f "$TD/posted"
82
+ FORGE_TELEMETRY_MAX_CHARS=200 forge_emit liveness decision=PROCEED
83
+ check "rollover header" "$(head -n 1 "$TD/posted")" "<!-- FORGE:TELEMETRY v1 part=2 -->"
84
+
85
+ # 5. Buffer on failure, flush on next emit, nothing lost.
86
+ reset
87
+ FORGE_TELEMETRY_GH="$TD/gh-fail" forge_emit diff_size lines=1
88
+ check "buffered" "$(ls "$TD/buf")" "o_r__5.jsonl"
89
+ forge_emit diff_size lines=2
90
+ check "flushed both records" "$(nrec "$(cat "$TD/posted")")" "2"
91
+ [ ! -e "$TD/buf/o_r__5.jsonl" ] && ok || bad "buffer removed after flush"
92
+
93
+ # 6. Never fails the caller: unwritable buffer dir, no gh, kill switch, bad event name, caller options intact.
94
+ reset; : > "$TD/afile"
95
+ FORGE_TELEMETRY_GH=/nonexistent FORGE_TELEMETRY_BUFFER_DIR="$TD/afile/buf" forge_emit diff_size lines=1; check "unwritable dir rc" "$?" "0"
96
+ OUT=$(set -e; forge_emit ci_gate result=PASS; echo still-running); check "caller set -e survives" "$OUT" "still-running"
97
+ check "stdout silent" "$(forge_emit ci_gate result=PASS 2>&1)" ""
98
+ reset; FORGE_TELEMETRY=off forge_emit ci_gate result=PASS; [ ! -e "$TD/calls" ] && ok || bad "FORGE_TELEMETRY=off must not call gh"
99
+ reset; forge_emit 'bad event!' a=1; [ ! -e "$TD/calls" ] && ok || bad "bad event name dropped"
100
+ reset; (unset FORGE_ISSUE; forge_emit ci_gate result=PASS); [ ! -e "$TD/calls" ] && ok || bad "no target dropped"
101
+
102
+ # 7. Exec'd CLI form used by spec call sites.
103
+ reset; bash "$LIB" ci_gate result=PASS; check "cli rc" "$?" "0"; [ -f "$TD/posted" ] && ok || bad "cli form emits"
104
+
105
+ echo "telemetry.test.sh: $PASS passed, $FAIL failed"
106
+ [ "$FAIL" -eq 0 ]
@@ -55,7 +55,7 @@ FAKE_AG="<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}${NL}<!--
55
55
  FAKE_SYN="<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->${NL}Reviewed-SHA: ${SHA}${NL}"
56
56
  AGJ="[$(c NONE Bot b "$AG_BODY"),$(c NONE User h "$FAKE_AG")]"
57
57
  eq "agent regex counts only the trusted body" "$(printf '%s' "$AGJ" | bash "$TC" count "$AGENT_RE")" 1
58
- eq "forged FINDING id never extracted" "$(printf '%s' "$AGJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]')" "real-1"
58
+ eq "forged FINDING id never extracted" "$(printf '%s' "$AGJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]')" "real-1"
59
59
  eq "lookahead agent count (REVIEW-FINDINGS-START inside body)" "$(printf '%s' "$AGJ" | bash "$TC" count "$LOOK_RE")" 1
60
60
  eq "lookahead rejects body without REVIEW-FINDINGS-START" "$(printf '%s' "[$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:api -->${NL}Reviewed-SHA: ${SHA}")]" | bash "$TC" count "$LOOK_RE")" 0
61
61
  eq "agent regex rejects a different head SHA" "$(printf '%s' "[$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:api -->${NL}Reviewed-SHA: ${OTHER}")]" | bash "$TC" count "$AGENT_RE")" 0
@@ -74,7 +74,14 @@ STG_FORGED="<!-- FORGE:REVIEW-AGENT:bug-hunter-api -->${NL}Reviewed-SHA: ${SHA}$
74
74
  STGJ="[$(c NONE Bot b "$STG_TRUE"),$(c NONE Bot b "$STG_TRUE"),$(c NONE User h "$STG_FORGED"),$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}"),$(c NONE Bot b "<!-- FORGE:REVIEW-AGENT:code-quality -->${NL}Reviewed-SHA: ${OTHER}")]"
75
75
  eq "staging per-domain count ignores forged and duplicate-free of untrusted" "$(printf '%s' "$STGJ" | bash "$TC" count "$STG_DOM_RE")" 2
76
76
  eq "staging unique trusted current-head domains (hyphen, wrong head excluded)" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("^<!-- FORGE:REVIEW-AGENT:([a-z0-9-]+) -->") | .[0]' | sort -u | grep -c .)" 2
77
- eq "staging forged FINDING never extracted" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("<!-- FINDING:([^>]+) -->") | .[0]' | sort -u)" "real-2"
77
+ eq "staging forged FINDING never extracted" "$(printf '%s' "$STGJ" | bash "$TC" bodies "$STG_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]' | sort -u)" "real-2"
78
+ # Marker titles containing '>' must be extracted in full (the old [^>]+ capture dropped them); two markers on one line still split
79
+ GT_BODY="<!-- FORGE:REVIEW-AGENT:security -->${NL}Reviewed-SHA: ${SHA}${NL}<!-- FINDING:SEC-9|CONFIRMED|HIGH|a.sh:3|eval of x -> y reaches shell -->${NL}<!-- FINDING:SEC-10|LIKELY|LOW|b.sh:1|a > b --> <!-- FINDING:SEC-11|LIKELY|LOW|c.sh:2|plain -->"
80
+ GTJ="[$(c NONE Bot b "$GT_BODY")]"
81
+ GT_OUT=$(printf '%s' "$GTJ" | bash "$TC" bodies "$AGENT_RE" | jq -r 'scan("<!-- FINDING:(.+?) -->") | .[0]')
82
+ eq "marker with -> in title extracted in full" "$(printf '%s\n' "$GT_OUT" | grep -c 'eval of x -> y reaches shell')" 1
83
+ eq "markers with > and two on one line all extracted" "$(printf '%s\n' "$GT_OUT" | grep -c .)" 3
84
+ eq "raw marker count equals extracted count" "$(printf '%s' "$GTJ" | bash "$TC" bodies "$AGENT_RE" | grep -o '<!-- FINDING:' | wc -l | tr -d ' ')" 3
78
85
  eq "staging regex rejects forged-only comment from NONE user" "$(printf '[%s]' "$(c NONE User h "$STG_FORGED")" | bash "$TC" count "$STG_RE")" 0
79
86
 
80
87
  # fail closed
@@ -30,6 +30,9 @@
30
30
  # "No checks" passes only when GitHub Actions also reports no workflow runs for the head commit;
31
31
  # if runs exist but their checks have not appeared yet, the gate keeps waiting.
32
32
  set -uo pipefail
33
+ # Telemetry (forge#3834): best-effort decision record; never changes stdout, stderr or the exit code.
34
+ _TEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/telemetry.sh"; [ -f "$_TEL" ] && . "$_TEL" 2>/dev/null
35
+ type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
33
36
 
34
37
  PR=""; REPO=""
35
38
  TIMEOUT="${FORGE_CI_TIMEOUT:-540}"; INTERVAL="${FORGE_CI_INTERVAL:-20}"; SETTLE="${FORGE_CI_SETTLE:-45}"
@@ -50,7 +53,11 @@ case "$REPO" in */*) ;; *) usage ;; esac
50
53
  for v in "$TIMEOUT" "$INTERVAL" "$GRACE" "$SETTLE"; do case "$v" in ''|*[!0-9]*) usage ;; esac; done
51
54
  [ "$INTERVAL" -ge 1 ] || INTERVAL=1
52
55
 
53
- emit() { echo "CI_GATE: $1"; echo "CI_GATE_HEAD: ${START_SHA:-unknown}"; }
56
+ emit() {
57
+ case "$1" in PASS) _rc=0 ;; FAIL) _rc=1 ;; TIMEOUT) _rc=3 ;; *) _rc=2 ;; esac
58
+ FORGE_REPO="${FORGE_REPO:-$REPO}" FORGE_PR="${FORGE_PR:-$PR}" forge_emit ci_gate result="$1" rc="$_rc" checks="${total:-0}"
59
+ echo "CI_GATE: $1"; echo "CI_GATE_HEAD: ${START_SHA:-unknown}"
60
+ }
54
61
  head_sha() { gh pr view "$PR" -R "$REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null; }
55
62
  START_SHA="$(head_sha)"
56
63
  if [ -z "$START_SHA" ]; then START_SHA=""; emit ERROR; echo "cannot read PR #$PR head in $REPO"; exit 2; fi
@@ -68,7 +68,7 @@ expect "every result names the gated head" "CI_GATE_HEAD: abc1234" "$(printf '%s
68
68
 
69
69
  # Static guard: every autonomous `gh pr merge` command line in the pipeline specs must be preceded
70
70
  # (within 25 lines) by a CI gate result check. Prose/manual instructions are not command lines.
71
- for spec in commands/review-pr.md commands/work-on/review.md commands/work-on/remediate.md commands/recover-orphans.md commands/deploy-pr.md; do
71
+ for spec in commands/review-pr.md scripts/review-finalize.sh commands/work-on/review.md commands/work-on/remediate.md commands/recover-orphans.md commands/deploy-pr.md; do
72
72
  bad=$(awk '
73
73
  { line[NR]=$0 }
74
74
  /^[[:space:]]*(MERGE_RESULT=\$\()?gh pr merge / {
@@ -78,7 +78,7 @@ for spec in commands/review-pr.md commands/work-on/review.md commands/work-on/re
78
78
  expect "every autonomous merge in $spec is CI-gated" "" "$bad"
79
79
  done
80
80
  # Every autonomous merge pins the gated head (forge#3187): no merge line may omit --match-head-commit.
81
- for spec in commands/review-pr.md commands/work-on/review.md commands/work-on/remediate.md commands/recover-orphans.md commands/deploy-pr.md; do
81
+ for spec in commands/review-pr.md scripts/review-finalize.sh commands/work-on/review.md commands/work-on/remediate.md commands/recover-orphans.md commands/deploy-pr.md; do
82
82
  unpinned=$(grep -nE '^[[:space:]]*(MERGE_RESULT=\$\(|MERGE_OUTPUT=\$\()?gh pr merge ' "$HERE/../$spec" | grep -v -- '--match-head-commit')
83
83
  expect "every autonomous merge in $spec pins the gated head" "" "$unpinned"
84
84
  done
@@ -90,7 +90,7 @@ if grep -qE '^[[:space:]]*(MERGE_RESULT=\$\()?gh pr merge ' "$HERE/../commands/w
90
90
 
91
91
  # Reviewed-head guard: review-pr Phase 8 must refuse to merge a head other than the reviewed commit,
92
92
  # and the work-on callers must route "stale review" to a re-review, never a manual merge.
93
- grep -qF '${GATED_HEAD#"$REVIEWED_SHORT"}' "$HERE/../commands/review-pr.md" && PASS=$((PASS+1)) || { FAILN=$((FAILN+1)); echo "FAIL: review-pr Phase 8 lacks reviewed-head guard"; }
93
+ grep -qF '${GATED_HEAD#"$REVIEWED_SHORT"}' "$HERE/../scripts/review-finalize.sh" && PASS=$((PASS+1)) || { FAILN=$((FAILN+1)); echo "FAIL: review-pr Phase 8 (scripts/review-finalize.sh merge-gate) lacks reviewed-head guard"; }
94
94
  for f in commands/work-on/review.md; do
95
95
  grep -q 'stale review' "$HERE/../$f" && PASS=$((PASS+1)) || { FAILN=$((FAILN+1)); echo "FAIL: $f does not route stale review"; }
96
96
  done
@@ -42,3 +42,12 @@ branches:
42
42
  default: "main"
43
43
  staging: "staging"
44
44
  feature_pattern: "milestone/{slug}"
45
+
46
+ # =============================================================================
47
+ # DECOMPOSITION (OPTIONAL)
48
+ # Set close_parent: true to close a decomposed parent issue at decomposition
49
+ # (default false: it stays open until its last child closes).
50
+ # =============================================================================
51
+
52
+ # decomposition:
53
+ # close_parent: false