forgedock 1.0.21 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +134 -80
- package/bin/demo.mjs +386 -0
- package/bin/forgedock.mjs +357 -30
- package/bin/init-detect.mjs +95 -0
- package/bin/runner.mjs +780 -0
- package/commands/cleanup.md +85 -39
- package/commands/deploy-info.md +6 -2
- package/commands/forge-stats.md +1 -1
- package/commands/optimize.md +1 -1
- package/commands/orchestrate.md +320 -26
- package/commands/quality-gate.md +91 -49
- package/commands/review-pr-agents.md +62 -38
- package/commands/review-pr.md +33 -2
- package/commands/scope.md +11 -3
- package/commands/security-audit.md +24 -16
- package/commands/sync-ecosystem.md +2 -2
- package/commands/work-on/build/architect.md +47 -3
- package/commands/work-on/build/context.md +36 -5
- package/commands/work-on/build/validate.md +16 -7
- package/commands/work-on/close.md +129 -1
- package/commands/work-on/investigate.md +24 -1
- package/commands/work-on.md +226 -8
- package/docs/site/annotations-explained.md +55 -0
- package/docs/site/command-learning-path.md +104 -0
- package/docs/site/command-reference.md +3 -0
- package/docs/site/getting-started.md +36 -14
- package/docs/site/troubleshooting.md +208 -0
- package/docs/spec/forge-protocol-v1.md +768 -0
- package/examples/forgedock-demo/README.md +113 -0
- package/examples/forgedock-demo/bootstrap.sh +125 -0
- package/examples/forgedock-demo/issues/01-bug-delete-missing-auth.md +37 -0
- package/examples/forgedock-demo/issues/02-feature-filter-notes.md +37 -0
- package/examples/forgedock-demo/issues/03-refactor-router-table.md +33 -0
- package/examples/forgedock-demo/issues/04-perf-index-lookup.md +35 -0
- package/examples/forgedock-demo/issues/05-docs-api-reference.md +39 -0
- package/examples/forgedock-demo/labels.json +21 -0
- package/examples/forgedock-demo/package.json +16 -0
- package/examples/forgedock-demo/scripts/smoke.js +61 -0
- package/examples/forgedock-demo/src/auth.js +28 -0
- package/examples/forgedock-demo/src/db.js +66 -0
- package/examples/forgedock-demo/src/routes/notes.js +64 -0
- package/examples/forgedock-demo/src/server.js +79 -0
- package/package.json +5 -1
- package/templates/forge.yaml.minimal +44 -0
|
@@ -31,6 +31,11 @@ gh pr diff [PR_NUMBER] --name-only
|
|
|
31
31
|
gh pr diff [PR_NUMBER]
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
**Hot-spot prior**:
|
|
35
|
+
[CHURN_CONTEXT]
|
|
36
|
+
|
|
37
|
+
If a file you are reviewing is listed above as a hot-spot, apply deeper scrutiny to it — high historical churn correlates with defect density. Prefer tracing that file's full code paths (Evidence-Based Review Protocol §2) over a quick pattern scan, and weight ambiguous findings in hot-spot files toward LIKELY rather than POSSIBLE.
|
|
38
|
+
|
|
34
39
|
### 2. Dynamic Exploration
|
|
35
40
|
- From each changed file, follow imports and function calls
|
|
36
41
|
- Trace data flows across service boundaries (API → Redis → Worker)
|
|
@@ -201,9 +206,10 @@ For each changed file, check:
|
|
|
201
206
|
|
|
202
207
|
FastAPI's default validation error response exposes internal field names, types, and constraints. Attackers can enumerate the complete request schema by sending malformed payloads.
|
|
203
208
|
```bash
|
|
204
|
-
|
|
209
|
+
while IFS= read -r f; do
|
|
210
|
+
[ -z "$f" ] && continue
|
|
205
211
|
grep -rn "RequestValidationError\|validation_exception_handler\|@app.exception_handler" services/api/app/main.py services/api/app/ 2>/dev/null | head -5
|
|
206
|
-
done
|
|
212
|
+
done <<< "$MODEL_FILES"
|
|
207
213
|
# If no custom handler found AND the PR adds new Pydantic models or FastAPI endpoints:
|
|
208
214
|
# LIKELY MEDIUM — FastAPI default exposes internal field names/types/constraints to any caller
|
|
209
215
|
# Safe pattern: catch RequestValidationError and return only {"detail": "Invalid request"} or a sanitized message
|
|
@@ -216,12 +222,13 @@ For each changed file, check:
|
|
|
216
222
|
|
|
217
223
|
Unauthenticated health endpoints are expected to be public — the Auth agent's auth-dependency check does not apply. The relevant question is whether they reveal more than `{"status": "ok"}`.
|
|
218
224
|
```bash
|
|
219
|
-
|
|
225
|
+
while IFS= read -r f; do
|
|
226
|
+
[ -z "$f" ] && continue
|
|
220
227
|
# Find unauthenticated endpoints (no Depends() in signature) and check return content
|
|
221
228
|
grep -A25 "@router\.\(get\|head\)\|async def health\|async def ping\|async def live\|async def ready\|async def status" "$f" | \
|
|
222
229
|
grep -iE "version|host|port|redis|postgres|db_url|tesseract|pillow|PIL\.__version__|software|engine|server" && \
|
|
223
230
|
echo "SEC: health endpoint in $f returns operational details. Reveals software versions (enables CVE targeting) or connectivity state (enables DoS confirmation). Safe pattern: return only {\"status\": \"ok\"} or {\"status\": \"degraded\"} — no version strings, no host/port, no connectivity details."
|
|
224
|
-
done
|
|
231
|
+
done <<< "$HEALTH_FILES"
|
|
225
232
|
```
|
|
226
233
|
**Confidence**: `LIKELY` — version strings may be intentionally scoped to internal-only endpoints; verify auth status of the endpoint.
|
|
227
234
|
**Severity**: MEDIUM for software version strings (direct CVE targeting input); LOW for DB/Redis connectivity state (useful for DoS confirmation, not exploit).
|
|
@@ -231,10 +238,11 @@ For each changed file, check:
|
|
|
231
238
|
|
|
232
239
|
Git SHAs and deployment topology exposed via response headers enable targeted CVE research and attack planning.
|
|
233
240
|
```bash
|
|
234
|
-
|
|
241
|
+
while IFS= read -r f; do
|
|
242
|
+
[ -z "$f" ] && continue
|
|
235
243
|
grep -nE "X-App-Version|X-Deployment-Color|X-.*-SHA|X-.*-Build|X-.*-Commit|Server:|X-Powered-By" "$f" 2>/dev/null && \
|
|
236
244
|
echo "SEC: response header in $f leaks infrastructure details. Git SHA enables targeted CVE diffing against public commits. Deployment color reveals blue/green topology. Safe pattern: omit these headers or gate them behind an internal-only middleware."
|
|
237
|
-
done
|
|
245
|
+
done <<< "$MIDDLEWARE_FILES"
|
|
238
246
|
```
|
|
239
247
|
**Confidence**: `LIKELY` — header may be intentional for internal observability tooling behind an auth gate; verify it applies to all responses.
|
|
240
248
|
**Severity**: LOW — information disclosure accelerates other attacks but is not directly exploitable without an additional vulnerability.
|
|
@@ -259,11 +267,12 @@ CONFIG_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "(traefik/|infra/ngi
|
|
|
259
267
|
|
|
260
268
|
**A. Port bind address audit** (when `docker-compose*.yml` is in diff — trigger: `$INFRA_FILES` non-empty)
|
|
261
269
|
```bash
|
|
262
|
-
|
|
270
|
+
while IFS= read -r f; do
|
|
271
|
+
[ -z "$f" ] && continue
|
|
263
272
|
# Flag non-web ports bound to 0.0.0.0 (no explicit host binding = binds to 0.0.0.0 by default)
|
|
264
273
|
grep -nE "^\s+- \"[0-9]+:[0-9]+\"" "$f" | grep -v "127\.0\.0\.1\|::1" | grep -vE "\"(80|443):" && \
|
|
265
274
|
echo "SEC: port published on 0.0.0.0 in $f — verify UFW/firewall is the ONLY protection. Docker daemon restart can reset iptables and expose the port directly."
|
|
266
|
-
done
|
|
275
|
+
done <<< "$INFRA_FILES"
|
|
267
276
|
```
|
|
268
277
|
**Confidence**: `LIKELY` — a firewall rule may exist that provides protection; flag for verification rather than hard-block.
|
|
269
278
|
**Severity**: HIGH for database/internal service ports (5432, 6432, 6379); MEDIUM for other non-web ports.
|
|
@@ -271,10 +280,11 @@ done
|
|
|
271
280
|
|
|
272
281
|
**B. Missing USER directive** (when any `Dockerfile` is in diff — trigger: `$DOCKERFILE_FILES` non-empty)
|
|
273
282
|
```bash
|
|
274
|
-
|
|
283
|
+
while IFS= read -r f; do
|
|
284
|
+
[ -z "$f" ] && continue
|
|
275
285
|
grep -q "^USER " "$f" || \
|
|
276
286
|
echo "SEC: CONFIRMED — no USER directive in $f. Container runs as UID 0. For services that process user-supplied URLs or untrusted input (worker, Playwright), running as root significantly lowers the bar for container escape."
|
|
277
|
-
done
|
|
287
|
+
done <<< "$DOCKERFILE_FILES"
|
|
278
288
|
```
|
|
279
289
|
**Confidence**: `CONFIRMED` — absence of USER directive is an objective, verifiable fact.
|
|
280
290
|
**Severity**: HIGH for services processing untrusted input; MEDIUM for internal-only services.
|
|
@@ -282,10 +292,11 @@ done
|
|
|
282
292
|
|
|
283
293
|
**C. Response header information disclosure** (when Python middleware/main.py adds response headers — trigger: `$HEADER_FILES` non-empty)
|
|
284
294
|
```bash
|
|
285
|
-
|
|
295
|
+
while IFS= read -r f; do
|
|
296
|
+
[ -z "$f" ] && continue
|
|
286
297
|
grep -nE "X-App-Version|X-Deployment-Color|Server:|X-Powered-By|X-.*-SHA|X-.*-Build" "$f" 2>/dev/null && \
|
|
287
298
|
echo "SEC: response header in $f leaks infrastructure details. Git SHA enables targeted CVE diffing against public commits. Deployment color reveals blue/green topology to attackers."
|
|
288
|
-
done
|
|
299
|
+
done <<< "$HEADER_FILES"
|
|
289
300
|
```
|
|
290
301
|
**Confidence**: `CONFIRMED` — header name reveals infra details by definition.
|
|
291
302
|
**Severity**: MEDIUM — information disclosure accelerates other attacks but is not directly exploitable.
|
|
@@ -293,11 +304,12 @@ done
|
|
|
293
304
|
|
|
294
305
|
**D. CI security scan gating** (when `.github/workflows/*.yml` is in diff — trigger: `$WORKFLOW_FILES` non-empty)
|
|
295
306
|
```bash
|
|
296
|
-
|
|
307
|
+
while IFS= read -r f; do
|
|
308
|
+
[ -z "$f" ] && continue
|
|
297
309
|
# Find security scanner steps with continue-on-error: true
|
|
298
310
|
grep -B5 "continue-on-error: true" "$f" | grep -iE "trivy|snyk|security|cve|scan|grype|dockle" && \
|
|
299
311
|
echo "SEC: security scanner in $f has continue-on-error: true — CVEs will be logged but will NEVER block a deploy. Remove continue-on-error or add a severity threshold."
|
|
300
|
-
done
|
|
312
|
+
done <<< "$WORKFLOW_FILES"
|
|
301
313
|
```
|
|
302
314
|
**Confidence**: `CONFIRMED` — `continue-on-error: true` on a security scanner step is objectively a non-blocking scan.
|
|
303
315
|
**Severity**: HIGH — security scanner provides zero protection when it cannot block.
|
|
@@ -305,10 +317,11 @@ done
|
|
|
305
317
|
|
|
306
318
|
**E. In-memory rate limiter** (when TypeScript files are in diff — trigger: `$TS_FILES` non-empty)
|
|
307
319
|
```bash
|
|
308
|
-
|
|
320
|
+
while IFS= read -r f; do
|
|
321
|
+
[ -z "$f" ] && continue
|
|
309
322
|
grep -nE "new Map\(\)|=\s*\{\}|= new Map" "$f" 2>/dev/null | grep -iE "rate|limit|attempt|count|throttle|window" && \
|
|
310
323
|
echo "SEC: in-memory rate limiter detected in $f — resets on every container restart, not shared across blue/green replicas. Use Redis (via Upstash, ioredis, or the existing Redis client) for distributed rate limiting."
|
|
311
|
-
done
|
|
324
|
+
done <<< "$TS_FILES"
|
|
312
325
|
```
|
|
313
326
|
**Confidence**: `LIKELY` — in-memory store may be intentional for non-critical paths; flag for verification.
|
|
314
327
|
**Severity**: MEDIUM — bypass requires restarting the container or hitting a different replica; not trivially exploitable but provides no protection in blue/green deploys.
|
|
@@ -316,10 +329,11 @@ done
|
|
|
316
329
|
|
|
317
330
|
**F. Cookie security flags** (when TypeScript files are in diff — trigger: `$TS_FILES` non-empty)
|
|
318
331
|
```bash
|
|
319
|
-
|
|
332
|
+
while IFS= read -r f; do
|
|
333
|
+
[ -z "$f" ] && continue
|
|
320
334
|
grep -nE "httpOnly:\s*false|secure:\s*false|sameSite.*['\"]none['\"]" "$f" 2>/dev/null && \
|
|
321
335
|
echo "SEC: cookie in $f has weakened security flag. httpOnly: false exposes cookie to JS (XSS risk). secure: false allows transmission over HTTP. sameSite: none without Secure enables CSRF."
|
|
322
|
-
done
|
|
336
|
+
done <<< "$TS_FILES"
|
|
323
337
|
```
|
|
324
338
|
**Confidence**: `CONFIRMED` — explicit `false`/`none` values are objective findings.
|
|
325
339
|
**Severity**: HIGH for `httpOnly: false` (direct XSS vector for session cookies); MEDIUM for `secure: false` or `sameSite: none`.
|
|
@@ -327,11 +341,12 @@ done
|
|
|
327
341
|
|
|
328
342
|
**G. Fallback credential scan in config files** (when config files are in diff — trigger: `$CONFIG_FILES` non-empty)
|
|
329
343
|
```bash
|
|
330
|
-
|
|
344
|
+
while IFS= read -r f; do
|
|
345
|
+
[ -z "$f" ] && continue
|
|
331
346
|
# Shell-style fallback: ${VAR:-default} — flag non-trivial fallback values that may be credentials
|
|
332
347
|
grep -nE "\$\{[A-Z_]+:-[^}]{4,}\}" "$f" | grep -ivE "(localhost|127\.0\.0\.1|0\.0\.0\.0|true|false|^[0-9]+$|/tmp|/var|/etc)" && \
|
|
333
348
|
echo "SEC: potential credential fallback in $f — verify fallback value is safe if env var is absent"
|
|
334
|
-
done
|
|
349
|
+
done <<< "$CONFIG_FILES"
|
|
335
350
|
```
|
|
336
351
|
For each hit: read the fallback value.
|
|
337
352
|
- Contains `$apr1$`, `$2b$`, `$bcrypt$`, `$argon2`, or hash-like long alphanumeric with `$` separators: **CONFIRMED HIGH** — htpasswd/bcrypt placeholder; active if env var is absent.
|
|
@@ -345,15 +360,17 @@ For each hit: read the fallback value.
|
|
|
345
360
|
|
|
346
361
|
**H. Runtime UID × volume ownership** (when any Dockerfile is in diff AND diff contains `USER`, `su-exec`, `gosu`, or `setuid` — trigger: `$DOCKERFILE_FILES` non-empty AND UID change detected)
|
|
347
362
|
```bash
|
|
348
|
-
UID_CHANGE=$(
|
|
363
|
+
UID_CHANGE=$(while IFS= read -r f; do
|
|
364
|
+
[ -z "$f" ] && continue
|
|
349
365
|
gh pr diff [PR_NUMBER] -- "$f" 2>/dev/null | grep -E '^\+.*(USER\s+[^0]|su-exec|gosu|setuid)' | grep -v '^+++' && break
|
|
350
|
-
done)
|
|
366
|
+
done <<< "$DOCKERFILE_FILES")
|
|
351
367
|
if [ -n "$UID_CHANGE" ]; then
|
|
352
368
|
echo "UID change detected — cross-referencing named volume mounts..."
|
|
353
369
|
COMPOSE_FILES=$(find . -maxdepth 2 -name "docker-compose*.yml" 2>/dev/null | sort)
|
|
354
|
-
|
|
370
|
+
while IFS= read -r cf; do
|
|
371
|
+
[ -z "$cf" ] && continue
|
|
355
372
|
grep -A2 "volumes:" "$cf" | grep -E "^\s+\w.*:" | grep -v "^--$" && echo " ^ in $cf"
|
|
356
|
-
done
|
|
373
|
+
done <<< "$COMPOSE_FILES"
|
|
357
374
|
fi
|
|
358
375
|
```
|
|
359
376
|
For each named volume found: determine its container mount point. Named volumes are created as **root-owned** by Docker at first use — a privilege drop (root → UID N) without a `chown` before the drop will silently break any service path that calls `mkdir`, `write_bytes`, `open(... 'w')`, or `os.makedirs` under that mount point.
|
|
@@ -1414,17 +1431,19 @@ If no infra context is configured above, derive the deployment model from the ch
|
|
|
1414
1431
|
OLD_VALUES=$(gh pr diff [PR_NUMBER] | grep "^\-" | grep -oP '"status"\s*:\s*"\K[^"]+' | sort -u)
|
|
1415
1432
|
|
|
1416
1433
|
# Search all non-application consumer locations for those old values
|
|
1417
|
-
|
|
1434
|
+
while IFS= read -r val; do
|
|
1435
|
+
[ -z "$val" ] && continue
|
|
1418
1436
|
echo "=== Searching for old response value: $val ==="
|
|
1419
1437
|
grep -rn "$val" scripts/ infra/ .github/ traefik/ 2>/dev/null | grep -v "^Binary"
|
|
1420
1438
|
grep -rn "$val" docker-compose*.yml 2>/dev/null
|
|
1421
|
-
done
|
|
1439
|
+
done <<< "$OLD_VALUES"
|
|
1422
1440
|
|
|
1423
1441
|
# Also search for endpoint path references in consumer locations
|
|
1424
1442
|
HEALTH_PATHS=$(gh pr diff [PR_NUMBER] | grep "^\+" | grep -oP '@\w+\.(?:get|post)\("\K[^"]+(?:health|status|ping|ready|live|readiness|liveness)[^"]*')
|
|
1425
|
-
|
|
1443
|
+
while IFS= read -r path; do
|
|
1444
|
+
[ -z "$path" ] && continue
|
|
1426
1445
|
grep -rn "$path" scripts/ infra/ .github/ traefik/ docker-compose*.yml 2>/dev/null | grep -v "^Binary"
|
|
1427
|
-
done
|
|
1446
|
+
done <<< "$HEALTH_PATHS"
|
|
1428
1447
|
```
|
|
1429
1448
|
|
|
1430
1449
|
**For each hit, classify:**
|
|
@@ -1522,13 +1541,14 @@ If no infra context is configured above, derive the deployment model from the ch
|
|
|
1522
1541
|
|
|
1523
1542
|
**Scan ALL `{{` occurrences in every `script:` block of every `appleboy/ssh-action` step in changed files:**
|
|
1524
1543
|
```bash
|
|
1525
|
-
|
|
1544
|
+
while IFS= read -r f; do
|
|
1545
|
+
[ -z "$f" ] && continue
|
|
1526
1546
|
if grep -q "appleboy/ssh-action" "$f"; then
|
|
1527
1547
|
echo "=== appleboy/ssh-action found in $f ==="
|
|
1528
1548
|
# Show all {{ occurrences with line numbers
|
|
1529
1549
|
grep -n "{{" "$f"
|
|
1530
1550
|
fi
|
|
1531
|
-
done
|
|
1551
|
+
done < <(gh pr diff [PR_NUMBER] --name-only | grep -E '\.github/workflows/.*\.yml$')
|
|
1532
1552
|
```
|
|
1533
1553
|
|
|
1534
1554
|
**For every `{{` found, classify and flag:**
|
|
@@ -1549,10 +1569,11 @@ If no infra context is configured above, derive the deployment model from the ch
|
|
|
1549
1569
|
**Scan changed config files for credential fallbacks:**
|
|
1550
1570
|
```bash
|
|
1551
1571
|
CONFIG_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "(traefik/|infra/nginx/|infra/|\.github/workflows/).*(\.ya?ml|\.toml|\.json|\.conf|\.ini)$" | head -20)
|
|
1552
|
-
|
|
1572
|
+
while IFS= read -r f; do
|
|
1573
|
+
[ -z "$f" ] && continue
|
|
1553
1574
|
# Shell-style fallback: ${VAR:-default} — filter out safe non-credential defaults
|
|
1554
1575
|
grep -nE "\$\{[A-Z_]+:-[^}]{4,}\}" "$f" | grep -ivE "(localhost|127\.0\.0\.1|0\.0\.0\.0|true|false|^[0-9]+$|/tmp|/var|/etc)" | head -20
|
|
1555
|
-
done
|
|
1576
|
+
done <<< "$CONFIG_FILES"
|
|
1556
1577
|
```
|
|
1557
1578
|
|
|
1558
1579
|
**For each hit, classify the fallback value:**
|
|
@@ -1588,9 +1609,10 @@ If no infra context is configured above, derive the deployment model from the ch
|
|
|
1588
1609
|
**Scan for host-side database tool invocations:**
|
|
1589
1610
|
```bash
|
|
1590
1611
|
DEPLOY_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "(\.github/workflows/.*\.yml$|scripts/deploy.*\.sh$|docker-entrypoint.*\.sh$)")
|
|
1591
|
-
|
|
1612
|
+
while IFS= read -r f; do
|
|
1613
|
+
[ -z "$f" ] && continue
|
|
1592
1614
|
grep -n "\bpsql\b\|\bcreatedb\b\|\bpg_dump\b\|\bpg_restore\b" "$f" | grep -v "docker exec" | head -20
|
|
1593
|
-
done
|
|
1615
|
+
done <<< "$DEPLOY_FILES"
|
|
1594
1616
|
```
|
|
1595
1617
|
|
|
1596
1618
|
**For each match, classify:**
|
|
@@ -1633,15 +1655,17 @@ If no infra context is configured above, derive the deployment model from the ch
|
|
|
1633
1655
|
COMPOSE_INFRA_FILES=$(gh pr diff [PR_NUMBER] --name-only | grep -E "docker-compose.*\.yml$")
|
|
1634
1656
|
|
|
1635
1657
|
# For each config file: read the full file (not just the diff) to understand the structure
|
|
1636
|
-
|
|
1658
|
+
while IFS= read -r f; do
|
|
1659
|
+
[ -z "$f" ] && continue
|
|
1637
1660
|
echo "=== Reading full config structure: $f ==="
|
|
1638
1661
|
cat "$f"
|
|
1639
|
-
done
|
|
1662
|
+
done <<< "$CONFIG_TOOL_FILES"
|
|
1640
1663
|
|
|
1641
1664
|
# Identify tool version from docker-compose (for version-aware schema reasoning)
|
|
1642
|
-
|
|
1665
|
+
while IFS= read -r f; do
|
|
1666
|
+
[ -z "$f" ] && continue
|
|
1643
1667
|
grep -E "image:\s*(traefik|nginx|haproxy|envoy|caddy|terraform):" "$f" 2>/dev/null && echo " ^ in $f"
|
|
1644
|
-
done
|
|
1668
|
+
done <<< "$COMPOSE_INFRA_FILES"
|
|
1645
1669
|
```
|
|
1646
1670
|
|
|
1647
1671
|
**Severity classification**:
|
package/commands/review-pr.md
CHANGED
|
@@ -624,6 +624,36 @@ echo "$DIFF" | grep -cE "subprocess|os\.system|eval\(|exec\(|pickle|yaml\.load[^
|
|
|
624
624
|
echo "$FILES" | grep -cE "^sdk/|openapi.*\.json$|openapi-versions/" && echo " SDK_OPENAPI" || true
|
|
625
625
|
```
|
|
626
626
|
|
|
627
|
+
**Churn (hot-spot) signal**: The signals above are all derived from the current diff content — none of them measure historical change frequency, which is one of the strongest empirical predictors of defect density. Compute a bounded per-file churn tier for the PR's changed files only (never repo-wide) and carry it forward as `CHURN_CONTEXT` for Phase 3C:
|
|
628
|
+
|
|
629
|
+
```bash
|
|
630
|
+
CHURN_WINDOW="90 days ago" # named constant — must match the same window used in architect.md Phase A5
|
|
631
|
+
CHURN_CONTEXT=""
|
|
632
|
+
# Herestring (not a piped `| while read`) — a pipe would run the loop body in a
|
|
633
|
+
# subshell in bash, silently discarding CHURN_CONTEXT once the loop exits. $FILES
|
|
634
|
+
# is one path per line (gh pr diff --name-only), so `read -r` per line is safe
|
|
635
|
+
# even when a path contains embedded spaces.
|
|
636
|
+
while IFS= read -r FILE; do
|
|
637
|
+
[ -z "$FILE" ] && continue
|
|
638
|
+
COMMITS=$(git log --oneline --since="$CHURN_WINDOW" -- "$FILE" 2>/dev/null | wc -l)
|
|
639
|
+
if [ "$COMMITS" -ge 15 ]; then
|
|
640
|
+
# Real newline appended via $'\n' (ANSI-C quoting), not a literal backslash-n.
|
|
641
|
+
# A literal "\n" would only be interpreted by `echo -e` — it survives
|
|
642
|
+
# unprocessed into Phase 3C's raw template substitution ([CHURN_CONTEXT] ->
|
|
643
|
+
# $CHURN_CONTEXT), which does not reprocess escapes, so with 2+ HOT files
|
|
644
|
+
# multi-hotspot PRs would render squished in the agent prompt. A real
|
|
645
|
+
# newline here makes the variable correct for every consumer.
|
|
646
|
+
CHURN_CONTEXT="${CHURN_CONTEXT}${FILE} (${COMMITS} commits in last 90 days — HOT)"$'\n'
|
|
647
|
+
fi
|
|
648
|
+
done <<< "$FILES"
|
|
649
|
+
if [ -z "$CHURN_CONTEXT" ]; then
|
|
650
|
+
CHURN_CONTEXT="No hot-spot files detected (all changed files under 15 commits in the last 90 days)."
|
|
651
|
+
fi
|
|
652
|
+
echo "$CHURN_CONTEXT"
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Tiers (fixed thresholds, identical to `architect.md` Phase A5): **HOT** = 15+ commits / 90 days, **MEDIUM** = 5–14, **LOW** = 0–4. Only HOT-tier files are listed in `CHURN_CONTEXT` — this keeps the agent prompt signal short and high-value rather than dumping a churn count for every file. This does not change agent selection (3B) — it is a scrutiny prior surfaced to whichever agents are already selected, substituted as `[CHURN_CONTEXT]` in Phase 3C.
|
|
656
|
+
|
|
627
657
|
### 3B: Select Agents
|
|
628
658
|
|
|
629
659
|
| Risk Signal | Required Agents |
|
|
@@ -687,8 +717,9 @@ This file contains the Evidence-Based Review Protocol, Structured Findings Proto
|
|
|
687
717
|
2. Substitute: `[PR_NUMBER]`, `[REVIEW_SHA]`, `[REVIEW_SHA_SHORT]`, `[TITLE]`, relevant files list
|
|
688
718
|
3. Substitute: `[PROJECT_NAME]` → `$PROJECT_NAME`, `[PROJECT_CONTEXT]` → `$PROJECT_CONTEXT`, `[TECH_STACK]` → `$TECH_STACK`
|
|
689
719
|
4. Substitute per-agent domain context: `[DOMAIN_CONTEXT]` → the agent's matching key from `forge.yaml → review.domains` (e.g., `$DOMAIN_CONTEXT_AUTH` for the auth agent, `$DOMAIN_CONTEXT_BILLING` for the billing agent)
|
|
690
|
-
5.
|
|
691
|
-
6.
|
|
720
|
+
5. Substitute the shared hot-spot prior: `[CHURN_CONTEXT]` → `$CHURN_CONTEXT` (computed once in Phase 3A, same value for every agent — this is a PR-level fact, not a per-domain config value, so it is NOT read from `forge.yaml`)
|
|
721
|
+
6. If Phase 2.5 found broken assumptions, append them to the agent's prompt as "Pre-found integration issues to verify"
|
|
722
|
+
7. Launch via `Task` tool with the resolved model (default `"sonnet"`, fallback `"opus"` if rate-limited)
|
|
692
723
|
|
|
693
724
|
**CRITICAL**: Launch ALL selected agents in a SINGLE message using multiple Task tool calls. Each agent posts findings directly to the PR via `gh pr comment`.
|
|
694
725
|
|
package/commands/scope.md
CHANGED
|
@@ -79,13 +79,21 @@ Limit `DOMAIN_TERMS` to the 10 most specific (skip generic words: "fix", "bug",
|
|
|
79
79
|
Search `REPO_PATH` for each keyword to find candidate files:
|
|
80
80
|
|
|
81
81
|
```bash
|
|
82
|
+
# {TARGET_FILES} and {DOMAIN_TERMS} are space-separated arguments (same contract
|
|
83
|
+
# as {AFFECTED_FILES} elsewhere) — split explicitly on IFS=' ' into arrays instead
|
|
84
|
+
# of a bare `for x in {PLACEHOLDER}`, which word-splits on the shell's default IFS
|
|
85
|
+
# (space, tab, AND newline) and would corrupt any file path or compound domain
|
|
86
|
+
# term containing a space.
|
|
87
|
+
IFS=' ' read -ra TARGET_FILES_ARR <<< "{TARGET_FILES}"
|
|
88
|
+
IFS=' ' read -ra DOMAIN_TERMS_ARR <<< "{DOMAIN_TERMS}"
|
|
89
|
+
|
|
82
90
|
# For each file explicitly named in the issue
|
|
83
|
-
for file in {
|
|
91
|
+
for file in "${TARGET_FILES_ARR[@]}"; do
|
|
84
92
|
find "$REPO_PATH" -name "$file" -not -path "*/.git/*" -not -path "*/node_modules/*" 2>/dev/null
|
|
85
93
|
done
|
|
86
94
|
|
|
87
95
|
# For each domain term: grep for references
|
|
88
|
-
for term in {
|
|
96
|
+
for term in "${DOMAIN_TERMS_ARR[@]}"; do
|
|
89
97
|
grep -rl "$term" "$REPO_PATH" \
|
|
90
98
|
--include="*.py" --include="*.ts" --include="*.tsx" \
|
|
91
99
|
--include="*.md" --include="*.yml" --include="*.yaml" \
|
|
@@ -96,7 +104,7 @@ done
|
|
|
96
104
|
|
|
97
105
|
# Git log — files recently changed alongside the named files
|
|
98
106
|
if [ -n "{TARGET_FILES}" ]; then
|
|
99
|
-
for file in {
|
|
107
|
+
for file in "${TARGET_FILES_ARR[@]}"; do
|
|
100
108
|
git -C "$REPO_PATH" log --oneline -20 -- "$file" \
|
|
101
109
|
--name-only --format="" 2>/dev/null | grep -v "^$" | sort -u | head -10
|
|
102
110
|
done
|
|
@@ -110,13 +110,14 @@ fi
|
|
|
110
110
|
**Check**:
|
|
111
111
|
```bash
|
|
112
112
|
DOCKER_COMPOSE_FILES=$(find "$REPO_PATH" -name "docker-compose*.yml" -not -path "*/.git/*" 2>/dev/null)
|
|
113
|
-
|
|
113
|
+
while IFS= read -r f; do
|
|
114
|
+
[ -z "$f" ] && continue
|
|
114
115
|
HITS=$(grep -n '0\.0\.0\.0:[0-9]' "$f" 2>/dev/null | grep -v '^\s*#' || true)
|
|
115
116
|
if [ -n "$HITS" ]; then
|
|
116
117
|
echo "FINDING [1A] $f:"
|
|
117
118
|
echo "$HITS"
|
|
118
119
|
fi
|
|
119
|
-
done
|
|
120
|
+
done <<< "$DOCKER_COMPOSE_FILES"
|
|
120
121
|
```
|
|
121
122
|
|
|
122
123
|
**Confirm**: For each hit, verify the port is NOT a web-facing port (80, 443, 8080, 3000). Ports like 6432 (PgBouncer), 6379 (Redis), 5432 (Postgres), 27017 (MongoDB) bound to `0.0.0.0` are confirmed findings.
|
|
@@ -134,12 +135,13 @@ done
|
|
|
134
135
|
**Check**:
|
|
135
136
|
```bash
|
|
136
137
|
DOCKERFILES=$(find "$REPO_PATH" -name "Dockerfile*" -not -path "*/.git/*" 2>/dev/null)
|
|
137
|
-
|
|
138
|
+
while IFS= read -r f; do
|
|
139
|
+
[ -z "$f" ] && continue
|
|
138
140
|
# Skip if USER directive is present (anywhere in the file, case-insensitive)
|
|
139
141
|
if ! grep -iq '^\s*USER\s' "$f" 2>/dev/null; then
|
|
140
142
|
echo "FINDING [1B] $f: no USER directive — container runs as root"
|
|
141
143
|
fi
|
|
142
|
-
done
|
|
144
|
+
done <<< "$DOCKERFILES"
|
|
143
145
|
```
|
|
144
146
|
|
|
145
147
|
**Confirm**: Check if the Dockerfile is for a long-running service (not a one-shot build image). If service container with no USER → confirmed finding.
|
|
@@ -157,14 +159,15 @@ done
|
|
|
157
159
|
**Check**:
|
|
158
160
|
```bash
|
|
159
161
|
DOCKER_COMPOSE_FILES=$(find "$REPO_PATH" -name "docker-compose*.yml" -not -path "*/.git/*" 2>/dev/null)
|
|
160
|
-
|
|
162
|
+
while IFS= read -r f; do
|
|
163
|
+
[ -z "$f" ] && continue
|
|
161
164
|
# Redis without requirepass
|
|
162
165
|
REDIS_NO_AUTH=$(grep -A5 'image:.*redis' "$f" 2>/dev/null | grep -v 'requirepass\|password' | grep -c 'redis' || true)
|
|
163
166
|
# Postgres without POSTGRES_PASSWORD
|
|
164
167
|
PG_NO_AUTH=$(awk '/image:.*postgres/{found=1} found && /POSTGRES_PASSWORD/{found=0} found && /^[[:space:]]*-[[:space:]]*[A-Z]/{print}' "$f" 2>/dev/null | grep -v 'POSTGRES_PASSWORD' | head -3 || true)
|
|
165
168
|
|
|
166
169
|
[ -n "$PG_NO_AUTH" ] && echo "FINDING [1C] $f: Postgres service may lack POSTGRES_PASSWORD"
|
|
167
|
-
done
|
|
170
|
+
done <<< "$DOCKER_COMPOSE_FILES"
|
|
168
171
|
```
|
|
169
172
|
|
|
170
173
|
**Manual review**: Check for `POSTGRES_PASSWORD` and `requirepass` in compose environment sections. Flag any service using a blank/trivial password as a documented value.
|
|
@@ -184,7 +187,8 @@ done
|
|
|
184
187
|
**Check**:
|
|
185
188
|
```bash
|
|
186
189
|
WORKFLOW_FILES=$(find "$REPO_PATH/.github/workflows" -name "*.yml" 2>/dev/null)
|
|
187
|
-
|
|
190
|
+
while IFS= read -r f; do
|
|
191
|
+
[ -z "$f" ] && continue
|
|
188
192
|
# Find security scanner steps then check if continue-on-error follows within 5 lines
|
|
189
193
|
awk '
|
|
190
194
|
/trivy|snyk|owasp|dependency.check|gitleaks|semgrep|gosec|bandit/ {
|
|
@@ -195,7 +199,7 @@ for f in $WORKFLOW_FILES; do
|
|
|
195
199
|
print FILENAME ":" NR ": continue-on-error: true near security scanner at line " scanner_line
|
|
196
200
|
}
|
|
197
201
|
' FILENAME="$f" "$f" 2>/dev/null || true
|
|
198
|
-
done
|
|
202
|
+
done <<< "$WORKFLOW_FILES"
|
|
199
203
|
```
|
|
200
204
|
|
|
201
205
|
**Confirm**: Verify the flagged step is indeed a security scanner step (not a notification or deploy step). Confirmed if scanner produces failure exit codes that are being swallowed.
|
|
@@ -213,10 +217,11 @@ done
|
|
|
213
217
|
**Check**:
|
|
214
218
|
```bash
|
|
215
219
|
CONFIG_FILES=$(find "$REPO_PATH/infra" "$REPO_PATH/services" -name "*.conf" -o -name "*.yml" -o -name "nginx.conf" 2>/dev/null | grep -v '.git')
|
|
216
|
-
|
|
220
|
+
while IFS= read -r f; do
|
|
221
|
+
[ -z "$f" ] && continue
|
|
217
222
|
HITS=$(grep -n '^\s*#.*\(deny\|allow\s\|auth_basic\|auth_request\|satisfy all\|limit_req\|access_by_lua\)' "$f" 2>/dev/null || true)
|
|
218
223
|
[ -n "$HITS" ] && echo "FINDING [2B] $f:" && echo "$HITS"
|
|
219
|
-
done
|
|
224
|
+
done <<< "$CONFIG_FILES"
|
|
220
225
|
```
|
|
221
226
|
|
|
222
227
|
**Confirm**: Distinguish intentional comments (documentation) from disabled access control rules. Confirmed if the commented line is a functional directive that would restrict access.
|
|
@@ -236,10 +241,11 @@ done
|
|
|
236
241
|
**Check**:
|
|
237
242
|
```bash
|
|
238
243
|
CSP_FILES=$(grep -rl "Content-Security-Policy\|script-src" "$REPO_PATH" --include="*.js" --include="*.ts" --include="*.json" --include="*.conf" --include="*.yml" 2>/dev/null | grep -v '.git' | grep -v node_modules)
|
|
239
|
-
|
|
244
|
+
while IFS= read -r f; do
|
|
245
|
+
[ -z "$f" ] && continue
|
|
240
246
|
HITS=$(grep -n "script-src.*unsafe-inline\|unsafe-inline.*script-src" "$f" 2>/dev/null || true)
|
|
241
247
|
[ -n "$HITS" ] && echo "FINDING [3A] $f:" && echo "$HITS"
|
|
242
|
-
done
|
|
248
|
+
done <<< "$CSP_FILES"
|
|
243
249
|
```
|
|
244
250
|
|
|
245
251
|
**Confirm**: Verify the CSP header applies to the production site (not just dev/storybook). Check if a nonce-based approach is already in use (nonce + unsafe-inline is still a finding but lower severity).
|
|
@@ -257,10 +263,11 @@ done
|
|
|
257
263
|
**Check**:
|
|
258
264
|
```bash
|
|
259
265
|
HEADER_FILES=$(grep -rl "X-App-Version\|X-Deployment-Color\|X-Git-Sha\|X-Build-Id\|Server:" "$REPO_PATH" --include="*.py" --include="*.ts" --include="*.js" --include="*.conf" 2>/dev/null | grep -v '.git' | grep -v node_modules)
|
|
260
|
-
|
|
266
|
+
while IFS= read -r f; do
|
|
267
|
+
[ -z "$f" ] && continue
|
|
261
268
|
HITS=$(grep -n '"X-App-Version"\|"X-Deployment-Color"\|"X-Git-Sha"\|"X-Build-Id"\|"Server:' "$f" 2>/dev/null || true)
|
|
262
269
|
[ -n "$HITS" ] && echo "FINDING [3B] $f:" && echo "$HITS"
|
|
263
|
-
done
|
|
270
|
+
done <<< "$HEADER_FILES"
|
|
264
271
|
```
|
|
265
272
|
|
|
266
273
|
**Confirm**: Check if the header is set in a response (not just read). If set in outbound responses → confirmed finding. Internal-only APIs (not publicly routed) are `priority:P3`; public APIs are `priority:P2`.
|
|
@@ -435,14 +442,15 @@ payment_intent.payment_failed
|
|
|
435
442
|
**Check**:
|
|
436
443
|
```bash
|
|
437
444
|
WEBHOOK_FILES=$(grep -rl "stripe\|webhook" "$REPO_PATH/services" --include="*.py" 2>/dev/null | grep -v '.git' | head -10)
|
|
438
|
-
|
|
445
|
+
while IFS= read -r f; do
|
|
446
|
+
[ -z "$f" ] && continue
|
|
439
447
|
REQUIRED_EVENTS="customer.subscription.deleted invoice.payment_failed charge.refunded charge.dispute.created"
|
|
440
448
|
for event in $REQUIRED_EVENTS; do
|
|
441
449
|
if ! grep -q "\"$event\"\|'$event'" "$f" 2>/dev/null; then
|
|
442
450
|
echo "FINDING [4A] $f: Stripe event '$event' not handled"
|
|
443
451
|
fi
|
|
444
452
|
done
|
|
445
|
-
done
|
|
453
|
+
done <<< "$WEBHOOK_FILES"
|
|
446
454
|
```
|
|
447
455
|
|
|
448
456
|
**Confirm**: Verify the event type appears in a handler dispatch (not just a comment). Confirmed if the event would change user entitlements or financial state but has no handler.
|
|
@@ -444,7 +444,7 @@ npm version {minor|patch|major} --no-git-tag-version
|
|
|
444
444
|
# 3. Commit the version bump
|
|
445
445
|
NEW_VERSION=$(grep '"version"' package.json | grep -oP '\d+\.\d+\.\d+')
|
|
446
446
|
git add package.json package-lock.json
|
|
447
|
-
git commit -m "chore: bump version to ${NEW_VERSION}"
|
|
447
|
+
git commit -s -m "chore: bump version to ${NEW_VERSION}"
|
|
448
448
|
git push origin main
|
|
449
449
|
|
|
450
450
|
# 4. Create GitHub Release (triggers {PUBLISH_WORKFLOW} automatically)
|
|
@@ -495,7 +495,7 @@ cd {SUBPATH} && npm version {minor|patch} --no-git-tag-version && cd -
|
|
|
495
495
|
|
|
496
496
|
# Commit and push
|
|
497
497
|
git add {SUBPATH}/pyproject.toml {SUBPATH}/package.json {SUBPATH}/package-lock.json
|
|
498
|
-
git commit -m "chore({COMPONENT}): bump version to {NEW_VERSION}"
|
|
498
|
+
git commit -s -m "chore({COMPONENT}): bump version to {NEW_VERSION}"
|
|
499
499
|
git push origin fix/{COMPONENT}-version-bump
|
|
500
500
|
|
|
501
501
|
# Create PR to {STAGING_BRANCH}, merge, then trigger publish from main
|
|
@@ -176,6 +176,11 @@ if [ -n "$DEVDOCS_PATH" ] && [ -f "$INDEX_PATH" ]; then
|
|
|
176
176
|
ISSUE_LABELS=$(gh issue view {NUMBER} -R {GH_REPO} --json labels \
|
|
177
177
|
--jq '[.labels[].name] | join(" ")' 2>/dev/null || echo "")
|
|
178
178
|
echo "Issue labels: ${ISSUE_LABELS:-none}"
|
|
179
|
+
# Separate newline-joined list for the loop below — GitHub label names CAN
|
|
180
|
+
# contain spaces (e.g. "good first issue"), so the space-joined $ISSUE_LABELS
|
|
181
|
+
# above is only safe for display, never for iteration.
|
|
182
|
+
ISSUE_LABELS_LIST=$(gh issue view {NUMBER} -R {GH_REPO} --json labels \
|
|
183
|
+
--jq '.labels[].name' 2>/dev/null || echo "")
|
|
179
184
|
|
|
180
185
|
# Read index content
|
|
181
186
|
INDEX_CONTENT=$(cat "$INDEX_PATH" 2>/dev/null || echo "")
|
|
@@ -190,7 +195,12 @@ if [ -n "$DEVDOCS_PATH" ] && [ -f "$INDEX_PATH" ]; then
|
|
|
190
195
|
|
|
191
196
|
# Extract domain blocks matching any issue label keyword
|
|
192
197
|
DOMAIN_PATHS=""
|
|
193
|
-
|
|
198
|
+
# $ISSUE_LABELS_LIST is one label per line — herestring (not a piped
|
|
199
|
+
# `| while read`, which would run the loop body in a subshell and discard
|
|
200
|
+
# DOMAIN_PATHS once the loop exits) preserves newline-safety for labels
|
|
201
|
+
# containing spaces.
|
|
202
|
+
while IFS= read -r label; do
|
|
203
|
+
[ -z "$label" ] && continue
|
|
194
204
|
KEYWORD=$(echo "$label" | sed 's/^workflow://; s/^priority://; s/^review-finding$//')
|
|
195
205
|
[ -z "$KEYWORD" ] && continue
|
|
196
206
|
# Sanitize KEYWORD before AWK injection — strip chars that would break awk regex delimiters
|
|
@@ -207,7 +217,7 @@ if [ -n "$DEVDOCS_PATH" ] && [ -f "$INDEX_PATH" ]; then
|
|
|
207
217
|
echo "Domain '${SAFE_KEYWORD}' matched — adding docs: $(echo "$BLOCK_PATHS" | tr '\n' ' ')"
|
|
208
218
|
DOMAIN_PATHS="${DOMAIN_PATHS}${BLOCK_PATHS}"$'\n'
|
|
209
219
|
fi
|
|
210
|
-
done
|
|
220
|
+
done <<< "$ISSUE_LABELS_LIST"
|
|
211
221
|
|
|
212
222
|
# Combine always_load + domain paths (deduplicate)
|
|
213
223
|
ALL_PATHS=$(printf "%s\n%s" "$ALWAYS_LOAD_PATHS" "$DOMAIN_PATHS" \
|
|
@@ -448,11 +458,45 @@ For each non-obvious interaction or potential failure mode:
|
|
|
448
458
|
- Suggest a mitigation or check
|
|
449
459
|
|
|
450
460
|
Focus on:
|
|
451
|
-
-
|
|
461
|
+
- Churn / hot-spot files (see below) — high historical change frequency correlates with defect density
|
|
452
462
|
- Paths that are called in both sync and async contexts
|
|
453
463
|
- Any place where the change touches a serialization boundary (JSON in/out, DB read/write)
|
|
454
464
|
- Unused or dormant code that the change might activate
|
|
455
465
|
|
|
466
|
+
### Churn (Hot-Spot) Signal
|
|
467
|
+
|
|
468
|
+
Recency alone (did the file appear in the last N commits?) is a weak, binary proxy. Compute a bounded per-file **churn tier** instead — how often each affected file has actually changed over a fixed window — and fold it into the risk table below.
|
|
469
|
+
|
|
470
|
+
For each file in `{AFFECTED_FILES}` (never repo-wide — this must stay O(affected files) to fit the Phase A0–A5 time budget):
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
CHURN_WINDOW="90 days ago" # named constant — must match the same window used in review-pr.md Phase 3A
|
|
474
|
+
|
|
475
|
+
# {AFFECTED_FILES} is a space-separated argument (see --files contract at the top
|
|
476
|
+
# of this file), not newline-separated like review-pr.md's $FILES — so a herestring
|
|
477
|
+
# `while read` line-loop would misparse it as a single line. Split explicitly on
|
|
478
|
+
# IFS=' ' into an array instead of relying on a bare `for FILE in {AFFECTED_FILES}`
|
|
479
|
+
# (which word-splits on the shell's default IFS — space, tab, AND newline). This
|
|
480
|
+
# keeps the existing space-separated contract intact while narrowing the splitting
|
|
481
|
+
# surface to spaces only.
|
|
482
|
+
IFS=' ' read -ra AFFECTED_FILES_ARR <<< "{AFFECTED_FILES}"
|
|
483
|
+
for FILE in "${AFFECTED_FILES_ARR[@]}"; do
|
|
484
|
+
COMMITS=$(git log --oneline --since="$CHURN_WINDOW" -- "$FILE" | wc -l)
|
|
485
|
+
if [ "$COMMITS" -ge 15 ]; then TIER="HOT"
|
|
486
|
+
elif [ "$COMMITS" -ge 5 ]; then TIER="MEDIUM"
|
|
487
|
+
else TIER="LOW"
|
|
488
|
+
fi
|
|
489
|
+
echo "$FILE: $TIER ($COMMITS commits in last 90 days)"
|
|
490
|
+
done
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
**Tiers** (fixed thresholds — keep identical to `review-pr.md` Phase 3A so both phases agree on what "hot" means):
|
|
494
|
+
- **HOT**: 15+ commits in the window
|
|
495
|
+
- **MEDIUM**: 5–14 commits in the window
|
|
496
|
+
- **LOW**: 0–4 commits in the window
|
|
497
|
+
|
|
498
|
+
Any file classified **HOT** MUST get an explicit row in the Risk Assessment table below (e.g. "High historical churn — {N} commits in last 90 days — changes here have a higher defect-density prior"), rated at least MEDIUM severity, with a mitigation such as "extra reviewer scrutiny" or "prefer smaller, isolated diffs to this file." MEDIUM-tier files may be noted inline without a dedicated row. LOW-tier files need no mention.
|
|
499
|
+
|
|
456
500
|
---
|
|
457
501
|
|
|
458
502
|
## Output Format
|