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,288 @@
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-prepare.sh (review-pr Phases 1-3B + delta/slices) and its shared libs. A stubbed `gh` and throwaway git repos
6
+ # stand in for GitHub and the PR checkout. Run: FORGEDOCK_HOME=$PWD bash scripts/review-prepare.test.sh (bash 3.2 compatible)
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ ROOT=$(cd "$DIR/.." && pwd)
11
+ pass=0; fail=0
12
+ ok() { pass=$((pass+1)); }
13
+ bad() { fail=$((fail+1)); echo "FAIL: $1"; }
14
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/review-prepare-test.XXXXXX")
15
+ trap 'rm -rf "$WORK"' EXIT
16
+ command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not installed"; exit 0; }
17
+ command -v yq >/dev/null 2>&1 || { echo "SKIP: yq not installed"; exit 0; }
18
+
19
+ export HOME="$WORK/home"; mkdir -p "$HOME"
20
+ unset FORGE_ROOT FORGE_HOME CLAUDE_PLUGIN_ROOT FORGE_CONFIG FORGE_SCRATCHPAD
21
+ export REVIEW_PREPARE_SLEEP=0 GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
22
+
23
+ # ---- stubbed gh: every answer comes from files under $FIX ----
24
+ FIX="$WORK/fix"; mkdir -p "$FIX" "$WORK/bin"
25
+ cat > "$WORK/bin/gh" <<'STUB'
26
+ #!/usr/bin/env bash
27
+ a="$*"
28
+ case "$a" in
29
+ "pr view"*"mergeable,mergeStateStatus"*)
30
+ f="$FIX/merge.seq"; l=$(head -n 1 "$f"); [ "$(wc -l < "$f")" -gt 1 ] && sed -i.bak 1d "$f"; echo "$l" ;;
31
+ "pr view"*"--json body"*) cat "$FIX/body.txt" ;;
32
+ "pr view"*) cat "$FIX/prinfo.json" ;;
33
+ "pr diff"*"--name-only"*) [ -f "$FIX/difffail" ] && exit 1; cat "$FIX/files" ;;
34
+ "pr diff"*) cat "$FIX/diff" ;;
35
+ "pr list"*) cat "$FIX/prior" 2>/dev/null || echo 1 ;;
36
+ "api --paginate"*) [ -f "$FIX/comments.fail" ] && exit 1; cat "$FIX/comments.json" 2>/dev/null || echo '[]' ;;
37
+ "issue view"*"labels"*) cat "$FIX/labels" 2>/dev/null || echo false ;;
38
+ "issue view"*"body"*) cat "$FIX/issuebody" 2>/dev/null ;;
39
+ *) exit 1 ;;
40
+ esac
41
+ STUB
42
+ chmod +x "$WORK/bin/gh"; export FIX PATH="$WORK/bin:$PATH"
43
+
44
+ # ---- fake trusted install roots (the real one, or a trimmed copy with a stub delta resolver) ----
45
+ mkroot() { # mkroot <dir> [no-roster] [delta-stub-file] [templates]
46
+ local r="$1"; mkdir -p "$r/scripts/lib"
47
+ cp "$ROOT/scripts/lib/forge-resolve.sh" "$ROOT/scripts/lib/review-delta-mode.sh" "$ROOT/scripts/lib/pr-issue-number.sh" "$ROOT/scripts/lib/review-template-resolve.sh" "$r/scripts/lib/"
48
+ cp "$ROOT/scripts/trusted-comments.sh" "$r/scripts/"
49
+ [ "${2:-}" = "no-roster" ] || cp "$ROOT/scripts/review-roster.sh" "$r/scripts/"
50
+ [ -n "${3:-}" ] && cp "$3" "$r/scripts/review-delta.sh"
51
+ if [ -n "${4:-}" ]; then mkdir -p "$r/commands/review-pr-agents"; echo "# protocols" > "$r/commands/review-pr-agents/protocols.md"; fi
52
+ }
53
+
54
+ # ---- throwaway PR checkout: main (A) + one more commit (B = PR head) ----
55
+ REPO_DIR="$WORK/repo"; mkdir -p "$REPO_DIR/services/api"
56
+ git -C "$REPO_DIR" init -q -b main
57
+ echo a > "$REPO_DIR/a.txt"; git -C "$REPO_DIR" add -A; git -C "$REPO_DIR" commit -qm A
58
+ SHA_A=$(git -C "$REPO_DIR" rev-parse HEAD)
59
+ git -C "$REPO_DIR" checkout -q -b pr-branch; echo b > "$REPO_DIR/services/api/auth.py"
60
+ git -C "$REPO_DIR" add -A; git -C "$REPO_DIR" commit -qm B
61
+ SHA_B=$(git -C "$REPO_DIR" rev-parse HEAD)
62
+ git -C "$REPO_DIR" checkout -q main
63
+ git -C "$REPO_DIR" remote add origin "$REPO_DIR"
64
+
65
+ CFG="$WORK/forge.yaml"
66
+ printf 'project:\n name: Demo\nverification:\n commands:\n typescript:\n build: "npm run build"\n python:\n format: "ruff format --check"\n' > "$CFG"
67
+
68
+ # setup <head> <base> <files> <diff> [sha]
69
+ setup() {
70
+ rm -f "${FIX:?}"/*; printf 'MERGEABLE|CLEAN\n' > "$FIX/merge.seq"; : > "$FIX/body.txt"
71
+ printf '{"number":7,"title":"t","author":{"login":"dev"},"baseRefName":"%s","headRefName":"%s","headRefOid":"%s","isCrossRepository":%s,"additions":5,"deletions":2,"changedFiles":1}\n' "$2" "$1" "${5:-$SHA_B}" "${CROSS_REPO:-false}" > "$FIX/prinfo.json"
72
+ printf '%s\n' "$3" > "$FIX/files"; printf '%s\n' "$4" > "$FIX/diff"
73
+ }
74
+ CTX=""; RC=0
75
+ # prep <install root> [extra args...]
76
+ prep() {
77
+ local r="$1"; shift
78
+ CTX=$(cd "$REPO_DIR" && FORGEDOCK_HOME="$r" FORGE_CONFIG="${CFG_OVERRIDE:-$CFG}" bash "$ROOT/scripts/review-prepare.sh" --pr 7 --repo o/r --out-dir "$WORK/out" "$@" 2>"$WORK/stderr"); RC=$?
79
+ }
80
+ jqv() { printf '%s' "$CTX" | jq -r "$1" 2>/dev/null; }
81
+ eq() { local got; got=$(jqv "$2"); if [ "$got" = "$3" ]; then ok; else bad "$1: $2 = '$got', want '$3'"; fi; }
82
+ rc_is() { if [ "$RC" = "$2" ]; then ok; else bad "$1: exit $RC, want $2 ($(head -c 300 "$WORK/stderr"))"; fi; }
83
+
84
+ DIFF_PLAIN='diff --git a/README.md b/README.md
85
+ +hello'
86
+ DIFF_AUTH_BILL='diff --git a/services/api/auth.py b/services/api/auth.py
87
+ +def f(): jwt = get_current_user(); stripe.charge()
88
+ diff --git a/services/api/billing.py b/services/api/billing.py
89
+ +credit = 1'
90
+
91
+ # --- 1. happy path, schema, small tier ---
92
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
93
+ prep "$ROOT"
94
+ rc_is "happy path" 0
95
+ eq "schema" .schema_version 1; eq "status" .status OK; eq "pr number" .pr.number 7; eq "review sha" .pr.review_sha "$SHA_B"
96
+ eq "sha7" .pr.review_sha_short "$(printf '%s' "$SHA_B" | cut -c1-7)"
97
+ eq "review depth defaults to STANDARD" .roster.review_depth STANDARD
98
+ eq "review weight key present in context" '.roster | has("review_weight")' true
99
+ eq "small tier" .roster.tier small; eq "small roster" '.roster.agents | join(" ")' security
100
+ eq "lines" '.pr.additions + .pr.deletions' 7
101
+ eq "first-time false" .pr.first_time_contributor false
102
+ eq "template source resolved" '.templates.source != "none"' true
103
+ eq "delta FULL" .delta.mode FULL
104
+
105
+ # --- 2. merge health ---
106
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'UNKNOWN|UNKNOWN\nUNKNOWN|UNKNOWN\nMERGEABLE|CLEAN\n' > "$FIX/merge.seq"
107
+ prep "$ROOT"; eq "retry count" .merge_health.retries 2; eq "retry result" .merge_health.mergeable MERGEABLE; eq "retry state" .merge_health.state CLEAN
108
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'CONFLICTING|DIRTY\n' > "$FIX/merge.seq"
109
+ prep "$ROOT"; eq "conflicting" .merge_health.mergeable CONFLICTING; eq "conflicting state" .merge_health.state DIRTY; eq "no retry on CONFLICTING" .merge_health.retries 0
110
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'UNKNOWN|UNKNOWN\n' > "$FIX/merge.seq"
111
+ prep "$ROOT"; eq "unknown stays unknown after 3 retries" .merge_health.retries 3; eq "still UNKNOWN" .merge_health.mergeable UNKNOWN
112
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'garbage|$(rm -rf)\n' > "$FIX/merge.seq"
113
+ prep "$ROOT"; eq "garbage state normalized" .merge_health.state UNKNOWN
114
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; echo 0 > "$FIX/prior"
115
+ prep "$ROOT"; eq "first-time contributor" .pr.first_time_contributor true
116
+
117
+ # --- 3. classification + scores ---
118
+ setup feat/x staging "services/api/auth.py
119
+ services/api/billing.py" "$DIFF_AUTH_BILL"
120
+ prep "$ROOT"
121
+ eq "services has API" '.classification.services | index("API") != null' true
122
+ eq "domains has AUTH" '.classification.domains | index("AUTH") != null' true
123
+ eq "domains has BILLING" '.classification.domains | index("BILLING") != null' true
124
+ eq "auth score" '.scores.AUTH > 0' true; eq "billing score" '.scores.BILLING > 0' true
125
+ eq "security baseline" .scores.SECURITY 1
126
+ eq "risk FINANCIAL" '.risk_signals | index("FINANCIAL") != null' true
127
+ eq "slice auth file exists" '.slices.auth.diff_file | length > 0' true
128
+ if grep -q "auth.py" "$WORK/out/slice-auth.diff" 2>/dev/null; then ok; else bad "auth slice should hold the auth.py diff"; fi
129
+ if grep -q "billing.py" "$WORK/out/slice-auth.diff" 2>/dev/null; then bad "auth slice leaked the billing diff"; else ok; fi
130
+ eq "auth slice paths" '.slices.auth.paths | join(" ")' "services/api/auth.py"
131
+ eq "security slice is the full diff" '.slices.security.paths | length' 2
132
+
133
+ # --- 4. roster: thorough, scraping config ordering, unresolved roster script ---
134
+ setup feat/x staging "services/api/auth.py
135
+ services/api/billing.py" "$DIFF_AUTH_BILL"
136
+ prep "$ROOT" --thorough true
137
+ eq "thorough tier" .roster.tier full
138
+ eq "thorough roster has auth+billing+concurrency" '.roster.agents | (index("auth") != null) and (index("billing") != null) and (index("concurrency") != null)' true
139
+ SCRAPE_DIFF='diff --git a/x/scraper.py b/x/scraper.py
140
+ +stealth scrape'
141
+ setup feat/x staging "x/scraper.py" "$SCRAPE_DIFF"
142
+ prep "$ROOT" --thorough true; eq "scraping off without config" '.roster.agents | index("scraper") != null' false
143
+ printf 'review:\n domains:\n scraping: "tiers"\n' > "$WORK/scrape.yaml"
144
+ CFG_OVERRIDE="$WORK/scrape.yaml" prep "$ROOT" --thorough true
145
+ eq "scraping scored on first pass once configured" '.roster.agents | index("scraper") != null' true
146
+ eq "scraping configured flag" .config.scraping_configured true
147
+ eq "absent yq key is empty, not null" '.config.domains.auth' ""
148
+ mkroot "$WORK/root-noroster" no-roster
149
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
150
+ prep "$WORK/root-noroster"
151
+ eq "unresolved roster script falls back to full" .roster.tier full
152
+ eq "unresolved roster still has security" '.roster.agents | index("security") != null' true
153
+
154
+ # --- 5. delta mode ---
155
+ cat > "$WORK/delta-stub.sh" <<STUB
156
+ #!/usr/bin/env bash
157
+ case "\${DELTA_KIND:-DELTA}" in
158
+ DELTA) printf 'DELTA %s..%s\nFULL_ROUNDS=%s\n' "$SHA_A" "$SHA_B" "\${DELTA_ROUNDS:-1}" ;;
159
+ BASE_SYNC_ONLY) printf 'BASE_SYNC_ONLY %s\nFULL_ROUNDS=1\n' "$SHA_A" ;;
160
+ JUNK) echo nonsense ;;
161
+ esac
162
+ STUB
163
+ mkroot "$WORK/root-delta" "" "$WORK/delta-stub.sh" templates
164
+ FIXFILES="services/api/auth.py
165
+ services/api/billing.py"
166
+ setup feat/x staging "$FIXFILES" "$DIFF_AUTH_BILL"
167
+ DELTA_KIND=DELTA DELTA_ROUNDS=1 prep "$WORK/root-delta"
168
+ eq "DELTA mode" .delta.mode DELTA; eq "DELTA from" .delta.from "$SHA_A"; eq "DELTA files" '.delta.files | join(" ")' "services/api/auth.py"
169
+ eq "early rounds keep the 3B roster" '.roster.agents | index("security") != null' true
170
+ eq "DELTA slice paths narrow to the delta" '.slices.security.paths | join(" ")' "services/api/auth.py"
171
+ eq "full_files stays whole" '.full_files | length' 2
172
+ DELTA_KIND=DELTA DELTA_ROUNDS=2 prep "$WORK/root-delta"
173
+ eq "round 3 roster is security + touched domains" '.roster.agents | join(" ")' "security auth api"
174
+ DELTA_KIND=BASE_SYNC_ONLY prep "$WORK/root-delta"
175
+ eq "BASE_SYNC_ONLY mode" .delta.mode BASE_SYNC_ONLY; eq "carried from" .delta.carried_from "$SHA_A"
176
+ eq "BASE_SYNC_ONLY roster empty" '.roster.agents | length' 0; eq "BASE_SYNC_ONLY no slices" '.slices | length' 0
177
+ DELTA_KIND=JUNK prep "$WORK/root-delta"; eq "junk resolver output is FULL" .delta.mode FULL
178
+ mkroot "$WORK/root-nodelta" "" "" templates
179
+ prep "$WORK/root-nodelta"; eq "unresolved delta script is FULL" .delta.mode FULL
180
+ DELTA_KIND=DELTA prep "$WORK/root-delta" --thorough true; eq "thorough never goes DELTA" .delta.mode FULL
181
+ setup feat/x staging "$FIXFILES" "$DIFF_AUTH_BILL" "notasha"
182
+ DELTA_KIND=DELTA prep "$WORK/root-delta"; eq "unvalidated sha is FULL" .delta.mode FULL
183
+
184
+ # --- 6. full-build lane: empty trusted config is BLOCKED, never a silent skip ---
185
+ TS_DIFF='diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
186
+ +x'
187
+ printf 'project:\n name: Demo\nverification:\n commands:\n typescript:\n typecheck: "tsc"\n' > "$WORK/nobuild.yaml"
188
+ setup milestone/m1 staging "web/src/app/page.tsx" "$TS_DIFF"
189
+ CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT"
190
+ rc_is "empty ts build on a milestone lane" 3; eq "BLOCKED status" .status BLOCKED
191
+ eq "blocker names typescript.build" '.blockers | join(" ") | test("typescript.build")' true
192
+ eq "config status blocked" .phase2.build_config.status blocked; eq "lane flag" .lane.requires_full_build true
193
+ prep "$ROOT"; rc_is "configured ts build passes" 0; eq "status OK" .status OK
194
+ # --build-gate advisory (review-pr-staging): an unconfigured build is a SKIPPED Phase 2 finding, never a block (#3952 QA-1)
195
+ CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT" --build-gate advisory
196
+ rc_is "advisory build gate does not block" 0; eq "advisory status OK" .status OK
197
+ eq "advisory config status skipped" .phase2.build_config.status skipped
198
+ eq "advisory records SKIPPED finding" '.phase2.findings | join(" ") | test("SKIPPED — typescript.build")' true
199
+ PY_DIFF='diff --git a/app/x.py b/app/x.py
200
+ +x'
201
+ printf 'project:\n name: Demo\n' > "$WORK/empty.yaml"
202
+ setup milestone/m1 staging "app/x.py" "$PY_DIFF"
203
+ CFG_OVERRIDE="$WORK/empty.yaml" prep "$ROOT"; rc_is "empty python.format on full-build lane" 3
204
+ eq "blocker names python.format" '.blockers | join(" ") | test("python.format")' true
205
+ setup milestone/m1 staging "README.md" "$DIFF_PLAIN"
206
+ CFG_OVERRIDE="$WORK/empty.yaml" prep "$ROOT"; rc_is "docs-only milestone needs no build config" 0
207
+ CFG_OVERRIDE="$WORK/missing.yaml" prep "$ROOT"; rc_is "no forge.yaml at all on a full-build lane" 3
208
+ setup feat/x staging "web/src/app/page.tsx" "$TS_DIFF"
209
+ CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT"; rc_is "empty ts build on a normal lane is fine" 0
210
+ setup staging main "web/src/app/page.tsx" "$TS_DIFF"
211
+ CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT"; rc_is "staging to main requires the build" 3
212
+
213
+ # --- 7. templates: none is a fatal setup error ---
214
+ mkroot "$WORK/root-notpl"
215
+ mkdir -p "$WORK/emptyrepo"; printf 'paths:\n root: %s\n' "$WORK/emptyrepo" > "$WORK/emptyroot.yaml"
216
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
217
+ CFG_OVERRIDE="$WORK/emptyroot.yaml" prep "$WORK/root-notpl"
218
+ rc_is "no template source" 3; eq "templates none" .templates.source none
219
+
220
+ # --- 8. issue number extraction and trusted contract ---
221
+ . "$ROOT/scripts/lib/pr-issue-number.sh"
222
+ chk() { local got; got=$(pr_issue_number_of "$2"); if [ "$got" = "$3" ]; then ok; else bad "$1: got '$got', want '$3'"; fi; }
223
+ chk "Closes" "Closes #12" 12
224
+ chk "closes lowercase" "intro
225
+ closes #7 and more" 7
226
+ chk "Fixes" "Fixes #345" 345
227
+ chk "Resolved" "Resolved #9" 9
228
+ chk "no reference yields empty, not the body" "Just a description with no issue reference" ""
229
+ chk "bare #N is not a closing keyword" "see #44" ""
230
+ chk "non numeric" "Closes #abc" ""
231
+ chk "first wins" "Closes #1, Fixes #2" 1
232
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'Just text, no issue\n' > "$FIX/body.txt"
233
+ prep "$ROOT"; eq "no linked issue" .contract.issue null; eq "no flags" .contract.flags ""
234
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'Fixes #55\n' > "$FIX/body.txt"
235
+ printf '[{"body":"<!-- FORGE:CONTRACT -->\\n**Risk**: HIGH\\nbilling change","author_association":"OWNER","user":{"login":"o","type":"User"}}]' > "$FIX/comments.json"
236
+ prep "$ROOT"; eq "trusted contract issue" .contract.issue 55
237
+ eq "trusted contract HIGH_RISK" '.contract.flags | test("HIGH_RISK")' true; eq "trusted contract billing flag" '.contract.flags | test("CONTRACT_BILLING")' true
238
+ eq "HIGH_RISK lifts the roster above small" '.roster.tier != "small"' true
239
+ printf '[{"body":"<!-- FORGE:CONTRACT -->\\n**Risk**: HIGH","author_association":"NONE","user":{"login":"evil","type":"User"}}]' > "$FIX/comments.json"
240
+ prep "$ROOT"; eq "planted contract from an untrusted author is ignored" .contract.flags ""; eq "roster stays small" .roster.tier small
241
+ touch "$FIX/comments.fail"
242
+ prep "$ROOT"; eq "a failed contract read widens, never narrows" '.contract.flags | test("HIGH_RISK")' true
243
+ rm -f "$FIX/comments.fail"
244
+ mkroot "$WORK/root-notrust"; rm "$WORK/root-notrust/scripts/trusted-comments.sh"
245
+ prep "$WORK/root-notrust"; eq "unresolved trust script widens, never narrows" '.contract.flags | test("HIGH_RISK")' true
246
+
247
+ # --- 9. restore_ref: git checkout -f of the recorded ref on every exit path ---
248
+ setup feat/x staging "services/api/auth.py" "$DIFF_AUTH_BILL"
249
+ echo dirty > "$REPO_DIR/a.txt"
250
+ prep "$ROOT" --checkout-pr
251
+ rc_is "checkout-pr run" 0
252
+ if [ "$(git -C "$REPO_DIR" symbolic-ref --short HEAD 2>/dev/null)" = "main" ]; then ok; else bad "restore_ref left HEAD detached, want main"; fi
253
+ git -C "$REPO_DIR" checkout -f -q main
254
+ setup feat/x staging "README.md" "$DIFF_PLAIN" "0000000000000000000000000000000000000000"
255
+ prep "$ROOT" --checkout-pr
256
+ rc_is "unknown sha cannot be checked out" 3
257
+ if [ "$(git -C "$REPO_DIR" symbolic-ref --short HEAD 2>/dev/null)" = "main" ]; then ok; else bad "failed checkout moved HEAD"; fi
258
+ if grep -q 'checkout -f "\$ORIG_REF"' "$ROOT/scripts/review-prepare.sh"; then ok; else bad "restore_ref must use git checkout -f"; fi
259
+
260
+ # --- 10. fail closed on gh and bad arguments ---
261
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
262
+ prep "$ROOT"; rc_is "gh pr diff failure" 3; eq "blocked on gh failure" .status BLOCKED
263
+ # a staging bundle review (--no-delta) reads an API-oversized diff from the long-lived branches; a per-PR review still blocks
264
+ setup pr-branch main "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
265
+ prep "$ROOT" --no-delta; rc_is "--no-delta falls back to git when the API diff fails" 0; eq "fallback status" .status OK
266
+ eq "fallback file list comes from git" '.full_files | index("services/api/auth.py") != null' true; eq "--no-delta mode is FULL" .delta.mode FULL
267
+ # forge#3935: the fallback is pinned to the review SHA and refuses fork PRs
268
+ setup pr-branch main "README.md" "$DIFF_PLAIN" "0000000000000000000000000000000000000000"; : > "$FIX/difffail"
269
+ prep "$ROOT" --no-delta; rc_is "--no-delta fallback refuses a branch tip that is not the review SHA" 3
270
+ CROSS_REPO=true setup pr-branch main "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
271
+ prep "$ROOT" --no-delta; rc_is "--no-delta fallback refuses a cross-repository PR" 3
272
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
273
+ prep "$ROOT" --no-delta; rc_is "--no-delta with unfetchable branches still fails closed" 3; eq "unfetchable blocked" .status BLOCKED
274
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
275
+ for bad_args in "--pr abc --repo o/r" "--pr 7 --repo noslash" "--pr 7 --repo o/r;x/y" "--pr 7 --repo o/r --bogus" "--repo o/r --pr"; do
276
+ # shellcheck disable=SC2086
277
+ (cd "$REPO_DIR" && FORGEDOCK_HOME="$ROOT" bash "$ROOT/scripts/review-prepare.sh" $bad_args >/dev/null 2>&1); rcx=$?
278
+ if [ "$rcx" = 2 ]; then ok; else bad "args '$bad_args' exit $rcx, want 2"; fi
279
+ done
280
+ # every helper resolves through the trusted resolver, never the cwd
281
+ if grep -q 'forge_resolve' "$ROOT/scripts/review-prepare.sh" && grep -q 'schema_version' "$ROOT/scripts/review-prepare.sh"; then ok; else bad "script must use forge_resolve and document schema_version"; fi
282
+ mkdir -p "$WORK/hostile/scripts"; printf '#!/usr/bin/env bash\necho SELECTED_AGENTS=evil\n' > "$WORK/hostile/scripts/review-roster.sh"
283
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
284
+ CTX=$(cd "$WORK/hostile" && FORGEDOCK_HOME="$ROOT" FORGE_CONFIG="$CFG" bash "$ROOT/scripts/review-prepare.sh" --pr 7 --repo o/r --out-dir "$WORK/out" 2>/dev/null)
285
+ eq "a hostile cwd roster script is never used" '.roster.agents | index("evil") != null' false
286
+
287
+ echo "review-prepare tests: $pass passed, $fail failed"
288
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env bash
2
+ # review-roster.sh — risk-tiered reviewer roster for /review-pr Phase 3B and /review-pr-staging (forge#3599).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Pure function: flags in, key=value lines out. No network, no git, no eval; every flag value is data.
7
+ # Resolve it through forge_resolve (trusted FORGE_ROOT / plugin cache), never from the repo under review.
8
+ #
9
+ # Usage: review-roster.sh [--scores "AUTH=n BILLING=n CONCURRENCY=n DATABASE=n INFRA=n SECURITY=n SCRAPING=n FRONTEND=n API=n"]
10
+ # [--changed-lines N] [--changed-files N] [--files-file PATH|-] [--fix-pr true|false]
11
+ # [--source-severity SEV] [--source-domain DOMAIN] [--contract-flags "HIGH_RISK CONTRACT_AUTH ..."]
12
+ # [--churn true|false] [--danger-zone true|false] [--thorough true|false] [--milestone true|false]
13
+ # [--scraping-configured true|false] [--database-container true|false] [--spec-cli true|false]
14
+ # [--review-depth STANDARD|EXTENDED]
15
+ # Output (stdout): SELECTED_AGENTS=<canonical marker domains, space separated> TIER=<tier> REASON=<text>
16
+ # Exit: 0 always for a parseable call; 2 on an unknown flag (callers fall back to the full union, never a narrower roster).
17
+ #
18
+ # Tiers, evaluated in this order:
19
+ # full THOROUGH or milestone->staging: Security plus every domain with a score > 0.
20
+ # fix-pr the PR closes a `review-finding` issue: Security, plus the source finding's domain when it was HIGH+.
21
+ # small <= 60 changed lines, or one file of <= 150 lines: Security only.
22
+ # medium 61-150 lines: Security plus the score-escalation table (no top-2 baseline).
23
+ # standard > 150 lines, or an unknown size (fail toward more agents): top-2 domains by score plus the table.
24
+ # --review-depth EXTENDED (forge#3862: the build's FORGE:DIFF_SIZE review_weight or out-of-plan scope flagged extended
25
+ # review) only ever ADDS reviewers: a small, medium or fix-pr roster is lifted to standard and the standard baseline takes
26
+ # the top-3 domains instead of top-2. It never narrows a roster and never touches the Security floor. Anything but the
27
+ # exact value EXTENDED is STANDARD. An extended roster prints an extra DEPTH=extended line.
28
+ # Path triggers escalate in EVERY tier except full (which already runs the union): CONTRACT HIGH_RISK, cross-critical
29
+ # pairs, churn hot-spot, danger zone, bin/engine* (Concurrency + spec-cli), bin/ (spec-cli), auth/session/trust-filter
30
+ # files (Auth), CI/docker/deploy/infra files (Infra), migration/.sql files (Database), SDK/OpenAPI files (API).
31
+ # Auth and Infra are path-gated: a diff-text score alone never selects them. Security is a hard floor in every tier.
32
+ # spec-cli is added only with --spec-cli true (the persona file exists in the repo under review); in the standard tier it
33
+ # also covers commands/**. Bash 3.2 portable (no mapfile, associative arrays, grep -P).
34
+ set -u
35
+
36
+ S_AUTH=0; S_BILLING=0; S_CONCURRENCY=0; S_DATABASE=0; S_INFRA=0; S_SCRAPING=0; S_FRONTEND=0; S_API=0
37
+ LINES=""; NFILES=""; FILES_FILE=""; FIX_PR=false; SRC_SEV=""; SRC_DOM=""; CFLAGS=""
38
+ DEPTH=STANDARD; CHURN=false; DANGER=false; THOROUGH=false; MILESTONE=false; SCRAPING_ON=false; DB_CONTAINER=false; SPEC_CLI=false
39
+
40
+ num() { case "${1:-}" in ''|*[!0-9]*) printf '0' ;; *) printf '%s' "$1" ;; esac; }
41
+ agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]'); case "$l" in infrastructure) l=infra ;; scraping) l=scraper ;; web) l=frontend ;; esac; printf '%s' "$l"; }
42
+ add_agent() {
43
+ local AGENT CUR HAVE=""
44
+ AGENT=$(agent_domain "${1}")
45
+ for CUR in $SELECTED_AGENTS; do
46
+ [ "$(agent_domain "$CUR")" = "$AGENT" ] && HAVE=1
47
+ done
48
+ [ -n "$HAVE" ] || SELECTED_AGENTS="$SELECTED_AGENTS $AGENT"
49
+ }
50
+
51
+ while [ $# -gt 0 ]; do
52
+ case "$1" in
53
+ --scores)
54
+ for kv in ${2:-}; do
55
+ v=$(num "${kv#*=}")
56
+ case "${kv%%=*}" in
57
+ AUTH) S_AUTH=$v ;; BILLING) S_BILLING=$v ;; CONCURRENCY) S_CONCURRENCY=$v ;; DATABASE) S_DATABASE=$v ;;
58
+ INFRA) S_INFRA=$v ;; SCRAPING) S_SCRAPING=$v ;; FRONTEND) S_FRONTEND=$v ;; API) S_API=$v ;;
59
+ esac
60
+ done; shift 2 ;;
61
+ --changed-lines) LINES="${2:-}"; shift 2 ;;
62
+ --changed-files) NFILES="${2:-}"; shift 2 ;;
63
+ --files-file) FILES_FILE="${2:-}"; shift 2 ;;
64
+ --fix-pr) FIX_PR="${2:-false}"; shift 2 ;;
65
+ --source-severity) SRC_SEV="${2:-}"; shift 2 ;;
66
+ --source-domain) SRC_DOM="${2:-}"; shift 2 ;;
67
+ --contract-flags) CFLAGS="${2:-}"; shift 2 ;;
68
+ --churn) CHURN="${2:-false}"; shift 2 ;;
69
+ --danger-zone) DANGER="${2:-false}"; shift 2 ;;
70
+ --thorough) THOROUGH="${2:-false}"; shift 2 ;;
71
+ --milestone) MILESTONE="${2:-false}"; shift 2 ;;
72
+ --scraping-configured) SCRAPING_ON="${2:-false}"; shift 2 ;;
73
+ --database-container) DB_CONTAINER="${2:-false}"; shift 2 ;;
74
+ --spec-cli) SPEC_CLI="${2:-false}"; shift 2 ;;
75
+ --review-depth) [ "${2:-}" = "EXTENDED" ] && DEPTH=EXTENDED; shift 2 ;;
76
+ *) echo "review-roster: unknown argument: $1" >&2; exit 2 ;;
77
+ esac
78
+ done
79
+
80
+ FILES=""
81
+ if [ "$FILES_FILE" = "-" ]; then FILES=$(cat)
82
+ elif [ -n "$FILES_FILE" ] && [ -f "$FILES_FILE" ]; then FILES=$(cat "$FILES_FILE"); fi
83
+ [ -n "$NFILES" ] || NFILES=$(printf '%s\n' "$FILES" | grep -c . || true)
84
+ case "$LINES" in ''|*[!0-9]*) LINES_KNOWN=false; LINES=0 ;; *) LINES_KNOWN=true ;; esac
85
+ case "$NFILES" in ''|*[!0-9]*) NFILES=0 ;; esac
86
+
87
+ SELECTED_AGENTS="security"
88
+ TIER=""; REASON=""
89
+
90
+ # Domains that may be picked by score alone. Auth and Infra are path-gated, Scraping is opt-in.
91
+ top_domains() { # $1 = how many
92
+ local d out="" list=""
93
+ list="$S_BILLING:BILLING $S_CONCURRENCY:CONCURRENCY $S_DATABASE:DATABASE $S_FRONTEND:FRONTEND $S_API:API"
94
+ [ "$SCRAPING_ON" = "true" ] && list="$list $S_SCRAPING:SCRAPING"
95
+ for d in $(printf '%s\n' "$list" | tr ' ' '\n' | sort -t: -k1,1 -rn | head -n "$1"); do
96
+ [ "${d%%:*}" -gt 0 ] && out="$out ${d#*:}"
97
+ done
98
+ printf '%s' "$out"
99
+ }
100
+
101
+ if [ "$THOROUGH" = "true" ] || [ "$MILESTONE" = "true" ]; then
102
+ TIER=full; REASON="thorough or milestone-to-staging: full union"
103
+ [ "$S_AUTH" -gt 0 ] && add_agent Auth
104
+ [ "$S_BILLING" -gt 0 ] && { add_agent Billing; add_agent Concurrency; }
105
+ [ "$S_DATABASE" -gt 0 ] && add_agent Database
106
+ [ "$S_INFRA" -gt 0 ] && add_agent Infra
107
+ [ "$S_SCRAPING" -gt 0 ] && [ "$SCRAPING_ON" = "true" ] && add_agent Scraping
108
+ [ "$S_FRONTEND" -gt 0 ] && add_agent Frontend
109
+ [ "$S_API" -gt 0 ] && add_agent API
110
+ printf 'SELECTED_AGENTS=%s\nTIER=%s\nREASON=%s\n' "${SELECTED_AGENTS# }" "$TIER" "$REASON"
111
+ exit 0
112
+ fi
113
+
114
+ HIGH_RISK=false
115
+ case " $CFLAGS " in *" HIGH_RISK "*) HIGH_RISK=true ;; esac
116
+
117
+ if [ "$FIX_PR" = "true" ]; then
118
+ TIER=fix-pr; REASON="fix PR for a review-finding issue"
119
+ elif [ "$LINES_KNOWN" = "true" ] && { [ "$LINES" -le 60 ] || { [ "$NFILES" -eq 1 ] && [ "$LINES" -le 150 ]; }; }; then
120
+ TIER=small; REASON="lines=$LINES files=$NFILES"
121
+ elif [ "$LINES_KNOWN" = "true" ] && [ "$LINES" -le 150 ]; then
122
+ TIER=medium; REASON="lines=$LINES"
123
+ else
124
+ TIER=standard
125
+ if [ "$LINES_KNOWN" = "true" ]; then REASON="lines=$LINES"; else REASON="size unknown"; fi
126
+ fi
127
+ # CONTRACT HIGH_RISK is never narrowed: a small, medium or fix-PR roster is lifted to the standard roster.
128
+ BASE_TIER="$TIER"
129
+ if [ "$HIGH_RISK" = "true" ] && [ "$TIER" != "standard" ]; then TIER=standard; REASON="$REASON; CONTRACT HIGH_RISK lifts $BASE_TIER to standard"; fi
130
+ # Extended review depth (forge#3862) is only ever additive: lift to standard, widen the standard baseline to top-3.
131
+ TOP_N=2
132
+ if [ "$DEPTH" = "EXTENDED" ]; then
133
+ TOP_N=3
134
+ if [ "$TIER" != "standard" ]; then REASON="$REASON; extended review depth lifts $TIER to standard"; TIER=standard; else REASON="$REASON; extended review depth"; fi
135
+ fi
136
+
137
+ TRIG=""
138
+ trig() { case " $TRIG " in *" $1 "*) ;; *) TRIG="$TRIG $1" ;; esac; }
139
+
140
+ # Tier baseline
141
+ case "$TIER" in
142
+ fix-pr)
143
+ case "$(printf '%s' "$SRC_SEV" | tr '[:lower:]' '[:upper:]')" in
144
+ HIGH|CRITICAL|BLOCKER)
145
+ if [ -n "$SRC_DOM" ]; then add_agent "$SRC_DOM"; else for d in $(top_domains 1); do add_agent "$d"; done; fi
146
+ REASON="$REASON; source finding ${SRC_SEV} adds its domain" ;;
147
+ esac ;;
148
+ standard) for d in $(top_domains "$TOP_N"); do add_agent "$d"; done ;;
149
+ esac
150
+
151
+ # Score escalation table (medium and standard only; diff-text signals, so never in the small or fix-pr tiers)
152
+ if [ "$TIER" = "medium" ] || [ "$TIER" = "standard" ]; then
153
+ [ "$S_BILLING" -ge 3 ] && { add_agent Billing; add_agent Concurrency; trig billing-score; }
154
+ [ "$S_DATABASE" -ge 3 ] && { add_agent Database; trig database-score; }
155
+ [ "$S_SCRAPING" -ge 3 ] && [ "$SCRAPING_ON" = "true" ] && { add_agent Scraping; trig scraping; }
156
+ [ "$S_INFRA" -ge 3 ] && [ "$DB_CONTAINER" = "true" ] && { add_agent Infra; trig db-container; }
157
+ fi
158
+
159
+ # Path and contract triggers (every tier)
160
+ if [ "$HIGH_RISK" = "true" ]; then
161
+ trig contract-high-risk
162
+ case " $CFLAGS " in *" CONTRACT_AUTH "*) add_agent Auth ;; esac
163
+ case " $CFLAGS " in *" CONTRACT_BILLING "*) add_agent Billing; add_agent Concurrency ;; esac
164
+ case " $CFLAGS " in *" CONTRACT_DATABASE "*) add_agent Database ;; esac
165
+ fi
166
+ { [ "$S_AUTH" -ge 2 ] && [ "$S_BILLING" -ge 2 ]; } && { add_agent Auth; add_agent Billing; add_agent Concurrency; trig cross-critical; }
167
+ { [ "$S_AUTH" -ge 2 ] && [ "$S_DATABASE" -ge 3 ]; } && { add_agent Auth; add_agent Database; trig cross-critical; }
168
+ { [ "$S_BILLING" -ge 2 ] && [ "$S_CONCURRENCY" -ge 2 ]; } && { add_agent Billing; add_agent Concurrency; add_agent Database; trig cross-critical; }
169
+ if [ "$CHURN" = "true" ] || [ "$DANGER" = "true" ]; then
170
+ for d in $(top_domains 1); do add_agent "$d"; trig churn-or-danger-zone; done
171
+ fi
172
+ hit() { printf '%s\n' "$FILES" | grep -qiE "$1"; }
173
+ hit '(^|[/_.-])(auth|authn|authz|session|sessions|jwt|oauth|login|logout|password|trust[-_]?filter|trusted[-_a-z]*)([/_.-]|$)' && { add_agent Auth; trig auth-path; }
174
+ hit '^\.github/|(^|/)Dockerfile|docker-compose|(^|/)(deploy|infra|traefik|nginx|k8s|helm|terraform)/|\.tf$' && { add_agent Infra; trig infra-path; }
175
+ hit 'migration|\.sql$' && { add_agent Database; trig migration-path; }
176
+ hit '^sdk/|openapi.*\.json$|openapi-versions/' && { add_agent API; trig sdk-path; }
177
+ hit '^bin/engine' && { add_agent Concurrency; trig bin-engine; }
178
+ if [ "$SPEC_CLI" = "true" ]; then
179
+ hit '^bin/' && { add_agent spec-cli; trig bin; }
180
+ [ "$TIER" = "standard" ] && hit '^commands/' && { add_agent spec-cli; trig commands; }
181
+ fi
182
+
183
+ [ -n "$TRIG" ] && REASON="$REASON; triggers:$TRIG"
184
+ printf 'SELECTED_AGENTS=%s\nTIER=%s\nREASON=%s\n' "${SELECTED_AGENTS# }" "$TIER" "$REASON"
185
+ [ "$DEPTH" = "EXTENDED" ] && printf 'DEPTH=extended\n'
186
+ exit 0