forgedock 1.0.21 → 1.1.1

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 (76) hide show
  1. package/README.md +227 -174
  2. package/bin/cinema.mjs +320 -0
  3. package/bin/demo.mjs +386 -0
  4. package/bin/engine/phases.mjs +148 -0
  5. package/bin/engine/projector.mjs +32 -0
  6. package/bin/engine/reconcile.mjs +11 -0
  7. package/bin/engine/runlog.mjs +79 -0
  8. package/bin/engine/state.mjs +27 -0
  9. package/bin/engine-cli.mjs +50 -0
  10. package/bin/engine.mjs +138 -0
  11. package/bin/forgedock.mjs +693 -1393
  12. package/bin/init-detect.mjs +95 -0
  13. package/bin/journey.mjs +783 -0
  14. package/bin/runner.mjs +847 -0
  15. package/bin/settings-hook.mjs +112 -0
  16. package/bin/tui.mjs +6 -3
  17. package/commands/autopilot.md +4 -4
  18. package/commands/cleanup.md +85 -39
  19. package/commands/deploy-info.md +11 -5
  20. package/commands/design-bench.md +1 -0
  21. package/commands/design-render-critique-loop.md +1 -0
  22. package/commands/design.md +1 -0
  23. package/commands/forge-stats.md +2 -1
  24. package/commands/issue.md +1 -1
  25. package/commands/optimize.md +1 -1
  26. package/commands/orchestrate.md +346 -34
  27. package/commands/quality-gate.md +168 -80
  28. package/commands/review-pr-agents.md +69 -45
  29. package/commands/review-pr.md +54 -6
  30. package/commands/scope.md +11 -3
  31. package/commands/security-audit.md +24 -16
  32. package/commands/sync-ecosystem.md +2 -2
  33. package/commands/work-on/build/architect.md +47 -3
  34. package/commands/work-on/build/context.md +36 -5
  35. package/commands/work-on/build/implement.md +16 -4
  36. package/commands/work-on/build/validate.md +54 -8
  37. package/commands/work-on/build.md +27 -9
  38. package/commands/work-on/close.md +131 -3
  39. package/commands/work-on/investigate.md +24 -1
  40. package/commands/work-on/review.md +24 -0
  41. package/commands/work-on-monolithic.md +1 -0
  42. package/commands/work-on.md +271 -11
  43. package/docs/BRAND.md +6 -2
  44. package/docs/CONFIG.md +39 -34
  45. package/docs/site/.vitepress/config.mjs +85 -0
  46. package/docs/site/annotations-explained.md +55 -0
  47. package/docs/site/command-learning-path.md +104 -0
  48. package/docs/site/command-reference.md +3 -0
  49. package/docs/site/getting-started.md +42 -13
  50. package/docs/site/index.md +1 -1
  51. package/docs/site/troubleshooting.md +208 -0
  52. package/docs/spec/forge-protocol-v1.md +768 -0
  53. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  54. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  55. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  56. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  57. package/examples/forgedock-demo/README.md +113 -0
  58. package/examples/forgedock-demo/bootstrap.sh +125 -0
  59. package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
  60. package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
  61. package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
  62. package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
  63. package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
  64. package/examples/forgedock-demo/labels.json +21 -0
  65. package/examples/forgedock-demo/package.json +16 -0
  66. package/examples/forgedock-demo/scripts/smoke.js +61 -0
  67. package/examples/forgedock-demo/src/auth.js +28 -0
  68. package/examples/forgedock-demo/src/db.js +66 -0
  69. package/examples/forgedock-demo/src/routes/notes.js +64 -0
  70. package/examples/forgedock-demo/src/server.js +79 -0
  71. package/package.json +13 -3
  72. package/scripts/verify-env-vars.sh +18 -2
  73. package/scripts/verify-host-headers.sh +4 -4
  74. package/scripts/verify-route-registration.sh +2 -2
  75. package/scripts/worktree-lifecycle.sh +202 -0
  76. package/templates/forge.yaml.minimal +44 -0
@@ -39,10 +39,10 @@ This quality gate uses **domain detection** to adapt to your project's actual te
39
39
  | FRONTEND | `.tsx`/`.ts` files in client component paths |
40
40
  | PROXY | Client components using `fetch`/`useSWR`/`apiFetch` |
41
41
  | SHELL | `.sh` files or scripts using `curl`/`wget` |
42
- | STRING_SETS | Python files in anti-detection or browser automation paths |
42
+ | STRING_SETS | *(scraping pack — opt-in)* Python files in anti-detection or browser automation paths. Enable via `forge.yaml → quality_gate.optional_domains: [STRING_SETS]` |
43
43
  | CONCURRENCY | Python files using `asyncio.shield`, `asyncio.wait_for`, or `Task.cancel` |
44
44
  | STATE | Python files with new module-level mutable variable assignments |
45
- | CAPACITY | Worker/infra Python files with new size/limit/threshold constants |
45
+ | CAPACITY | *(scraping pack — opt-in)* Worker/infra Python files with new size/limit/threshold constants. Enable via `forge.yaml → quality_gate.optional_domains: [CAPACITY]` |
46
46
  | WORKFLOW | `.yml` files in `.github/workflows/` |
47
47
  | IMPORT_RESOLUTION | Python files with new `from app.*` imports added outside `try:` blocks |
48
48
  | INFRA | Dockerfiles or entrypoints with runtime UID changes |
@@ -92,10 +92,10 @@ Before running checks, classify the changed files into domains. This avoids runn
92
92
  | `*.tsx`, `*.ts` in `web/src/` (excluding `route.ts`) | FRONTEND |
93
93
  | `*.tsx`, `*.ts` client components with fetch/useSWR | PROXY |
94
94
  | `*.sh` files or scripts with `curl`/`wget` | SHELL |
95
- | `*.py` in `anti_detection/`, `consumers/`, `browser/`, or `shared/detection/` | STRING_SETS |
95
+ | `*.py` in `anti_detection/`, `consumers/`, `browser/`, or `shared/detection/` | STRING_SETS *(scraping pack — opt-in, see 2J)* |
96
96
  | `*.py` containing `asyncio.shield`, `asyncio.wait_for`, or `Task.cancel` | CONCURRENCY |
97
97
  | `*.py` files with new module-level `dict`, `set`, `Counter`, `Lock`, or `defaultdict` assignments | STATE |
98
- | `*.py` in `services/worker/`, `infra/`, or `browser/` with new assignments to variables containing `MB`, `SIZE`, `MAX`, `LIMIT`, `THRESHOLD`, or `TIMEOUT` | CAPACITY |
98
+ | `*.py` in `services/worker/`, `infra/`, or `browser/` with new assignments to variables containing `MB`, `SIZE`, `MAX`, `LIMIT`, `THRESHOLD`, or `TIMEOUT` | CAPACITY *(scraping pack — opt-in, see 2K)* |
99
99
  | `*.yml` in `.github/workflows/` | WORKFLOW |
100
100
  | `*.py` with new `from app.` import statements added outside `try:` blocks | IMPORT_RESOLUTION |
101
101
  | `Dockerfile*` or `entrypoint*.sh` with added/changed `USER`, `su-exec`, `gosu`, or `setuid` | INFRA |
@@ -109,7 +109,12 @@ Before running checks, classify the changed files into domains. This avoids runn
109
109
  # Initialize domain flags
110
110
  DOMAINS=""
111
111
 
112
- for f in {CHANGED_FILES}; do
112
+ # {CHANGED_FILES} is a space-separated argument (same contract as {AFFECTED_FILES}
113
+ # in architect.md) — split explicitly on IFS=' ' into an array instead of relying
114
+ # on a bare `for f in {CHANGED_FILES}`, which word-splits on the shell's default
115
+ # IFS (space, tab, AND newline) and would corrupt any path containing a space.
116
+ IFS=' ' read -ra CHANGED_FILES_ARR <<< "{CHANGED_FILES}"
117
+ for f in "${CHANGED_FILES_ARR[@]}"; do
113
118
  case "$f" in
114
119
  *.sql|*migrations/*) DOMAINS="$DOMAINS DATABASE" ;;
115
120
  esac
@@ -126,7 +131,12 @@ for f in {CHANGED_FILES}; do
126
131
  esac
127
132
  case "$f" in
128
133
  *anti_detection/*|*consumers/*|*browser/*|*shared/detection/*)
129
- echo "$f" | grep -qE '\.py$' && DOMAINS="$DOMAINS STRING_SETS"
134
+ # STRING_SETS is a scraping-pack domain — opt-in via forge.yaml → quality_gate.optional_domains
135
+ # <!-- Updated: forge#1349 — not enabled by default; scraping-product-specific -->
136
+ if echo "$f" | grep -qE '\.py$'; then
137
+ _OPT_DOMAINS=$(grep -A5 'optional_domains:' "{WORKTREE_PATH}/forge.yaml" 2>/dev/null | grep -oE 'STRING_SETS' | head -1 || true)
138
+ [ -n "$_OPT_DOMAINS" ] && DOMAINS="$DOMAINS STRING_SETS"
139
+ fi
130
140
  ;;
131
141
  esac
132
142
  case "$f" in
@@ -144,34 +154,45 @@ grep -lE "asyncio\.shield|asyncio\.wait_for|Task\.cancel" {CHANGED_FILES} 2>/dev
144
154
  grep -lE "^\w+ = (\{\}|\[\]|set\(\)|Lock\(\)|defaultdict|Counter)" {CHANGED_FILES} 2>/dev/null | grep -qE '\.py$' && DOMAINS="$DOMAINS STATE"
145
155
 
146
156
  # Check for capacity constants in worker/infra/browser Python files
147
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'services/worker/|infra/|browser/'); do
148
- git diff HEAD -- "$f" 2>/dev/null | grep -qE '^\+[A-Za-z_]\w*(MB|SIZE|MAX|LIMIT|THRESHOLD|TIMEOUT)\w*\s*=' && DOMAINS="$DOMAINS CAPACITY" && break
149
- done
157
+ # CAPACITY is a scraping-pack domain — opt-in via forge.yaml → quality_gate.optional_domains
158
+ # <!-- Updated: forge#1349 — not enabled by default; scraping-product-specific -->
159
+ _OPT_CAPACITY=$(grep -A5 'optional_domains:' "{WORKTREE_PATH}/forge.yaml" 2>/dev/null | grep -oE 'CAPACITY' | head -1 || true)
160
+ if [ -n "$_OPT_CAPACITY" ]; then
161
+ while IFS= read -r f; do
162
+ [ -z "$f" ] && continue
163
+ git diff HEAD -- "$f" 2>/dev/null | grep -qE '^\+[A-Za-z_]\w*(MB|SIZE|MAX|LIMIT|THRESHOLD|TIMEOUT)\w*\s*=' && DOMAINS="$DOMAINS CAPACITY" && break
164
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'services/worker/|infra/|browser/')
165
+ fi
150
166
 
151
167
  # Check for env var usage in changed files
152
168
  grep -lE "os\.getenv|process\.env" {CHANGED_FILES} 2>/dev/null && DOMAINS="$DOMAINS DEPLOY"
153
169
 
154
170
  # Check for new app.* imports added outside try: blocks in Python files
155
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
171
+ while IFS= read -r f; do
172
+ [ -z "$f" ] && continue
156
173
  NEW_IMPORTS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+\s*(from app\.|import app\.)' | grep -v '^+++')
157
174
  if [ -n "$NEW_IMPORTS" ]; then
158
175
  DOMAINS="$DOMAINS IMPORT_RESOLUTION"
159
176
  break
160
177
  fi
161
- done
178
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
162
179
 
163
180
  # Check for runtime UID changes in Dockerfiles or entrypoint scripts
164
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -iE 'Dockerfile|entrypoint.*\.sh'); do
181
+ while IFS= read -r f; do
182
+ [ -z "$f" ] && continue
165
183
  git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+.*(USER\s+[^0]|su-exec|gosu|setuid)' | grep -v '^+++' | grep -q . && DOMAINS="$DOMAINS INFRA" && break
166
- done
184
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -iE 'Dockerfile|entrypoint.*\.sh')
167
185
 
168
186
  # Check for gate condition narrowing in router Python files (condition removal = fix may be incomplete across siblings)
169
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'router'); do
187
+ while IFS= read -r f; do
188
+ [ -z "$f" ] && continue
170
189
  git diff HEAD -- "$f" 2>/dev/null | grep -E '^-.*\bif\b.*\bor\b' | grep -v '^---' | grep -q . && DOMAINS="$DOMAINS ROUTER_BUG" && break
171
- done
190
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'router')
172
191
 
173
192
  # Check for external tool config files (structural schema validation advisory)
174
- for f in {CHANGED_FILES}; do
193
+ # {CHANGED_FILES} is space-separated (see array-split note above) — reuse the
194
+ # same CHANGED_FILES_ARR built above instead of re-splitting.
195
+ for f in "${CHANGED_FILES_ARR[@]}"; do
175
196
  case "$f" in
176
197
  traefik/*|infra/nginx/*|k8s/*|terraform/*)
177
198
  DOMAINS="$DOMAINS CONFIG_SCHEMA" && break ;;
@@ -228,7 +249,11 @@ For every changed file, check:
228
249
 
229
250
  ```bash
230
251
  # Quick automated checks
231
- for f in {CHANGED_FILES}; do
252
+ # {CHANGED_FILES} is a space-separated argument — split explicitly on IFS=' '
253
+ # into an array instead of a bare `for f in {CHANGED_FILES}` (see Step 1.5 for
254
+ # the full rationale; this is a separate bash block so the array is rebuilt here).
255
+ IFS=' ' read -ra CHANGED_FILES_ARR <<< "{CHANGED_FILES}"
256
+ for f in "${CHANGED_FILES_ARR[@]}"; do
232
257
  # f-string SQL
233
258
  grep -nE "f['\"].*SELECT|f['\"].*INSERT|f['\"].*UPDATE|f['\"].*DELETE|f['\"].*WHERE" "$f" 2>/dev/null && echo "SEC: f-string SQL in $f"
234
259
  # Hardcoded secrets
@@ -251,15 +276,28 @@ done
251
276
  ### 2B: Auth conventions (Python routers, TypeScript route handlers)
252
277
 
253
278
  For new/modified endpoints:
254
- 1. Which auth dependency is used — `SessionUser` or `CurrentUser`?
255
- 2. Does the route path match? Dashboard routes (`/api/dashboard/*`) must use `SessionUser`. Public API routes (`/api/v1/*`) must use `CurrentUser`.
256
- 3. Does the endpoint check resource ownership (`user_id`) before returning data?
279
+ 1. Which auth dependency is used? Read `forge.yaml → review.domains.auth` for the configured dependency name(s). If absent, skip the naming check and fall back to generic resource-ownership verification only.
280
+ 2. Does the endpoint check resource ownership (`user_id` or equivalent) before returning data?
281
+
282
+ Route-path-to-dependency convention (e.g. "dashboard routes must use X, public routes must use Y") is project-specific. Configure it in `forge.yaml → review.domains.auth` if your project uses one. Do not enforce a hard-coded convention universally. <!-- Updated: forge#1349 — removed hardcoded SessionUser/CurrentUser rule -->
257
283
 
258
284
  ```bash
259
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E 'router|route'); do
260
- grep -nE "CurrentUser|SessionUser|Depends\(get_" "$f" 2>/dev/null
285
+ # Read configured auth dependency names from forge.yaml (if present)
286
+ AUTH_DEPS=""
287
+ if [ -f "{WORKTREE_PATH}/forge.yaml" ]; then
288
+ AUTH_DEPS=$(grep -A5 'domains:' "{WORKTREE_PATH}/forge.yaml" 2>/dev/null \
289
+ | grep -A3 'auth:' | grep -oE '[A-Za-z_]+User|[A-Za-z_]+Auth|get_[a-z_]+' | sort -u | tr '\n' '|' | sed 's/|$//')
290
+ fi
291
+
292
+ while IFS= read -r f; do
293
+ [ -z "$f" ] && continue
294
+ if [ -n "$AUTH_DEPS" ]; then
295
+ grep -nE "($AUTH_DEPS)|Depends\(get_" "$f" 2>/dev/null
296
+ else
297
+ grep -nE "Depends\(get_" "$f" 2>/dev/null
298
+ fi
261
299
  grep -nE "@router\.(get|post|put|delete)" "$f" 2>/dev/null
262
- done
300
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E 'router|route')
263
301
  ```
264
302
 
265
303
  ### 2C: Deploy chain (when new env vars are introduced)
@@ -270,8 +308,13 @@ if [ -n "$NEW_ENVS" ]; then
270
308
  for var in $NEW_ENVS; do
271
309
  # Check if it exists in .env.example
272
310
  grep -q "$var" .env.example 2>/dev/null || echo "DEPLOY: $var not in .env.example"
273
- # Check if it's in decrypt-secrets.sh ENV_MAPPING
274
- grep -q "$var" scripts/decrypt-secrets.sh 2>/dev/null || echo "DEPLOY: $var not in decrypt-secrets.sh ENV_MAPPING"
311
+ # Check decrypt-secrets.sh ENV_MAPPING only when the SOPS mapping script exists.
312
+ # Emitting a finding for a file that doesn't exist in the repo produces false BLOCKINGs
313
+ # on non-SOPS repos. Use verify-sops-chain.sh for the full SOPS chain check instead.
314
+ # <!-- Updated: forge#1349 — gate on file presence -->
315
+ if [ -f "{WORKTREE_PATH}/scripts/decrypt-secrets.sh" ]; then
316
+ grep -q "$var" "{WORKTREE_PATH}/scripts/decrypt-secrets.sh" 2>/dev/null || echo "DEPLOY: $var not in decrypt-secrets.sh ENV_MAPPING"
317
+ fi
275
318
  done
276
319
  fi
277
320
  ```
@@ -286,34 +329,31 @@ For each `.sql` file:
286
329
  5. **Migration number collision**: Check against origin branch
287
330
 
288
331
  ```bash
289
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep '\.sql$'); do
332
+ while IFS= read -r f; do
333
+ [ -z "$f" ] && continue
290
334
  grep -nE "ADD COLUMN.*NOT NULL" "$f" | grep -v "DEFAULT" && echo "DB: NOT NULL without DEFAULT in $f"
291
335
  grep -nE "DROP (TABLE|COLUMN|INDEX)" "$f" | grep -v "IF EXISTS" && echo "DB: DROP without IF EXISTS in $f"
292
336
  grep -nE "SELECT.*FROM" "$f" | grep -v "LIMIT" | grep -v "WHERE.*created_at" && echo "DB: possibly unbounded query in $f"
293
- done
337
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep '\.sql$')
294
338
 
295
339
  # Migration prefix collision scan (Deploy Gate — HIGH)
296
- # Runs whenever any SQL file changed. Scans the full infra/migrations/ tree for duplicate
297
- # numeric prefixes not in GRANDFATHERED_DUPLICATES. Duplicates not in the allowlist WILL
298
- # hard-fail deploy-production.yml's validate-migration-order.sh gate.
340
+ # Runs whenever any SQL file changed. Scans the configured migrations directory for
341
+ # duplicate numeric prefixes. Duplicate prefixes can cause ordering conflicts during deploy.
342
+ # <!-- Updated: forge#1349 — removed stale validate-migration-order.sh reference (script does not exist) -->
299
343
  MIGRATIONS_DIR="{WORKTREE_PATH}/infra/migrations"
344
+ # Support configured migration directory from forge.yaml (if set)
345
+ if [ -f "{WORKTREE_PATH}/forge.yaml" ]; then
346
+ _CONFIGURED_MIGRATIONS=$(grep -E '^\s*migrations_dir:' "{WORKTREE_PATH}/forge.yaml" 2>/dev/null \
347
+ | head -1 | sed 's/.*migrations_dir:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs)
348
+ [ -n "$_CONFIGURED_MIGRATIONS" ] && MIGRATIONS_DIR="{WORKTREE_PATH}/${_CONFIGURED_MIGRATIONS}"
349
+ fi
300
350
  if [ -d "$MIGRATIONS_DIR" ]; then
301
- # Extract grandfathered prefixes from validate-migration-order.sh if it exists
302
- GRANDFATHER_SCRIPT="{WORKTREE_PATH}/scripts/validate-migration-order.sh"
303
- GRANDFATHERED=""
304
- if [ -f "$GRANDFATHER_SCRIPT" ]; then
305
- GRANDFATHERED=$(grep -oE '[0-9]{4}' "$GRANDFATHER_SCRIPT" | sort -u | tr '\n' ' ')
306
- fi
307
351
  # Find duplicate numeric prefixes across all migration files
308
352
  DUPLICATE_PREFIXES=$(ls "$MIGRATIONS_DIR"/*.sql 2>/dev/null \
309
353
  | sed 's|.*/\([0-9]*\)_.*|\1|' \
310
354
  | sort | uniq -d)
311
355
  for prefix in $DUPLICATE_PREFIXES; do
312
- if echo "$GRANDFATHERED" | grep -qw "$prefix"; then
313
- echo "DB: migration prefix $prefix is duplicated but grandfathered (INFO)"
314
- else
315
- echo "DB-COLLISION | HIGH | infra/migrations/ | Duplicate migration prefix $prefix is NOT grandfathered — will hard-fail deploy-production.yml validate-migration-order.sh gate. Classify as CRITICAL BLOCKER."
316
- fi
356
+ echo "DB-COLLISION | HIGH | $MIGRATIONS_DIR | Duplicate migration prefix $prefix — ordering conflict will occur during deploy. Classify as CRITICAL BLOCKER."
317
357
  done
318
358
  fi
319
359
  ```
@@ -330,7 +370,8 @@ For each `.tsx`/`.ts` client component:
330
370
  7. **Rules of Hooks**: Do function components avoid early returns before hook calls?
331
371
 
332
372
  ```bash
333
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.tsx?$' | grep -v 'route\.ts'); do
373
+ while IFS= read -r f; do
374
+ [ -z "$f" ] && continue
334
375
  # Check for useEffect without cleanup (basic heuristic)
335
376
  grep -c "useEffect" "$f" 2>/dev/null
336
377
  grep -c "return.*cleanup\|return.*\(\) =>" "$f" 2>/dev/null
@@ -370,15 +411,35 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.tsx?$' | grep -v 'rou
370
411
  }
371
412
  in_component && /\buse[A-Z]/ { found_hook=1 }
372
413
  ' FILENAME="$f" "$f" 2>/dev/null
373
- done
414
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.tsx?$' | grep -v 'route\.ts')
374
415
  ```
375
416
 
376
417
  ### 2F: Frontend proxy wiring (client-side fetch calls)
377
418
 
419
+ The direct-backend path prefix to flag is read from `forge.yaml → review.layout.direct_api_prefix`.
420
+ If absent, defaults to `/api/v1/` (backward compatible). This prevents false findings on projects
421
+ that use a different API path scheme. <!-- Updated: forge#1349 — gate on forge.yaml, not hardcoded prefix -->
422
+
378
423
  ```bash
379
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.(tsx?|jsx?)$' | grep -v 'route\.ts$'); do
380
- grep -nE '(fetch|useSWR|apiFetch)\s*[(<]\s*[`"'"'"']/api/v1/' "$f" 2>/dev/null && echo "PROXY: direct /api/v1/ call in $f — must use /api/ proxy"
381
- done
424
+ # Read configured direct API prefix from forge.yaml, fall back to /api/v1/
425
+ DIRECT_API_PREFIX="/api/v1/"
426
+ if [ -f "{WORKTREE_PATH}/forge.yaml" ]; then
427
+ _CONFIGURED_PREFIX=$(grep -E '^\s*direct_api_prefix:' "{WORKTREE_PATH}/forge.yaml" 2>/dev/null \
428
+ | head -1 | sed 's/.*direct_api_prefix:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs)
429
+ # Also check under review.layout section
430
+ if [ -z "$_CONFIGURED_PREFIX" ]; then
431
+ _CONFIGURED_PREFIX=$(grep -A 10 'layout:' "{WORKTREE_PATH}/forge.yaml" 2>/dev/null \
432
+ | grep -E '^\s*direct_api_prefix:' \
433
+ | head -1 | sed 's/.*direct_api_prefix:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs)
434
+ fi
435
+ [ -n "$_CONFIGURED_PREFIX" ] && DIRECT_API_PREFIX="$_CONFIGURED_PREFIX"
436
+ fi
437
+ ESCAPED_PREFIX=$(printf '%s' "$DIRECT_API_PREFIX" | sed 's|/|\\/|g')
438
+
439
+ while IFS= read -r f; do
440
+ [ -z "$f" ] && continue
441
+ grep -nE "(fetch|useSWR|apiFetch)\\s*[(<]\\s*[\`\"']${ESCAPED_PREFIX}" "$f" 2>/dev/null && echo "PROXY: direct ${DIRECT_API_PREFIX} call in $f — must use /api/ proxy (configure review.layout.direct_api_prefix in forge.yaml to change the flagged prefix)"
442
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.(tsx?|jsx?)$' | grep -v 'route\.ts$')
382
443
  ```
383
444
 
384
445
  ### 2G: Cross-service integration (shell scripts with curl/wget)
@@ -413,7 +474,8 @@ The script `verify-host-headers.sh` checks for the `FORGE_INTERNAL_PATTERNS` env
413
474
  **Why this matters**: Every DB connection function in a deploy script must use `${POSTGRES_HOST:-localhost}` (or equivalent env var interpolation), not a bare `"localhost"` string literal. A hardcoded `"localhost"` works in local development but fails in any environment where PostgreSQL runs on a separate host. The pattern is reliably grep-detectable. <!-- Added: forge#303 -->
414
475
 
415
476
  ```bash
416
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.sh$'); do
477
+ while IFS= read -r f; do
478
+ [ -z "$f" ] && continue
417
479
  # Only check files that contain DB connection tools
418
480
  if grep -qE "PGPASSWORD|createdb|psql|pg_dump|pg_restore" "$f" 2>/dev/null; then
419
481
  # Look for new/modified lines with hardcoded localhost in variable assignments
@@ -428,7 +490,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.sh$'); do
428
490
  echo "$HARDCODED"
429
491
  fi
430
492
  fi
431
- done
493
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.sh$')
432
494
  ```
433
495
 
434
496
  Report any hit as **HIGH** — the DB connection will fail in any environment where PostgreSQL is not on `127.0.0.1:5432`.
@@ -494,7 +556,8 @@ Report each conflict as **HIGH** — a shadowed native command is a silent user-
494
556
  **Severity**: MEDIUM (flags for review, not auto-blocking). The review agent's concurrency auditor verifies with full context.
495
557
 
496
558
  ```bash
497
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
559
+ while IFS= read -r f; do
560
+ [ -z "$f" ] && continue
498
561
  # asyncio.shield usage — outer await may be reached with pending CancelledError
499
562
  grep -nE "asyncio\.shield" "$f" 2>/dev/null && \
500
563
  echo "ASYNC: asyncio.shield in $f — verify outer await isn't reachable with pending CancelledError. Prefer loop.create_task() for fire-and-forget."
@@ -509,7 +572,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
509
572
  sed -n "$((LINENO+1)),$((LINENO+5))p" "$f" | grep -q "await" && \
510
573
  echo "ASYNC: await in except/finally block at $f:$LINENO — may fail if CancelledError is pending"
511
574
  done
512
- done
575
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
513
576
  ```
514
577
 
515
578
 
@@ -532,7 +595,8 @@ For each new module-level state variable in the diff:
532
595
  ```
533
596
 
534
597
  ```bash
535
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
598
+ while IFS= read -r f; do
599
+ [ -z "$f" ] && continue
536
600
  # Find new module-level state variables introduced in the diff
537
601
  NEW_VARS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+[A-Za-z_]\w* = (\{\}|\[\]|set\(\)|Lock\(\)|defaultdict|Counter)' | grep -oE '^[+][A-Za-z_]\w*' | tr -d '+' | sort -u)
538
602
  for new_var in $NEW_VARS; do
@@ -549,16 +613,18 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
549
613
  done
550
614
  done
551
615
  done
552
- done
616
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
553
617
  ```
554
618
 
555
619
  **Severity**: MEDIUM — missing a mutation site is usually a latent bug, not an immediate crash, but it causes incorrect behavior under concurrent load.
556
620
 
557
621
  ---
558
622
 
559
- ### 2J: String literal consistency (anti-detection/scraping code)
623
+ ### 2J: String literal consistency (anti-detection/scraping code) *(Scraping Pack — opt-in)*
624
+
625
+ > **This domain is opt-in and disabled by default.** It is specific to scraping/anti-detection products and produces false findings on general repos. Enable it by adding `STRING_SETS` to `forge.yaml → quality_gate.optional_domains`. <!-- Added: forge#1349 -->
560
626
 
561
- **Triggered when**: changed files include Python files in `anti_detection/`, `consumers/`, `browser/`, or `shared/detection/` directories.
627
+ **Triggered when**: changed files include Python files in `anti_detection/`, `consumers/`, `browser/`, or `shared/detection/` directories AND `STRING_SETS` is in `forge.yaml → quality_gate.optional_domains`.
562
628
 
563
629
  **Why this matters**: Detection keyword sets (e.g., `CHALLENGE_KEYWORDS`, `COMMON_COOKIES`) across related modules share a domain truth — if Akamai adds a new cookie marker, it must appear in ALL related sets. A typo or omission in one set silently breaks detection for that service path. This check catches both inter-file inconsistencies and intra-file comment/code drift.
564
630
 
@@ -567,11 +633,12 @@ For each changed file in the anti-detection/scraping domains:
567
633
  **1. Identify modified string identifier sets** (frozensets, tuples, or lists of string literals used for detection):
568
634
 
569
635
  ```bash
570
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/'); do
636
+ while IFS= read -r f; do
637
+ [ -z "$f" ] && continue
571
638
  echo "=== String sets in $f ==="
572
639
  # Find named constants that are frozenset/tuple/list of strings
573
640
  grep -nE "^[A-Z_]+ = (frozenset|tuple|set)\(\[?['\"]|^[A-Z_]+ = \(['\"]|^[A-Z_]+ = \[['\"]" "$f" 2>/dev/null
574
- done
641
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/')
575
642
  ```
576
643
 
577
644
  **2. For each string in a modified set, check if it appears in sibling detection files:**
@@ -579,7 +646,8 @@ done
579
646
  ```bash
580
647
  SERVICE_DIR=$(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E 'anti_detection/|consumers/|browser/' | head -1 | grep -oE '^services/[^/]+/[^/]+' || echo "services/worker/worker")
581
648
 
582
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/'); do
649
+ while IFS= read -r f; do
650
+ [ -z "$f" ] && continue
583
651
  # Extract string values from detection sets in this file
584
652
  STRINGS=$(grep -oE "'[a-z_][a-z0-9_-]*'" "$f" 2>/dev/null | tr -d "'" | sort -u)
585
653
  for s in $STRINGS; do
@@ -587,13 +655,14 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_
587
655
  FOUND=$(grep -rl "\"$s\"\|'$s'" $SERVICE_DIR/ 2>/dev/null | grep -v "^$f$" | grep '\.py$' | head -1)
588
656
  [ -z "$FOUND" ] && echo "STR: '$s' in $f appears NOWHERE else in $SERVICE_DIR — possible typo or dead entry"
589
657
  done
590
- done
658
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/')
591
659
  ```
592
660
 
593
661
  **3. Check for identifiers mentioned in comments/docstrings of the SAME file that are NOT in the actual set** (catches the `abck` vs `_abck` class of bug):
594
662
 
595
663
  ```bash
596
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/'); do
664
+ while IFS= read -r f; do
665
+ [ -z "$f" ] && continue
597
666
  echo "=== Comment/code drift in $f ==="
598
667
  # Extract string literals from actual sets (remove quotes)
599
668
  SET_STRINGS=$(grep -oE "frozenset\(\[([^]]+)\]\)" "$f" 2>/dev/null | grep -oE "'[^']+'" | tr -d "'" | sort -u)
@@ -605,20 +674,25 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_
605
674
  for ref in $COMMENT_REFS; do
606
675
  echo "$SET_STRINGS" | grep -q "^${ref}$" || echo "STR: '$ref' mentioned in comment in $f but NOT found in any string set — possible omission or typo"
607
676
  done
608
- done
677
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/|shared/detection/')
609
678
  ```
610
679
 
611
680
  **4. Cross-reference sibling sets in related modules** (the primary inter-file check):
612
681
 
613
682
  ```bash
614
683
  # Find all detection-related Python files in the same service
615
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/'); do
684
+ while IFS= read -r f; do
685
+ [ -z "$f" ] && continue
616
686
  SIBLINGS=$(find $(dirname "$f")/../ -name "*.py" -path "*/anti_detection/*" -o -name "*.py" -path "*/consumers/*" -o -name "*.py" -path "*/browser/*" 2>/dev/null | grep -v "^$f$" | head -10)
617
687
 
618
688
  # Extract detection set names from the changed file
619
689
  SET_NAMES=$(grep -oE "^[A-Z_]+ = (frozenset|tuple|set)" "$f" 2>/dev/null | grep -oE "^[A-Z_]+")
620
690
 
621
- for sib in $SIBLINGS; do
691
+ # $SIBLINGS is one file path per line (`find`) — herestring (not a piped
692
+ # `| while read`, which would run the loop body in a subshell and discard
693
+ # any accumulator set inside it) preserves newline-safety for paths with spaces.
694
+ while IFS= read -r sib; do
695
+ [ -z "$sib" ] && continue
622
696
  # Extract strings from sibling detection sets
623
697
  SIB_STRINGS=$(grep -oE "'[a-z_][a-z0-9_-]*'" "$sib" 2>/dev/null | tr -d "'" | sort -u)
624
698
 
@@ -629,8 +703,8 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_
629
703
  echo "$SIB_STRINGS" | grep -q "^${s}$" || \
630
704
  echo "STR: '$s' in $f not found in sibling file $sib — verify if cross-module consistency is required"
631
705
  done
632
- done
633
- done
706
+ done <<< "$SIBLINGS"
707
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'anti_detection/|consumers/|browser/')
634
708
  ```
635
709
 
636
710
  **Flag as findings**:
@@ -640,16 +714,19 @@ done
640
714
 
641
715
  ---
642
716
 
643
- ### 2K: Capacity constant validation (worker/infra code)
717
+ ### 2K: Capacity constant validation (worker/infra code) *(Scraping Pack — opt-in)*
718
+
719
+ > **This domain is opt-in and disabled by default.** It is specific to scraping/browser-pool products and produces false findings on general repos. Enable it by adding `CAPACITY` to `forge.yaml → quality_gate.optional_domains`. <!-- Added: forge#1349 -->
644
720
 
645
- **Triggered when**: changed Python files in `services/worker/`, `infra/`, or `browser/` introduce new assignments to variables whose names contain `MB`, `SIZE`, `MAX`, `LIMIT`, `THRESHOLD`, or `TIMEOUT`.
721
+ **Triggered when**: changed Python files in `services/worker/`, `infra/`, or `browser/` introduce new assignments to variables whose names contain `MB`, `SIZE`, `MAX`, `LIMIT`, `THRESHOLD`, or `TIMEOUT` AND `CAPACITY` is in `forge.yaml → quality_gate.optional_domains`.
646
722
 
647
723
  **Why this matters**: Hardcoded capacity constants (e.g., `_BROWSER_ESTIMATED_MB = 200`) are design-time guesses that drift from production reality. When used in guard conditions (e.g., "5 × 200 MB = 1000 MB peak"), a wrong constant directly limits system throughput and may never be caught until a slot fails to spawn. A constant is correctly-typed and correctly-used but factually wrong — logic checks pass, the bug ships.
648
724
 
649
725
  For each new or modified capacity constant in the diff:
650
726
 
651
727
  ```bash
652
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'services/worker/|infra/|browser/'); do
728
+ while IFS= read -r f; do
729
+ [ -z "$f" ] && continue
653
730
  # Find new capacity constant assignments introduced by this diff
654
731
  NEW_CAPS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+[A-Za-z_]\w*(MB|SIZE|MAX|LIMIT|THRESHOLD|TIMEOUT)\w*\s*=' | grep -oE '^[+][A-Za-z_]\w+' | tr -d '+' | sort -u)
655
732
  for cap_var in $NEW_CAPS; do
@@ -667,7 +744,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'servi
667
744
  git log --all -15 --oneline --grep="memory" 2>/dev/null | head -5
668
745
  git log --all -15 --oneline --grep="$(echo $keywords | cut -d' ' -f1)" 2>/dev/null | head -5
669
746
  done
670
- done
747
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'services/worker/|infra/|browser/')
671
748
  ```
672
749
 
673
750
  **Flag as findings**:
@@ -685,7 +762,8 @@ done
685
762
  **Why this matters**: `appleboy/ssh-action` processes every `script:` field through Go's `text/template` engine **client-side, before SSH transmission**. Any `{{` in the script — including in comments, `docker ps --format` strings, and `docker inspect --format` strings — is interpreted as a Go template directive. If the expression does not resolve against the action's data context (which is an empty map for `appleboy/ssh-action`), the step exits with status 1 **before the script reaches the remote shell**. Shell error handlers (`|| fallback`, `2>/dev/null`, `set -e`) are completely bypassed. `continue-on-error: true` masks the failure silently, producing apparent deploy success with no remote shell output.
686
763
 
687
764
  ```bash
688
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.github/workflows/.*\.yml$'); do
765
+ while IFS= read -r f; do
766
+ [ -z "$f" ] && continue
689
767
  if grep -q "appleboy/ssh-action" "$f"; then
690
768
  TEMPLATE_HITS=$(grep -n "{{" "$f")
691
769
  if [ -n "$TEMPLATE_HITS" ]; then
@@ -702,7 +780,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.github/workflows/.*\.
702
780
  echo " docker ps --format json | jq -r '.Status'"
703
781
  fi
704
782
  fi
705
- done
783
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.github/workflows/.*\.yml$')
706
784
  ```
707
785
 
708
786
  Report any hit as **CONFIRMED HIGH** — the step will exit 1 before reaching the remote shell.
@@ -718,7 +796,8 @@ Go template directives in `appleboy/ssh-action` `script:` blocks are preprocesse
718
796
  ```bash
719
797
  PR_BASE="${PR_BASE:-staging}"
720
798
 
721
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
799
+ while IFS= read -r f; do
800
+ [ -z "$f" ] && continue
722
801
  # Extract new app.* import statements added in the diff
723
802
  # Only consider lines added (starting with +), skip diff headers (+++)
724
803
  NEW_IMPORTS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^\+\s*(from app\.|import app\.)' | grep -v '^+++' | sed 's/^+//')
@@ -752,7 +831,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$'); do
752
831
  fi
753
832
  fi
754
833
  done
755
- done
834
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$')
756
835
  ```
757
836
 
758
837
  **Flag as findings**:
@@ -775,20 +854,24 @@ A builder agent working in a milestone-branch worktree has access to milestone-o
775
854
  # Step 1: Identify the service directory from the changed Dockerfile/entrypoint path
776
855
  # e.g. services/worker/Dockerfile → service root = services/worker/
777
856
  SERVICE_DIR=""
778
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -iE 'Dockerfile|entrypoint.*\.sh'); do
857
+ while IFS= read -r f; do
858
+ [ -z "$f" ] && continue
779
859
  SERVICE_DIR=$(dirname "$f")
780
860
  break
781
- done
861
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -iE 'Dockerfile|entrypoint.*\.sh')
782
862
 
783
863
  if [ -n "$SERVICE_DIR" ]; then
784
864
  # Step 2: Find volume mount points for this service in any docker-compose*.yml
785
865
  COMPOSE_FILES=$(find . -maxdepth 3 -name "docker-compose*.yml" 2>/dev/null | sort)
786
866
  VOLUME_MOUNTS=""
787
- for cf in $COMPOSE_FILES; do
867
+ # $COMPOSE_FILES is one path per line (`find`) — herestring, not a piped
868
+ # `| while read`, so VOLUME_MOUNTS remains visible after the loop exits.
869
+ while IFS= read -r cf; do
870
+ [ -z "$cf" ] && continue
788
871
  # Extract named volume mount points (lines like: - volume_name:/container/path)
789
872
  MOUNTS=$(grep -oE '[a-z_-]+:/[^: ]+' "$cf" 2>/dev/null | grep -v '^#' | cut -d: -f2)
790
873
  VOLUME_MOUNTS="$VOLUME_MOUNTS $MOUNTS"
791
- done
874
+ done <<< "$COMPOSE_FILES"
792
875
 
793
876
  # Step 3: Grep the service directory for filesystem write operations
794
877
  WRITE_OPS=$(grep -rn "mkdir\|write_bytes\|open(.*['\"]w\|makedirs\|shutil\.copy\|shutil\.move" \
@@ -818,7 +901,8 @@ fi
818
901
  ```bash
819
902
  PR_BASE="${PR_BASE:-staging}"
820
903
 
821
- for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'router'); do
904
+ while IFS= read -r f; do
905
+ [ -z "$f" ] && continue
822
906
  # Extract removed condition lines from the diff (lines starting with -)
823
907
  REMOVED_CONDITIONS=$(git diff HEAD -- "$f" 2>/dev/null | grep -E '^-.*\bif\b' | grep -v '^---' | sed 's/^-//' | sed 's/^\s*//' | head -5)
824
908
 
@@ -841,7 +925,7 @@ for f in $(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'route
841
925
  fi
842
926
  done
843
927
  fi
844
- done
928
+ done < <(echo {CHANGED_FILES} | tr ' ' '\n' | grep -E '\.py$' | grep -E 'router')
845
929
  ```
846
930
 
847
931
  **Flag as findings**:
@@ -872,7 +956,11 @@ if [ -n "$TOOL_FILES" ]; then
872
956
  WORKFLOW_DIR="{WORKTREE_PATH}/.github/workflows"
873
957
  CI_MISSING=""
874
958
 
875
- for f in $TOOL_FILES; do
959
+ # $TOOL_FILES is one path per line (already newline-split above via `tr`) —
960
+ # herestring, not a piped `| while read`, so CI_MISSING remains visible
961
+ # after the loop exits.
962
+ while IFS= read -r f; do
963
+ [ -z "$f" ] && continue
876
964
  case "$f" in
877
965
  traefik/*)
878
966
  grep -rlE "traefik.*validate|traefik.*check" "$WORKFLOW_DIR" 2>/dev/null | grep -q . || \
@@ -892,7 +980,7 @@ if [ -n "$TOOL_FILES" ]; then
892
980
  CI_MISSING="$CI_MISSING terraform"
893
981
  ;;
894
982
  esac
895
- done
983
+ done <<< "$TOOL_FILES"
896
984
 
897
985
  # Deduplicate
898
986
  CI_MISSING=$(echo "$CI_MISSING" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ')