forgedock 1.0.6 → 1.0.8

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.
@@ -14,12 +14,28 @@ Performs comprehensive review of `staging` before merging to `main`. Handles lar
14
14
 
15
15
  ---
16
16
 
17
+ ## Config Resolution
18
+
19
+ Read `forge.yaml` to resolve branch names before running any commands:
20
+
21
+ ```bash
22
+ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
23
+ GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE")
24
+ GH_FLAG="-R $GH_REPO"
25
+ DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
26
+ STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE")
27
+ ```
28
+
29
+ All `$DEFAULT_BRANCH` and `$STAGING_BRANCH` references below are populated from `forge.yaml`.
30
+
31
+ ---
32
+
17
33
  ## Evidence-Based Review Protocol (ALL Agents)
18
34
 
19
35
  ### Diff-First Approach
20
36
  ```bash
21
- git fetch origin main staging
22
- git diff origin/main...origin/staging
37
+ git fetch origin $DEFAULT_BRANCH $STAGING_BRANCH
38
+ git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH
23
39
  ```
24
40
 
25
41
  ### Dynamic Exploration
@@ -29,9 +45,9 @@ From each changed file, follow imports and function calls. Trace data flows acro
29
45
 
30
46
  | Confidence | Criteria | Action |
31
47
  |------------|----------|--------|
32
- | CONFIRMED | Traced full code path, found specific proof | Report as P1 |
33
- | LIKELY | Pattern suggests issue, mitigations might exist | Report as P2 |
34
- | POSSIBLE | Suspicious but couldn't trace fully | Report as P3 |
48
+ | CONFIRMED | Traced full code path, found specific proof | Report as priority:P1 |
49
+ | LIKELY | Pattern suggests issue, mitigations might exist | Report as priority:P2 |
50
+ | POSSIBLE | Suspicious but couldn't trace fully | Report as priority:P3 |
35
51
  | UNFOUNDED | Found correct handling | Do NOT report |
36
52
 
37
53
  ### Severity Decision Tree
@@ -77,17 +93,17 @@ Include ALL findings (CONFIRMED, LIKELY, POSSIBLE). One line per finding, sequen
77
93
  **Why this matters**: Review findings are filed before the originating PR merges to staging. Without this gate, a staging→main bundle can include commits with known unfixed bugs — the review system caught the issue, but the deploy path ignored it. This gate closes the gap between issue discovery and deploy execution. <!-- Added: forge#303 -->
78
94
 
79
95
  ```bash
80
- git fetch origin main staging
96
+ git fetch origin $DEFAULT_BRANCH $STAGING_BRANCH
81
97
 
82
98
  # Step 1: Find all PR numbers in the staging→main bundle
83
99
  # These are the PRs whose commits are included in staging but not yet in main
84
- BUNDLE_PRS=$(git log origin/main..origin/staging --oneline \
100
+ BUNDLE_PRS=$(git log origin/$DEFAULT_BRANCH..origin/$STAGING_BRANCH --oneline \
85
101
  | grep -oP '#\d+' \
86
102
  | sort -u \
87
103
  | tr -d '#')
88
104
 
89
105
  # Also extract PR numbers from merge commit subjects (most reliable)
90
- MERGE_PRS=$(git log origin/main..origin/staging --merges --oneline \
106
+ MERGE_PRS=$(git log origin/$DEFAULT_BRANCH..origin/$STAGING_BRANCH --merges --oneline \
91
107
  | grep -oP '(?<=pull request #)\d+' \
92
108
  | sort -u)
93
109
 
@@ -178,10 +194,10 @@ If the gate exits with `RESULT: BLOCK DEPLOY` → **STOP**. Do NOT proceed to Ph
178
194
  ## Phase 0B: Scope Analysis
179
195
 
180
196
  ```bash
181
- git fetch origin main staging
182
- git diff origin/main...origin/staging --stat | tail -20
183
- git diff origin/main...origin/staging --numstat | awk '{add+=$1; del+=$2} END {print "Added:", add, "Deleted:", del, "Total:", add+del}'
184
- git diff origin/main...origin/staging --name-only | sort | uniq
197
+ git fetch origin $DEFAULT_BRANCH $STAGING_BRANCH
198
+ git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH --stat | tail -20
199
+ git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH --numstat | awk '{add+=$1; del+=$2} END {print "Added:", add, "Deleted:", del, "Total:", add+del}'
200
+ git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH --name-only | sort | uniq
185
201
  ```
186
202
 
187
203
  Categorize by service (API, Worker, Web, Shared, Infra). Identify high-risk files (billing, credits, pricing, auth, security, migration, scraper).
@@ -193,26 +209,70 @@ Create review chunks by priority: Billing/Pricing (CRITICAL) → Security/Auth (
193
209
  ## Phase 1: Automated Checks
194
210
 
195
211
  ### 1A: Python Linting
212
+
213
+ Read `forge.yaml → verification.commands.python` for project-specific tool commands:
214
+
196
215
  ```bash
197
- cd services/api && poetry run black --check app/ && poetry run isort --check app/
198
- cd services/worker && poetry run black --check worker/ && poetry run isort --check worker/
216
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
217
+ PYTHON_LINT=$(yq '.verification.commands.python.lint // ""' forge.yaml 2>/dev/null || echo '')
218
+
219
+ if [ -n "$PYTHON_FORMAT" ]; then
220
+ eval "$PYTHON_FORMAT" 2>&1 | head -30
221
+ else
222
+ echo "SKIPPED — python.format not configured in verification.commands"
223
+ fi
224
+
225
+ if [ -n "$PYTHON_LINT" ]; then
226
+ eval "$PYTHON_LINT" 2>&1 | head -30
227
+ else
228
+ echo "SKIPPED — python.lint not configured in verification.commands"
229
+ fi
199
230
  ```
200
231
 
201
232
  ### 1B: TypeScript Type-Check + Build (MANDATORY)
233
+
234
+ Read `forge.yaml → verification.commands.typescript` for project-specific tool commands:
235
+
202
236
  ```bash
203
- cd web && npx tsc --noEmit 2>&1
204
- cd web && npx next build 2>&1 | tail -50
237
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
238
+ TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
239
+
240
+ if [ -n "$TS_TYPECHECK" ]; then
241
+ eval "$TS_TYPECHECK" 2>&1
242
+ TS_EXIT=$?
243
+ [ "$TS_EXIT" -ne 0 ] && echo "BLOCKING: typecheck failed — deploy WILL fail"
244
+ else
245
+ echo "SKIPPED — typescript.typecheck not configured in verification.commands"
246
+ TS_EXIT=0
247
+ fi
248
+
249
+ if [ -n "$TS_BUILD" ]; then
250
+ eval "$TS_BUILD" 2>&1 | tail -50
251
+ BUILD_EXIT=$?
252
+ [ "$BUILD_EXIT" -ne 0 ] && echo "BLOCKING: build failed — deploy WILL fail"
253
+ else
254
+ echo "SKIPPED — typescript.build not configured in verification.commands"
255
+ fi
205
256
  ```
206
- `next build` failure is BLOCKING — deploy WILL fail. `tsc` alone misses SSG prerender failures.
257
+ Build failure is BLOCKING — deploy WILL fail. Typecheck alone misses SSG/prerender failures — configure `typescript.build` in `verification.commands`.
207
258
 
208
259
  ### 1C: Python Tests
260
+
261
+ Read `forge.yaml → verification.commands.python.test`:
262
+
209
263
  ```bash
210
- cd services/api && poetry run pytest tests/ -x -q --tb=short 2>&1 | tail -50
264
+ PYTHON_TEST=$(yq '.verification.commands.python.test // ""' forge.yaml 2>/dev/null || echo '')
265
+
266
+ if [ -n "$PYTHON_TEST" ]; then
267
+ eval "$PYTHON_TEST" 2>&1 | tail -50
268
+ else
269
+ echo "SKIPPED — python.test not configured in verification.commands"
270
+ fi
211
271
  ```
212
272
 
213
273
  ### 1D: Secrets Scan
214
274
  ```bash
215
- git diff origin/main...origin/staging | grep -iE "(api[_-]?key|secret|password|token|credential)" | grep -vE "(#|//|\.example|placeholder)" | head -20
275
+ git diff origin/$DEFAULT_BRANCH...origin/$STAGING_BRANCH | grep -iE "(api[_-]?key|secret|password|token|credential)" | grep -vE "(#|//|\.example|placeholder)" | head -20
216
276
  ```
217
277
 
218
278
  ### 1E: CI Status Gate (BLOCKING)
@@ -304,7 +364,7 @@ Only assign milestone if reviewing a milestone/* branch. Plain staging reviews g
304
364
  Check for open review-finding issues at same file:line → skip. Closed issues at same location → potential regression (elevate priority).
305
365
 
306
366
  ### 7F: Create Issues
307
- Sequential creation. Title: `Staging Review: {summary} (staging → main)`. Labels: review-finding, needs-validation, staging-review, P1/P2/P3. Body includes: source branch context (`staging`), code context, evidence, validation checklist.
367
+ Sequential creation. Title: `Staging Review: {summary} (staging → main)`. Labels: review-finding, needs-validation, staging-review, priority:P1/priority:P2/priority:P3. Body includes: source branch context (`staging`), code context, evidence, validation checklist.
308
368
 
309
369
  **For each finding** (that passes dedup), create issue:
310
370
  ```bash
@@ -156,27 +156,78 @@ Record: services touched, domains detected, PR scope (1-2 sentences), change cat
156
156
  ## Phase 2: Automated Checks (Run ALL in Parallel)
157
157
 
158
158
  ### 2A: Python Linting (if Python changed)
159
+
160
+ Read `forge.yaml → verification.commands.python` for project-specific tool commands:
161
+
159
162
  ```bash
160
- cd services/api && poetry run black --check app/
161
- cd services/api && poetry run isort --check app/
162
- cd services/worker && poetry run black --check worker/
163
- cd services/worker && poetry run isort --check worker/
164
- python3 -m py_compile <each-changed-python-file>
163
+ # Read toolchain commands from forge.yaml
164
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
165
+ PYTHON_LINT=$(yq '.verification.commands.python.lint // ""' forge.yaml 2>/dev/null || echo '')
166
+ PYTHON_TYPECHECK=$(yq '.verification.commands.python.typecheck // ""' forge.yaml 2>/dev/null || echo '')
167
+
168
+ # Run format check
169
+ if [ -n "$PYTHON_FORMAT" ]; then
170
+ eval "$PYTHON_FORMAT" 2>&1 | head -30
171
+ else
172
+ echo "SKIPPED — python.format not configured in verification.commands"
173
+ fi
174
+
175
+ # Run lint
176
+ if [ -n "$PYTHON_LINT" ]; then
177
+ eval "$PYTHON_LINT" 2>&1 | head -30
178
+ else
179
+ echo "SKIPPED — python.lint not configured in verification.commands"
180
+ fi
181
+
182
+ # Run typecheck
183
+ if [ -n "$PYTHON_TYPECHECK" ]; then
184
+ eval "$PYTHON_TYPECHECK" 2>&1 | head -50
185
+ else
186
+ echo "SKIPPED — python.typecheck not configured in verification.commands"
187
+ fi
188
+
189
+ # Always run compile check on changed Python files (fast, language-universal)
190
+ gh pr diff $ARGUMENTS --name-only | grep '\.py$' | while IFS= read -r f; do
191
+ python3 -m py_compile "$f" 2>&1
192
+ done
165
193
  ```
166
- **IMPORTANT**: Always `cd` into service dir and `poetry run black`. Never `pipx run black`.
167
194
 
168
- ### 2B: TypeScript/JS (if web/ changed)
195
+ ### 2B: TypeScript/JS (if TypeScript/JS changed)
196
+
197
+ Read `forge.yaml → verification.commands.typescript` for project-specific tool commands:
198
+
169
199
  ```bash
170
- cd web && npx prettier --check "src/**/*.{ts,tsx}"
171
- cd web && npx tsc --noEmit
172
- cd web && npx next lint
200
+ TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' forge.yaml 2>/dev/null || echo '')
201
+ TS_LINT=$(yq '.verification.commands.typescript.lint // ""' forge.yaml 2>/dev/null || echo '')
202
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
203
+
204
+ # Run format check
205
+ if [ -n "$TS_FORMAT" ]; then
206
+ eval "$TS_FORMAT" 2>&1 | head -30
207
+ else
208
+ echo "SKIPPED — typescript.format not configured in verification.commands"
209
+ fi
210
+
211
+ # Run lint
212
+ if [ -n "$TS_LINT" ]; then
213
+ eval "$TS_LINT" 2>&1 | head -30
214
+ else
215
+ echo "SKIPPED — typescript.lint not configured in verification.commands"
216
+ fi
217
+
218
+ # Run typecheck
219
+ if [ -n "$TS_TYPECHECK" ]; then
220
+ eval "$TS_TYPECHECK" 2>&1 | head -50
221
+ TS_TYPECHECK_EXIT=$?
222
+ else
223
+ echo "SKIPPED — typescript.typecheck not configured in verification.commands"
224
+ TS_TYPECHECK_EXIT=0
225
+ fi
173
226
  ```
174
227
 
175
228
  ### 2C: Static Type Checking (if Python changed)
176
- ```bash
177
- cd services/api && poetry run mypy app/ --ignore-missing-imports --no-error-summary 2>&1 | head -50
178
- cd services/api && poetry run ruff check app/ --select=E,W,F,B,S --ignore=E501 2>&1 | head -30
179
- ```
229
+
230
+ Covered by `PYTHON_TYPECHECK` command in 2A above. If `verification.commands.python.typecheck` is unset, this step is explicitly skipped with a log line — it does not silently pass.
180
231
 
181
232
  ### 2D: Environment Variable Audit
182
233
  ```bash
@@ -192,7 +243,7 @@ gh pr diff $ARGUMENTS | grep -oE "['\"][A-Za-z0-9+/=]{40,}['\"]" | head -10
192
243
 
193
244
  ### 2F: SQL Migration Validation (if *.sql changed)
194
245
  ```bash
195
- for sql_file in $(gh pr diff $ARGUMENTS --name-only | grep "\.sql$"); do
246
+ gh pr diff $ARGUMENTS --name-only | grep "\.sql$" | while IFS= read -r sql_file; do
196
247
  grep -E "FOR UPDATE" "$sql_file" | grep -qE "(SUM|COUNT|AVG|MIN|MAX)\s*\(" && echo "ERROR: FOR UPDATE with aggregate"
197
248
  grep -qE "DROP (TABLE|COLUMN|INDEX)" "$sql_file" && ! grep -qE "IF EXISTS" "$sql_file" && echo "WARNING: DROP without IF EXISTS"
198
249
  grep -qE "ALTER TABLE.*ADD COLUMN.*NOT NULL" "$sql_file" && ! grep -qE "DEFAULT" "$sql_file" && echo "WARNING: NOT NULL without DEFAULT"
@@ -206,8 +257,27 @@ git diff origin/main...HEAD -- "**/package.json" | grep -E "^\+" | grep -v "^\+\
206
257
  ```
207
258
 
208
259
  ### 2H: Tests
260
+
261
+ Read `forge.yaml → verification.commands.{lang}.test` for the project's test command:
262
+
209
263
  ```bash
210
- cd services/api && poetry run pytest tests/ -x -q --tb=short 2>&1 | tail -30
264
+ # Check each configured language for a test command
265
+ for lang in python typescript go rust; do
266
+ TEST_CMD=$(yq ".verification.commands.${lang}.test // \"\"" forge.yaml 2>/dev/null || echo '')
267
+ if [ -n "$TEST_CMD" ]; then
268
+ echo "=== Running tests (${lang}): ${TEST_CMD} ==="
269
+ eval "$TEST_CMD" 2>&1 | tail -30
270
+ TEST_EXIT=$?
271
+ [ "$TEST_EXIT" -ne 0 ] && echo "BLOCKING: ${lang} tests failed (exit $TEST_EXIT)"
272
+ fi
273
+ done
274
+
275
+ # If no test commands were configured, log explicitly
276
+ PYTHON_TEST=$(yq '.verification.commands.python.test // ""' forge.yaml 2>/dev/null || echo '')
277
+ TS_TEST=$(yq '.verification.commands.typescript.test // ""' forge.yaml 2>/dev/null || echo '')
278
+ if [ -z "$PYTHON_TEST" ] && [ -z "$TS_TEST" ]; then
279
+ echo "SKIPPED — no test commands configured in verification.commands"
280
+ fi
211
281
  ```
212
282
  **BLOCKING if tests fail.**
213
283
 
@@ -217,36 +287,67 @@ cd services/api && poetry run pytest tests/ -x -q --tb=short 2>&1 | tail -30
217
287
  CHANGED_FILES=$(gh pr diff $ARGUMENTS --name-only)
218
288
  HAS_TS=$(echo "$CHANGED_FILES" | grep -E '\.(tsx?|jsx?)$' | head -1)
219
289
  HAS_PY=$(echo "$CHANGED_FILES" | grep -E '\.py$' | head -1)
220
- IS_STAGING_TO_MAIN=$([[ "$HEAD" == "staging" && "$BASE" == "main" ]] && echo "true" || echo "false")
221
- IS_MILESTONE_TO_STAGING=$([[ "$HEAD" =~ ^milestone/ && "$BASE" == "staging" ]] && echo "true" || echo "false")
222
- REQUIRES_FULL_BUILD=$([[ "$IS_STAGING_TO_MAIN" == "true" || "$IS_MILESTONE_TO_STAGING" == "true" ]] && echo "true" || echo "false")
290
+ # Use POSIX-portable if/else (avoid bash-only [[ ]])
291
+ IS_STAGING_TO_MAIN="false"
292
+ if [ "$HEAD" = "staging" ] && [ "$BASE" = "main" ]; then IS_STAGING_TO_MAIN="true"; fi
293
+ IS_MILESTONE_TO_STAGING="false"
294
+ case "$HEAD" in milestone/*) if [ "$BASE" = "staging" ]; then IS_MILESTONE_TO_STAGING="true"; fi ;; esac
295
+ REQUIRES_FULL_BUILD="false"
296
+ if [ "$IS_STAGING_TO_MAIN" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; then REQUIRES_FULL_BUILD="true"; fi
223
297
  ```
224
298
 
225
299
  **TypeScript files changed:**
300
+
301
+ Read `forge.yaml → verification.commands.typescript.typecheck` and `.build`:
302
+
226
303
  ```bash
227
304
  gh pr checkout $ARGUMENTS --detach 2>/dev/null
228
- cd web && npx tsc --noEmit 2>&1
229
- TSC_EXIT=$?
230
- if [ "$REQUIRES_FULL_BUILD" = "true" ] || [ "$TSC_EXIT" -eq 0 ]; then
231
- npx next build 2>&1 | tail -30
305
+
306
+ TS_TYPECHECK=$(yq '.verification.commands.typescript.typecheck // ""' forge.yaml 2>/dev/null || echo '')
307
+ TS_BUILD=$(yq '.verification.commands.typescript.build // ""' forge.yaml 2>/dev/null || echo '')
308
+
309
+ if [ -n "$TS_TYPECHECK" ]; then
310
+ eval "$TS_TYPECHECK" 2>&1
311
+ TSC_EXIT=$?
312
+ else
313
+ echo "SKIPPED — typescript.typecheck not configured in verification.commands"
314
+ TSC_EXIT=0
315
+ fi
316
+
317
+ if [ -n "$TS_BUILD" ] && { [ "$REQUIRES_FULL_BUILD" = "true" ] || [ "$TSC_EXIT" -eq 0 ]; }; then
318
+ eval "$TS_BUILD" 2>&1 | tail -30
232
319
  BUILD_EXIT=$?
320
+ elif [ -z "$TS_BUILD" ]; then
321
+ echo "SKIPPED — typescript.build not configured in verification.commands"
233
322
  fi
234
- cd .. && git checkout - 2>/dev/null
323
+
324
+ git checkout - 2>/dev/null
235
325
  ```
236
326
 
237
327
  If `TSC_EXIT != 0`: **CONFIRMED blocking** — type errors.
238
- If `BUILD_EXIT != 0`: **CONFIRMED blocking** — SSG prerender failure.
328
+ If `BUILD_EXIT != 0`: **CONFIRMED blocking** — build/prerender failure.
239
329
 
240
- **CRITICAL**: `tsc --noEmit` alone is NOT sufficient for staging→main or milestone→staging. PR #2565 passed tsc but failed next build. PR #11637 (Session Intelligence milestone) was APPROVED without any build check because milestone→staging was excluded — 4 build errors shipped to staging.
330
+ **CRITICAL**: typecheck alone is NOT sufficient for staging→main or milestone→staging — configure `typescript.build` in `verification.commands` to catch SSG/prerender failures that typecheck misses.
241
331
 
242
332
  **Python files changed:**
333
+
334
+ Read `forge.yaml → verification.commands.python.format` and `.build`:
335
+
243
336
  ```bash
244
337
  gh pr checkout $ARGUMENTS --detach 2>/dev/null
245
- for f in $(echo "$CHANGED_FILES" | grep '\.py$'); do python3 -m py_compile "$f" 2>&1; done
338
+
339
+ # Compile-check all changed Python files (language-universal — no config needed)
340
+ echo "$CHANGED_FILES" | grep '\.py$' | while IFS= read -r f; do python3 -m py_compile "$f" 2>&1; done
341
+
246
342
  if [ "$REQUIRES_FULL_BUILD" = "true" ]; then
247
- cd services/api && poetry run black --check app/ 2>&1
248
- cd ../.. && cd services/worker && poetry run black --check worker/ 2>&1
343
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
344
+ if [ -n "$PYTHON_FORMAT" ]; then
345
+ eval "$PYTHON_FORMAT" 2>&1
346
+ else
347
+ echo "SKIPPED — python.format not configured in verification.commands (full-build format check skipped)"
348
+ fi
249
349
  fi
350
+
250
351
  git checkout - 2>/dev/null
251
352
  ```
252
353
 
@@ -258,9 +359,9 @@ Check whether the PR's actual changes match what the builder committed to in its
258
359
 
259
360
  ```bash
260
361
  # Find the contract comment on the linked issue
261
- ISSUE_NUM=$(gh pr view $ARGUMENTS --json body --jq '.body' | grep -oP 'Closes #\K\d+|#\K\d+' | head -1)
362
+ ISSUE_NUM=$(gh pr view $ARGUMENTS --json body --jq '.body | gsub("(?s).*?(?:Closes #|#)(?<n>[0-9]+).*"; "\(.n)") // empty' 2>/dev/null | head -1)
262
363
  if [ -n "$ISSUE_NUM" ]; then
263
- CONTRACT_FILES=$(gh api repos/${REPO}/issues/${ISSUE_NUM}/comments --jq '[.[] | select(.body | contains("FORGE:CONTRACT"))] | .[0].body' 2>/dev/null | grep -oP '`[^`]+\.(py|tsx?|sql|sh|yml|yaml|json)`' | tr -d '`' | sort -u)
364
+ CONTRACT_FILES=$(gh api repos/${REPO}/issues/${ISSUE_NUM}/comments --jq '[.[] | select(.body | contains("FORGE:CONTRACT"))] | .[0].body' 2>/dev/null | grep -E '`[^`]+\.(py|tsx?|sql|sh|yml|yaml|json)`' | grep -oE '`[^`]+\.(py|tsx?|sql|sh|yml|yaml|json)`' | tr -d '`' | sort -u)
264
365
  PR_FILES=$(gh pr diff $ARGUMENTS --name-only | sort -u)
265
366
 
266
367
  # Files in PR but NOT in contract
@@ -289,17 +390,28 @@ For each changed file, identify its **activation path** — how does execution r
289
390
 
290
391
  ### Step 2.5A: Identify File Types and Their Registration Points
291
392
 
292
- Map each changed file to its activation requirements:
393
+ Map each changed file to its activation requirements.
394
+
395
+ **Layout path resolution** — Before applying the table below, read your project's layout from `forge.yaml → review.layout` and substitute the values into the pattern column. Defaults (used when the key is absent) match the AlterLab monorepo layout:
396
+
397
+ | `forge.yaml` key | Default | Used in table as |
398
+ |-----------------|---------|-----------------|
399
+ | `review.layout.pages` | `web/src/app` | `{PAGES_ROOT}` |
400
+ | `review.layout.api_routers` | `services/api/app/routers` | `{API_ROUTERS}` |
401
+ | `review.layout.api_main` | `services/api/app/main.py` | `{API_MAIN}` |
402
+ | `review.layout.api_middleware` | `services/api/app/middleware` | `{API_MIDDLEWARE}` |
403
+ | `review.layout.migrations` | `infra/migrations` | `{MIGRATIONS}` |
404
+ | `review.layout.worker` | `services/worker` | `{WORKER}` |
293
405
 
294
406
  | Changed File Pattern | Assumption | Verification Target |
295
407
  |---------------------|------------|---------------------|
296
- | `web/src/app/api/**/*.ts` (Route Handler) | Requests reach this handler | Check `web/next.config.js` rewrites don't shadow it; check `infra/nginx/nginx.conf` routes path to Next.js |
297
- | `services/api/app/routers/*.py` (API Router) | Router is registered in app | Check `services/api/app/main.py` includes this router |
298
- | `services/api/app/middleware/*.py` | Middleware is in the stack | Check `services/api/app/main.py` middleware registration order |
299
- | `infra/migrations/*.sql` | Migration runs on current schema | Check previous migration's end state matches assumptions |
408
+ | `{PAGES_ROOT}/api/**/*.ts` (Route Handler) | Requests reach this handler | Check `web/next.config.js` rewrites don't shadow it; check `infra/nginx/nginx.conf` routes path to Next.js |
409
+ | `{API_ROUTERS}/*.py` (API Router) | Router is registered in app | Check `{API_MAIN}` includes this router |
410
+ | `{API_MIDDLEWARE}/*.py` | Middleware is in the stack | Check `{API_MAIN}` middleware registration order |
411
+ | `{MIGRATIONS}/*.sql` | Migration runs on current schema | Check previous migration's end state matches assumptions |
300
412
  | `shared/**/*.py` | Imported by consumer services | Verify import paths exist in api/worker; check Docker volume mounts |
301
- | `services/worker/worker/*.py` (Consumer) | Queue consumer is registered | Check consumer registration in worker startup |
302
- | Any file using `os.getenv("NEW_VAR")` | Env var is set at runtime | Check `docker-compose.yml`, `.env.example`, `services/api/app/core/env_validation.py` |
413
+ | `{WORKER}/**/*.py` (Consumer) | Queue consumer is registered | Check consumer registration in worker startup |
414
+ | Any file using `os.getenv("NEW_VAR")` | Env var is set at runtime | Check `docker-compose.yml`, `.env.example`, `{API_MAIN}` env validation module |
303
415
  | `scripts/decrypt-secrets.sh` (ENV_MAPPING) | Secret reaches running container | Trace full chain: SOPS key → ENV_MAPPING → deploy workflow SCP target → merge script path → `docker-compose.prod.yml` env_file. See Step 2.5B SOPS deploy chain check. |
304
416
  | `.secrets/prod.enc.yaml` | SOPS key maps to ENV_MAPPING | Verify key path in YAML matches the tuple in `decrypt-secrets.sh` ENV_MAPPING |
305
417
  | `.github/workflows/deploy-production.yml` | Deploy paths are consistent | Verify SCP target + merge script `PROJECT` var resolve to same dir as `docker-compose.prod.yml` env_file |
@@ -317,55 +429,73 @@ Map each changed file to its activation requirements:
317
429
 
318
430
  For each changed file, execute the relevant checks using the standalone verification scripts in `$FORGE_HOME/scripts/`. These scripts can also be run independently outside the review context (e.g., from `/quality-gate` or `/work-on` builder steps).
319
431
 
432
+ **Platform note**: The verify-*.sh scripts require bash and standard POSIX tools. On Windows without bash (Git Bash / WSL / MSYS2), these checks are skipped with an explicit message — the review continues without them.
433
+
320
434
  ```bash
321
435
  CHANGED_FILES=$(gh pr diff $ARGUMENTS --name-only)
322
436
  REPO_ROOT="." # Assumes cwd is the repo root
323
437
 
324
- # Write changed files and diff to temp files for script consumption
325
- CHANGED_FILES_TMP=$(mktemp)
326
- DIFF_TMP=$(mktemp)
327
- echo "$CHANGED_FILES" > "$CHANGED_FILES_TMP"
328
- gh pr diff $ARGUMENTS > "$DIFF_TMP"
329
-
330
- # --- Script-based checks (reusable, testable, deterministic) ---
331
- # Each script exits 0 (pass), 1 (blocking findings), or 2 (warnings only).
332
- # Output is structured: "BLOCKING: ...", "WARNING: ...", "OK: ..." per line.
333
-
334
- # 1. Route/router/middleware/shared-module/component registration
335
- echo "=== Running: verify-route-registration.sh ==="
336
- $FORGE_HOME/scripts/verify-route-registration.sh "$CHANGED_FILES_TMP" "$REPO_ROOT" || true
337
-
338
- # 2. Environment variable wiring (checks .env.example, docker-compose, env_validation, SOPS mapping)
339
- echo "=== Running: verify-env-vars.sh ==="
340
- $FORGE_HOME/scripts/verify-env-vars.sh "$DIFF_TMP" "$REPO_ROOT" || true
341
-
342
- # 3. Host headers in shell scripts + client-side proxy bypass check
343
- # Read project-specific internal service patterns from forge.yaml (if present)
344
- FORGE_INTERNAL_PATTERNS=""
345
- if [ -f "$REPO_ROOT/forge.yaml" ]; then
346
- FORGE_INTERNAL_PATTERNS=$(grep -A 999 'internal_service_patterns:' "$REPO_ROOT/forge.yaml" \
347
- | grep -E '^\s*-\s+' \
348
- | sed 's/^\s*-\s*//' \
349
- | tr -d '"'"'" \
350
- | paste -sd '|' -)
438
+ # --- Platform / bash capability guard ---
439
+ # The verify-*.sh scripts require bash. Detect availability before invoking.
440
+ # On Windows without Git Bash/WSL, skip gracefully rather than crash.
441
+ BASH_AVAILABLE=false
442
+ if command -v bash >/dev/null 2>&1 && bash -c 'echo ok' >/dev/null 2>&1; then
443
+ BASH_AVAILABLE=true
351
444
  fi
352
- export FORGE_INTERNAL_PATTERNS
353
- echo "=== Running: verify-host-headers.sh ==="
354
- $FORGE_HOME/scripts/verify-host-headers.sh "$CHANGED_FILES_TMP" "$REPO_ROOT" || true
355
445
 
356
- # 4. SOPS deploy chain (ENV_MAPPING consistency, deploy path drift, hotfix sync)
357
- echo "=== Running: verify-sops-chain.sh ==="
358
- $FORGE_HOME/scripts/verify-sops-chain.sh "$DIFF_TMP" "$CHANGED_FILES_TMP" "$REPO_ROOT" || true
446
+ if [ "$BASH_AVAILABLE" = "true" ]; then
447
+ # Write changed files and diff to temp files for script consumption.
448
+ # Use PID-based names instead of mktemp for cross-platform compatibility.
449
+ CHANGED_FILES_TMP="/tmp/forge-review-changed-$$.tmp"
450
+ DIFF_TMP="/tmp/forge-review-diff-$$.tmp"
451
+ echo "$CHANGED_FILES" > "$CHANGED_FILES_TMP"
452
+ gh pr diff $ARGUMENTS > "$DIFF_TMP"
453
+
454
+ # --- Script-based checks (reusable, testable, deterministic) ---
455
+ # Each script exits 0 (pass), 1 (blocking findings), or 2 (warnings only).
456
+ # Output is structured: "BLOCKING: ...", "WARNING: ...", "OK: ..." per line.
457
+
458
+ # 1. Route/router/middleware/shared-module/component registration
459
+ echo "=== Running: verify-route-registration.sh ==="
460
+ bash "$FORGE_HOME/scripts/verify-route-registration.sh" "$CHANGED_FILES_TMP" "$REPO_ROOT" || true
461
+
462
+ # 2. Environment variable wiring (checks .env.example, docker-compose, env_validation, SOPS mapping)
463
+ echo "=== Running: verify-env-vars.sh ==="
464
+ bash "$FORGE_HOME/scripts/verify-env-vars.sh" "$DIFF_TMP" "$REPO_ROOT" || true
465
+
466
+ # 3. Host headers in shell scripts + client-side proxy bypass check
467
+ # Read project-specific internal service patterns from forge.yaml (if present)
468
+ FORGE_INTERNAL_PATTERNS=""
469
+ if [ -f "$REPO_ROOT/forge.yaml" ]; then
470
+ FORGE_INTERNAL_PATTERNS=$(grep -A 999 'internal_service_patterns:' "$REPO_ROOT/forge.yaml" \
471
+ | grep -E '^\s*-\s+' \
472
+ | sed 's/^\s*-\s*//' \
473
+ | tr -d '"'"'" \
474
+ | awk 'NR>1{printf "|"}{printf $0}END{print ""}')
475
+ fi
476
+ export FORGE_INTERNAL_PATTERNS
477
+ echo "=== Running: verify-host-headers.sh ==="
478
+ bash "$FORGE_HOME/scripts/verify-host-headers.sh" "$CHANGED_FILES_TMP" "$REPO_ROOT" || true
479
+
480
+ # 4. SOPS deploy chain (ENV_MAPPING consistency, deploy path drift, hotfix sync)
481
+ echo "=== Running: verify-sops-chain.sh ==="
482
+ bash "$FORGE_HOME/scripts/verify-sops-chain.sh" "$DIFF_TMP" "$CHANGED_FILES_TMP" "$REPO_ROOT" || true
359
483
 
360
- # Cleanup temp files
361
- rm -f "$CHANGED_FILES_TMP" "$DIFF_TMP"
484
+ # Cleanup temp files
485
+ rm -f "$CHANGED_FILES_TMP" "$DIFF_TMP"
486
+ else
487
+ echo "=== Phase 2.5B: verify-*.sh skipped — bash not available on this platform ==="
488
+ echo " The verify-*.sh scripts require bash (POSIX shell)."
489
+ echo " Install Git Bash (Windows) or WSL to enable these checks."
490
+ echo " The review continues — integration assumptions should be verified manually."
491
+ fi
362
492
 
363
493
  # --- Inline checks (not yet extracted to scripts) ---
364
494
 
365
495
  # Python scoping hazard check — local imports that shadow module-level names
366
496
  # A local `import X` makes X a local variable for the ENTIRE function scope.
367
497
  # Any reference to X ABOVE the local import will crash with UnboundLocalError.
368
- for f in $(echo "$CHANGED_FILES" | grep -E '\.py$'); do
498
+ echo "$CHANGED_FILES" | grep -E '\.py$' | while IFS= read -r f; do
369
499
  echo "=== Python Scoping Check: $f ==="
370
500
  # Find function-scoped imports (indented import statements)
371
501
  grep -nE "^\s+import [a-z]" "$f" 2>/dev/null | while read line; do
@@ -379,7 +509,7 @@ for f in $(echo "$CHANGED_FILES" | grep -E '\.py$'); do
379
509
  done
380
510
 
381
511
  # Config file assumption check (baked into Docker image vs volume-mounted)
382
- for f in $(echo "$CHANGED_FILES" | grep -E "config/.*\.(json|yaml|yml)$"); do
512
+ echo "$CHANGED_FILES" | grep -E "config/.*\.(json|yaml|yml)$" | while IFS= read -r f; do
383
513
  echo "=== Config File: $f ==="
384
514
  grep -n "$(dirname $f)" docker-compose.yml 2>/dev/null || echo "WARNING: Config dir may not be mounted — changes may require --build"
385
515
  grep -n "$(dirname $f)" services/*/Dockerfile 2>/dev/null || true
@@ -396,7 +526,9 @@ done
396
526
  # diff. Pre-existing drift is the most dangerous kind — it lurks until
397
527
  # staging→main and then blocks the deploy.
398
528
  WORKFLOW_FILES=$(echo "$CHANGED_FILES" | grep -E "^\.github/workflows/.*\.yml$" || true)
399
- IS_STAGING_PR=$([[ "$HEAD" == "staging" && "$BASE" == "main" ]] && echo "true" || echo "false")
529
+ # Use POSIX-portable conditional (avoid bash-only [[ ]])
530
+ IS_STAGING_PR="false"
531
+ if [ "$HEAD" = "staging" ] && [ "$BASE" = "main" ]; then IS_STAGING_PR="true"; fi
400
532
 
401
533
  if [ -n "$WORKFLOW_FILES" ] || [ "$IS_STAGING_PR" = "true" ]; then
402
534
  echo "=== Sibling Workflow Drift Check (MANDATORY for staging→main) ==="
@@ -415,9 +547,14 @@ if [ -n "$WORKFLOW_FILES" ] || [ "$IS_STAGING_PR" = "true" ]; then
415
547
 
416
548
  echo "--- Comparing '$JOB' job between ci.yml and deploy-production.yml ---"
417
549
 
418
- # Extract env vars from ALL steps in the job (not just pytest)
419
- CI_ENVS=$(sed -n "/^ ${JOB}:/,/^ [a-z]/p" "$CI_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
420
- DEPLOY_ENVS=$(sed -n "/^ ${JOB}:/,/^ [a-z]/p" "$DEPLOY_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
550
+ # Extract env vars from ALL steps in the job (not just pytest).
551
+ # Flag-based awk avoids the range-collapse bug: /pat1/,/pat2/ collapses
552
+ # to a single line when the header (e.g. " test-api:") matches both
553
+ # patterns simultaneously. The flag form sets p=1 on the header line,
554
+ # prints body lines while p=1, and clears p when the next sibling job
555
+ # header (same indentation, lowercase start) is seen. <!-- Added: forge#310 -->
556
+ CI_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
557
+ DEPLOY_ENVS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -E "PYTHONPATH|DATABASE_URL|REDIS_URL|TESTING" | sed 's/^ *//' | sort)
421
558
 
422
559
  # Check for PYTHONPATH specifically — the exact var that caused the #11356 failure
423
560
  CI_PYPATH=$(echo "$CI_ENVS" | grep "PYTHONPATH" || echo "(not set)")
@@ -430,16 +567,16 @@ if [ -n "$WORKFLOW_FILES" ] || [ "$IS_STAGING_PR" = "true" ]; then
430
567
  fi
431
568
 
432
569
  # Check for dependency installation steps that exist in one but not the other
433
- CI_INSTALLS=$(sed -n "/^ ${JOB}:/,/^ [a-z]/p" "$CI_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
434
- DEPLOY_INSTALLS=$(sed -n "/^ ${JOB}:/,/^ [a-z]/p" "$DEPLOY_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
570
+ CI_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
571
+ DEPLOY_INSTALLS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep -c "poetry install\|pip install\|npm install" || echo 0)
435
572
  if [ "$CI_INSTALLS" != "$DEPLOY_INSTALLS" ]; then
436
573
  echo " WARNING: Different number of dependency install steps in '$JOB' — ci.yml has $CI_INSTALLS, deploy has $DEPLOY_INSTALLS"
437
574
  echo " ACTION: Read both files and verify all dependencies needed by tests are installed in both workflows."
438
575
  fi
439
576
 
440
577
  # Check step names — if CI has a step that deploy doesn't, flag it
441
- CI_STEPS=$(sed -n "/^ ${JOB}:/,/^ [a-z]/p" "$CI_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
442
- DEPLOY_STEPS=$(sed -n "/^ ${JOB}:/,/^ [a-z]/p" "$DEPLOY_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
578
+ CI_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$CI_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
579
+ DEPLOY_STEPS=$(awk -v pat="^ ${JOB}:" 'BEGIN{p=0} $0~pat{p=1; print; next} p && /^ [a-z]/{p=0} p{print}' "$DEPLOY_WF" 2>/dev/null | grep "- name:" | sed 's/.*- name: //' | sort)
443
580
  MISSING_IN_DEPLOY=$(comm -23 <(echo "$CI_STEPS") <(echo "$DEPLOY_STEPS") 2>/dev/null || true)
444
581
  if [ -n "$MISSING_IN_DEPLOY" ]; then
445
582
  echo " WARNING: Steps in ci.yml '$JOB' missing from deploy-production.yml:"
@@ -568,7 +705,10 @@ gh api repos/{owner}/{repo}/issues/$ARGUMENTS/comments --jq '.[-10:] | .[].body[
568
705
  **Skip if**: Only 1 agent OR total findings ≤ 3.
569
706
 
570
707
  ```bash
571
- ALL_FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --jq '.[].body' | grep -oP '(?<=<!-- FINDING:).*?(?= -->)')
708
+ # Extract structured finding IDs from FINDING HTML comments.
709
+ # Uses jq's scan() (POSIX-portable, no grep -oP required).
710
+ ALL_FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
711
+ --jq '[.[].body | scan("<!-- FINDING:([^>]+) -->") | .[0]] | join("\n")')
572
712
  AGENT_COUNT=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --jq '[.[] | select(.body | test("REVIEW-FINDINGS-START"))] | length')
573
713
  FINDING_COUNT=$(echo "$ALL_FINDINGS" | grep -c '.' || echo 0)
574
714
  ```
@@ -595,9 +735,13 @@ REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner')
595
735
  HAS_SYNTHESIS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --jq '.[].body' | grep -c 'REVIEW-FINDINGS-SYNTHESIZED-START' || echo 0)
596
736
 
597
737
  if [ "$HAS_SYNTHESIS" -gt 0 ]; then
598
- FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --jq '.[] | select(.body | test("REVIEW-FINDINGS-SYNTHESIZED-START")) | .body' | grep -oP '(?<=<!-- FINDING:).*?(?= -->)')
738
+ # Extract finding IDs from synthesized block using jq scan() — no grep -oP needed
739
+ FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
740
+ --jq '[.[] | select(.body | test("REVIEW-FINDINGS-SYNTHESIZED-START")) | .body | scan("<!-- FINDING:([^>]+) -->") | .[0]] | join("\n")')
599
741
  else
600
- FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --jq '.[].body' | grep -oP '(?<=<!-- FINDING:).*?(?= -->)')
742
+ # Extract finding IDs from all agent comments using jq scan() — portable, no grep -oP
743
+ FINDINGS=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
744
+ --jq '[.[].body | scan("<!-- FINDING:([^>]+) -->") | .[0]] | join("\n")')
601
745
  fi
602
746
  ```
603
747
 
@@ -924,7 +1068,8 @@ fi
924
1068
 
925
1069
  ```bash
926
1070
  CURRENT_SHA=$(gh pr view $ARGUMENTS --json headRefOid --jq '.headRefOid')
927
- REVIEW_IS_STALE=$([[ "$CURRENT_SHA" != "$REVIEW_SHA" ]] && echo "true" || echo "false")
1071
+ REVIEW_IS_STALE="false"
1072
+ if [ "$CURRENT_SHA" != "$REVIEW_SHA" ]; then REVIEW_IS_STALE="true"; fi
928
1073
  ```
929
1074
 
930
1075
  ```bash