forgedock 1.12.1 → 1.13.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 (64) hide show
  1. package/bin/engine/phases.mjs +54 -12
  2. package/bin/engine/runlog.mjs +12 -0
  3. package/bin/engine-cli.mjs +3 -1
  4. package/bin/engine.mjs +64 -16
  5. package/bin/hooks/interactive-engine.mjs +135 -212
  6. package/bin/hooks/pre-tool-use.mjs +16 -4
  7. package/bin/labels.json +5 -0
  8. package/bin/opencode-adapter.mjs +1 -1
  9. package/bin/runner.mjs +234 -21
  10. package/commands/autopilot.md +6 -6
  11. package/commands/cleanup.md +7 -4
  12. package/commands/orchestrate/phase-1-resolve.md +1 -1
  13. package/commands/orchestrate/phase-3-dependency.md +14 -19
  14. package/commands/orchestrate/phase-4-execution.md +50 -38
  15. package/commands/orchestrate/phase-6-report.md +6 -0
  16. package/commands/orchestrate/safety.md +1 -1
  17. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  18. package/commands/pipeline-health/phase-5-report.md +1 -0
  19. package/commands/pipeline-resume.md +1 -1
  20. package/commands/pipeline-status.md +2 -2
  21. package/commands/review-pr-agents/api.md +1 -1
  22. package/commands/review-pr-agents/auth.md +1 -1
  23. package/commands/review-pr-agents/infra.md +9 -3
  24. package/commands/review-pr-agents/protocols.md +19 -5
  25. package/commands/review-pr-agents/scraper.md +4 -1
  26. package/commands/review-pr-agents/security.md +17 -5
  27. package/commands/review-pr-agents.md +1 -1
  28. package/commands/review-pr-staging.md +302 -8
  29. package/commands/review-pr.md +254 -34
  30. package/commands/work-on/build/architect.md +36 -0
  31. package/commands/work-on/build/validate.md +88 -4
  32. package/commands/work-on/build.md +95 -3
  33. package/commands/work-on/remediate.md +115 -13
  34. package/commands/work-on.md +4 -4
  35. package/docs/CONFIG.md +29 -0
  36. package/docs/RELEASE-NOTES.md +45 -0
  37. package/docs/spec/forge-protocol-v1.md +22 -1
  38. package/docs/spec/review-protocol.md +24 -4
  39. package/package.json +2 -1
  40. package/scripts/check-blast-radius.sh +164 -0
  41. package/scripts/check-blast-radius.test.sh +183 -0
  42. package/scripts/check-contract-scope.sh +137 -0
  43. package/scripts/check-contract-scope.test.sh +83 -0
  44. package/scripts/check-spec-bash.sh +60 -3
  45. package/scripts/check-spec-bash.test.sh +56 -0
  46. package/scripts/check-spec-markers.sh +29 -0
  47. package/scripts/check-test-wiring.sh +57 -0
  48. package/scripts/check-test-wiring.test.sh +70 -0
  49. package/scripts/classify-finding.sh +72 -8
  50. package/scripts/classify-finding.test.sh +77 -8
  51. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  52. package/scripts/diff-size.test.sh +3 -2
  53. package/scripts/doctor-pipeline-state.sh +35 -0
  54. package/scripts/forge-annotation.sh +3 -0
  55. package/scripts/forge-root.test.sh +70 -1
  56. package/scripts/forge-run.sh +2 -2
  57. package/scripts/recovery-claim-live.test.sh +10 -7
  58. package/scripts/remediate-basesync.test.sh +103 -0
  59. package/scripts/review-delta.sh +206 -0
  60. package/scripts/review-delta.test.sh +213 -0
  61. package/scripts/review-pr-gate2.test.sh +97 -0
  62. package/scripts/review-pr-staging-scope.test.sh +99 -0
  63. package/scripts/run-shell-tests.sh +115 -0
  64. package/scripts/select-fix-targets.sh +17 -5
package/docs/CONFIG.md CHANGED
@@ -457,6 +457,35 @@ pipeline:
457
457
 
458
458
  ---
459
459
 
460
+ ## `engine` (OPTIONAL)
461
+
462
+ Per-command `--model` and `--max-turns` for the CLI backend (`forgedock run-issue` phases and `forgedock run <command>`). Opt-in: with no keys set, the `claude` invocation is unchanged and uses the CLI's own default model with no turn bound. `forge.yaml` is parsed one level deep, so settings are **flat** keys under `engine:`, named `<key>_model` and `<key>_max_turns`, where `<key>` is the command name with every non-alphanumeric character replaced by `_`.
463
+
464
+ ```yaml
465
+ engine:
466
+ # work-on/review -> work_on_review (the review phase is the most expensive).
467
+ work_on_review_model: sonnet
468
+ work_on_review_max_turns: 120
469
+
470
+ # review-pr-staging -> review_pr_staging (non-work-on commands work the same way).
471
+ review_pr_staging_max_turns: 80
472
+ ```
473
+
474
+ | Field | Type | Required | Description |
475
+ |-------|------|----------|-------------|
476
+ | `<key>_model` | string | No | Model alias (`sonnet`, `opus`, `haiku`) or full model id passed as `--model`. Invalid values are ignored. |
477
+ | `<key>_max_turns` | integer | No | Positive integer passed as `--max-turns`. Invalid values are ignored. |
478
+
479
+ **Precedence** (model): `engine.<key>_model`, then the run-level model the caller passed explicitly, then omitted. `max_turns` comes only from this section. No defaults ship.
480
+
481
+ **Version gate**: the flags are added only when the installed `claude --version` supports them (`--max-turns` is hidden from `claude --help`, so help text is never scraped); an unparseable version omits both flags.
482
+
483
+ **Turn exhaustion**: when `--max-turns` is exhausted the run ends `engine-error` (detail `max-turns`) and the phase is never committed, so `forgedock run-issue --retry` re-enters the same phase. Raise `<key>_max_turns` before retrying. Phase runlog events record `model` and `turns` next to `usage` so a limit can be sized from measured turn counts.
484
+
485
+ **Commands that use this section**: CLI backend (`runCommand` in `bin/runner.mjs`)
486
+
487
+ ---
488
+
460
489
  ## `build` (OPTIONAL)
461
490
 
462
491
  Tuning for the `/work-on` build phase. Today it holds the diff-size gate: after the implement phase stages its changes and before validation commits them, the build measures the changed lines (added plus deleted) of the index and any earlier commits against `origin/{base}`, using `scripts/diff-size.sh`. Review findings per PR rise steeply with diff size, so an oversized build is split before it is validated.
@@ -2,6 +2,51 @@
2
2
 
3
3
  Operator-facing notes for behavior changes that need action or awareness. Newest first.
4
4
 
5
+ ## Review damper: LOW needs keyword + CONFIRMED; a missing classifier blocks the review (#3639)
6
+
7
+ `scripts/classify-finding.sh` is stricter for LOW findings. A safety keyword now files a LOW finding only when it is also CONFIRMED (LOW LIKELY/POSSIBLE with a keyword is a NOTE), and a dedicated domain agent (Auth, Billing, Concurrency, Database) rescues MEDIUM findings only, no longer LOW CONFIRMED. MEDIUM behaviour is unchanged.
8
+
9
+ `/review-pr` §6B.5 and `/review-pr-staging` §7B.5 no longer let the model hand-classify (`classifier=manual`) when the script cannot be resolved. They return `REVIEW_RESULT: status: BLOCKED` with blocker `classifier unavailable (classify-finding.sh not resolvable through the trusted resolver)`, file nothing and do not merge or approve a deploy. The staging per-finding runtime-failure fallback is renamed `ISSUE classifier-error`. The `classifier=` marker stays and is always `script`.
10
+
11
+ Action: if reviews now report `classifier unavailable`, fix the ForgeDock install so the trusted resolver finds `scripts/classify-finding.sh`, then re-run the review.
12
+
13
+ ## Introduced-line gate no longer demotes CRITICAL/HIGH (#3597, #3598)
14
+
15
+ `/review-pr` §6B.4 gate 2 never routes a CRITICAL/HIGH finding (any confidence) as `pre-existing`: a new caller of an unchanged unsafe helper is an interaction defect (`protocols.md` §5), so it stays a blocking `review-finding` under §7B criterion 2, including with `--auto-merge`. The pre-existing route now covers MEDIUM and lower only. A file with no `patch` in the PR files API (large or truncated diff) is treated as UNKNOWN and its findings are kept, never routed as pre-existing. Expect slightly more blocking findings on PRs that touch unsafe code through unchanged helpers.
16
+
17
+ ## Contract out-of-scope items now typed; review demotes matching findings (#3447)
18
+
19
+ The `### Out of Scope` section of `FORGE:CONTRACT` is no longer free text. It is `None.` or bullets that each name a backticked path and one disposition: `deferred → #N`, `not-affected: <evidence>` or `accepted-risk: <reason>`. A contract that keeps a known-flawed design must list it as `deferred → #N`.
20
+
21
+ - **Build** (B2.0) files a follow-up issue for each `deferred` item before the contract is posted (idempotent through a `FORGE:DEFERRED_FROM: #N` body marker) and validates the section with the new `scripts/check-contract-scope.sh`. An untyped item blocks the build after one retry.
22
+ - **Review** (`/review-pr` §6B.5) reads the latest trusted contract and passes it to `scripts/classify-finding.sh --contract-scope`. A finding on a `deferred` path (follow-up still open) or an `accepted-risk` path becomes a note instead of a new `review-finding`; deferred matches are commented on the existing issue. `not-affected` items, HIGH/CRITICAL findings, safety-exempt `accepted-risk` findings and files the PR itself changed are never demoted. `FORGE:NOTE_DISPOSITION` gains `contract_demoted=N`.
23
+ - The review scope-creep check now reads only the contract's `### Deliverables`, so backticked out-of-scope paths no longer count as in-scope files.
24
+
25
+ Action: none. Older contracts with free-text Out of Scope sections are not parsed and demote nothing.
26
+
27
+ ## Review provenance gates and noise share (#3452)
28
+
29
+ `/review-pr` §6B.4 now checks each finding before it can become a `review-finding` issue: the cited line must exist at the reviewed head SHA (else dropped as stale), must be added or changed by the PR diff (else routed as `pre-existing`), and must carry a concrete `**Failure scenario**` (else demoted to a note; CRITICAL/HIGH, any confidence, are exempt). Pre-existing defects are never counted as this PR's findings or in the amplification ratio; only MEDIUM or lower ones are routed this way, and the safety-domain ones are filed once with the new `pre-existing` label. The `FORGE:NOTE_DISPOSITION` record gains `findings_dropped_stale`, `findings_preexisting`, `notes_demoted_no_scenario`, and `/pipeline-health` reports a noise share (2E.5).
30
+
31
+ Action: run `npx forgedock labels setup` to create the `pre-existing` label (the review also creates it on demand).
32
+
33
+ ## Diff-size gate in `/work-on` build (#3450)
34
+
35
+ `/work-on` build now measures the changed lines (added plus deleted) after implement stages its changes and before validate commits them. The gate is **on by default at 1000 lines**. Review findings per PR rise steeply with diff size, so an oversized build is split instead of validated.
36
+
37
+ - **Over the threshold**: the build posts a `FORGE:DIFF_SIZE` comment with a split proposal and exits `NEEDS_DECOMPOSE`; the router then runs `work-on:decompose`.
38
+ - **Tune or disable**: set `build.diff_size.threshold` in `forge.yaml` (`0` disables the gate) and add exclusions with `build.diff_size.exclude_globs`. Lockfiles, `*.min.*`, `*.snap` and vendored/generated directories are excluded by default.
39
+ - **Per-issue bypass**: post a `FORGE:SIZE_OVERRIDE` comment with a non-empty justification on the line after the marker. Only comments from trusted authors count.
40
+ - **No re-decomposition**: an issue that is already a decomposed child, or has `FORGE:DECOMPOSED`, blocks until an override is posted instead of being split again.
41
+
42
+ Action: if your builds routinely exceed 1000 changed lines, raise `build.diff_size.threshold` or set it to `0`. See the `build` section of [CONFIG.md](CONFIG.md).
43
+
44
+ ## Stricter `review-pr` / `review-pr-staging` argument rejection (#3466)
45
+
46
+ `/review-pr` and `/review-pr-staging` now reject an argument string as a whole if it contains a quote, backtick, `$`, backslash, newline or tab. Nothing is parsed from a rejected string, so no PR number, repo, merge value or flag survives, and `--auto-merge` is not honoured. `/review-pr-staging` stops with nothing reviewed and nothing posted.
47
+
48
+ Action: callers must pass `--gh-flag -R owner/repo` unquoted, and must not forward untrusted text (issue titles, comment bodies) into the argument string.
49
+
5
50
  ## Spec bash no longer corrupted by Claude Code argument substitution
6
51
 
7
52
  Claude Code replaces `$0`..`$9` in a skill body with the invocation's arguments (0-based), so spec bash loaded via `Skill(...)` with args was silently rewritten: `awk '{print $2}'` became `awk '{print --issue}'`, `local AGENT="$1"` became `local AGENT="--auto-merge"`, which broke `/review-pr` agent selection and the CI/deploy comparisons with no error. `${N}`, `$(N)`, `$NF`, `$@`, `$#` and `$10`+ are not substituted.
@@ -251,9 +251,30 @@ findings and what will actually be built.
251
251
  {auth model, new configuration, data-safety, security surface, migration risk}
252
252
 
253
253
  ### Out of Scope
254
- {items explicitly excluded}
254
+ None.
255
255
  ```
256
256
 
257
+ `### Out of Scope` is either the single line `None.` or a list of typed bullets. Each bullet names at
258
+ least one backticked path or symbol followed by exactly one disposition:
259
+
260
+ ```
261
+ - `path` — deferred → #N: <why>
262
+ - `path` — not-affected: <evidence>
263
+ - `path` — accepted-risk: <reason>
264
+ ```
265
+
266
+ - `deferred → #N`: a follow-up issue `#N` was filed before the contract was posted (`->` is accepted
267
+ for `→`). A contract that keeps a known-flawed design must list the flaw as a `deferred` item.
268
+ - `not-affected: <evidence>`: the path was checked and is not reached by the change.
269
+ - `accepted-risk: <reason>`: a known gap that is knowingly kept.
270
+ - Free-text items, a `deferred` item without an issue number, or a missing/empty section are invalid.
271
+ `scripts/check-contract-scope.sh validate|list` is the reference parser (TSV
272
+ `disposition<TAB>path<TAB>issue`).
273
+ - Review demotion: a finding on a `deferred` path (follow-up issue still open) or an `accepted-risk`
274
+ path becomes a note instead of a new `review-finding`; `not-affected` items, HIGH/CRITICAL findings,
275
+ safety-exempt `accepted-risk` findings and files the PR changed are never demoted. Only a trusted
276
+ author's contract is read. The count is recorded as `contract_demoted` in `FORGE:NOTE_DISPOSITION`.
277
+
257
278
  #### `FORGE:CONTEXT`
258
279
 
259
280
  - **Written by**: a context-gathering agent
@@ -25,6 +25,9 @@ Pipeline agents MUST NOT use `find` (unbounded or filesystem-wide) to locate pro
25
25
  Every agent MUST follow this protocol:
26
26
 
27
27
  ### 1. Start From the PR Diff
28
+
29
+ **Input scoping**: You have been given a pre-computed diff slice containing only the files relevant to your domain (`[DOMAIN_DIFF_SLICE]`). Do NOT re-fetch `gh pr diff [PR_NUMBER]` in full — use the slice provided. This is capped at ~100K chars, mirroring the runner's tool-result limit.
30
+
28
31
  ```bash
29
32
  # Verify review is still current before reading diff
30
33
  CURRENT_SHA=$(gh pr view [PR_NUMBER] --json headRefOid --jq '.headRefOid')
@@ -34,10 +37,24 @@ if [ "$CURRENT_SHA" != "[REVIEW_SHA]" ]; then
34
37
  echo "Current HEAD: $(echo $CURRENT_SHA | cut -c1-7)"
35
38
  fi
36
39
 
40
+ # List files in your domain slice
37
41
  gh pr diff [PR_NUMBER] --name-only
38
- gh pr diff [PR_NUMBER]
42
+
43
+ # Materialize the pre-computed domain diff slice supplied by the orchestrator.
44
+ # Persona greps below read "$SLICE_FILE" -- they never re-fetch the full diff.
45
+ # Deterministic path (PR + reviewed SHA + domain): every Bash call is a fresh shell, so later fences
46
+ # re-declare this exact line instead of relying on a variable set here.
47
+ SLICE_FILE="${TMPDIR:-/tmp}/forge-slice-[PR_NUMBER]-[REVIEW_SHA_SHORT]-[AGENT_DOMAIN].diff"
48
+ rm -f "$SLICE_FILE"
49
+ cat > "$SLICE_FILE" <<'FORGE_SLICE_EOF'
50
+ [DOMAIN_DIFF_SLICE]
51
+ FORGE_SLICE_EOF
39
52
  ```
40
53
 
54
+ **Later fences**: persona fences that read `$SLICE_FILE` re-declare the identical `SLICE_FILE=` line above and guard it with `[ -f "$SLICE_FILE" ] || { echo "SLICE_FILE missing ..." >&2; exit 1; }`. The guard tests existence, not size, so a legitimately empty slice still takes the fallback below while a lost path fails loudly instead of reporting a false clean scan.
55
+
56
+ **Empty or truncated slice**: if the slice is empty or ends mid-hunk, read the specific changed files with bounded reads (`sed -n 'X,Yp'`, `head -N`) — never fall back to fetching the full PR diff.
57
+
41
58
  **Hot-spot prior**:
42
59
  [CHURN_CONTEXT]
43
60
 
@@ -82,7 +99,7 @@ If a file you are reviewing is listed above as a hot-spot, apply deeper scrutiny
82
99
  3. Will this cause degraded performance? → **MEDIUM**
83
100
  4. Is it genuinely cosmetic with no runtime impact after tracing all consumers? → **LOW**
84
101
 
85
- If you're unsure whether something is cosmetic or a runtime error, **assume it's a runtime error** and flag it for investigation. A false positive costs a minute of review time. A missed runtime error costs production downtime.
102
+ **Precision first.** Report defects that are likely to be real in the code this PR changes and the code it reaches. If you cannot verify a runtime impact after tracing the path, report the finding at **POSSIBLE** confidence — do not inflate its severity or confidence to be safe. A false positive costs reviewer trust and triggers a cascade of follow-up issues and re-reviews; an unverified suspicion belongs at POSSIBLE, where it stays a non-blocking note, not at an inflated severity.
86
103
 
87
104
  ### 5. INTERACTION ANALYSIS — "Pre-existing" Is Not "Safe"
88
105
 
@@ -95,6 +112,8 @@ A redundant import, an unused variable, or a duplicated constant may be harmless
95
112
  2. For each reference, ask: "Does the pre-existing construct cause this new line to fail at runtime?"
96
113
  3. If yes → CONFIRMED finding, not a dismissal
97
114
 
115
+ **Severity rule**: CRITICAL/HIGH findings, any confidence, are never routed as pre-existing by the `/review-pr` introduced-line gate (§6B.4): they stay blocking `review-finding` issues. The `**Scope**` tag does not gate routing.
116
+
98
117
  ### 6. FALSE POSITIVE PREVENTION
99
118
 
100
119
  **Before claiming variable scope issues:** Read the FULL function, count indentation levels, check if/else structure.
@@ -120,7 +139,7 @@ Every finding must include:
120
139
 
121
140
  ## Structured Findings Protocol
122
141
 
123
- **All review agents MUST include a machine-readable findings block at the end of their PR comment.** This is NON-OPTIONAL. Without structured findings, the review system cannot create GitHub issues, and findings die as unread PR comments. Every finding that doesn't become a GitHub issue is a finding that will never be addressed.
142
+ **All review agents MUST include a machine-readable findings block at the end of their PR comment.** This is NON-OPTIONAL. Without structured findings, the review system cannot classify, file, or track what you found. The orchestrator (`/review-pr` §6B.4/§6B.5) decides disposition: CONFIRMED/LIKELY findings that pass its gates become issues, while LOW severity and POSSIBLE confidence findings are non-blocking NOTEs that are not filed as standalone issues.
124
143
 
125
144
  ### Persist Before Post
126
145
 
@@ -138,7 +157,7 @@ Append this block at the very end of your comment (after the `---` footer line,
138
157
 
139
158
  ### Rules
140
159
 
141
- 1. **Include ALL findings at CONFIRMED, LIKELY, and POSSIBLE confidence** — every finding becomes a GitHub issue. Nothing stays as just a PR comment. **POSSIBLE findings are informational advisories (P3/non-blocking)** — they are tracked but do not require a fix PR and do not block merge. CONFIRMED and LIKELY findings are blocking at P1/P2 respectively.
160
+ 1. **Include every finding you can support at CONFIRMED, LIKELY, or POSSIBLE confidence in the block** — the orchestrator needs them all to classify. Inclusion does not mean a GitHub issue is filed: `/review-pr` §6B.4/§6B.5 decide filing. **POSSIBLE confidence and LOW severity findings are non-blocking NOTEs** — reported in the review, never filed as standalone issues, never block merge. CONFIRMED and LIKELY findings are blocking at P1/P2 respectively. Do not pad the block with unverified or speculative items; precision over recall.
142
161
  2. **One line per finding** — sequential numbering (PREFIX-1, PREFIX-2, ...)
143
162
  3. **Confidence**: `CONFIRMED`, `LIKELY`, or `POSSIBLE`
144
163
  4. **Severity**: `CRITICAL`, `HIGH`, `MEDIUM`, or `LOW`
@@ -148,6 +167,7 @@ Append this block at the very end of your comment (after the `---` footer line,
148
167
  8. **HTML comments**: The block is invisible in rendered markdown but parseable by the review system
149
168
  9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the marker domain the orchestrator passes you (the canonical value from the roster-to-marker table in `review-pr.md`, e.g. `infra` and `scraper`; never a lowercased display name such as `infrastructure` or `scraping`).
150
169
  10. **Reviewed SHA**: Include exactly one line `Reviewed-SHA: [REVIEW_SHA]` (the full 40-hex head you were pinned to) in the persisted body, outside the marker. `/review-pr` scopes its completion wait and idempotent re-entry to this line, so a comment without the current head does not count as a completed review. Do NOT put the SHA inside the `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker.
170
+ 11. **Failure scenario and scope (provenance inputs)**: every finding's body section (the prose above the findings block) MUST carry a `**Failure scenario**` line — concrete inputs leading to a wrong output or behaviour — and a `**Scope**: changed` or `**Scope**: pre-existing` line (`changed` = the cited line is added or modified by this PR; `pre-existing` = it was already there). `/review-pr` §6B.4 reads these: a finding without a failure scenario is demoted to a note (unless CRITICAL/HIGH, any confidence), and a MEDIUM or lower finding whose cited line is not in the PR diff is routed as pre-existing (CRITICAL/HIGH never are; see §5). The `**Scope**` tag is informational and never gates routing. Speculative hardening with no failure scenario is a note, not a finding. The one-line `<!-- FINDING:... -->` grammar above is unchanged. <!-- Added: forge#3452 -->
151
171
 
152
172
  ### Domain Prefixes
153
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.12.1",
3
+ "version": "1.13.1",
4
4
  "description": "Autonomous development pipeline for coding agents. GitHub as institutional memory, with native Claude Code and OpenCode entrypoints.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -48,6 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "test": "node --test \"bin/tests/**/*.test.mjs\" \"bin/engine/**/*.test.mjs\" \"packages/protocol/test/**/*.test.mjs\"",
51
+ "test:sh": "bash scripts/run-shell-tests.sh",
51
52
  "docs:dev": "vitepress dev docs/site",
52
53
  "docs:build": "vitepress build docs/site",
53
54
  "docs:preview": "vitepress preview docs/site"
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-blast-radius.sh — Enforce the FORGE:ARCHITECT blast-radius manifest against the final tree.
6
+ #
7
+ # Usage: check-blast-radius.sh --manifest <file> --base <ref> [--repo <path>]
8
+ #
9
+ # <file> is either the extracted manifest block or a whole FORGE:ARCHITECT comment body. Only the text between
10
+ # <!-- FORGE:BLAST_RADIUS:BEGIN --> and <!-- FORGE:BLAST_RADIUS:END --> is parsed (all such regions, concatenated).
11
+ # Grammar, one record per line (blank lines and markdown code-fence lines are the only other lines allowed):
12
+ # SYMBOL: id=<sN> kind=function|field|flag|schema|contract name=<symbol> query="<fixed-string>"
13
+ # HIT: symbol=<sN> file=<path> role=producer|consumer|sibling disposition=change
14
+ # HIT: symbol=<sN> file=<path> role=producer|consumer|sibling disposition=verified-unaffected reason=<one-token>
15
+ # Values contain no spaces (use hyphens in reason). query must match ^[A-Za-z0-9_.:/-]{3,80}$ and not start with "-".
16
+ #
17
+ # For every SYMBOL the query is re-run as a fixed-string search over the final tree (working tree, tracked plus
18
+ # untracked files, so it works before and after the commit). A file that matches must be in the change set
19
+ # (base...HEAD plus anything staged, modified or untracked) or carry a verified-unaffected row.
20
+ # A "change" row whose file is not in the change set is a planned-but-not-done item.
21
+ #
22
+ # Exit codes:
23
+ # 0 covered, or no manifest ("SKIP: no manifest")
24
+ # 1 UNLISTED: <symbol> <file> and/or NOT_DONE: <symbol> <file>
25
+ # 2 malformed manifest, invalid query, unresolvable base/repo, or a git failure — NEVER a pass; callers report DEGRADED
26
+ #
27
+ # Offline: no network, no gh. Portable to bash 3.2: no mapfile, no associative arrays, no sort -V.
28
+
29
+ set -uo pipefail
30
+
31
+ MAX_HITS=200
32
+ MANIFEST=""; BASE=""; REPO="."
33
+ die2() { echo "check-blast-radius: $*" >&2; exit 2; }
34
+
35
+ while [ "$#" -gt 0 ]; do
36
+ case "$1" in
37
+ --manifest) [ "$#" -ge 2 ] || die2 "--manifest needs a value"; MANIFEST="$2"; shift 2 ;;
38
+ --base) [ "$#" -ge 2 ] || die2 "--base needs a value"; BASE="$2"; shift 2 ;;
39
+ --repo) [ "$#" -ge 2 ] || die2 "--repo needs a value"; REPO="$2"; shift 2 ;;
40
+ *) die2 "usage: check-blast-radius.sh --manifest <file> --base <ref> [--repo <path>]" ;;
41
+ esac
42
+ done
43
+ [ -n "$MANIFEST" ] || die2 "usage: check-blast-radius.sh --manifest <file> --base <ref> [--repo <path>]"
44
+ [ -f "$MANIFEST" ] || die2 "manifest file not found: $MANIFEST"
45
+
46
+ TMP=$(mktemp -d "${TMPDIR:-/tmp}/check-blast-radius.XXXXXX") || die2 "mktemp failed"
47
+ trap 'rm -rf "$TMP"' EXIT
48
+
49
+ # ---- extract BEGIN..END regions -------------------------------------------------------------------------
50
+ awk -v out="$TMP/region" '
51
+ index($0, "<!-- FORGE:BLAST_RADIUS:BEGIN -->") { if (inb) { bad = 1 } inb = 1; seen = 1; next }
52
+ index($0, "<!-- FORGE:BLAST_RADIUS:END -->") { if (!inb) { bad = 1 } inb = 0; next }
53
+ inb { print > out }
54
+ END { if (inb || bad) exit 3; if (!seen) exit 4 }
55
+ ' "$MANIFEST"
56
+ case $? in
57
+ 0) ;;
58
+ 4) echo "SKIP: no manifest"; exit 0 ;;
59
+ *) die2 "unbalanced FORGE:BLAST_RADIUS:BEGIN/END markers" ;;
60
+ esac
61
+ [ -f "$TMP/region" ] || { echo "SKIP: no manifest"; exit 0; }
62
+
63
+ # has_symbol <id>: awk over the file (no grep -q in a pipeline: SIGPIPE under pipefail misreports the status)
64
+ has_symbol() { awk -F'\t' -v s="$1" '$1==s {ok=1} END{exit ok?0:1}' "$TMP/symbols"; }
65
+
66
+ # ---- parse ----------------------------------------------------------------------------------------------
67
+ : > "$TMP/symbols" # id<TAB>name<TAB>query
68
+ : > "$TMP/hits" # symbol<TAB>file<TAB>disposition
69
+ QRE='^[A-Za-z0-9_.:/-]{3,80}$'
70
+ FRE='^[A-Za-z0-9_./@+=:,-]+$'
71
+ LN=0
72
+ while IFS= read -r line || [ -n "$line" ]; do
73
+ LN=$((LN+1))
74
+ line="${line%$'\r'}"
75
+ trimmed="${line#"${line%%[![:space:]]*}"}"
76
+ case "$trimmed" in
77
+ ""|'```'*) continue ;;
78
+ "SYMBOL: "*) kind_rec=SYMBOL; rest="${trimmed#SYMBOL: }" ;;
79
+ "HIT: "*) kind_rec=HIT; rest="${trimmed#HIT: }" ;;
80
+ *) die2 "line $LN: unrecognised record: $trimmed" ;;
81
+ esac
82
+ id=""; kind=""; name=""; query=""; symbol=""; file=""; role=""; disp=""; reason=""
83
+ set -f
84
+ for tok in $rest; do
85
+ case "$tok" in
86
+ *=*) k="${tok%%=*}"; v="${tok#*=}" ;;
87
+ *) set +f; die2 "line $LN: token without key=value: $tok" ;;
88
+ esac
89
+ case "$k" in
90
+ id) id="$v" ;; kind) kind="$v" ;; name) name="$v" ;;
91
+ query) v="${v#\"}"; v="${v%\"}"; query="$v" ;;
92
+ symbol) symbol="$v" ;; file) file="$v" ;; role) role="$v" ;;
93
+ disposition) disp="$v" ;; reason) reason="$v" ;;
94
+ *) set +f; die2 "line $LN: unknown key: $k" ;;
95
+ esac
96
+ done
97
+ set +f
98
+ if [ "$kind_rec" = SYMBOL ]; then
99
+ case "$id" in s[0-9]*) ;; *) die2 "line $LN: SYMBOL id must look like s1: '$id'" ;; esac
100
+ case "$id" in *[!A-Za-z0-9]*) die2 "line $LN: invalid SYMBOL id: '$id'" ;; esac
101
+ case "$kind" in function|field|flag|schema|contract) ;; *) die2 "line $LN: invalid kind: '$kind'" ;; esac
102
+ [ -n "$name" ] || die2 "line $LN: SYMBOL without name"
103
+ [ -n "$query" ] || die2 "line $LN: empty query"
104
+ case "$query" in -*) die2 "line $LN: query must not start with '-'" ;; esac
105
+ [[ "$query" =~ $QRE ]] || die2 "line $LN: invalid query (want $QRE): $query"
106
+ has_symbol "$id" && die2 "line $LN: duplicate SYMBOL id: $id"
107
+ printf '%s\t%s\t%s\n' "$id" "$name" "$query" >> "$TMP/symbols"
108
+ else
109
+ [ -n "$symbol" ] || die2 "line $LN: HIT without symbol"
110
+ has_symbol "$symbol" || die2 "line $LN: HIT references unknown symbol: $symbol"
111
+ [ -n "$file" ] || die2 "line $LN: HIT without file"
112
+ case "$file" in -*) die2 "line $LN: file must not start with '-'" ;; esac
113
+ [[ "$file" =~ $FRE ]] || die2 "line $LN: invalid file path: $file"
114
+ case "$role" in producer|consumer|sibling) ;; *) die2 "line $LN: invalid role: '$role'" ;; esac
115
+ case "$disp" in
116
+ change) ;;
117
+ verified-unaffected) [ -n "$reason" ] || die2 "line $LN: verified-unaffected requires reason=<token>" ;;
118
+ *) die2 "line $LN: invalid disposition: '$disp'" ;;
119
+ esac
120
+ printf '%s\t%s\t%s\n' "$symbol" "$file" "$disp" >> "$TMP/hits"
121
+ fi
122
+ done < "$TMP/region"
123
+
124
+ if [ ! -s "$TMP/symbols" ]; then
125
+ if [ -s "$TMP/hits" ]; then die2 "HIT records without any SYMBOL"; fi
126
+ echo "SKIP: no manifest"; exit 0
127
+ fi
128
+
129
+ # ---- repo, base, change set -----------------------------------------------------------------------------
130
+ case "$BASE" in ""|-*) die2 "invalid --base: '$BASE'" ;; esac
131
+ git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1 || die2 "not a git work tree: $REPO"
132
+ git -C "$REPO" rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1 || die2 "cannot resolve base ref '$BASE'"
133
+
134
+ : > "$TMP/changed"
135
+ git -C "$REPO" diff -z --name-only "${BASE}...HEAD" -- > "$TMP/c1" 2>/dev/null || die2 "git diff ${BASE}...HEAD failed"
136
+ git -C "$REPO" diff -z --name-only HEAD -- > "$TMP/c2" 2>/dev/null || die2 "git diff HEAD failed"
137
+ git -C "$REPO" ls-files -z --others --exclude-standard > "$TMP/c3" 2>/dev/null || die2 "git ls-files failed"
138
+ cat "$TMP/c1" "$TMP/c2" "$TMP/c3" | tr '\0' '\n' | sed '/^$/d' | sort -u > "$TMP/changed"
139
+
140
+ # ---- check ----------------------------------------------------------------------------------------------
141
+ RC=0
142
+ while IFS="$(printf '\t')" read -r sid sname squery; do
143
+ git -C "$REPO" grep -z --untracked -l -F -e "$squery" -- > "$TMP/g" 2>"$TMP/gerr"
144
+ grc=$?
145
+ if [ "$grc" -gt 1 ]; then die2 "git grep failed for symbol $sid (rc=$grc): $(head -n 1 "$TMP/gerr")"; fi
146
+ tr '\0' '\n' < "$TMP/g" | sed '/^$/d' | sort -u > "$TMP/found"
147
+ n=$(wc -l < "$TMP/found" | tr -d ' ')
148
+ [ "$n" -le "$MAX_HITS" ] || die2 "query too broad for symbol $sid ($n files > $MAX_HITS): use a more distinctive query"
149
+ while IFS= read -r f; do
150
+ [ -n "$f" ] || continue
151
+ if grep -Fxq -- "$f" "$TMP/changed"; then continue; fi
152
+ if awk -F'\t' -v s="$sid" -v f="$f" '$1==s && $2==f && $3=="verified-unaffected" {ok=1} END{exit ok?0:1}' "$TMP/hits"; then continue; fi
153
+ echo "UNLISTED: $sid $f"; RC=1
154
+ done < "$TMP/found"
155
+ # planned-but-not-done: change rows for this symbol whose file is not in the change set
156
+ while IFS="$(printf '\t')" read -r hs hf hd; do
157
+ [ "$hs" = "$sid" ] && [ "$hd" = change ] || continue
158
+ grep -Fxq -- "$hf" "$TMP/changed" || { echo "NOT_DONE: $sid $hf"; RC=1; }
159
+ done < "$TMP/hits"
160
+ done < "$TMP/symbols"
161
+
162
+ # HIT rows whose symbol exists were validated at parse time; a manifest that covers every hit passes.
163
+ [ "$RC" -eq 0 ] && echo "OK: blast-radius manifest covered ($(wc -l < "$TMP/symbols" | tr -d ' ') symbol(s))"
164
+ exit "$RC"
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/check-blast-radius.sh. Run: bash scripts/check-blast-radius.test.sh (bash 3.2 compatible)
6
+
7
+ set -u
8
+ DIR=$(cd "$(dirname "$0")" && pwd)
9
+ S="$DIR/check-blast-radius.sh"
10
+ T=$(mktemp -d "${TMPDIR:-/tmp}/check-blast-radius-test.XXXXXX")
11
+ trap 'rm -rf "$T"' EXIT
12
+ pass=0; fail=0
13
+ OUT=""; RC=0
14
+
15
+ g() { git -C "$T/r" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
16
+ run() { OUT=$(bash "$S" --manifest "$T/m" --base staging --repo "$T/r" 2>&1); RC=$?; }
17
+ manifest() { { echo '<!-- FORGE:BLAST_RADIUS:BEGIN -->'; cat; echo '<!-- FORGE:BLAST_RADIUS:END -->'; } > "$T/m"; }
18
+ expect() { # expect <want_rc> <desc> [want_substring]
19
+ if [ "$RC" = "$1" ] && { [ -z "${3:-}" ] || case "$OUT" in *"$3"*) true ;; *) false ;; esac; }; then
20
+ pass=$((pass+1))
21
+ else
22
+ fail=$((fail+1)); echo "FAIL: $2 (rc=$RC, want $1)"; printf '%s\n' "$OUT" | sed 's/^/ /'
23
+ fi
24
+ }
25
+ expect_absent() { # expect_absent <desc> <substring>
26
+ case "$OUT" in *"$2"*) fail=$((fail+1)); echo "FAIL: $1 (unexpected '$2')"; printf '%s\n' "$OUT" | sed 's/^/ /' ;; *) pass=$((pass+1)) ;; esac
27
+ }
28
+ reset_branch() { g checkout -q -f staging; g branch -q -D feat; g checkout -q -b feat; }
29
+
30
+ mkdir "$T/r"; git -C "$T/r" init -q -b staging >/dev/null 2>&1 || { git -C "$T/r" init -q; g checkout -b staging; }
31
+ mkdir -p "$T/r/routers" "$T/r/docs"
32
+ for f in a b c; do printf 'def handler():\n use(billing_principal)\n' > "$T/r/routers/$f.py"; done
33
+ echo 'notes about nothing' > "$T/r/docs/readme.md"
34
+ g add -A; g commit -m base
35
+ g checkout -q -b feat
36
+
37
+ SYM='SYMBOL: id=s1 kind=field name=billing_principal query="billing_principal"'
38
+
39
+ # (a) 3 sibling callers, 1 changed, 2 unlisted -> FAIL naming both unlisted files
40
+ echo 'use(billing_principal, new)' >> "$T/r/routers/a.py"; g add -A; g commit -m "change a"
41
+ manifest <<M
42
+ $SYM
43
+ HIT: symbol=s1 file=routers/a.py role=consumer disposition=change
44
+ M
45
+ run; expect 1 "3 siblings, 1 changed, 2 unlisted fails" "UNLISTED: s1 routers/b.py"
46
+ case "$OUT" in *"UNLISTED: s1 routers/c.py"*) pass=$((pass+1)) ;; *) fail=$((fail+1)); echo "FAIL: second unlisted sibling not named"; echo "$OUT" ;; esac
47
+ expect_absent "changed file is not unlisted" "UNLISTED: s1 routers/a.py"
48
+
49
+ # (b) all 3 changed -> PASS
50
+ echo 'use(billing_principal, new)' >> "$T/r/routers/b.py"; echo 'use(billing_principal, new)' >> "$T/r/routers/c.py"
51
+ g add -A; g commit -m "change b c"
52
+ manifest <<M
53
+ $SYM
54
+ HIT: symbol=s1 file=routers/a.py role=consumer disposition=change
55
+ M
56
+ run; expect 0 "all siblings changed passes (changed files need no row)" "OK:"
57
+
58
+ # (c) 1 changed + 2 verified-unaffected -> PASS
59
+ reset_branch; echo 'use(billing_principal, new)' >> "$T/r/routers/a.py"; g add -A; g commit -m "change a"
60
+ manifest <<M
61
+ $SYM
62
+ HIT: symbol=s1 file=routers/a.py role=consumer disposition=change
63
+ HIT: symbol=s1 file=routers/b.py role=sibling disposition=verified-unaffected reason=reads-only-never-writes
64
+ HIT: symbol=s1 file=routers/c.py role=sibling disposition=verified-unaffected reason=dead-code-path
65
+ M
66
+ run; expect 0 "verified-unaffected siblings pass" "OK:"
67
+
68
+ # (c2) staged but uncommitted and untracked changes count (validate runs before the commit)
69
+ reset_branch; echo 'use(billing_principal, new)' >> "$T/r/routers/a.py"; echo 'use(billing_principal, new)' >> "$T/r/routers/b.py"
70
+ g add routers/a.py; echo 'billing_principal = 1' > "$T/r/routers/d.py"
71
+ manifest <<M
72
+ $SYM
73
+ M
74
+ run; expect 1 "uncommitted: c.py still unlisted" "UNLISTED: s1 routers/c.py"
75
+ expect_absent "staged change counts" "UNLISTED: s1 routers/a.py"
76
+ expect_absent "unstaged tracked change counts" "UNLISTED: s1 routers/b.py"
77
+ expect_absent "untracked new file counts" "UNLISTED: s1 routers/d.py"
78
+ g checkout -q -f staging; rm -f "$T/r/routers/d.py"; g checkout -q -f feat; rm -f "$T/r/routers/d.py"; g reset -q --hard
79
+
80
+ # (d) change row whose file is not in the diff -> FAIL (planned but not done)
81
+ reset_branch; echo 'use(billing_principal, new)' >> "$T/r/routers/a.py"; g add -A; g commit -m "change a"
82
+ manifest <<M
83
+ $SYM
84
+ HIT: symbol=s1 file=routers/a.py role=consumer disposition=change
85
+ HIT: symbol=s1 file=docs/readme.md role=consumer disposition=change
86
+ HIT: symbol=s1 file=routers/b.py role=sibling disposition=verified-unaffected reason=x-reason
87
+ HIT: symbol=s1 file=routers/c.py role=sibling disposition=verified-unaffected reason=x-reason
88
+ M
89
+ run; expect 1 "planned-but-not-done change row fails" "NOT_DONE: s1 docs/readme.md"
90
+
91
+ # (e) rc 2 cases
92
+ manifest <<M
93
+ SYMBOL: id=s1 kind=field name=x query="-rf"
94
+ M
95
+ run; expect 2 "leading dash query rejected"
96
+ manifest <<M
97
+ SYMBOL: id=s1 kind=field name=x query=""
98
+ M
99
+ run; expect 2 "empty query rejected"
100
+ manifest <<M
101
+ SYMBOL: id=s1 kind=field name=x query="ab"
102
+ M
103
+ run; expect 2 "too-short query rejected"
104
+ manifest <<M
105
+ SYMBOL: id=s1 kind=field name=x query="foo bar baz"
106
+ M
107
+ run; expect 2 "query with spaces rejected"
108
+ manifest <<M
109
+ SYMBOL: id=s1 kind=field name=x query="a;rm -rf"
110
+ M
111
+ run; expect 2 "query with shell metacharacters rejected"
112
+ manifest <<M
113
+ $SYM
114
+ this is not a record
115
+ M
116
+ run; expect 2 "malformed line rejected"
117
+ manifest <<M
118
+ $SYM
119
+ HIT: symbol=s1 file=routers/b.py role=sibling disposition=verified-unaffected
120
+ M
121
+ run; expect 2 "verified-unaffected without reason rejected"
122
+ manifest <<M
123
+ $SYM
124
+ HIT: symbol=s9 file=routers/b.py role=sibling disposition=change
125
+ M
126
+ run; expect 2 "HIT with unknown symbol rejected"
127
+ manifest <<M
128
+ $SYM
129
+ HIT: symbol=s1 file=routers/b.py role=sibling disposition=maybe
130
+ M
131
+ run; expect 2 "invalid disposition rejected"
132
+ manifest <<M
133
+ $SYM
134
+ $SYM
135
+ M
136
+ run; expect 2 "duplicate symbol id rejected"
137
+ printf '<!-- FORGE:BLAST_RADIUS:BEGIN -->\n%s\n' "$SYM" > "$T/m"
138
+ run; expect 2 "unterminated BEGIN rejected"
139
+ manifest <<M
140
+ $SYM
141
+ M
142
+ OUT=$(bash "$S" --manifest "$T/m" --base no-such-ref --repo "$T/r" 2>&1); RC=$?; expect 2 "bad base ref is rc 2, not a pass"
143
+ mkdir "$T/notgit"
144
+ OUT=$(bash "$S" --manifest "$T/m" --base staging --repo "$T/notgit" 2>&1); RC=$?; expect 2 "non-git repo is rc 2, not a pass"
145
+ OUT=$(bash "$S" --manifest "$T/missing" --base staging --repo "$T/r" 2>&1); RC=$?; expect 2 "missing manifest file is rc 2"
146
+ OUT=$(bash "$S" --base staging 2>&1); RC=$?; expect 2 "missing --manifest is rc 2"
147
+
148
+ # (f) absent / empty manifest -> SKIP rc 0
149
+ : > "$T/m"; run; expect 0 "empty manifest skips" "SKIP: no manifest"
150
+ printf '## Implementation Plan\nno block here\n' > "$T/m"; run; expect 0 "no BEGIN/END block skips" "SKIP: no manifest"
151
+ printf '## Plan\nprose\n<!-- FORGE:BLAST_RADIUS:BEGIN -->\n%s\nHIT: symbol=s1 file=routers/a.py role=consumer disposition=change\n<!-- FORGE:BLAST_RADIUS:END -->\ntrailing prose\n<!-- FORGE:ARCHITECT:COMPLETE -->\n' "$SYM" > "$T/m"
152
+ run; expect 1 "whole comment body is accepted, prose outside the block ignored" "UNLISTED: s1 routers/b.py"
153
+
154
+ # fixed-string, not regex: a query with metacharacter-like text must match literally
155
+ g checkout -q -f staging; echo 'a.b(c)' > "$T/r/docs/regex.txt"; echo 'aXb(c)' > "$T/r/docs/other.txt"; g add -A; g commit -m regex
156
+ g branch -q -D feat; g checkout -q -b feat
157
+ manifest <<M
158
+ SYMBOL: id=s1 kind=function name=dot query="a.b"
159
+ M
160
+ run; expect 1 "fixed-string match lists the literal hit" "UNLISTED"
161
+ expect_absent "regex-style wildcard does not match aXb" "other.txt"
162
+ HIT_OK='HIT: symbol=s1 file=docs/regex.txt role=producer disposition=verified-unaffected reason=literal-only'
163
+ manifest <<M
164
+ SYMBOL: id=s1 kind=function name=dot query="a.b"
165
+ $HIT_OK
166
+ M
167
+ run; expect 0 "literal hit covered by verified-unaffected" "OK:"
168
+
169
+ # zero-hit symbol passes
170
+ manifest <<M
171
+ SYMBOL: id=s1 kind=flag name=nothing query="zz_never_present_zz"
172
+ M
173
+ run; expect 0 "zero-hit symbol passes" "OK:"
174
+
175
+ # query too broad -> rc 2
176
+ reset_branch; mkdir -p "$T/r/many"; i=0; while [ "$i" -lt 205 ]; do echo broadtoken > "$T/r/many/f$i.txt"; i=$((i+1)); done; g add -A; g commit -m many
177
+ manifest <<M
178
+ SYMBOL: id=s1 kind=flag name=broad query="broadtoken"
179
+ M
180
+ run; expect 2 "over-broad query is rc 2" "too broad"
181
+
182
+ echo "check-blast-radius.test.sh: $pass passed, $fail failed"
183
+ [ "$fail" -eq 0 ]