forgedock 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/state.mjs +20 -28
  9. package/bin/engine-cli.mjs +80 -5
  10. package/bin/engine.mjs +390 -18
  11. package/bin/forgedock.mjs +336 -59
  12. package/bin/hooks/interactive-engine.mjs +50 -24
  13. package/bin/hooks/pre-tool-use.mjs +169 -13
  14. package/bin/journey.mjs +8 -2
  15. package/bin/labels.json +5 -0
  16. package/bin/runner.mjs +321 -10
  17. package/bin/watch-utils.mjs +255 -0
  18. package/commands/cleanup.md +7 -3
  19. package/commands/orchestrate/config.md +41 -1
  20. package/commands/orchestrate/phase-1-resolve.md +234 -8
  21. package/commands/orchestrate/phase-3-dependency.md +27 -18
  22. package/commands/orchestrate/phase-4-execution.md +221 -22
  23. package/commands/quality-gate.md +6 -6
  24. package/commands/review-pr.md +23 -7
  25. package/commands/upgrade-deps.md +7 -2
  26. package/commands/work-on/decompose.md +2 -0
  27. package/commands/work-on/investigate.md +22 -4
  28. package/commands/work-on/remediate.md +2 -0
  29. package/commands/work-on/review.md +40 -1
  30. package/commands/work-on.md +19 -4
  31. package/docs/CONFIG.md +120 -0
  32. package/docs/spec/eval-run-result.md +8 -4
  33. package/docs/spec/label-state-machine.md +9 -0
  34. package/package.json +2 -2
  35. package/packages/protocol/src/emit.js +5 -22
  36. package/packages/protocol/src/html-comment-escape.js +68 -0
  37. package/packages/protocol/src/index.js +1 -0
  38. package/packages/protocol/src/parse.js +5 -6
  39. package/packages/protocol/src/phases.js +105 -0
  40. package/packages/protocol/src/types.js +17 -0
  41. package/scripts/check-admission-parity.mjs +167 -0
  42. package/scripts/check-command-side-effects.sh +181 -66
  43. package/scripts/check-phase-registry-drift.mjs +96 -0
  44. package/scripts/check-spec-markers.sh +1 -0
  45. package/scripts/forge-run.sh +35 -4
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Canonical FORGE pipeline phase table — single source of truth for phase ids
3
+ * (in dispatch order) and the exact marker/label strings that indicate each
4
+ * phase has committed.
5
+ *
6
+ * Consumers: `bin/engine/phases.mjs` (the authoritative engine gate) and
7
+ * `bin/hooks/interactive-engine.mjs` (the interactive-session SubagentStop
8
+ * hook) both import this module instead of hand-declaring their own copies
9
+ * of the marker strings. Before this module existed, the two files carried
10
+ * independent literal copies of the same strings and drifted out of sync at
11
+ * least twice (forge#1669 established the strict `:COMPLETE` gate for
12
+ * `context`/`architect`; forge#2375/PR#2395 then had to hand-patch the hook's
13
+ * copy back into agreement with the engine's). See forge#2378.
14
+ *
15
+ * `completionMarker` values are sourced from `RESERVED_TYPES[*].completionSentinel`
16
+ * wherever that reserved annotation type exists (INVESTIGATOR, CONTEXT,
17
+ * ARCHITECT, BUILDER) — so the reserved-annotation registry in `types.js` and
18
+ * this phase registry cannot drift apart for those four phases; there is
19
+ * exactly one literal declaration of each of those marker strings in the
20
+ * entire codebase, in `types.js`.
21
+ *
22
+ * `review` and `close` have no corresponding `RESERVED_TYPES` entry with a
23
+ * matching sentinel today (`REVIEWER` has no `completionSentinel` defined in
24
+ * spec §4.1; `close` is gated on a GitHub *label*, not a comment annotation),
25
+ * so those two are declared directly in this file — still single-sourced
26
+ * across the two consumers, just not additionally derived from `types.js`.
27
+ *
28
+ * @license MIT
29
+ */
30
+
31
+ import { RESERVED_TYPES } from './types.js';
32
+
33
+ /** Phase ids, in canonical dispatch order (mirrors `commands/work-on.md`'s
34
+ * "Universal Phase Dispatcher" table and `bin/engine/phases.mjs`'s `PHASES`
35
+ * array).
36
+ *
37
+ * `decompose` and `remediate` (forge#2379) are branch phases, not part of the
38
+ * six-phase linear happy path — `decompose` is reached only when `investigate`
39
+ * signals `DECOMPOSE:YES`, and `remediate` only when `review` escalates to
40
+ * `needs-human`. They are still declared here, in the exact same array
41
+ * position `bin/engine/phases.mjs`'s `PHASES` array declares them, because
42
+ * `scripts/check-phase-registry-drift.mjs` requires the two lists to match
43
+ * index-for-index — see that file's own doc comment. */
44
+ export const PHASE_IDS = ['investigate', 'decompose', 'context', 'architect', 'build', 'review', 'remediate', 'close'];
45
+
46
+ /**
47
+ * @typedef {Object} PhaseMarkerEntry
48
+ * @property {string} [completionMarker] - marker/sentinel string (or label, for `close`)
49
+ * whose presence means this phase has committed.
50
+ * @property {string} [invalidMarker] - (investigate only) marks the issue INVALID.
51
+ * @property {string} [decomposedMarker] - (investigate only) marks the issue DECOMPOSED.
52
+ * @property {string} [partialMarker] - sentinel for an interrupted/partial annotation.
53
+ * @property {string} [presenceMarker] - bare annotation-opener substring, used only by
54
+ * phases whose completion is non-critical (see `context` below) — presence alone
55
+ * (without `:COMPLETE`) is enough to report a soft/visible skip rather than a hard fail.
56
+ * @property {string} [completionLabel] - a GitHub issue *label* (not a comment marker)
57
+ * whose presence means this phase has committed. Only `close` uses this form.
58
+ */
59
+
60
+ /** @type {Object.<string, PhaseMarkerEntry>} */
61
+ export const PHASE_MARKERS = {
62
+ investigate: {
63
+ completionMarker: RESERVED_TYPES.INVESTIGATOR.completionSentinel, // 'INVESTIGATION:COMPLETE'
64
+ invalidMarker: 'INVESTIGATION:INVALID',
65
+ decomposedMarker: 'DECOMPOSE:YES',
66
+ },
67
+ // forge#2379: `decompose` actually runs work-on/decompose (sub-issue
68
+ // fan-out) once `investigate` hands off on `DECOMPOSE:YES` — see
69
+ // bin/engine/phases.mjs's `decompose` phase entry for the handoff mechanics.
70
+ decompose: {
71
+ completionMarker: RESERVED_TYPES.DECOMPOSED.completionSentinel, // 'FORGE:DECOMPOSED:COMPLETE'
72
+ },
73
+ context: {
74
+ completionMarker: RESERVED_TYPES.CONTEXT.completionSentinel, // 'FORGE:CONTEXT:COMPLETE'
75
+ partialMarker: RESERVED_TYPES.CONTEXT.partialSentinel, // 'FORGE:CONTEXT:PARTIAL'
76
+ // Bare presence marker: context is documented as non-critical (spec §7) — a
77
+ // missing completion marker is a visible skip, not a hard fail. Only
78
+ // bin/engine/phases.mjs's `context.detectOutcome` uses this bare form;
79
+ // `context.reconcile` (and everything in interactive-engine.mjs) uses the
80
+ // strict `completionMarker` above. Do not collapse these two fields.
81
+ presenceMarker: `FORGE:${RESERVED_TYPES.CONTEXT.type}`, // 'FORGE:CONTEXT'
82
+ },
83
+ architect: {
84
+ completionMarker: RESERVED_TYPES.ARCHITECT.completionSentinel, // 'FORGE:ARCHITECT:COMPLETE'
85
+ partialMarker: RESERVED_TYPES.ARCHITECT.partialSentinel, // 'FORGE:ARCHITECT:PARTIAL'
86
+ },
87
+ build: {
88
+ completionMarker: RESERVED_TYPES.BUILDER.completionSentinel, // 'FORGE:BUILDER:COMPLETE'
89
+ },
90
+ review: {
91
+ // No RESERVED_TYPES entry defines this sentinel (REVIEWER has no
92
+ // completionSentinel in spec §4.1) — declared directly here.
93
+ completionMarker: 'FORGE:REVIEWER:MERGED',
94
+ },
95
+ // forge#2379: `remediate` re-drives a needs-human PR (commands/work-on/remediate.md).
96
+ // Posted to BOTH the PR and the linked issue (Phase M8) — bin/engine/phases.mjs's
97
+ // `remediate` phase reads it off the issue, consistent with every other phase here.
98
+ remediate: {
99
+ completionMarker: RESERVED_TYPES.REMEDIATION.completionSentinel, // 'FORGE:REMEDIATION:COMPLETE'
100
+ },
101
+ close: {
102
+ // A GitHub label, not a comment marker — see `completionLabel` above.
103
+ completionLabel: 'workflow:merged',
104
+ },
105
+ };
@@ -86,6 +86,23 @@ export const RESERVED_TYPES = {
86
86
  requiredFields: ['Verdict'],
87
87
  verdictValues: ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'],
88
88
  },
89
+ /**
90
+ * REMEDIATION — paper trail for a needs-human PR re-driven by
91
+ * commands/work-on/remediate.md. Posted to BOTH the PR and the linked
92
+ * issue (Phase M8) once the re-gate outcome (AUTO-LANDED /
93
+ * HELD-AWAITING-MERGE / RE-ESCALATED / UNFIXABLE) is known. Added in
94
+ * forge#2379 to single-source the completion sentinel that
95
+ * bin/engine/phases.mjs's `remediate` phase gates on. <!-- Added: forge#2379 -->
96
+ */
97
+ REMEDIATION: {
98
+ type: 'REMEDIATION',
99
+ category: Category.LIFECYCLE,
100
+ completionSentinel: 'FORGE:REMEDIATION:COMPLETE',
101
+ partialSentinel: null,
102
+ inlineValue: false,
103
+ requiredFields: [],
104
+ reGateOutcomeValues: ['AUTO-LANDED', 'HELD-AWAITING-MERGE', 'RE-ESCALATED', 'UNFIXABLE'],
105
+ },
89
106
  TRAJECTORY: {
90
107
  type: 'TRAJECTORY',
91
108
  category: Category.LIFECYCLE,
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI parity check: bin/engine/admission.mjs's `CASCADE_PRESETS` table is the
4
+ * typed, unit-tested reference implementation of the cascade admission
5
+ * policy (forge#2234). The logic actually EXECUTED at runtime is a
6
+ * hand-written bash/yq mirror in commands/orchestrate/phase-4-execution.md's
7
+ * Step 4A.pre `case "$CASCADE_POLICY_NAME" in ... esac` block — the two are
8
+ * kept in sync by hand, with no automated check catching divergence
9
+ * (forge#2340). This script closes that gap: it imports the real
10
+ * `CASCADE_PRESETS` table and diffs it against the values parsed out of the
11
+ * bash mirror, failing non-zero on any mismatch.
12
+ *
13
+ * Usage: node scripts/check-admission-parity.mjs
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
+ import path from "node:path";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const REPO_ROOT = path.resolve(__dirname, "..");
22
+ const ADMISSION_MJS_PATH = path.join(REPO_ROOT, "bin/engine/admission.mjs");
23
+ const SPEC_PATH = path.join(REPO_ROOT, "commands/orchestrate/phase-4-execution.md");
24
+
25
+ /**
26
+ * Parse a single bash literal captured from the mirror's case arm into the
27
+ * same JS types `CASCADE_PRESETS` uses (number | "unlimited" | boolean).
28
+ * @param {string} raw
29
+ */
30
+ function parseBashValue(raw) {
31
+ const trimmed = raw.trim().replace(/^"(.*)"$/, "$1");
32
+ if (trimmed === "true") return true;
33
+ if (trimmed === "false") return false;
34
+ if (trimmed === "unlimited") return "unlimited";
35
+ const n = Number(trimmed);
36
+ return Number.isFinite(n) && trimmed !== "" ? n : trimmed;
37
+ }
38
+
39
+ /**
40
+ * Extract each `PRESET_*` assignment from a single case-arm body.
41
+ * @param {string} body - text between `{name})` and the arm's `;;`
42
+ */
43
+ function parseArmBody(body) {
44
+ const get = (key) => {
45
+ const m = body.match(new RegExp(`${key}=("?[^;"]*"?)`));
46
+ return m ? parseBashValue(m[1]) : undefined;
47
+ };
48
+ return {
49
+ maxGeneration: get("PRESET_MAX_GEN"),
50
+ tokenBudget: get("PRESET_TOKEN_BUDGET"),
51
+ deferOnBatchGated: get("PRESET_DEFER_GATED"),
52
+ keywordHeuristic: get("PRESET_KEYWORD"),
53
+ p3SameFileDefer: get("PRESET_P3_SAME_FILE"),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Isolate the `case "$CASCADE_POLICY_NAME" in ... esac` block and return a
59
+ * map of arm-name -> resolved preset object. The wildcard `*` fallback arm
60
+ * is excluded (it re-declares "balanced", not a distinct preset name).
61
+ */
62
+ function extractBashMirrorPresets(specText) {
63
+ const caseMatch = specText.match(/case "\$CASCADE_POLICY_NAME" in([\s\S]*?)\nesac/);
64
+ if (!caseMatch) {
65
+ return null;
66
+ }
67
+ const caseBlock = caseMatch[1];
68
+
69
+ const armRe = /\n\s*([a-zA-Z0-9_*]+)\)([\s\S]*?);;/g;
70
+ const presets = {};
71
+ let m;
72
+ while ((m = armRe.exec(caseBlock))) {
73
+ const [, name, body] = m;
74
+ if (name === "*") continue; // wildcard fallback arm — not a named preset
75
+ presets[name] = parseArmBody(body);
76
+ }
77
+ return presets;
78
+ }
79
+
80
+ async function main() {
81
+ let admissionModule;
82
+ try {
83
+ // pathToFileURL is required for cross-platform dynamic import() of an absolute
84
+ // path — a bare Windows path like "C:\..." is not a valid ESM specifier and
85
+ // throws ERR_UNSUPPORTED_ESM_URL_SCHEME ("Only URLs with a scheme in: file,
86
+ // data, and node are supported"). POSIX absolute paths import fine either way,
87
+ // but file:// URLs work identically on both, so always convert.
88
+ admissionModule = await import(pathToFileURL(ADMISSION_MJS_PATH));
89
+ } catch (err) {
90
+ console.error(`ERROR: failed to import ${ADMISSION_MJS_PATH}: ${err.message}`);
91
+ process.exit(1);
92
+ }
93
+ const { CASCADE_PRESETS } = admissionModule;
94
+ if (!CASCADE_PRESETS) {
95
+ console.error(`ERROR: ${ADMISSION_MJS_PATH} does not export CASCADE_PRESETS — has it been renamed?`);
96
+ process.exit(1);
97
+ }
98
+
99
+ let specText;
100
+ try {
101
+ specText = readFileSync(SPEC_PATH, "utf8");
102
+ } catch (err) {
103
+ console.error(`ERROR: failed to read ${SPEC_PATH}: ${err.message}`);
104
+ process.exit(1);
105
+ }
106
+
107
+ const bashPresets = extractBashMirrorPresets(specText);
108
+ if (!bashPresets) {
109
+ console.error(
110
+ `ERROR: could not locate the case "$CASCADE_POLICY_NAME" in ... esac bash mirror block in ${SPEC_PATH}.`,
111
+ );
112
+ console.error(
113
+ " (parity check has nothing to compare admission.mjs against — has the mirror been renamed or restructured?)",
114
+ );
115
+ process.exit(1);
116
+ }
117
+
118
+ let failed = false;
119
+ const mjsNames = Object.keys(CASCADE_PRESETS);
120
+ const bashNames = Object.keys(bashPresets);
121
+
122
+ for (const name of mjsNames) {
123
+ if (!(name in bashPresets)) {
124
+ console.error(
125
+ `MISMATCH: preset "${name}" exists in admission.mjs CASCADE_PRESETS but has no matching bash case arm in ${SPEC_PATH}`,
126
+ );
127
+ failed = true;
128
+ continue;
129
+ }
130
+ const mjsPreset = CASCADE_PRESETS[name];
131
+ const bashPreset = bashPresets[name];
132
+ for (const key of Object.keys(mjsPreset)) {
133
+ if (mjsPreset[key] !== bashPreset[key]) {
134
+ console.error(
135
+ `MISMATCH: preset "${name}".${key} — admission.mjs=${JSON.stringify(mjsPreset[key])} bash-mirror=${JSON.stringify(bashPreset[key])}`,
136
+ );
137
+ failed = true;
138
+ }
139
+ }
140
+ }
141
+
142
+ for (const name of bashNames) {
143
+ if (!(name in CASCADE_PRESETS)) {
144
+ console.error(
145
+ `MISMATCH: bash case arm "${name}" in ${SPEC_PATH} has no matching preset in admission.mjs CASCADE_PRESETS`,
146
+ );
147
+ failed = true;
148
+ }
149
+ }
150
+
151
+ if (failed) {
152
+ console.error("");
153
+ console.error(
154
+ "admission.mjs CASCADE_PRESETS and the phase-4-execution.md bash mirror have drifted out of sync.",
155
+ );
156
+ console.error(
157
+ "Update both together — see the admission.mjs module docstring and phase-4-execution.md Step 4A.pre ('Cascade admission policy resolution').",
158
+ );
159
+ process.exit(1);
160
+ }
161
+
162
+ console.log(
163
+ `OK: admission.mjs CASCADE_PRESETS matches the phase-4-execution.md bash mirror for: ${mjsNames.join(", ")}`,
164
+ );
165
+ }
166
+
167
+ main();
@@ -14,11 +14,17 @@
14
14
  # Gists created by the pipeline MUST be secret — --public exposes private repo
15
15
  # titles, root causes, and file paths to the world. (Ref: forge#1587)
16
16
  #
17
- # Class B — Side-effect verbs in sections with no DRY_RUN/governor guard (diff-aware):
17
+ # Class B — Side-effect verbs in code blocks with no DRY_RUN/governor guard (diff-aware):
18
18
  # When added lines introduce a side-effect verb (gh issue create, gh pr merge,
19
19
  # git push, gh issue edit|comment, --auto-merge, --add/remove-label) inside a
20
- # code block in a section that has no guard expression (DRY_RUN, GOVERNOR,
21
- # --dry-run) anywhere in that section's code blocks, the change is flagged.
20
+ # code block that has no guard expression (DRY_RUN, GOVERNOR, --dry-run)
21
+ # anywhere in THAT SAME code block, the change is flagged. Guard/side-effect
22
+ # correlation is scoped to the individual fenced code block, not the whole
23
+ # section — a guard mentioned in an unrelated code block elsewhere in the
24
+ # same section (before or after the flagged verb) does not silence the
25
+ # finding. (Ref: forge#2289 — a decoy guard anywhere in the section, in any
26
+ # code block, previously silenced detection of an actually-unguarded side
27
+ # effect in a different block of that same section.)
22
28
  # Operates on the diff (GITHUB_BASE_SHA or HEAD^) to avoid flagging legacy corpus.
23
29
  # (Ref: forge#1609 — signal-planner.md DRY_RUN guard placed after the create it guards)
24
30
  #
@@ -99,45 +105,99 @@ class_a_scan() {
99
105
  local HAS_GIST=0
100
106
  local BLOCK_LINES=""
101
107
  local BLOCK_START=0
102
- local LINENO=0
103
- local line PUBLIC_LINENO ACTUAL_LINE
108
+ local LN=0
109
+ local line PUBLIC_LN ACTUAL_LINE IS_FENCE IS_BARE
110
+
111
+ # flush_a_block — evaluate the currently-accumulated block's HAS_GIST/
112
+ # BLOCK_LINES state for a 'gh gist create/edit --public' violation, then
113
+ # reset it. Defined inside class_a_scan (not at file scope) so it shares
114
+ # this invocation's `local` variables via bash's dynamic scoping — the same
115
+ # mechanism Class B's flush_block() relies on for its own loop-local state.
116
+ # Called from two sites: (1) the existing fence-close path below, when a
117
+ # bare fence brings IN_CB back to 0, and (2) once more, defensively, after
118
+ # the read loop ends — covering a file that ends with an unterminated
119
+ # fence (IN_CB still > 0 at EOF), which previously discarded any
120
+ # accumulated violation silently. (forge#2289 added the equivalent
121
+ # end-of-file flush for Class B only; this closes the same gap in Class A.)
122
+ flush_a_block() {
123
+ if [ "$HAS_GIST" -eq 1 ] && echo "$BLOCK_LINES" | grep -qE '^[[:space:]]*--public([[:space:]]|$)'; then
124
+ # Find the line number of --public within the block
125
+ PUBLIC_LN=$(echo "$BLOCK_LINES" | grep -n '^[[:space:]]*--public' | head -1 | cut -d: -f1)
126
+ ACTUAL_LINE=$((BLOCK_START + PUBLIC_LN))
127
+ if ! echo "$BLOCK_LINES" | grep -qF "$ALLOWLIST_TOKEN"; then
128
+ echo "HIGH | $file | line ~$ACTUAL_LINE | Class A: 'gh gist create/edit --public' in code block — gists MUST be secret (omit --public); --public exposes private repo data. (forge#1587)" >&2
129
+ VIOLATIONS=$((VIOLATIONS + 1))
130
+ fi
131
+ fi
132
+ HAS_GIST=0
133
+ BLOCK_LINES=""
134
+ }
104
135
 
105
136
  while IFS= read -r line; do
106
- LINENO=$((LINENO + 1))
137
+ LN=$((LN + 1))
138
+
139
+ # Fence detection is anchored to line-start (ignoring leading whitespace).
140
+ # IN_CB is a true nesting-DEPTH counter, not a 0/1 flag: a fence line
141
+ # carrying an info string (e.g. a nested ```bash) while already inside a
142
+ # block opens one more nesting level (increment); a bare fence (nothing
143
+ # after the backticks but optional trailing whitespace) closes exactly
144
+ # one level (decrement). The accumulated block is only evaluated once
145
+ # depth returns to 0 — i.e. once the OUTERMOST fence has actually closed,
146
+ # not merely an inner one. A binary flag cannot distinguish "still inside
147
+ # the outer fence, past an inner fence's close" from "outside all
148
+ # fences"; depth tracking can. (forge#2210 fixed anchoring/info-string-is-
149
+ # content; forge#2288 adds true depth so 2+ nesting levels close correctly.)
150
+ IS_FENCE=0
151
+ IS_BARE=0
152
+ if echo "$line" | grep -qE "^[[:space:]]*${FENCE}"; then
153
+ IS_FENCE=1
154
+ if echo "$line" | grep -qE "^[[:space:]]*${FENCE}[[:space:]]*\$"; then
155
+ IS_BARE=1
156
+ fi
157
+ fi
107
158
 
108
- if echo "$line" | grep -qF "$FENCE"; then
109
- if [ "$IN_CB" -eq 0 ]; then
159
+ if [ "$IN_CB" -eq 0 ]; then
160
+ if [ "$IS_FENCE" -eq 1 ]; then
110
161
  IN_CB=1
111
162
  HAS_GIST=0
112
163
  BLOCK_LINES=""
113
- BLOCK_START=$LINENO
114
- else
115
- # End of code block — check if it had both gh gist and --public
116
- if [ "$HAS_GIST" -eq 1 ] && echo "$BLOCK_LINES" | grep -qE '^[[:space:]]*--public([[:space:]]|$)'; then
117
- # Find the line number of --public within the block
118
- PUBLIC_LINENO=$(echo "$BLOCK_LINES" | grep -n '^[[:space:]]*--public' | head -1 | cut -d: -f1)
119
- ACTUAL_LINE=$((BLOCK_START + PUBLIC_LINENO))
120
- if ! echo "$BLOCK_LINES" | grep -qF "$ALLOWLIST_TOKEN"; then
121
- echo "HIGH | $file | line ~$ACTUAL_LINE | Class A: 'gh gist create/edit --public' in code block — gists MUST be secret (omit --public); --public exposes private repo data. (forge#1587)" >&2
122
- VIOLATIONS=$((VIOLATIONS + 1))
123
- fi
124
- fi
125
- IN_CB=0
126
- HAS_GIST=0
127
- BLOCK_LINES=""
164
+ BLOCK_START=$LN
128
165
  fi
129
166
  continue
130
167
  fi
131
168
 
132
- if [ "$IN_CB" -eq 1 ]; then
169
+ # IN_CB >= 1 — inside a block, possibly nested.
170
+ if [ "$IS_FENCE" -eq 1 ]; then
171
+ if [ "$IS_BARE" -eq 1 ]; then
172
+ IN_CB=$((IN_CB - 1))
173
+ else
174
+ IN_CB=$((IN_CB + 1))
175
+ fi
176
+ if [ "$IN_CB" -eq 0 ]; then
177
+ # Fully closed (outermost fence) — evaluate the accumulated block now.
178
+ flush_a_block
179
+ continue
180
+ fi
181
+ # Still inside (nested transition) — the fence line itself is block content.
133
182
  BLOCK_LINES="${BLOCK_LINES}${line}
134
183
  "
135
- # Check if this line has gh gist create/edit (even with line continuation \)
136
- if echo "$line" | grep -qE 'gh[[:space:]]+gist[[:space:]]+(create|edit)'; then
137
- HAS_GIST=1
138
- fi
184
+ continue
185
+ fi
186
+
187
+ # Regular content line while inside (at any depth).
188
+ BLOCK_LINES="${BLOCK_LINES}${line}
189
+ "
190
+ # Check if this line has gh gist create/edit (even with line continuation \)
191
+ if echo "$line" | grep -qE 'gh[[:space:]]+gist[[:space:]]+(create|edit)'; then
192
+ HAS_GIST=1
139
193
  fi
140
194
  done < "$file"
195
+
196
+ # Defensive final flush — evaluates any block state left accumulated if the
197
+ # file ended with an unterminated fence (IN_CB never returned to 0). A
198
+ # no-op if the last block already closed and was flushed above. Mirrors
199
+ # Class B's identical end-of-file `flush_block` call. (forge#2305)
200
+ flush_a_block
141
201
  }
142
202
 
143
203
  while IFS= read -r file; do
@@ -185,64 +245,119 @@ else
185
245
  continue
186
246
  fi
187
247
 
188
- # Parse the full file to map sections → (has_guard, has_side_effect_in_added_lines)
248
+ # Parse the full file to map code blocks → (has_guard, has_side_effect_in_added_lines).
249
+ # SECTION is tracked only as a human-readable label for the violation message —
250
+ # it plays NO role in guard/side-effect correlation. Correlation is scoped to
251
+ # the individual fenced code block (BLOCK_* state), reset on every block open
252
+ # and evaluated on every block close, so a guard mentioned in a different code
253
+ # block — even one earlier or later in the very same section — cannot silence
254
+ # an unguarded side effect in another block. (forge#2289)
189
255
  IN_CB=0
190
256
  SECTION="(top)"
191
- SECTION_HAS_GUARD=0
192
- SECTION_HAS_ADDED_SE=0
193
- SECTION_SE_LINE=0
194
- SECTION_SE_VERB=""
195
- LINENO=0
196
-
197
- flush_and_reset() {
198
- local new_heading="$1"
199
- if [ "$SECTION_HAS_ADDED_SE" -eq 1 ] && [ "$SECTION_HAS_GUARD" -eq 0 ]; then
200
- echo "HIGH | $file | line $SECTION_SE_LINE | Class B: side-effect '$SECTION_SE_VERB' added to section '$SECTION' which has no DRY_RUN/governor guard — add guard before this line or wrap in DRY_RUN check" >&2
257
+ BLOCK_HAS_GUARD=0
258
+ BLOCK_HAS_ADDED_SE=0
259
+ BLOCK_SE_LINE=0
260
+ BLOCK_SE_VERB=""
261
+ LN=0
262
+
263
+ flush_block() {
264
+ # Evaluate and reset the current code block's guard/side-effect state.
265
+ # Called whenever a fenced code block's outermost fence closes (depth
266
+ # returns to 0), and once more, defensively, at end-of-file to cover a
267
+ # malformed/unterminated block (mirrors the pre-forge#2289 behavior of
268
+ # unconditionally evaluating accumulated state before resetting).
269
+ if [ "$BLOCK_HAS_ADDED_SE" -eq 1 ] && [ "$BLOCK_HAS_GUARD" -eq 0 ]; then
270
+ echo "HIGH | $file | line $BLOCK_SE_LINE | Class B: side-effect '$BLOCK_SE_VERB' added in section '$SECTION' with no DRY_RUN/governor guard in the same code block — add a guard inside this code block or wrap the effect in a DRY_RUN check" >&2
201
271
  VIOLATIONS=$((VIOLATIONS + 1))
202
272
  fi
203
- SECTION="$new_heading"
204
- SECTION_HAS_GUARD=0
205
- SECTION_HAS_ADDED_SE=0
206
- SECTION_SE_LINE=0
207
- SECTION_SE_VERB=""
208
- IN_CB=0
273
+ BLOCK_HAS_GUARD=0
274
+ BLOCK_HAS_ADDED_SE=0
275
+ BLOCK_SE_LINE=0
276
+ BLOCK_SE_VERB=""
209
277
  }
210
278
 
211
279
  while IFS= read -r line; do
212
- LINENO=$((LINENO + 1))
280
+ LN=$((LN + 1))
281
+
282
+ # Code block fence — checked BEFORE the heading test so IN_CB reflects
283
+ # this line's actual state. IN_CB is a true nesting-DEPTH counter, not
284
+ # a 0/1 flag: a fence line carrying an info string (e.g. nested
285
+ # ```bash) encountered while already inside a block opens one more
286
+ # nesting level (increment); a bare fence (nothing after the
287
+ # backticks) closes exactly one level (decrement). Heading detection
288
+ # below only fires once depth returns to 0 — i.e. once we are outside
289
+ # ALL fences, not merely an inner one. A binary flag cannot
290
+ # distinguish "still inside the outer fence, past an inner fence's
291
+ # close" from "outside all fences"; this is exactly the gap that let
292
+ # embedded `## Step N` headers inside one continuous outer code block
293
+ # (e.g. commands/review-pr-agents/spec-cli.md) be misdetected as real
294
+ # section boundaries. (forge#2210 fixed anchoring/info-string-is-
295
+ # content for a single nesting level; forge#2288 adds true depth so
296
+ # 2+ nesting levels close correctly.)
297
+ IS_FENCE=0
298
+ IS_BARE=0
299
+ if echo "$line" | grep -qE "^[[:space:]]*${FENCE}"; then
300
+ IS_FENCE=1
301
+ if echo "$line" | grep -qE "^[[:space:]]*${FENCE}[[:space:]]*\$"; then
302
+ IS_BARE=1
303
+ fi
304
+ fi
305
+
306
+ if [ "$IN_CB" -eq 0 ]; then
307
+ if [ "$IS_FENCE" -eq 1 ]; then
308
+ IN_CB=1
309
+ continue
310
+ fi
213
311
 
214
- # Section heading reset
215
- if echo "$line" | grep -qE '^#{1,4}[[:space:]]+'; then
216
- heading=$(echo "$line" | sed 's/^#*[[:space:]]*//' | sed 's/[[:space:]]*$//')
217
- flush_and_reset "$heading"
312
+ # Section heading — label-only update (forge#2210). This no longer
313
+ # flushes guard/side-effect state: that state is now block-scoped
314
+ # (forge#2289) and is always already at rest here, since this branch
315
+ # is only reached when IN_CB is 0 — i.e. any block that was open has
316
+ # already been closed and flushed at its own fence-close below.
317
+ if echo "$line" | grep -qE '^#{1,6}[[:space:]]+'; then
318
+ heading=$(echo "$line" | sed 's/^#*[[:space:]]*//' | sed 's/[[:space:]]*$//')
319
+ SECTION="$heading"
320
+ continue
321
+ fi
218
322
  continue
219
323
  fi
220
324
 
221
- # Code block fence
222
- if echo "$line" | grep -qF "$FENCE"; then
223
- [ "$IN_CB" -eq 0 ] && IN_CB=1 || IN_CB=0
325
+ # IN_CB >= 1 — inside a block, possibly nested.
326
+ if [ "$IS_FENCE" -eq 1 ]; then
327
+ if [ "$IS_BARE" -eq 1 ]; then
328
+ IN_CB=$((IN_CB - 1))
329
+ else
330
+ IN_CB=$((IN_CB + 1))
331
+ fi
332
+ if [ "$IN_CB" -eq 0 ]; then
333
+ # Outermost fence just closed — evaluate this block's guard/SE
334
+ # correlation now, before starting the next block. (forge#2289)
335
+ flush_block
336
+ fi
224
337
  continue
225
338
  fi
226
339
 
227
- if [ "$IN_CB" -eq 1 ]; then
228
- if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
340
+ # Content line while inside (at any depth).
341
+ if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
229
342
 
230
- # Check for guard
231
- echo "$line" | grep -qE "$GUARD_PATTERN" && SECTION_HAS_GUARD=1
343
+ # Check for guard — scoped to THIS code block only (forge#2289)
344
+ echo "$line" | grep -qE "$GUARD_PATTERN" && BLOCK_HAS_GUARD=1
232
345
 
233
- # Check if this line is in the diff's added lines AND has a side-effect verb
234
- if [ "$SECTION_HAS_ADDED_SE" -eq 0 ] && echo "$line" | grep -qE "$SIDE_EFFECT_PATTERN"; then
235
- # Is this specific line in the added content?
236
- if echo "$ADDED_CONTENT" | grep -qF "${line:0:80}" 2>/dev/null; then
237
- SECTION_HAS_ADDED_SE=1
238
- SECTION_SE_LINE=$LINENO
239
- SECTION_SE_VERB=$(echo "$line" | grep -oE "$SIDE_EFFECT_PATTERN" | head -1 || echo "side-effect")
240
- fi
346
+ # Check if this line is in the diff's added lines AND has a side-effect verb
347
+ if [ "$BLOCK_HAS_ADDED_SE" -eq 0 ] && echo "$line" | grep -qE "$SIDE_EFFECT_PATTERN"; then
348
+ # Is this specific line in the added content?
349
+ if echo "$ADDED_CONTENT" | grep -qF "${line:0:80}" 2>/dev/null; then
350
+ BLOCK_HAS_ADDED_SE=1
351
+ BLOCK_SE_LINE=$LN
352
+ BLOCK_SE_VERB=$(echo "$line" | grep -oE "$SIDE_EFFECT_PATTERN" | head -1 || echo "side-effect")
241
353
  fi
242
354
  fi
243
355
  done < "$file"
244
356
 
245
- flush_and_reset "(end-of-file)"
357
+ # Defensive final flush — evaluates any block state left accumulated if
358
+ # the file ended with an unterminated fence (malformed input); a no-op
359
+ # if the last block already closed and was flushed above.
360
+ flush_block
246
361
 
247
362
  done <<< "$CHANGED_SPECS"
248
363
  fi