forge-workflow 0.0.5 → 0.0.6

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 (129) hide show
  1. package/.claude/commands/dev.md +6 -1
  2. package/.claude/commands/plan.md +59 -14
  3. package/.claude/commands/premerge.md +10 -0
  4. package/.claude/commands/review.md +7 -1
  5. package/.claude/commands/ship.md +95 -47
  6. package/.claude/commands/status.md +42 -0
  7. package/.claude/commands/validate.md +7 -1
  8. package/.claude/commands/verify.md +52 -4
  9. package/.claude/rules/workflow.md +16 -0
  10. package/.claude/scripts/greptile-resolve.sh +32 -0
  11. package/.cline/workflows/dev.md +6 -1
  12. package/.cline/workflows/plan.md +59 -14
  13. package/.cline/workflows/premerge.md +10 -0
  14. package/.cline/workflows/review.md +7 -1
  15. package/.cline/workflows/ship.md +95 -47
  16. package/.cline/workflows/status.md +42 -0
  17. package/.cline/workflows/validate.md +7 -1
  18. package/.cline/workflows/verify.md +52 -4
  19. package/.codex/skills/dev/SKILL.md +6 -1
  20. package/.codex/skills/plan/SKILL.md +59 -14
  21. package/.codex/skills/premerge/SKILL.md +10 -0
  22. package/.codex/skills/review/SKILL.md +7 -1
  23. package/.codex/skills/ship/SKILL.md +95 -47
  24. package/.codex/skills/status/SKILL.md +42 -0
  25. package/.codex/skills/validate/SKILL.md +7 -1
  26. package/.codex/skills/verify/SKILL.md +52 -4
  27. package/.cursor/commands/dev.md +6 -1
  28. package/.cursor/commands/plan.md +59 -14
  29. package/.cursor/commands/premerge.md +10 -0
  30. package/.cursor/commands/review.md +7 -1
  31. package/.cursor/commands/ship.md +95 -47
  32. package/.cursor/commands/status.md +42 -0
  33. package/.cursor/commands/validate.md +7 -1
  34. package/.cursor/commands/verify.md +52 -4
  35. package/.github/prompts/dev.prompt.md +6 -1
  36. package/.github/prompts/plan.prompt.md +59 -14
  37. package/.github/prompts/premerge.prompt.md +10 -0
  38. package/.github/prompts/review.prompt.md +7 -1
  39. package/.github/prompts/ship.prompt.md +95 -47
  40. package/.github/prompts/status.prompt.md +42 -0
  41. package/.github/prompts/validate.prompt.md +7 -1
  42. package/.github/prompts/verify.prompt.md +52 -4
  43. package/.kilocode/workflows/dev.md +6 -1
  44. package/.kilocode/workflows/plan.md +59 -14
  45. package/.kilocode/workflows/premerge.md +10 -0
  46. package/.kilocode/workflows/review.md +7 -1
  47. package/.kilocode/workflows/ship.md +95 -47
  48. package/.kilocode/workflows/status.md +42 -0
  49. package/.kilocode/workflows/validate.md +7 -1
  50. package/.kilocode/workflows/verify.md +52 -4
  51. package/.opencode/commands/dev.md +6 -1
  52. package/.opencode/commands/plan.md +59 -14
  53. package/.opencode/commands/premerge.md +10 -0
  54. package/.opencode/commands/review.md +7 -1
  55. package/.opencode/commands/ship.md +95 -47
  56. package/.opencode/commands/status.md +42 -0
  57. package/.opencode/commands/validate.md +7 -1
  58. package/.opencode/commands/verify.md +52 -4
  59. package/.roo/commands/dev.md +6 -1
  60. package/.roo/commands/plan.md +59 -14
  61. package/.roo/commands/premerge.md +10 -0
  62. package/.roo/commands/review.md +7 -1
  63. package/.roo/commands/ship.md +95 -47
  64. package/.roo/commands/status.md +42 -0
  65. package/.roo/commands/validate.md +7 -1
  66. package/.roo/commands/verify.md +52 -4
  67. package/AGENTS.md +97 -0
  68. package/CLAUDE.md +10 -0
  69. package/README.md +2 -2
  70. package/bin/forge-cmd.js +5 -1
  71. package/bin/forge-preflight.js +15 -2
  72. package/bin/forge.js +178 -9
  73. package/docs/ENHANCED_ONBOARDING.md +96 -86
  74. package/docs/ROADMAP.md +2 -2
  75. package/docs/TOOLCHAIN.md +23 -0
  76. package/docs/VALIDATION.md +1 -1
  77. package/lefthook.yml +11 -0
  78. package/lib/agents-config.js +2 -2
  79. package/lib/commands/_registry.js +134 -0
  80. package/lib/commands/clean.js +181 -0
  81. package/lib/commands/dev.js +58 -0
  82. package/lib/commands/push.js +196 -0
  83. package/lib/commands/recommend.js +1 -1
  84. package/lib/commands/sync.js +55 -0
  85. package/lib/commands/team.js +37 -0
  86. package/lib/commands/test.js +207 -0
  87. package/lib/commands/worktree.js +310 -0
  88. package/lib/docs-command.js +51 -0
  89. package/lib/docs-copy.js +50 -0
  90. package/lib/freshness-token.js +148 -0
  91. package/lib/greptile-match.js +80 -0
  92. package/lib/reset.js +309 -0
  93. package/lib/task-ownership.js +117 -0
  94. package/package.json +2 -2
  95. package/scripts/beads-context.sh +157 -22
  96. package/scripts/beads-context.test.js +5 -1
  97. package/scripts/check-forge-token.js +98 -0
  98. package/scripts/conflict-detect.sh +2 -2
  99. package/scripts/dep-guard.sh +6 -28
  100. package/scripts/file-index.sh +117 -23
  101. package/scripts/forge-team/index.sh +86 -0
  102. package/scripts/forge-team/lib/agent-prompt.sh +52 -0
  103. package/scripts/forge-team/lib/claim.sh +256 -0
  104. package/scripts/forge-team/lib/dashboard.sh +341 -0
  105. package/scripts/forge-team/lib/epic.sh +332 -0
  106. package/scripts/forge-team/lib/hooks.sh +253 -0
  107. package/scripts/forge-team/lib/identity.sh +235 -0
  108. package/scripts/forge-team/lib/sync-github.sh +317 -0
  109. package/scripts/forge-team/lib/verify.sh +284 -0
  110. package/scripts/forge-team/lib/workload.sh +296 -0
  111. package/scripts/forge-team/tests/agent-prompt.test.sh +72 -0
  112. package/scripts/forge-team/tests/claim.test.sh +179 -0
  113. package/scripts/forge-team/tests/dashboard.test.sh +170 -0
  114. package/scripts/forge-team/tests/dispatcher.test.sh +79 -0
  115. package/scripts/forge-team/tests/epic.test.sh +176 -0
  116. package/scripts/forge-team/tests/hooks.test.sh +239 -0
  117. package/scripts/forge-team/tests/identity.test.sh +176 -0
  118. package/scripts/forge-team/tests/integration.test.sh +371 -0
  119. package/scripts/forge-team/tests/sync-github.test.sh +209 -0
  120. package/scripts/forge-team/tests/verify.test.sh +314 -0
  121. package/scripts/forge-team/tests/workflow-integration.test.sh +43 -0
  122. package/scripts/forge-team/tests/workload.test.sh +209 -0
  123. package/scripts/lib/eval-runner.js +39 -0
  124. package/scripts/lib/jsonl-lock.sh +48 -0
  125. package/scripts/lib/sanitize.sh +116 -0
  126. package/scripts/pr-coordinator.sh +706 -0
  127. package/scripts/smart-status.sh +37 -10
  128. package/scripts/sync-utils.sh +24 -29
  129. package/scripts/test.js +3 -1
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+
6
+ # ── Setup temp dir for test isolation ────────────────────────────────────
7
+ TEST_TMP="$(mktemp -d)"
8
+ trap 'rm -rf "$TEST_TMP"' EXIT
9
+
10
+ # Override TEAM_MAP_ROOT so tests don't touch real .beads/
11
+ export TEAM_MAP_ROOT="$TEST_TMP"
12
+
13
+ # Fix "now" so stale detection is deterministic
14
+ # forge-bbb updated 2026-03-25T10:00:00Z → 50h ago at this fixed time
15
+ export WORKLOAD_NOW="2026-03-27T12:00:00Z"
16
+
17
+ # ── Create mock gh ───────────────────────────────────────────────────────
18
+ mock_dir="$(mktemp -d)"
19
+
20
+ cat > "$mock_dir/gh" << 'MOCK'
21
+ #!/usr/bin/env bash
22
+ case "$1 $2" in
23
+ "api user")
24
+ echo "devone"
25
+ ;;
26
+ esac
27
+ MOCK
28
+ chmod +x "$mock_dir/gh"
29
+
30
+ # ── Create mock bd ───────────────────────────────────────────────────────
31
+ cat > "$mock_dir/bd" << 'MOCK'
32
+ #!/usr/bin/env bash
33
+ case "$1 $2" in
34
+ "list --status=open,in_progress")
35
+ echo "◐ forge-aaa · Feature A"
36
+ echo "○ forge-bbb · Feature B"
37
+ echo "◐ forge-ccc · Feature C"
38
+ ;;
39
+ "show forge-aaa")
40
+ echo "◐ forge-aaa · Feature A [● P2 · IN_PROGRESS]"
41
+ echo "Owner: devone"
42
+ echo "Updated: 2026-03-27T10:00:00Z"
43
+ ;;
44
+ "show forge-bbb")
45
+ echo "○ forge-bbb · Feature B [● P2 · OPEN]"
46
+ echo "Owner: devtwo"
47
+ echo "Updated: 2026-03-25T10:00:00Z"
48
+ ;;
49
+ "show forge-ccc")
50
+ echo "◐ forge-ccc · Feature C [● P1 · IN_PROGRESS]"
51
+ echo "Owner: devone"
52
+ echo "Updated: 2026-03-27T09:00:00Z"
53
+ echo ""
54
+ echo "DEPENDS ON"
55
+ echo " → forge-aaa: Feature A"
56
+ ;;
57
+ esac
58
+ MOCK
59
+ chmod +x "$mock_dir/bd"
60
+
61
+ # ── Create mock bd with no issues ────────────────────────────────────────
62
+ cat > "$mock_dir/bd-empty" << 'MOCK'
63
+ #!/usr/bin/env bash
64
+ case "$1 $2" in
65
+ "list --status=open,in_progress")
66
+ echo ""
67
+ ;;
68
+ esac
69
+ MOCK
70
+ chmod +x "$mock_dir/bd-empty"
71
+
72
+ export GH_CMD="$mock_dir/gh"
73
+ export BD_CMD="$mock_dir/bd"
74
+
75
+ # Source the library under test
76
+ source "$SCRIPT_DIR/lib/workload.sh"
77
+
78
+ PASS=0
79
+ FAIL=0
80
+
81
+ assert_eq() {
82
+ local label="$1" expected="$2" actual="$3"
83
+ if [[ "$actual" == "$expected" ]]; then
84
+ PASS=$((PASS + 1)); echo " PASS: $label"
85
+ else
86
+ FAIL=$((FAIL + 1)); echo " FAIL: $label (expected '$expected', got '$actual')"
87
+ fi
88
+ }
89
+
90
+ assert_contains() {
91
+ local label="$1" expected="$2" actual="$3"
92
+ if [[ "$actual" == *"$expected"* ]]; then
93
+ PASS=$((PASS + 1)); echo " PASS: $label"
94
+ else
95
+ FAIL=$((FAIL + 1)); echo " FAIL: $label (expected to contain '$expected', got '$actual')"
96
+ fi
97
+ }
98
+
99
+ assert_not_contains() {
100
+ local label="$1" unexpected="$2" actual="$3"
101
+ if [[ "$actual" != *"$unexpected"* ]]; then
102
+ PASS=$((PASS + 1)); echo " PASS: $label"
103
+ else
104
+ FAIL=$((FAIL + 1)); echo " FAIL: $label (expected NOT to contain '$unexpected', got '$actual')"
105
+ fi
106
+ }
107
+
108
+ assert_exit() {
109
+ local label="$1" expected="$2" actual="$3"
110
+ if [[ "$actual" -eq "$expected" ]]; then
111
+ PASS=$((PASS + 1)); echo " PASS: $label"
112
+ else
113
+ FAIL=$((FAIL + 1)); echo " FAIL: $label (expected exit $expected, got $actual)"
114
+ fi
115
+ }
116
+
117
+ # ── Test 1: All developers shown with grouped issues ─────────────────────
118
+ echo "── Test 1: All developers shown with grouped issues ──"
119
+ unset _GITHUB_USER_CACHE
120
+ rc=0
121
+ output="$(cmd_workload 2>/dev/null)" || rc=$?
122
+ assert_exit "exits 0" 0 "$rc"
123
+ assert_contains "shows devone header" "Developer: devone" "$output"
124
+ assert_contains "shows devtwo header" "Developer: devtwo" "$output"
125
+ assert_contains "shows forge-aaa" "forge-aaa" "$output"
126
+ assert_contains "shows forge-bbb" "forge-bbb" "$output"
127
+ assert_contains "shows forge-ccc" "forge-ccc" "$output"
128
+
129
+ # ── Test 2: --developer=devone filters correctly ─────────────────────────
130
+ echo ""
131
+ echo "── Test 2: --developer=devone filters correctly ──"
132
+ unset _GITHUB_USER_CACHE
133
+ rc=0
134
+ output="$(cmd_workload --developer=devone 2>/dev/null)" || rc=$?
135
+ assert_exit "exits 0" 0 "$rc"
136
+ assert_contains "shows devone" "Developer: devone" "$output"
137
+ assert_not_contains "excludes devtwo" "Developer: devtwo" "$output"
138
+ assert_contains "shows forge-aaa" "forge-aaa" "$output"
139
+ assert_contains "shows forge-ccc" "forge-ccc" "$output"
140
+ assert_not_contains "excludes forge-bbb" "forge-bbb" "$output"
141
+
142
+ # ── Test 3: --me uses get_github_user() to filter ────────────────────────
143
+ echo ""
144
+ echo "── Test 3: --me uses get_github_user() to filter ──"
145
+ unset _GITHUB_USER_CACHE
146
+ rc=0
147
+ output="$(cmd_workload --me 2>/dev/null)" || rc=$?
148
+ assert_exit "exits 0" 0 "$rc"
149
+ assert_contains "shows devone (current user)" "Developer: devone" "$output"
150
+ assert_not_contains "excludes devtwo" "Developer: devtwo" "$output"
151
+
152
+ # ── Test 4: No issues → "No active work" ─────────────────────────────────
153
+ echo ""
154
+ echo "── Test 4: No issues → No active work ──"
155
+ unset _GITHUB_USER_CACHE
156
+ export BD_CMD="$mock_dir/bd-empty"
157
+ rc=0
158
+ output="$(cmd_workload 2>/dev/null)" || rc=$?
159
+ assert_exit "exits 0" 0 "$rc"
160
+ assert_contains "shows no active work message" "No active work" "$output"
161
+ export BD_CMD="$mock_dir/bd"
162
+
163
+ # ── Test 5: Stale assignment flagged (>48h) ──────────────────────────────
164
+ echo ""
165
+ echo "── Test 5: Stale assignment flagged (>48h) ──"
166
+ unset _GITHUB_USER_CACHE
167
+ rc=0
168
+ output="$(cmd_workload 2>/dev/null)" || rc=$?
169
+ assert_exit "exits 0" 0 "$rc"
170
+ # forge-bbb was updated 2026-03-25T10:00:00Z, which is >48h ago from "now"
171
+ assert_contains "stale flag on forge-bbb" "stale:" "$output"
172
+
173
+ # ── Test 6: --format=json returns valid JSON ─────────────────────────────
174
+ echo ""
175
+ echo "── Test 6: --format=json returns valid JSON ──"
176
+ unset _GITHUB_USER_CACHE
177
+ rc=0
178
+ output="$(cmd_workload --format=json 2>/dev/null)" || rc=$?
179
+ assert_exit "exits 0" 0 "$rc"
180
+ # Validate it's parseable JSON
181
+ if echo "$output" | jq . >/dev/null 2>&1; then
182
+ PASS=$((PASS + 1)); echo " PASS: output is valid JSON"
183
+ else
184
+ FAIL=$((FAIL + 1)); echo " FAIL: output is not valid JSON: $output"
185
+ fi
186
+ # Check JSON has developer keys
187
+ if echo "$output" | jq -e '.devone' >/dev/null 2>&1; then
188
+ PASS=$((PASS + 1)); echo " PASS: JSON contains devone key"
189
+ else
190
+ FAIL=$((FAIL + 1)); echo " FAIL: JSON missing devone key"
191
+ fi
192
+ if echo "$output" | jq -e '.devtwo' >/dev/null 2>&1; then
193
+ PASS=$((PASS + 1)); echo " PASS: JSON contains devtwo key"
194
+ else
195
+ FAIL=$((FAIL + 1)); echo " FAIL: JSON missing devtwo key"
196
+ fi
197
+
198
+ # ── Test 7: Blocked issue flagged ─────────────────────────────────────────
199
+ echo ""
200
+ echo "── Test 7: Blocked issue flagged ──"
201
+ unset _GITHUB_USER_CACHE
202
+ rc=0
203
+ output="$(cmd_workload 2>/dev/null)" || rc=$?
204
+ assert_contains "blocked flag on forge-ccc" "blocked by forge-aaa" "$output"
205
+
206
+ # ── Results ──────────────────────────────────────────────────────────────
207
+ echo ""
208
+ echo "Results: $PASS passed, $FAIL failed"
209
+ [[ $FAIL -eq 0 ]] && exit 0 || exit 1
@@ -11,6 +11,42 @@
11
11
  const path = require('path');
12
12
  const { execSync } = require('node:child_process');
13
13
 
14
+ // ── active worktree tracking (cleanup on crash) ─────────────────────
15
+ // Tracks active eval worktrees so we can clean up on process exit/crash.
16
+ // Prevents orphaned eval-* branches when interrupted.
17
+ // Note: execSync is safe here — all paths are internally generated, never user input.
18
+ const activeEvalWorktrees = new Map(); // path -> branch
19
+
20
+ function cleanupActiveWorktrees() {
21
+ if (activeEvalWorktrees.size === 0) return;
22
+ let repoRoot;
23
+ try { repoRoot = getRepoRoot(); } catch (_err) { return; }
24
+ for (const [wtPath, branch] of activeEvalWorktrees) {
25
+ try {
26
+ execSync(`git worktree remove --force "${wtPath}"`, { cwd: repoRoot, stdio: 'pipe' });
27
+ } catch (_err) { /* already removed */ }
28
+ if (branch && branch.startsWith('eval-')) {
29
+ try {
30
+ execSync(`git branch -D "${branch}"`, { cwd: repoRoot, stdio: 'pipe' });
31
+ } catch (_err) { /* already deleted */ }
32
+ }
33
+ }
34
+ try { execSync('git worktree prune', { cwd: repoRoot, stdio: 'pipe' }); } catch (_err) { /* ignore */ }
35
+ activeEvalWorktrees.clear();
36
+ }
37
+
38
+ process.on('exit', cleanupActiveWorktrees);
39
+ process.on('SIGINT', () => {
40
+ const hadWork = activeEvalWorktrees.size > 0;
41
+ cleanupActiveWorktrees();
42
+ if (hadWork) process.exit(130);
43
+ });
44
+ process.on('SIGTERM', () => {
45
+ const hadWork = activeEvalWorktrees.size > 0;
46
+ cleanupActiveWorktrees();
47
+ if (hadWork) process.exit(143);
48
+ });
49
+
14
50
  // ── helpers ──────────────────────────────────────────────────────────
15
51
 
16
52
  /**
@@ -56,6 +92,7 @@ async function createEvalWorktree() {
56
92
  stdio: ['pipe', 'pipe', 'pipe'],
57
93
  });
58
94
 
95
+ activeEvalWorktrees.set(wtPath, branch);
59
96
  return { path: wtPath, branch };
60
97
  }
61
98
 
@@ -98,6 +135,8 @@ async function destroyEvalWorktree(worktreePath) {
98
135
  stdio: ['pipe', 'pipe', 'pipe'],
99
136
  });
100
137
 
138
+ activeEvalWorktrees.delete(worktreePath);
139
+
101
140
  // Delete the temporary branch (force in case it's not fully merged)
102
141
  if (branch && branch.startsWith('eval-')) {
103
142
  try {
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/lib/jsonl-lock.sh — Atomic JSONL append with file locking
3
+ #
4
+ # Provides atomic_jsonl_append() for concurrent-safe JSONL file writes.
5
+ # Cross-platform: uses flock on Linux/WSL/Git Bash, mkdir-based fallback on macOS.
6
+ #
7
+ # Usage (source this file):
8
+ # source scripts/lib/jsonl-lock.sh
9
+ # atomic_jsonl_append "/path/to/file.jsonl" '{"key":"value"}'
10
+ #
11
+ # Exit codes: 0=success, 1=lock timeout or write failure
12
+ # Lock timeout: 5 seconds
13
+
14
+ # atomic_jsonl_append <jsonl_file> <json_line>
15
+ # Appends a JSON line to a JSONL file with file locking.
16
+ # Lock file: <jsonl_file>.lock (flock) or <jsonl_file>.lock.d (mkdir)
17
+ # Timeout: 5 seconds
18
+ # Exit codes: 0=success, 1=lock timeout or write failure
19
+ atomic_jsonl_append() {
20
+ local jsonl_file="$1"
21
+ local json_line="$2"
22
+
23
+ mkdir -p "$(dirname "$jsonl_file")"
24
+
25
+ if command -v flock &>/dev/null; then
26
+ # flock-based locking (Linux, WSL, Git Bash)
27
+ local lock_file="${jsonl_file}.lock"
28
+ (
29
+ flock -w 5 200 || { echo "Error: JSONL lock timeout after 5s" >&2; return 1; }
30
+ printf '%s\n' "$json_line" >> "$jsonl_file"
31
+ ) 200>"$lock_file"
32
+ else
33
+ # mkdir-based fallback (macOS, systems without flock)
34
+ local lock_file="${jsonl_file}.lock.d"
35
+ local attempts=0
36
+ while ! mkdir "$lock_file" 2>/dev/null; do
37
+ attempts=$((attempts + 1))
38
+ if [[ $attempts -ge 50 ]]; then # 50 * 0.1s = 5s timeout
39
+ echo "Error: JSONL lock timeout after 5s" >&2
40
+ return 1
41
+ fi
42
+ sleep 0.1
43
+ done
44
+ # RETURN trap handles cleanup on both success and failure
45
+ trap 'rmdir "$lock_file" 2>/dev/null; trap - RETURN' RETURN
46
+ printf '%s\n' "$json_line" >> "$jsonl_file"
47
+ fi
48
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bash
2
+ # sanitize.sh — Shared input sanitization and validation library.
3
+ #
4
+ # Extracted from file-index.sh, dep-guard.sh, beads-context.sh, sync-utils.sh
5
+ # to eliminate duplication. Source this file; do NOT run it directly.
6
+ #
7
+ # Functions:
8
+ # sanitize <string> Strip shell-injection patterns (OWASP A03)
9
+ # sanitize_config_value <string> Strip injection + pipes, trim whitespace
10
+ # validate_branch_name <string> Validate git branch name format
11
+ # validate_pr_number <string> Validate GitHub PR number (digits only)
12
+ # validate_label_name <string> Validate GitHub label format
13
+ #
14
+ # This file does NOT set errexit/pipefail — callers manage their own shell options.
15
+ # OWASP A03: All inputs validated and shell-injection patterns stripped.
16
+
17
+ # Guard against double-sourcing
18
+ if [[ -n "${_SANITIZE_LIB_LOADED:-}" ]]; then
19
+ return 0 2>/dev/null || true
20
+ fi
21
+ _SANITIZE_LIB_LOADED=1
22
+
23
+ # ── sanitize ──────────────────────────────────────────────────────────────
24
+ # Sanitize a string: strip shell-injection patterns (OWASP A03).
25
+ # Removes: double quotes, $(...), backticks, semicolons, and newlines.
26
+ # Usage: clean="$(sanitize "$raw")"
27
+ sanitize() {
28
+ local val="$1"
29
+ # Remove double quotes
30
+ val="${val//\"/}"
31
+ # Remove $(...) command substitution patterns (loop handles nested)
32
+ val="$(printf '%s' "$val" | sed -e ':loop' -e 's/\$([^()]*)//g' -e 't loop')"
33
+ # Remove backtick command substitution
34
+ val="${val//\`/}"
35
+ # Remove semicolons (command chaining)
36
+ val="${val//;/}"
37
+ # Replace newlines with spaces
38
+ val="$(printf '%s' "$val" | tr '\n' ' ')"
39
+ printf '%s' "$val"
40
+ }
41
+
42
+ # ── sanitize_config_value ────────────────────────────────────────────────
43
+ # Sanitize a config value: strip shell-injection patterns + pipes (OWASP A03).
44
+ # Removes: backticks, $(...), semicolons, pipes, newlines. Trims whitespace.
45
+ # Usage: clean="$(sanitize_config_value "$raw")"
46
+ sanitize_config_value() {
47
+ local val="$1"
48
+ # Remove backtick command substitution
49
+ val="${val//\`/}"
50
+ # Remove $(...) command substitution patterns (loop handles nested)
51
+ val="$(printf '%s' "$val" | sed -e ':loop' -e 's/\$([^()]*)//g' -e 't loop')"
52
+ # Remove semicolons (command chaining)
53
+ val="${val//;/}"
54
+ # Remove pipes (command chaining)
55
+ val="${val//|/}"
56
+ # Collapse newlines to spaces
57
+ val="$(printf '%s' "$val" | tr '\n' ' ')"
58
+ # Trim leading/trailing whitespace
59
+ val="$(printf '%s' "$val" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
60
+ printf '%s' "$val"
61
+ }
62
+
63
+ # ── validate_branch_name ─────────────────────────────────────────────────
64
+ # Validate a git branch name.
65
+ # Allowed: alphanumeric, dots, hyphens, underscores, forward slashes, @
66
+ # Usage: validate_branch_name "feat/my-feature" || exit 1
67
+ validate_branch_name() {
68
+ local name="${1:-}"
69
+ if [[ -z "$name" ]]; then
70
+ echo "Error: branch name cannot be empty" >&2
71
+ return 1
72
+ fi
73
+ if [[ ! "$name" =~ ^[a-zA-Z0-9._/@-]+$ ]]; then
74
+ echo "Error: invalid branch name format: $name" >&2
75
+ return 1
76
+ fi
77
+ # Git rejects: double dots (..), trailing .lock, leading/trailing slash, leading hyphen
78
+ if [[ "$name" == *..* ]] || [[ "$name" == *.lock ]] || [[ "$name" == /* ]] || [[ "$name" == */ ]] || [[ "$name" == -* ]]; then
79
+ echo "Error: invalid branch name (contains .., .lock suffix, leading/trailing /, or leading -): $name" >&2
80
+ return 1
81
+ fi
82
+ return 0
83
+ }
84
+
85
+ # ── validate_pr_number ───────────────────────────────────────────────────
86
+ # Validate a GitHub PR number (digits only).
87
+ # Usage: validate_pr_number "42" || exit 1
88
+ validate_pr_number() {
89
+ local num="${1:-}"
90
+ if [[ -z "$num" ]]; then
91
+ echo "Error: PR number cannot be empty" >&2
92
+ return 1
93
+ fi
94
+ if [[ ! "$num" =~ ^[0-9]+$ ]]; then
95
+ echo "Error: invalid PR number format: $num" >&2
96
+ return 1
97
+ fi
98
+ return 0
99
+ }
100
+
101
+ # ── validate_label_name ──────────────────────────────────────────────────
102
+ # Validate a GitHub label name.
103
+ # Allowed: alphanumeric, dots, hyphens, underscores, forward slashes
104
+ # Usage: validate_label_name "forge/has-deps" || exit 1
105
+ validate_label_name() {
106
+ local name="${1:-}"
107
+ if [[ -z "$name" ]]; then
108
+ echo "Error: label name cannot be empty" >&2
109
+ return 1
110
+ fi
111
+ if [[ ! "$name" =~ ^[a-zA-Z0-9._/-]+$ ]]; then
112
+ echo "Error: invalid label name format: $name" >&2
113
+ return 1
114
+ fi
115
+ return 0
116
+ }