@boyingliu01/xp-gate 0.30.2 → 0.30.4

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 (47) hide show
  1. package/adapters/iac.sh +45 -4
  2. package/adapters/java.sh +82 -16
  3. package/adapters/powershell.sh +70 -9
  4. package/gate-10.sh +110 -13
  5. package/gate-3.sh +17 -0
  6. package/gate-4.sh +12 -0
  7. package/gate-7.sh +4 -0
  8. package/hooks/gate-10.sh +110 -13
  9. package/hooks/gate-3.sh +17 -0
  10. package/hooks/gate-4.sh +12 -0
  11. package/hooks/gate-7.sh +4 -0
  12. package/hooks/hook-module-resolution.sh +14 -0
  13. package/hooks/lib/coverage-total.sh +1 -1
  14. package/hooks/pre-commit +138 -17
  15. package/hooks/pre-push +150 -9
  16. package/hooks/sprint-gate.sh +81 -2
  17. package/lib/__tests__/hook-health.test.js +85 -0
  18. package/lib/coverage-total.sh +92 -0
  19. package/lib/doctor.js +44 -0
  20. package/lib/hook-health.js +121 -0
  21. package/lib/mattpocock-license-verdict.json +1 -1
  22. package/mock-policy/AGENTS.md +2 -2
  23. package/mutation/AGENTS.md +2 -2
  24. package/mutation/__tests__/gate-m-survivors.test.ts +133 -0
  25. package/mutation/gate-m.ts +114 -11
  26. package/package.json +1 -1
  27. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  29. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  30. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  31. package/plugins/dsh/package.json +1 -1
  32. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  33. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  34. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  43. package/principles/AGENTS.md +2 -2
  44. package/skills/delphi-review/AGENTS.md +2 -2
  45. package/skills/sprint-flow/AGENTS.md +2 -2
  46. package/skills/test-specification-alignment/AGENTS.md +2 -2
  47. package/sprint-gate.sh +81 -2
package/adapters/iac.sh CHANGED
@@ -64,10 +64,29 @@ run_static_analysis() {
64
64
  if command -v checkov >/dev/null 2>&1; then
65
65
  echo "Running checkov IaC security scan..."
66
66
 
67
- # checkov 3.x always exits 0 even on failures. We capture output to a
68
- # temp file and parse it for "FAILED for resource" lines instead.
67
+ # checkov 3.x exits 0 on "found failures" (we parse the output for those),
68
+ # so the status cannot be the only signal. It is still NOT discardable:
69
+ # exit 2 means checkov itself crashed -- e.g. AttributeError from a broken
70
+ # detect_secrets dependency -- and a crashed scanner has not judged
71
+ # anything. Counting a traceback as findings invents security issues; a
72
+ # traceback that happens to lack the marker reports a clean pass. Both are
73
+ # wrong, so a crash is tracked separately and answered as "unusable" (#552).
69
74
  local checkov_log=$(mktemp)
70
75
  local checkov_args="--compact"
76
+ local CHECKOV_CRASHED=0
77
+ local checkov_scan_count=0
78
+
79
+ # Record a checkov run's exit status and flag a crash. Codes 0 and 1 are
80
+ # normal (1 = findings); anything else, or any traceback in the log, is the
81
+ # tool failing rather than reporting.
82
+ record_checkov_run() {
83
+ local status=$1 file=$2
84
+ checkov_scan_count=$((checkov_scan_count + 1))
85
+ if [ "$status" -ne 0 ] && [ "$status" -ne 1 ]; then
86
+ CHECKOV_CRASHED=1
87
+ echo " ⚠️ checkov exited $status on $file — the scanner failed, this is not a finding"
88
+ fi
89
+ }
71
90
 
72
91
  # Run checkov on all detected IaC files
73
92
  local tf_files=$(echo "$iac_files" | grep "TERRAFORM:" | sed 's/TERRAFORM://')
@@ -79,6 +98,7 @@ run_static_analysis() {
79
98
  for file in $tf_files; do
80
99
  if [ -f "$file" ]; then
81
100
  checkov --file "$file" $checkov_args >> "$checkov_log" 2>&1
101
+ record_checkov_run $? "$file"
82
102
  fi
83
103
  done
84
104
  fi
@@ -99,6 +119,7 @@ run_static_analysis() {
99
119
  for file in $k8s_only; do
100
120
  if [ -f "$file" ]; then
101
121
  checkov --file "$file" $checkov_args --framework kubernetes >> "$checkov_log" 2>&1
122
+ record_checkov_run $? "$file"
102
123
  fi
103
124
  done
104
125
  fi
@@ -108,6 +129,7 @@ run_static_analysis() {
108
129
  for file in $gh_files; do
109
130
  if [ -f "$file" ]; then
110
131
  checkov --file "$file" $checkov_args --framework github_actions >> "$checkov_log" 2>&1
132
+ record_checkov_run $? "$file"
111
133
  fi
112
134
  done
113
135
  fi
@@ -118,20 +140,39 @@ run_static_analysis() {
118
140
  for file in $docker_files; do
119
141
  if [ -f "$file" ]; then
120
142
  checkov --file "$file" $checkov_args >> "$checkov_log" 2>&1
143
+ record_checkov_run $? "$file"
121
144
  fi
122
145
  done
123
146
  fi
124
147
 
125
148
  # Print captured output then check for failures
126
149
  cat "$checkov_log"
150
+
151
+ # A crash is answered before any parsing. The traceback is not evidence of
152
+ # anything: its text decides the verdict if we let grep see it.
153
+ if [ "$CHECKOV_CRASHED" -eq 1 ]; then
154
+ rm -f "$checkov_log"
155
+ echo ""
156
+ echo "❌ BLOCKED - checkov could not run (the scanner crashed, so nothing was scanned)."
157
+ echo " This is NOT a security finding. Fix the environment, usually:"
158
+ echo " pip install -U checkov detect-secrets"
159
+ echo " (A common cause is a detect_secrets version whose FileType enum lacks JSON.)"
160
+ echo " To accept the risk for this commit: XP_GATE_IAC_SOFT=1"
161
+ if [ "${XP_GATE_IAC_SOFT:-}" = "1" ]; then
162
+ echo "⚠️ XP_GATE_IAC_SOFT=1: unusable scanner downgraded to WARN"
163
+ return 3
164
+ fi
165
+ return 2
166
+ fi
167
+
127
168
  local failed_count=$(grep -c "FAILED for resource" "$checkov_log" 2>/dev/null || true)
128
169
  rm -f "$checkov_log"
129
170
 
130
171
  if [ "$failed_count" -gt 0 ]; then
131
- echo "❌ $failed_count security issue(s) found by checkov. Blocking commit."
172
+ echo "❌ $failed_count security issue(s) found by checkov across $checkov_scan_count file(s). Blocking commit."
132
173
  exit_code=1
133
174
  fi
134
- echo "checkov scan completed."
175
+ echo "checkov scan completed ($checkov_scan_count file(s) scanned)."
135
176
  return $exit_code
136
177
  fi
137
178
 
package/adapters/java.sh CHANGED
@@ -106,10 +106,23 @@ java_staged_files() {
106
106
  # counts from the object count -> unusable -> BLOCK;
107
107
  # - a truncated response (no closing bracket) is unusable -> BLOCK.
108
108
  # Prints the violation (object) count on success; returns 1 if unusable.
109
+ #
110
+ # On rejection, a one-line reason goes to STDERR naming the invariant that
111
+ # failed plus the measured counts (#545). Before this, every rejection looked
112
+ # identical to the operator as "not a usable violations JSON", which is why
113
+ # #545 was filed against a code path that was never involved. The number the
114
+ # callers capture comes from stdout, so diagnostics on stderr change nothing
115
+ # for `count=$(wc_java_json_violation_count ...)`.
116
+ wc_java_diag() {
117
+ echo " wc-java JSON rejected: $*" >&2
118
+ }
109
119
  # ---------------------------------------------------------------------------
110
120
  wc_java_json_violation_count() {
111
121
  local json_file="$1"
112
- [ -s "$json_file" ] || return 1
122
+ if [ ! -s "$json_file" ]; then
123
+ wc_java_diag "empty or missing response file ($json_file)"
124
+ return 1
125
+ fi
113
126
 
114
127
  # Two shapes are accepted, each closed by its own bracket:
115
128
  # - bare array `[ ... ]` — the originally pinned contract ([] = clean);
@@ -122,9 +135,18 @@ wc_java_json_violation_count() {
122
135
  head1=$(head -c 1 "$json_file")
123
136
  tail=$(tr -d '[:space:]' < "$json_file" | tail -c 1)
124
137
  case "$head1" in
125
- "[") shape=array; [ "$tail" = "]" ] || return 1 ;;
126
- "{") shape=object; [ "$tail" = "}" ] || return 1 ;;
127
- *) return 1 ;;
138
+ "[") shape=array
139
+ if [ "$tail" != "]" ]; then
140
+ wc_java_diag "array response is unterminated (first char '[', tail '$tail' not ']') — truncated output?"
141
+ return 1
142
+ fi ;;
143
+ "{") shape=object
144
+ if [ "$tail" != "}" ]; then
145
+ wc_java_diag "object response is unterminated (first char '{', tail '$tail' not '}') — truncated output?"
146
+ return 1
147
+ fi ;;
148
+ *) wc_java_diag "response does not start with a JSON bracket (entry char '$head1') — a banner/log line on stdout would cause this"
149
+ return 1 ;;
128
150
  esac
129
151
 
130
152
  local structural obj_count file_count rule_count sev_count
@@ -155,15 +177,36 @@ wc_java_json_violation_count() {
155
177
  file_count=$(grep -o '"file"' <<< "$structural" | wc -l | tr -d '[:space:]')
156
178
  rule_count=$(grep -o '"rule"' <<< "$structural" | wc -l | tr -d '[:space:]')
157
179
  sev_count=$(grep -o '"severity"' <<< "$structural" | wc -l | tr -d '[:space:]')
158
- [ "$file_count" -eq "$rule_count" ] || return 1
159
- [ "$file_count" -eq "$sev_count" ] || return 1
180
+ if [ "$file_count" -ne "$rule_count" ] || [ "$file_count" -ne "$sev_count" ]; then
181
+ wc_java_diag "issue keys are out of sync (file=$file_count rule=$rule_count severity=$sev_count) — an issue record is missing a required key"
182
+ return 1
183
+ fi
160
184
  # Everything from the "issues" member onward (the tool emits it last; if
161
185
  # a future format moves it, keys before it fall outside record parsing ->
162
186
  # sum mismatch -> BLOCK, fail closed). Flatten to one line first: real
163
187
  # output is pretty-printed across lines.
164
188
  flattened=$(tr '\n' ' ' <<< "$structural")
165
189
  tail_from_issues=$(sed -n 's/.*"issues"[[:space:]]*:[[:space:]]*\[/\n/p' <<< "$flattened")
166
- [ -n "$tail_from_issues" ] || return 1
190
+ if [ -z "$tail_from_issues" ]; then
191
+ # `"issues": null` is a legitimate zero-violation report, not a broken one
192
+ # (#545). The Go producer's nil slice marshals to null, and it does so
193
+ # whenever the scan hid at least one info-level finding -- so a file whose
194
+ # only problems are info-level gets null while a fully clean file gets [].
195
+ # Whichever branch produced it, null means "no visible violations".
196
+ if sed -n 's/.*"issues"[[:space:]]*:[[:space:]]*null.*/found/p' <<< "$flattened" | grep -q found; then
197
+ # null is only trustworthy when the summary agrees there is nothing to
198
+ # report. A nonzero total_issues with null is the lying-tool shape.
199
+ null_total=$(sed -n 's/.*"total_issues"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' <<< "$flattened")
200
+ if [ -n "$null_total" ] && [ "$null_total" -gt 0 ]; then
201
+ wc_java_diag "issues is null but total_issues=$null_total — the tool claims violations it did not report"
202
+ return 1
203
+ fi
204
+ echo 0
205
+ return 0
206
+ fi
207
+ wc_java_diag 'no "issues" array found after the summary object — the response is not the {summary, issues} contract'
208
+ return 1
209
+ fi
167
210
  records=$(awk '
168
211
  { rest = $0
169
212
  depth = 0; rec = ""
@@ -182,15 +225,17 @@ wc_java_json_violation_count() {
182
225
  cf=$(grep -o '"file"' <<< "$_rec" | wc -l | tr -d '[:space:]')
183
226
  cr=$(grep -o '"rule"' <<< "$_rec" | wc -l | tr -d '[:space:]')
184
227
  cs=$(grep -o '"severity"' <<< "$_rec" | wc -l | tr -d '[:space:]')
185
- [ "$cf" -eq 1 ] || return 1
186
- [ "$cr" -eq 1 ] || return 1
187
- [ "$cs" -eq 1 ] || return 1
228
+ if [ "$cf" -ne 1 ] || [ "$cr" -ne 1 ] || [ "$cs" -ne 1 ]; then
229
+ wc_java_diag "issue record ${rec_count} has the wrong key multiplicity (file=$cf rule=$cr severity=$cs, each must be 1)"
230
+ return 1
231
+ fi
188
232
  rf=$((rf + cf)); rr=$((rr + cr)); rs=$((rs + cs))
189
233
  rec_count=$((rec_count + 1))
190
234
  done <<< "$records"
191
- [ "$rf" -eq "$file_count" ] || return 1
192
- [ "$rr" -eq "$rule_count" ] || return 1
193
- [ "$rs" -eq "$sev_count" ] || return 1
235
+ if [ "$rf" -ne "$file_count" ] || [ "$rr" -ne "$rule_count" ] || [ "$rs" -ne "$sev_count" ]; then
236
+ wc_java_diag "per-record key totals do not match the document (records rf=$rf rr=$rr rs=$rs vs document file=$file_count rule=$rule_count severity=$sev_count) — a required key appears outside an issue record"
237
+ return 1
238
+ fi
194
239
  echo "$rec_count"
195
240
  return 0
196
241
  fi
@@ -199,9 +244,10 @@ wc_java_json_violation_count() {
199
244
  file_count=$(grep -o '"file"' <<< "$structural" | wc -l | tr -d '[:space:]')
200
245
  rule_count=$(grep -o '"rule"' <<< "$structural" | wc -l | tr -d '[:space:]')
201
246
  sev_count=$(grep -o '"severity"' <<< "$structural" | wc -l | tr -d '[:space:]')
202
- [ "$obj_count" -eq "$file_count" ] || return 1
203
- [ "$obj_count" -eq "$rule_count" ] || return 1
204
- [ "$obj_count" -eq "$sev_count" ] || return 1
247
+ if [ "$obj_count" -ne "$file_count" ] || [ "$obj_count" -ne "$rule_count" ] || [ "$obj_count" -ne "$sev_count" ]; then
248
+ wc_java_diag "array elements and keys are out of sync (objects=$obj_count file=$file_count rule=$rule_count severity=$sev_count)"
249
+ return 1
250
+ fi
205
251
 
206
252
  echo "$obj_count"
207
253
  }
@@ -223,6 +269,26 @@ run_wc_java_lint() {
223
269
  return 9 # not installed -> caller falls back to the legacy chain
224
270
  fi
225
271
 
272
+ # #550: --staged means "only the Java files in the index", and wc-java-lint
273
+ # 0.4.1 honours that for checkstyle, PMD, lizard and simian -- but spotbugs
274
+ # scans the whole target/classes tree regardless. Measured with only
275
+ # docs/_tmp_probe.md staged: three tools reported "skipped: no Java files"
276
+ # while spotbugs reported "checked 1 .class file, found 3 issues" from a class
277
+ # compiled earlier. The verdict then blocks a docs commit over a warning that
278
+ # predates it and that its author never touched.
279
+ #
280
+ # The adapter cannot fix the tool, but it can refuse to relay a verdict the
281
+ # tool had no basis to produce. With no staged Java file there is nothing this
282
+ # gate is entitled to say, so it says nothing -- out loud, not silently.
283
+ local staged_java
284
+ staged_java=$(java_staged_files 2>/dev/null | grep -c '.' || true)
285
+ if [ "${staged_java:-0}" -eq 0 ]; then
286
+ echo "⏭️ SKIPPED - Java static analysis: no staged .java files (wc-java-lint --staged)"
287
+ echo " (spotbugs scans all of target/classes regardless of --staged, so its"
288
+ echo " verdict is withheld here - upstream defect, see xp-gate #550)"
289
+ return 0
290
+ fi
291
+
226
292
  timeout_s=$(resolve_wc_java_lint_timeout)
227
293
  out_file=$(mktemp "${TMPDIR:-/tmp}/wc-java-lint.XXXXXX")
228
294
  err_file="${out_file}.err"
@@ -17,16 +17,46 @@
17
17
  # SKIP for now.
18
18
 
19
19
  # Detect PowerShell executable (prefer pwsh 7+, fallback to powershell.exe 5.1)
20
+ #
21
+ # #547 finding 4: `command -v pwsh` alone is not enough. Under git-bash with a
22
+ # stripped PATH, the standard Windows installs are invisible even though the
23
+ # user can run pwsh from any terminal: pwsh 7 installs to
24
+ # %LOCALAPPDATA%\Microsoft\WindowsApps (a PATH alias that git-bash does not
25
+ # always inherit) and PS 5.1 lives at System32\WindowsPowerShell\v1.0. Probing
26
+ # those absolute paths converts a silent degrade into a working gate.
27
+ #
28
+ # The order is unchanged: whatever is on PATH wins, absolute paths are a
29
+ # fallback, and an empty result still means "not available" -- this widens
30
+ # detection, it does not invent an interpreter.
20
31
  _detect_pwsh() {
21
32
  if command -v pwsh &>/dev/null 2>&1; then
22
33
  echo "pwsh"
23
- elif command -v powershell.exe &>/dev/null 2>&1; then
34
+ return 0
35
+ fi
36
+ if command -v powershell.exe &>/dev/null 2>&1; then
24
37
  echo "powershell.exe"
25
- elif command -v powershell &>/dev/null 2>&1; then
38
+ return 0
39
+ fi
40
+ if command -v powershell &>/dev/null 2>&1; then
26
41
  echo "powershell"
27
- else
28
- echo ""
42
+ return 0
29
43
  fi
44
+ # Windows absolute-path fallbacks.
45
+ local candidate
46
+ for candidate in \
47
+ "${LOCALAPPDATA:-$HOME/AppData/Local}/Microsoft/WindowsApps/pwsh.exe" \
48
+ "/c/Program Files/PowerShell/7/pwsh.exe" \
49
+ "/c/Program Files/PowerShell/6/pwsh.exe" \
50
+ "/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" \
51
+ "/mnt/c/Program Files/PowerShell/7/pwsh.exe" \
52
+ "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; do
53
+ if [ -n "$candidate" ] && [ -x "$candidate" ]; then
54
+ echo "$candidate"
55
+ return 0
56
+ fi
57
+ done
58
+ echo ""
59
+ return 1
30
60
  }
31
61
 
32
62
  _find_powershell_files() {
@@ -87,18 +117,49 @@ run_static_analysis() {
87
117
  echo "Running PSScriptAnalyzer static analysis on PowerShell scripts..."
88
118
  # Recursively analyze all .ps1 files from repo root
89
119
  # Exit with non-zero if Error or Warning severity issues found
90
- "$PWSH" "${POWERSHELL_ARGS[@]}" "
120
+ local PSSA_RC=0
121
+ local PSSA_OUT
122
+ PSSA_OUT=$("$PWSH" "${POWERSHELL_ARGS[@]}" "
123
+ if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
124
+ Write-Output 'PSSA_MODULE_MISSING'
125
+ exit 0
126
+ }
91
127
  \$results = Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning
92
128
  if (\$results) {
93
129
  \$results | Format-Table -AutoSize
94
130
  exit 1
95
131
  }
96
132
  exit 0
97
- "
98
- return $?
133
+ " 2>&1) || PSSA_RC=$?
134
+ printf '%s\n' "$PSSA_OUT"
135
+ case "$PSSA_RC" in
136
+ 0)
137
+ if printf '%s\n' "$PSSA_OUT" | grep -q 'PSSA_MODULE_MISSING'; then
138
+ # #547 finding 2: the engine exists but its module does not. That is
139
+ # NOT a pass -- nothing was analysed. The XP_G1_REASON protocol (the
140
+ # one the java branch uses) turns this into a WARN that stays in the
141
+ # scoring denominator instead of vanishing.
142
+ echo "XP_G1_REASON: PSScriptAnalyzer module not installed (Install-Module PSScriptAnalyzer)"
143
+ return 3
144
+ fi
145
+ return 0
146
+ ;;
147
+ 1)
148
+ return 1 # real findings -> BLOCK upstream
149
+ ;;
150
+ *)
151
+ echo "XP_G1_REASON: PSScriptAnalyzer run failed (exit $PSSA_RC)"
152
+ return 2 # unusable -> fail closed upstream
153
+ ;;
154
+ esac
99
155
  else
100
- echo "PowerShell not available, skipping PowerShell static analysis"
101
- return 0
156
+ # #547 finding 4: previously `return 0`, i.e. "no PowerShell available"
157
+ # was indistinguishable from "PowerShell is clean" -- a silent false pass on
158
+ # a PowerShell commit. Return the not-available verdict so the caller can
159
+ # WARN and record a reason.
160
+ echo "PowerShell executable not found (probe: pwsh, powershell.exe, and the standard Windows install paths)"
161
+ echo "XP_G1_REASON: pwsh not available (install PowerShell 7: https://aka.ms/powershell)"
162
+ return 3
102
163
  fi
103
164
  }
104
165
 
package/gate-10.sh CHANGED
@@ -12,7 +12,20 @@ GATE_10_START=$(gate_start_ms)
12
12
  GATE_10_STATUS=""
13
13
  # #536: audit trail for "did this commit actually get scanned?". The report
14
14
  # surfaces reason + scanned_files so a non-scan never reads as a clean PASS.
15
+ #
16
+ # GATE_10_REASON and GATE_10_SCANNED_FILES are consumed by the sourcing parent
17
+ # (pre-commit builds its report JSON from them after `source gate-10.sh`), not
18
+ # inside this file. The linter checks one file at a time and cannot see the
19
+ # consumer, so each assignment is flagged SC2034 "appears unused" -- which
20
+ # blocks Gate 1 for any repo that vendors this adapter, because that repo's own
21
+ # lint pass runs over the vendored copy (#548). Each assignment carries a
22
+ # scoped directive with that reason stated at the site.
23
+ #
24
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
25
+ # shellcheck disable=SC2034
15
26
  GATE_10_REASON=""
27
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
28
+ # shellcheck disable=SC2034
16
29
  GATE_10_SCANNED_FILES=0
17
30
 
18
31
  # Semgrep availability check
@@ -29,14 +42,21 @@ if [ -z "$SEMGREP_CMD" ]; then
29
42
  echo " Pre-cache rules: semgrep --config=p/security-audit"
30
43
  echo " Gate 10: SAST Security (WARN, semgrep not installed)"
31
44
  GATE_10_STATUS="WARN"
45
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
46
+ # shellcheck disable=SC2034
32
47
  GATE_10_REASON="tool_not_installed"
33
48
  else
34
- # Get staged files filtered to Semgrep-supported languages
35
- SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
49
+ # Get staged files filtered to Semgrep-supported languages.
50
+ # #547 finding 3: ps1|psm1 were missing, so a PowerShell-only commit never
51
+ # reached semgrep even when Gate 10 ran -- the SAST gate was simply absent
52
+ # for that language.
53
+ SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift|ps1|psm1)$' || true)
36
54
 
37
55
  if [ -z "$SEMGREP_FILES" ]; then
38
56
  echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
39
57
  GATE_10_STATUS="SKIP"
58
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
59
+ # shellcheck disable=SC2034
40
60
  GATE_10_REASON="no_supported_files"
41
61
  else
42
62
  # One path per line into an array. Quoting the newline-joined list would hand
@@ -71,11 +91,27 @@ else
71
91
  [ -n "$_semgrep_file" ] || continue
72
92
  SEMGREP_FILE_COUNT=$((SEMGREP_FILE_COUNT + 1))
73
93
  SEMGREP_ARGS+=("--include=${_semgrep_file##*/}")
74
- _semgrep_dir="./${_semgrep_file%/*}"
75
- # A repo-root file has no directory component ("./x.ts" strips to "./").
76
- [ "$_semgrep_dir" = "./" ] && _semgrep_dir="."
94
+ # A repo-root file has no slash at all, so "%/*" returns it unchanged and
95
+ # the old comment's assumption (`"./x.ts" strips to "./"`) never held:
96
+ # the target became the FILE path dressed as a directory ("./x.ts").
97
+ # Strip only when a slash is present; otherwise the target is the repo
98
+ # root, spelled ".". Scanning "." widens the scan to the whole tree, which
99
+ # is the safe side (see the note above) and is what a root-level staged
100
+ # file requires in order to be analysed at all.
101
+ case "$_semgrep_file" in
102
+ */*) _semgrep_dir="./${_semgrep_file%/*}" ;;
103
+ *) _semgrep_dir="." ;;
104
+ esac
105
+ # #558: dedup must match WHOLE lines. The previous `case "$SEEN" in
106
+ # *"$_semgrep_dir"*)` was an unanchored substring test: once
107
+ # ./fastdl/tests was recorded, the parent ./fastdl matched it and was
108
+ # never added as a target, so a staged fastdl/a.py contributed an
109
+ # --include= with no directory to scan it in. Nothing errored and nothing
110
+ # was reported -- the file was simply not analysed, which for a security
111
+ # gate is the worst outcome: a silent net loss that looks like a pass.
112
+ # Sibling prefixes (src/api vs src/app) collapsed the same way.
77
113
  case "$SEMGREP_DIRS_SEEN" in
78
- *"$_semgrep_dir"*) : ;;
114
+ *$'\n'"$_semgrep_dir"$'\n'*) : ;;
79
115
  *) SEMGREP_ARGS+=("$_semgrep_dir"); SEMGREP_DIRS_SEEN="$SEMGREP_DIRS_SEEN$_semgrep_dir"$'\n' ;;
80
116
  esac
81
117
  done <<EOF
@@ -91,6 +127,8 @@ EOF
91
127
  echo " Install node (https://nodejs.org); without it SAST findings cannot be classified"
92
128
  echo " ⏭️ SKIPPED - SAST (node not installed)"
93
129
  GATE_10_STATUS="SKIP"
130
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
131
+ # shellcheck disable=SC2034
94
132
  GATE_10_REASON="tool_not_installed"
95
133
  else
96
134
 
@@ -109,9 +147,41 @@ EOF
109
147
  # code is demoted to "did the tool itself run". --error stays OFF: it would
110
148
  # add nothing the JSON does not already provide, and keeping it off leaves
111
149
  # failure exit codes unambiguous for the #475 diagnostics path.
112
- SEMGREP_STDOUT_FILE=$(mktemp)
113
- SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
114
- SEMGREP_EXIT=$?
150
+ # #559: bound the run. Without a timeout, a wedged semgrep blocks the
151
+ # invocation for as long as its process lives; the fail-closed verdict logic
152
+ # below is correct but unreachable, so the hook hangs instead of judging.
153
+ # A gate that can wedge an interactive `git commit` gets bypassed with
154
+ # --no-verify, which is worse than any verdict it could have produced.
155
+ # `-k` after the primary signal: pysemgrep spinning in a syscall ignores
156
+ # SIGTERM, and the shipped 0.29 code used the same `-k 10` shape.
157
+ SEMGREP_TIMEOUT_S="${XP_GATE_SEMGREP_TIMEOUT:-180}"
158
+ SEMGREP_TIMED_OUT=false
159
+ if command -v timeout >/dev/null 2>&1; then
160
+ SEMGREP_STDOUT_FILE=$(mktemp)
161
+ SEMGREP_OUTPUT=$(timeout -k 10 "$SEMGREP_TIMEOUT_S" \
162
+ "$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check \
163
+ "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
164
+ SEMGREP_EXIT=$?
165
+ # 124 = timeout's own "I gave up" code. 137 = 128+SIGKILL, which is what
166
+ # `timeout -k` returns when the child ignored SIGTERM and had to be
167
+ # killed -- measured with a semgrep stand-in that traps TERM. Both mean
168
+ # "bounded, no verdict", so both must be reported as a timeout; checking
169
+ # only 124 told the user their semgrep was unparseable when in fact the
170
+ # scan had been cut off.
171
+ case "$SEMGREP_EXIT" in
172
+ 124|137) SEMGREP_TIMED_OUT=true ;;
173
+ esac
174
+ else
175
+ # No `timeout` on this platform (stock macOS). Say so: an unbounded run
176
+ # nobody mentioned is exactly how #559 stayed invisible for a whole
177
+ # release. The scan still runs -- refusing to scan would be a worse
178
+ # trade -- but the unbounded window is now stated.
179
+ echo " ⚠️ 'timeout' command not available — semgrep runs UNBOUNDED (install coreutils or set up gtimeout)"
180
+ SEMGREP_STDOUT_FILE=$(mktemp)
181
+ SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check \
182
+ "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
183
+ SEMGREP_EXIT=$?
184
+ fi
115
185
 
116
186
  # One node pass over the JSON: first line = verdict counts, remaining lines =
117
187
  # finding details. The severity vocabulary is the UNION of the two scales
@@ -153,7 +223,18 @@ console.log("");});
153
223
  if [ "$V_PARSE" != "OK" ] || [ -z "$V_RESULTS" ] || [ -z "$V_ERRORS" ] || [ -z "$V_BLOCKING" ] || [ -z "$V_WARNINGS" ]; then
154
224
  # Unparseable output: nothing the exit code claims can be trusted (#518).
155
225
  # Fail closed — SKIP with diagnostics, never a PASS.
156
- echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
226
+ #
227
+ # #559: name a timeout as a timeout. The generic "unparseable" message
228
+ # would be true but useless here -- it points at the JSON parser when the
229
+ # real problem is that the scan was cut off. Same verdict (SKIP), but a
230
+ # diagnosis the reader can act on.
231
+ if [ "$SEMGREP_TIMED_OUT" = true ]; then
232
+ echo " ⏱️ semgrep exceeded ${SEMGREP_TIMEOUT_S}s and was killed — no verdict is possible"
233
+ echo " This is NOT a finding and NOT a clean scan. Raise XP_GATE_SEMGREP_TIMEOUT"
234
+ echo " if the repo legitimately takes longer; investigate if semgrep itself is wedged."
235
+ else
236
+ echo " ⚠️ semgrep output unparseable or verdict fields missing (exit code ${SEMGREP_EXIT}) — cannot determine verdict"
237
+ fi
157
238
  if [ -n "$SEMGREP_OUTPUT" ]; then
158
239
  echo " ── what semgrep reported on stderr ──"
159
240
  printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
@@ -162,9 +243,19 @@ console.log("");});
162
243
  echo " ── what semgrep wrote to stdout (tail) ──"
163
244
  printf '%s\n' "$SEMGREP_STDOUT_TAIL" | sed 's/^/ /'
164
245
  fi
165
- echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
166
- GATE_10_STATUS="SKIP"
167
- GATE_10_REASON="output_unparseable"
246
+ if [ "$SEMGREP_TIMED_OUT" = true ]; then
247
+ echo " ⏭️ SKIPPED - SAST (semgrep timed out after ${SEMGREP_TIMEOUT_S}s)"
248
+ GATE_10_STATUS="SKIP"
249
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
250
+ # shellcheck disable=SC2034
251
+ GATE_10_REASON="semgrep_timeout"
252
+ else
253
+ echo " ⏭️ SKIPPED - SAST (semgrep output unparseable)"
254
+ GATE_10_STATUS="SKIP"
255
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
256
+ # shellcheck disable=SC2034
257
+ GATE_10_REASON="output_unparseable"
258
+ fi
168
259
  elif [ "$V_RESULTS" -eq 0 ] && { [ "$V_ERRORS" -gt 0 ] || [ "$SEMGREP_EXIT" -ne 0 ]; }; then
169
260
  # Nothing was scanned to a verdict: semgrep either failed outright
170
261
  # (nonzero exit) or completed but reported scan errors for every input
@@ -178,6 +269,8 @@ console.log("");});
178
269
  fi
179
270
  echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
180
271
  GATE_10_STATUS="SKIP"
272
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
273
+ # shellcheck disable=SC2034
181
274
  GATE_10_REASON="semgrep_runtime_error"
182
275
  elif [ "$V_BLOCKING" -gt 0 ]; then
183
276
  echo ""
@@ -192,9 +285,13 @@ console.log("");});
192
285
  echo "$FINDING_DETAILS"
193
286
  echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
194
287
  GATE_10_STATUS="FAIL"
288
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
289
+ # shellcheck disable=SC2034
195
290
  GATE_10_SCANNED_FILES=$SEMGREP_FILE_COUNT
196
291
  exit 1
197
292
  else
293
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
294
+ # shellcheck disable=SC2034
198
295
  GATE_10_SCANNED_FILES=$SEMGREP_FILE_COUNT
199
296
  echo ""
200
297
  echo " ✅ PASSED - No blocking vulnerabilities (files scanned: $SEMGREP_FILE_COUNT)"
package/gate-3.sh CHANGED
@@ -8,11 +8,15 @@
8
8
  2>&1 echo "→ Gate 3: Cyclomatic complexity..."
9
9
  GATE_3_START=$(gate_start_ms)
10
10
  # #537: every non-run records WHY so the report can say why a gate is SKIP.
11
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
12
+ # shellcheck disable=SC2034
11
13
  GATE_3_REASON=""
12
14
 
13
15
  if [ "$PROJECT_LANG" = "documentation-only" ]; then
14
16
  echo "⏭️ SKIPPED - Complexity (documentation project)."
15
17
  GATE_3_STATUS="SKIP"
18
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
19
+ # shellcheck disable=SC2034
16
20
  GATE_3_REASON="documentation_project"
17
21
 
18
22
  elif [ "$PROJECT_LANG" = "powershell" ]; then
@@ -21,6 +25,8 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
21
25
  GATE_3_STATUS="SKIP"
22
26
  # #537: a PowerShell-primary repo was invisible to this dimension while the
23
27
  # score implied full coverage — say exactly why nothing ran.
28
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
29
+ # shellcheck disable=SC2034
24
30
  GATE_3_REASON="no_powershell_tool"
25
31
 
26
32
  else
@@ -159,7 +165,10 @@ else
159
165
  # empty stdin — the verdict comes back empty and Gate 3 passes on zero
160
166
  # rows (measured while wiring #544: same input passed or blocked
161
167
  # depending only on where the prefix sat).
168
+ # Consumed by evaluate_ccn_ratchet in the sourced lib, not here.
169
+ # shellcheck disable=SC2034
162
170
  CCN_WARN_THRESHOLD="$CCN_THRESHOLD"
171
+ # shellcheck disable=SC2034
163
172
  CCN_BLOCK_THRESHOLD=$((CCN_THRESHOLD * 2))
164
173
  RATCHET_VERDICT=$(printf '%s\n' "$CC_CSV" | evaluate_ccn_ratchet ".ccn-baseline.json")
165
174
  CC_VIOLATIONS=$(printf '%s\n' "$RATCHET_VERDICT" | grep -c '^BLOCK ' || true)
@@ -205,6 +214,8 @@ else
205
214
  END { exit(found ? 0 : 1) }'; then
206
215
  echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
207
216
  GATE_3_STATUS="WARN"
217
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
218
+ # shellcheck disable=SC2034
208
219
  GATE_3_REASON="lizard_output_unparsable"
209
220
  elif [ "$CC_WARNINGS" -gt 0 ]; then
210
221
  echo "$CC_OUTPUT"
@@ -212,6 +223,8 @@ else
212
223
  echo "❌ BLOCKED - $CC_WARNINGS functions with CCN > $CCN_THRESHOLD found."
213
224
  echo "Refactor high-complexity functions to keep below $CCN_THRESHOLD complexity."
214
225
  GATE_3_STATUS="BLOCK"
226
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
227
+ # shellcheck disable=SC2034
215
228
  GATE_3_REASON="ccn_over_threshold"
216
229
  exit 1
217
230
  else
@@ -222,6 +235,8 @@ else
222
235
  else
223
236
  echo "⏭️ SKIPPED - Complexity (no source files to check)."
224
237
  GATE_3_STATUS="SKIP"
238
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
239
+ # shellcheck disable=SC2034
225
240
  GATE_3_REASON="no_source_files_changed"
226
241
  fi
227
242
  else
@@ -229,6 +244,8 @@ else
229
244
  echo " Install with: pip install --user lizard"
230
245
  echo " Gate 3: Complexity check (WARN, tool not available)"
231
246
  GATE_3_STATUS="WARN"
247
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
248
+ # shellcheck disable=SC2034
232
249
  GATE_3_REASON="tool_not_installed"
233
250
  fi
234
251
  fi