forgedock 1.2.0 → 1.3.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 (53) hide show
  1. package/README.md +8 -2
  2. package/bin/engine/phases.mjs +178 -28
  3. package/bin/engine/state.mjs +39 -7
  4. package/bin/engine-cli.mjs +63 -2
  5. package/bin/engine.mjs +25 -4
  6. package/bin/forgedock.mjs +235 -4
  7. package/bin/hooks/pre-tool-use.mjs +26 -12
  8. package/bin/journey.mjs +103 -11
  9. package/commands/forge-stats.md +5 -4
  10. package/commands/issue.md +5 -1
  11. package/commands/orchestrate/phase-4-execution.md +2 -0
  12. package/commands/qa-sweep.md +17 -0
  13. package/commands/replay.md +16 -2
  14. package/commands/review-pr-agents/spec-cli.md +179 -0
  15. package/commands/review-pr.md +49 -0
  16. package/commands/upgrade-deps.md +518 -0
  17. package/commands/work-on/build/validate.md +61 -1
  18. package/commands/work-on/build.md +9 -1
  19. package/commands/work-on/decompose.md +8 -1
  20. package/docs/AIDER.md +264 -0
  21. package/docs/AUTOPILOT-CRON.md +130 -0
  22. package/docs/CONFIG.md +36 -0
  23. package/docs/CURSOR.md +135 -0
  24. package/docs/OPENCODE.md +312 -0
  25. package/docs/demo.gif +0 -0
  26. package/docs/eval/README.md +4 -4
  27. package/docs/launch/show-hn.md +3 -3
  28. package/docs/site/architecture.md +180 -0
  29. package/docs/site/command-reference.md +17 -0
  30. package/docs/site/configuration.md +243 -0
  31. package/docs/site/for-companies.md +4 -4
  32. package/docs/site/forge-annotation-protocol.md +1 -1
  33. package/docs/site/github-is-the-memory.md +2 -2
  34. package/docs/site/how-it-works.md +1 -1
  35. package/docs/site/public/llms-full.txt +380 -0
  36. package/docs/site/public/llms.txt +35 -0
  37. package/docs/spec-graph-schema.md +3 -3
  38. package/package.json +2 -2
  39. package/packages/protocol/README.md +75 -0
  40. package/packages/protocol/src/card.js +114 -0
  41. package/packages/protocol/src/cli.js +4 -77
  42. package/packages/protocol/src/emit.js +14 -3
  43. package/packages/protocol/src/index.js +2 -1
  44. package/packages/protocol/src/parse.js +17 -8
  45. package/packages/protocol/src/types.js +1 -1
  46. package/packages/protocol/src/validate.js +11 -0
  47. package/scripts/check-command-side-effects.sh +261 -0
  48. package/scripts/check-spec-markers.sh +273 -0
  49. package/scripts/classify-lane.sh +29 -7
  50. package/scripts/forge-run.sh +322 -0
  51. package/templates/cross-repo-trigger.yml +282 -0
  52. package/templates/cursor/forge.mdc +148 -0
  53. package/templates/github-actions/forgedock-autopilot.yml +113 -0
@@ -42,10 +42,10 @@
42
42
 
43
43
  import { readFileSync, readdirSync } from 'node:fs';
44
44
  import { resolve, extname } from 'node:path';
45
- import { createHash } from 'node:crypto';
46
45
  import { parse } from './parse.js';
47
46
  import { validate } from './validate.js';
48
47
  import { emit } from './emit.js';
48
+ import { encodeCard, decodeCardInlineValue } from './card.js';
49
49
 
50
50
  // ---------------------------------------------------------------------------
51
51
  // Argv routing
@@ -114,16 +114,15 @@ function cmdEmit(argv) {
114
114
  // Payload: canonical JSON (sorted keys, single line, UTF-8).
115
115
  // The Base64url alphabet [A-Za-z0-9_-] cannot contain '>', '<', or '!' —
116
116
  // so -->, --!>, and <!-- are structurally unrepresentable by construction.
117
+ // Codec lives in ./card.js (shared with validate.js and the test suite).
117
118
  const payload = Object.keys(fields).length > 0 ? { ...fields } : {};
118
119
  if (type) payload.type = type.toUpperCase();
119
- const canonical = canonicalJson(payload);
120
- const b64url = toBase64url(canonical);
121
- const sha8 = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8);
120
+ const { inlineValue } = encodeCard(payload);
122
121
  // The colon-separator form "<!-- FORGE:CARD: v1 sha:X b64:Y -->" matches
123
122
  // OPENING_TAG_RE so parse() can extract the inline value "v1 sha:X b64:Y".
124
123
  // This keeps the canonical FORGE inline-value syntax (§3.4) while adding
125
124
  // the sha8 integrity prefix and Base64url payload from the design decision.
126
- process.stdout.write(`<!-- FORGE:CARD: v1 sha:${sha8} b64:${b64url} -->\n`);
125
+ process.stdout.write(`<!-- FORGE:CARD: ${inlineValue} -->\n`);
127
126
  return;
128
127
  }
129
128
 
@@ -351,75 +350,3 @@ function die(msg) {
351
350
  process.stderr.write(`forge-protocol-cli: ${msg}\n`);
352
351
  process.exit(1);
353
352
  }
354
-
355
- /**
356
- * Produce canonical JSON: sorted keys, single line, UTF-8.
357
- * This is the payload that gets Base64url-encoded in the CARD format.
358
- */
359
- function canonicalJson(obj) {
360
- return JSON.stringify(sortKeysDeep(obj));
361
- }
362
-
363
- function sortKeysDeep(val) {
364
- if (Array.isArray(val)) return val.map(sortKeysDeep);
365
- if (val !== null && typeof val === 'object') {
366
- return Object.fromEntries(
367
- Object.keys(val)
368
- .sort()
369
- .map(k => [k, sortKeysDeep(val[k])]),
370
- );
371
- }
372
- return val;
373
- }
374
-
375
- /**
376
- * Encode a UTF-8 string as Base64url (URL-safe alphabet, no padding).
377
- * The Base64url alphabet [A-Za-z0-9_-] cannot contain '>', '<', or '!' —
378
- * so -->, --!>, and <!-- are structurally unrepresentable by construction
379
- * (design decision 2026-07-08: encoding beats escaping).
380
- *
381
- * @param {string} str — UTF-8 string to encode
382
- * @returns {string} Base64url-encoded string without padding
383
- */
384
- function toBase64url(str) {
385
- return Buffer.from(str, 'utf8')
386
- .toString('base64')
387
- .replace(/\+/g, '-')
388
- .replace(/\//g, '_')
389
- .replace(/=/g, '');
390
- }
391
-
392
- /**
393
- * Decode a CARD inline value of the form "v1 sha:<sha8hex> b64:<base64url>"
394
- * and return the parsed JSON object, or null if the format is unrecognized.
395
- * Verifies the sha8 integrity prefix; returns null on corruption or tampering.
396
- *
397
- * @param {string} inlineValue — the parsed inlineValue from a CARD annotation
398
- * @returns {object|null}
399
- */
400
- function decodeCardInlineValue(inlineValue) {
401
- const m = inlineValue.match(/^v1\s+sha:([0-9a-f]{8})\s+b64:([A-Za-z0-9_-]+)$/);
402
- if (!m) return null;
403
- const [, sha8, b64url] = m;
404
-
405
- // Restore standard Base64 padding and swap the URL-safe alphabet back.
406
- const b64std = b64url.replace(/-/g, '+').replace(/_/g, '/');
407
- const padded = b64std + '='.repeat((4 - (b64std.length % 4)) % 4);
408
-
409
- let canonical;
410
- try {
411
- canonical = Buffer.from(padded, 'base64').toString('utf8');
412
- } catch {
413
- return null;
414
- }
415
-
416
- // Integrity check: first 8 hex chars of SHA-256 must match the prefix.
417
- const expectedSha8 = createHash('sha256').update(canonical, 'utf8').digest('hex').slice(0, 8);
418
- if (expectedSha8 !== sha8) return null;
419
-
420
- try {
421
- return JSON.parse(canonical);
422
- } catch {
423
- return null;
424
- }
425
- }
@@ -56,15 +56,26 @@ function sanitizeFieldValue(rawValue) {
56
56
  `emit(): field value resolves to a bare FORGE tag/sentinel line, which is not permitted: ${JSON.stringify(rawValue)}`,
57
57
  );
58
58
  }
59
+ // Escape "&" FIRST, before either delimiter-escape pass below. Without this,
60
+ // a value that already contains literal entity-like text (e.g. "<!--&gt;"
61
+ // or "&lt;!--") collides with the encoded form of an unrelated real
62
+ // delimiter — sanitizeFieldValue() would not be injective, and the
63
+ // collision is unrecoverable by unescapeFieldValue() on decode
64
+ // (forge#2137). Escaping "&" first guarantees every "&lt;!--"/"--&gt;"/
65
+ // "--!&gt;" sequence in the output was produced by *this* pass, never by
66
+ // pre-existing entity text (which is now doubly-escaped, e.g.
67
+ // "&" -> "&amp;", left untouched by the delimiter passes that only match
68
+ // literal "<!--"/"--"+">" character sequences).
69
+ const withAmpEscaped = folded.replace(/&/g, '&amp;');
59
70
  // Escape the HTML comment opener ("<!--") so a value cannot start a new
60
71
  // unterminated HTML comment in GitHub's renderer, which would visually swallow
61
72
  // subsequent lines — the same rendering-leak the closer escaping below prevents
62
73
  // from the opposite direction. (forge#1638)
63
- const withOpenerEscaped = folded.replace(/<!--/g, '&lt;!--');
74
+ const withOpenerEscaped = withAmpEscaped.replace(/<!--/g, '&lt;!--');
64
75
  // Escape both HTML comment-close forms ("-->" and "--!>") — only replacing the
65
76
  // literal character sequence (not just documenting the risk) prevents the
66
- // enclosing comment from terminating early. (forge#1594; noted but never fixed
67
- // for the FORGE:STATE codec in bin/engine/state.mjs — see issue investigation)
77
+ // enclosing comment from terminating early. (forge#1594; the FORGE:STATE codec
78
+ // in bin/engine/state.mjs now mirrors this full three-form scheme — forge#2119)
68
79
  return withOpenerEscaped.replace(/--(!)?>/g, (_, bang) => `--${bang || ''}&gt;`);
69
80
  }
70
81
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @forgedock/protocol — FORGE Annotation Protocol v1.0 reference implementation.
3
3
  *
4
- * Provides parse, validate, and emit for all 13 reserved annotation types
4
+ * Provides parse, validate, and emit for all reserved annotation types
5
5
  * defined in the FORGE Annotation Protocol specification (CC-BY-4.0).
6
6
  *
7
7
  * @example
@@ -29,6 +29,7 @@ export { parse } from './parse.js';
29
29
  export { validate } from './validate.js';
30
30
  export { emit, emitPartial, isKnownType } from './emit.js';
31
31
  export { RESERVED_TYPES, RESERVED_TYPE_NAMES, Category, SentinelState } from './types.js';
32
+ export { canonicalJson, toBase64url, encodeCard, decodeCardInlineValue } from './card.js';
32
33
 
33
34
  /**
34
35
  * @typedef {Object} ParsedAnnotation
@@ -101,17 +101,25 @@ function escapeRegExp(str) {
101
101
  /**
102
102
  * Reverse the HTML entity escapes applied by emit()'s sanitizeFieldValue().
103
103
  *
104
- * emit() escapes three HTML comment delimiter sequences to protect GitHub's
105
- * renderer from comment injection:
104
+ * emit() escapes "&" first, then three HTML comment delimiter sequences, to
105
+ * protect GitHub's renderer from comment injection AND to keep the encoding
106
+ * injective (reversible for every input, not just delimiter-free ones):
107
+ * 0. `&` → `&amp;` (forge#2137 — must run first on encode)
106
108
  * 1. `<!--` → `&lt;!--` (forge#1638)
107
109
  * 2. `--!>` → `--!&gt;` (forge#1594)
108
110
  * 3. `-->` → `--&gt;` (forge#1594)
109
111
  *
110
- * parse() must apply the inverse so that round-tripping through emit → parse
111
- * is lossless. Unescape order: `--!&gt;` before `--&gt;` (most-specific first)
112
- * to avoid the `!` being left dangling by an overly-greedy `--&gt;` pass.
113
- * The `&lt;!--` unescape is independent and can run in any relative order.
114
- * (forge#1662)
112
+ * parse() must apply the exact inverse, in reverse order, so that
113
+ * round-tripping through emit → parse is lossless. Unescape order:
114
+ * `--!&gt;` before `--&gt;` (most-specific first) to avoid the `!` being left
115
+ * dangling by an overly-greedy `--&gt;` pass; `&lt;!--` can run in any order
116
+ * relative to those two. The `&amp;` → `&` unescape MUST run LAST — doing it
117
+ * first (or interleaved) would corrupt the `&` that is part of a freshly
118
+ * restored `<!--`/`-->`/`--!>` sequence. Running "&" first on encode and last
119
+ * on decode is what makes the scheme injective: any pre-existing entity-like
120
+ * text in the original value (e.g. `&lt;!--` or `<!--&gt;`) is doubly-escaped
121
+ * at encode time and can never collide with the single-escaped form produced
122
+ * by a real delimiter (forge#2137; forge#1662).
115
123
  *
116
124
  * @param {string} raw
117
125
  * @returns {string}
@@ -120,7 +128,8 @@ function unescapeFieldValue(raw) {
120
128
  return raw
121
129
  .replace(/--!&gt;/g, '--!>')
122
130
  .replace(/--&gt;/g, '-->')
123
- .replace(/&lt;!--/g, '<!--');
131
+ .replace(/&lt;!--/g, '<!--')
132
+ .replace(/&amp;/g, '&');
124
133
  }
125
134
 
126
135
  /**
@@ -18,7 +18,7 @@ export const SentinelState = {
18
18
  };
19
19
 
20
20
  /**
21
- * All 16 reserved annotation types from spec §4.
21
+ * All reserved annotation types from spec §4.
22
22
  * Spec ref: §4.1 lifecycle, §4.2 cross-artifact, §4.3 control/error markers.
23
23
  * Added CLAIM and CLAIM_RELEASED in forge#1736 — claims board for claim-level parallelism.
24
24
  * Added AUTOPILOT_CYCLE in forge#1753 — durable cycle state for /autopilot.
@@ -8,12 +8,14 @@
8
8
  * - Required field presence (§4.1 per-type field tables)
9
9
  * - Required field enum values (Verdict, Confidence, Severity, Task Type)
10
10
  * - Inline-value non-empty (§3.4)
11
+ * - CARD inline-value decodability + sha8 integrity (§4.2 — see card.js)
11
12
  * - Partial sentinel marking (§3.3) — generates a warning, not an error
12
13
  *
13
14
  * @license MIT
14
15
  */
15
16
 
16
17
  import { RESERVED_TYPES, SentinelState } from './types.js';
18
+ import { decodeCardInlineValue } from './card.js';
17
19
 
18
20
  /**
19
21
  * Validate a parsed annotation against the spec.
@@ -46,6 +48,15 @@ export function validate(annotation) {
46
48
  if (typeDef.inlineValue) {
47
49
  if (inlineValue === null || inlineValue === '') {
48
50
  errors.push(`Inline-value annotation "${type}" must carry a non-empty value (§3.4)`);
51
+ } else if (type === 'CARD') {
52
+ // CARD-specific (§4.2): the inline value must decode via the Base64url
53
+ // codec and pass its sha8 integrity check. A malformed encoding or a
54
+ // tampered/corrupted payload must not be treated as a valid annotation.
55
+ if (decodeCardInlineValue(inlineValue) === null) {
56
+ errors.push(
57
+ `Inline-value "CARD" annotation failed to decode or failed its sha8 integrity check (§4.2)`,
58
+ );
59
+ }
49
60
  }
50
61
  return { valid: errors.length === 0, errors, warnings };
51
62
  }
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-command-side-effects.sh — Scan commands/**/*.md for two classes of
6
+ # spec-as-code side-effect defects.
7
+ #
8
+ # Command specs are spec-as-code: they drive autonomous side effects (auto-merge,
9
+ # gh gist create, git push, label mutations, issue creation). This script catches
10
+ # two defect classes before they reach staging:
11
+ #
12
+ # Class A — Unconditionally prohibited patterns (full-corpus, always blocked):
13
+ # `gh gist create --public` or `gh gist edit --public` anywhere in a code block.
14
+ # Gists created by the pipeline MUST be secret — --public exposes private repo
15
+ # titles, root causes, and file paths to the world. (Ref: forge#1587)
16
+ #
17
+ # Class B — Side-effect verbs in sections with no DRY_RUN/governor guard (diff-aware):
18
+ # When added lines introduce a side-effect verb (gh issue create, gh pr merge,
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.
22
+ # Operates on the diff (GITHUB_BASE_SHA or HEAD^) to avoid flagging legacy corpus.
23
+ # (Ref: forge#1609 — signal-planner.md DRY_RUN guard placed after the create it guards)
24
+ #
25
+ # Allowlist: add <!-- allowlist:check-command-side-effects --> on the same line as
26
+ # the side-effect verb to suppress that specific hit.
27
+ #
28
+ # Usage:
29
+ # check-command-side-effects.sh [--full] [<commands_dir>]
30
+ # --full Scan entire corpus (not just diff) for Class A violations only.
31
+ # Class B is diff-aware by design — --full does not change Class B.
32
+ # commands_dir Path to the commands/ directory (default: ./commands)
33
+ #
34
+ # Environment:
35
+ # GITHUB_BASE_SHA When set, used as the base commit for Class B diff mode.
36
+ #
37
+ # Exit codes:
38
+ # 0 no violations found
39
+ # 1 one or more violations found (listed to stderr)
40
+ # 2 usage / dependency error
41
+ #
42
+ # <!-- Added: forge#1609 -->
43
+
44
+ set -euo pipefail
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Argument parsing
48
+ # ---------------------------------------------------------------------------
49
+
50
+ FULL_MODE=0
51
+ COMMANDS_DIR="./commands"
52
+
53
+ for arg in "$@"; do
54
+ case "$arg" in
55
+ -h|--help)
56
+ sed -n '2,/^set -/p' "$0" | grep '^#' | sed 's/^# *//'
57
+ exit 0
58
+ ;;
59
+ --full) FULL_MODE=1 ;;
60
+ -*) echo "ERROR: unknown option: $arg" >&2; exit 2 ;;
61
+ *) COMMANDS_DIR="$arg" ;;
62
+ esac
63
+ done
64
+
65
+ if [ ! -d "$COMMANDS_DIR" ]; then
66
+ echo "ERROR: commands directory not found: $COMMANDS_DIR" >&2
67
+ echo "Usage: $0 [--full] [<commands_dir>]" >&2
68
+ exit 2
69
+ fi
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Constants
73
+ # ---------------------------------------------------------------------------
74
+
75
+ # Code fence marker — stored in variable to avoid backtick shell-expansion bugs.
76
+ # Do NOT inline as grep -E '^[[:space:]]*\`\`\`' — escaped backticks in grep
77
+ # patterns undergo shell expansion, turning the pattern into '^[[:space:]]*'
78
+ # which matches every line. (forge#1609)
79
+ FENCE='```'
80
+
81
+ # Guard expressions: any occurrence in a section's code blocks marks it as guarded
82
+ GUARD_PATTERN='DRY_RUN|GOVERNOR|--dry-run|DRY_RUN_MODE|DryRun'
83
+
84
+ # Class B side-effect verbs (in code blocks)
85
+ SIDE_EFFECT_PATTERN='gh[[:space:]]+(issue[[:space:]]+(create|edit|comment)|pr[[:space:]]+(merge|create|edit)|gist[[:space:]]+(create|edit))|git[[:space:]]+push|--auto-merge|--add-label|--remove-label'
86
+
87
+ ALLOWLIST_TOKEN='allowlist:check-command-side-effects'
88
+
89
+ VIOLATIONS=0
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Class A: Scan for gh gist create/edit --public in code blocks (full corpus)
93
+ # This runs regardless of --full or diff mode.
94
+ # ---------------------------------------------------------------------------
95
+
96
+ class_a_scan() {
97
+ local file="$1"
98
+ local IN_CB=0
99
+ local HAS_GIST=0
100
+ local BLOCK_LINES=""
101
+ local BLOCK_START=0
102
+ local LINENO=0
103
+ local line PUBLIC_LINENO ACTUAL_LINE
104
+
105
+ while IFS= read -r line; do
106
+ LINENO=$((LINENO + 1))
107
+
108
+ if echo "$line" | grep -qF "$FENCE"; then
109
+ if [ "$IN_CB" -eq 0 ]; then
110
+ IN_CB=1
111
+ HAS_GIST=0
112
+ 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=""
128
+ fi
129
+ continue
130
+ fi
131
+
132
+ if [ "$IN_CB" -eq 1 ]; then
133
+ BLOCK_LINES="${BLOCK_LINES}${line}
134
+ "
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
139
+ fi
140
+ done < "$file"
141
+ }
142
+
143
+ while IFS= read -r file; do
144
+ [ -f "$file" ] || continue
145
+ class_a_scan "$file"
146
+ done < <(find "$COMMANDS_DIR" -name '*.md' | sort)
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Class B: Diff-aware scan for unguarded side-effect verbs in added lines
150
+ # ---------------------------------------------------------------------------
151
+
152
+ # Determine base SHA for diff
153
+ BASE_SHA=""
154
+ if [ -n "${GITHUB_BASE_SHA:-}" ]; then
155
+ BASE_SHA="$GITHUB_BASE_SHA"
156
+ elif [ -n "${GITHUB_EVENT_PULL_REQUEST_BASE_SHA:-}" ]; then
157
+ BASE_SHA="$GITHUB_EVENT_PULL_REQUEST_BASE_SHA"
158
+ else
159
+ BASE_SHA="$(git rev-parse HEAD^ 2>/dev/null || echo '')"
160
+ fi
161
+
162
+ if [ -z "$BASE_SHA" ]; then
163
+ echo "INFO: No git base SHA available — Class B (diff-aware) check skipped" >&2
164
+ else
165
+ # Get list of changed command spec files
166
+ CHANGED_SPECS=$(git diff --name-only "$BASE_SHA"...HEAD -- "${COMMANDS_DIR}" 2>/dev/null \
167
+ | grep -E '\.md$' | grep -v '^$' || true)
168
+
169
+ if [ -z "$CHANGED_SPECS" ]; then
170
+ echo "OK (Class B): No commands/*.md files changed — diff check skipped"
171
+ else
172
+ echo "Class B: Checking changed spec files for unguarded side-effect verbs:"
173
+ echo "$CHANGED_SPECS"
174
+ echo ""
175
+
176
+ while IFS= read -r file; do
177
+ [ -f "$file" ] || continue
178
+
179
+ # Get added lines for this file
180
+ ADDED_CONTENT=$(git diff "$BASE_SHA"...HEAD -- "$file" 2>/dev/null \
181
+ | grep '^+' | grep -v '^+++' | sed 's/^+//' || true)
182
+
183
+ # Skip if no added lines contain side-effect verbs
184
+ if ! echo "$ADDED_CONTENT" | grep -qE "$SIDE_EFFECT_PATTERN"; then
185
+ continue
186
+ fi
187
+
188
+ # Parse the full file to map sections → (has_guard, has_side_effect_in_added_lines)
189
+ IN_CB=0
190
+ 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
201
+ VIOLATIONS=$((VIOLATIONS + 1))
202
+ 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
209
+ }
210
+
211
+ while IFS= read -r line; do
212
+ LINENO=$((LINENO + 1))
213
+
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"
218
+ continue
219
+ fi
220
+
221
+ # Code block fence
222
+ if echo "$line" | grep -qF "$FENCE"; then
223
+ [ "$IN_CB" -eq 0 ] && IN_CB=1 || IN_CB=0
224
+ continue
225
+ fi
226
+
227
+ if [ "$IN_CB" -eq 1 ]; then
228
+ if echo "$line" | grep -qF "$ALLOWLIST_TOKEN"; then continue; fi
229
+
230
+ # Check for guard
231
+ echo "$line" | grep -qE "$GUARD_PATTERN" && SECTION_HAS_GUARD=1
232
+
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
241
+ fi
242
+ fi
243
+ done < "$file"
244
+
245
+ flush_and_reset "(end-of-file)"
246
+
247
+ done <<< "$CHANGED_SPECS"
248
+ fi
249
+ fi
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Summary
253
+ # ---------------------------------------------------------------------------
254
+
255
+ if [ "$VIOLATIONS" -gt 0 ]; then
256
+ echo "check-command-side-effects: $VIOLATIONS violation(s) found. See stderr for details." >&2
257
+ exit 1
258
+ fi
259
+
260
+ echo "OK: No side-effect violations found"
261
+ exit 0