@boyingliu01/xp-gate 0.31.0 → 0.31.2

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 (85) hide show
  1. package/adapter-common.sh +41 -3
  2. package/adapters/gate-3.sh +8 -1
  3. package/adapters/gate-8.sh +67 -6
  4. package/adapters/go.sh +18 -1
  5. package/adapters/iac.sh +18 -1
  6. package/adapters/powershell.sh +110 -7
  7. package/adapters/python.sh +18 -4
  8. package/gate-3.sh +10 -1
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +10 -1
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/coverage-total.sh +20 -5
  14. package/hooks/lib/gate-5c-alignment.sh +145 -0
  15. package/hooks/lib/jscpd-run.sh +40 -1
  16. package/hooks/lib/native-path.sh +106 -0
  17. package/hooks/lib/prepush-score.py +143 -0
  18. package/hooks/lib/sprint-gate-report.sh +23 -0
  19. package/hooks/lib/toolchain-path.sh +370 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  21. package/hooks/pre-commit +371 -163
  22. package/hooks/pre-push +192 -74
  23. package/hooks/sprint-gate.sh +14 -3
  24. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  25. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  26. package/lib/__tests__/doctor-delphi.test.js +46 -0
  27. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  28. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  30. package/lib/__tests__/doctor.test.js +7 -1
  31. package/lib/__tests__/hook-health-path.test.js +9 -1
  32. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  33. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  34. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  35. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  36. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  37. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  38. package/lib/arch.js +261 -44
  39. package/lib/check-alignment-runner.cjs +23 -0
  40. package/lib/check.js +4 -1
  41. package/lib/doctor.js +194 -49
  42. package/lib/gate-runner.js +265 -58
  43. package/lib/mattpocock-license-verdict.json +1 -1
  44. package/lib/principles.js +228 -6
  45. package/lib/shared-paths.js +53 -19
  46. package/mock-policy/AGENTS.md +3 -3
  47. package/mutation/AGENTS.md +3 -3
  48. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  49. package/package.json +1 -1
  50. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  51. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  53. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  54. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  55. package/plugins/dsh/package.json +1 -1
  56. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  57. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  58. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  59. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  60. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  61. package/plugins/dsh/src/delphi-config.ts +151 -9
  62. package/plugins/opencode/package.json +1 -1
  63. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  64. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  65. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  66. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  67. package/plugins/qoder/plugin.json +1 -1
  68. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  69. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  70. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  71. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  72. package/principles/AGENTS.md +3 -3
  73. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  74. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  75. package/principles/adapters/java.ts +127 -11
  76. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  77. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  78. package/principles/rules/solid/lsp.ts +348 -23
  79. package/scripts/__tests__/sync-package-content.test.js +32 -1
  80. package/scripts/sync-package-content.js +6 -1
  81. package/skills/delphi-review/AGENTS.md +3 -3
  82. package/skills/delphi-review/SKILL.md +50 -0
  83. package/skills/sprint-flow/AGENTS.md +3 -3
  84. package/skills/test-specification-alignment/AGENTS.md +3 -3
  85. package/sprint-gate.sh +14 -3
package/adapter-common.sh CHANGED
@@ -155,19 +155,57 @@ require_tool() {
155
155
  local tool_name="$1"
156
156
  local gate_name="${2:-Gate}"
157
157
  local install_hint="${3:-}"
158
-
158
+
159
+ # #593: on Windows the hook runs under git-bash, which does not see the
160
+ # toolchains installed on the Windows side (%APPDATA%\npm, the node dir,
161
+ # pip --user Scripts, ~/bin). Bootstrapping before probing turns "not on this
162
+ # shell's PATH" back into "found", instead of reporting an installed tool as
163
+ # missing. lib/toolchain-path.sh is sourced by pre-commit before this runs; the
164
+ # guard keeps require_tool usable from adapters that load on their own.
165
+ if command -v bootstrap_toolchain_path >/dev/null 2>&1; then
166
+ bootstrap_toolchain_path
167
+ fi
168
+
169
+ # #580: on WSL (appendWindowsPath=false) and in git-bash, `command -v node`
170
+ # returns 1 while node.exe sits in the same directory and runs. One root cause
171
+ # gave Gate 1 a silent SKIP, Gate 1 a false "Biome check failed" and Gate 8 a
172
+ # false "gitleaks not installed" — an environment fault reported as a code
173
+ # verdict in all three. tool_is_available also counts the .exe suffix and the
174
+ # XP_GATE_<TOOL>_BIN override.
175
+ if command -v tool_is_available >/dev/null 2>&1 && tool_is_available "$tool_name"; then
176
+ return 0
177
+ fi
178
+
159
179
  if command -v "$tool_name" >/dev/null 2>&1; then
160
180
  return 0
161
181
  fi
162
-
182
+
183
+ if command -v "${tool_name}.exe" >/dev/null 2>&1; then
184
+ return 0
185
+ fi
186
+
163
187
  if command -v npx >/dev/null 2>&1 && npx --no-install "$tool_name" --version >/dev/null 2>&1; then
164
188
  return 0
165
189
  fi
166
-
190
+
191
+ # #593: monorepos keep the tool in a subproject's node_modules/.bin, where
192
+ # neither PATH nor `npx` from the repo root can see it.
193
+ if command -v resolve_tool_path >/dev/null 2>&1 && resolve_tool_path "$tool_name" >/dev/null 2>&1; then
194
+ if command -v ensure_tool_dir_on_path >/dev/null 2>&1; then
195
+ ensure_tool_dir_on_path "$tool_name" || true
196
+ fi
197
+ return 0
198
+ fi
199
+
167
200
  echo "❌ BLOCKED - Required tool '$tool_name' not available for $gate_name"
168
201
  if [ -n "$install_hint" ]; then
169
202
  echo " Install: $install_hint"
170
203
  fi
204
+ # #593 point 3: "not installed" and "installed but not found" need different
205
+ # fixes, so name every location that was searched.
206
+ if command -v describe_tool_search >/dev/null 2>&1; then
207
+ describe_tool_search "$tool_name"
208
+ fi
171
209
  echo " Per QUALITY-GATES-CODE-OF-CONDUCT.md: tool unavailable = BLOCK, not SKIP"
172
210
  return 1
173
211
  }
@@ -109,7 +109,14 @@ else
109
109
  GATE_3_STATUS="PASS"
110
110
  fi
111
111
  else
112
- echo "⏭️ SKIPPED - Complexity (no source files to check)."
112
+ # #589: "no source files to check" described an empty PROJECT when the real
113
+ # cause is an empty CHANGED_FILES. The old wording read like a pass.
114
+ if [ -n "${GATE_SCOPE:-}" ] && [ "$GATE_SCOPE" = "all" ]; then
115
+ echo "⏭️ SKIPPED - Complexity: no tracked source files in this repository (scope: --all, whole tree)."
116
+ else
117
+ echo "⏭️ SKIPPED - Complexity: no changed source files in this run (scope: diff)."
118
+ echo " Run 'xp-gate check . --all' to scan the whole tree."
119
+ fi
113
120
  GATE_3_STATUS="SKIP"
114
121
  GATE_3_REASON="no_source_files_changed"
115
122
  fi
@@ -33,10 +33,23 @@ fi
33
33
 
34
34
  GITLEAKS_CONFIG=""
35
35
  if [ -f ".gitleaks.toml" ]; then
36
- # gitleaks is a native binary and may not understand MSYS paths, so pass an
36
+ # gitleaks is a native binary and may not understand MSYS/WSL paths, so pass an
37
37
  # absolute path in the host's own format when we can derive one (#449).
38
- if command -v cygpath >/dev/null 2>&1; then
38
+ #
39
+ # #582 found this the hard way: `cygpath` exists only in Git-for-Windows/MSYS, and on
40
+ # WSL its absence meant the fallback handed `/mnt/d/projects/<repo>/.gitleaks.toml`
41
+ # to the native gitleaks.exe. It could not open the file, exited 1, and Gate 8
42
+ # reported "Secrets detected". The conversion now lives in a shared library so the
43
+ # next gate handing a path to a native binary does not re-derive it (#581).
44
+ #
45
+ # This script may also run standalone (archived/copied hook) without the library, so
46
+ # the converter is used when present and the inline branch kept as the fallback.
47
+ if declare -F to_native_path >/dev/null 2>&1; then
48
+ GITLEAKS_CONFIG="--config=$(to_native_path "$(pwd)/.gitleaks.toml")"
49
+ elif command -v cygpath >/dev/null 2>&1; then
39
50
  GITLEAKS_CONFIG="--config=$(cygpath -w "$(pwd)/.gitleaks.toml")"
51
+ elif command -v wslpath >/dev/null 2>&1; then
52
+ GITLEAKS_CONFIG="--config=$(wslpath -w "$(pwd)/.gitleaks.toml")"
40
53
  else
41
54
  GITLEAKS_CONFIG="--config=$(pwd)/.gitleaks.toml"
42
55
  fi
@@ -75,10 +88,41 @@ if [ "$GITLEAKS_EXIT" -eq 0 ]; then
75
88
  echo " ✅ PASSED - No secrets detected."
76
89
  GATE_8_STATUS="PASS"
77
90
  elif [ "$GITLEAKS_FINDINGS" = "?" ]; then
78
- # Exit 0 but no readable report: we have no evidence the scan happened.
79
- # Fail closed on the *claim*: report SKIP, never PASS.
80
- echo " ⚠️ gitleaks exited 0 but produced no readable report"
81
- echo " ⏭️ SKIPPED - secret scanning produced no evidence (not a pass)"
91
+ # #592: exit 0 with no usable report has TWO very different causes, and the
92
+ # operator can only act if we tell them which one happened:
93
+ #
94
+ # (a) the scan never ran — config unreadable, FTL, bad --report-path.
95
+ # Nothing was checked, so this must fail closed with the tool's own
96
+ # words, not be filed as "no evidence about the code".
97
+ # (b) the scan ran and found nothing, but the tool wrote no file (reported
98
+ # for gitleaks 8.30.1 on WSL; measured NOT to happen in git-bash with
99
+ # the same version, so the code must handle both). Only stdout is left,
100
+ # and stdout is weaker evidence than an artifact, so it still may not
101
+ # produce a PASS.
102
+ #
103
+ # The order matters and is the point: artifact first, tool failure second,
104
+ # tool output last (#449 established the principle; #592 re-opens it).
105
+ if printf '%s\n' "$GITLEAKS_OUTPUT" | grep -qiE '(^|[[:space:]])(FTL|fatal error)[[:space:]:]|unable to load'; then
106
+ echo " ❌ BLOCKED - ENVIRONMENT: gitleaks exited 0 but reported a failure, so"
107
+ echo " NO secret scan took place. This is a tooling problem, not a verdict"
108
+ echo " about the staged code."
109
+ printf '%s\n' "$GITLEAKS_OUTPUT" | grep -iE 'FTL|fatal|unable' | head -3 | sed 's/^/ /'
110
+ echo " Fix the gitleaks config/invocation above and commit again."
111
+ exit 1
112
+ fi
113
+
114
+ if printf '%s\n' "$GITLEAKS_OUTPUT" | grep -q 'no leaks found'; then
115
+ # The scan demonstrably ran and found nothing, but there is no artifact to
116
+ # prove it beyond this line. Reporting PASS from log wording is exactly what
117
+ # #449 forbade, so this stays SKIP — and says WHY, which the old message did
118
+ # not: without that, SKIP is indistinguishable from "tool missing".
119
+ echo " ⚠️ gitleaks scanned and reported no leaks, but wrote no report file"
120
+ echo " (${GITLEAKS_REPORT} missing). No artifact means no PASS (#449)."
121
+ echo " ⏭️ SKIPPED - secret scanning produced no artifact (scan reported clean)."
122
+ else
123
+ echo " ⚠️ gitleaks exited 0 but produced no readable report and no scan summary"
124
+ echo " ⏭️ SKIPPED - secret scanning produced no evidence (not a pass)"
125
+ fi
82
126
  GATE_8_STATUS="SKIP"
83
127
  else
84
128
  echo " ❌ BLOCKED - gitleaks reported $GITLEAKS_FINDINGS finding(s)"
@@ -88,6 +132,23 @@ if [ "$GITLEAKS_EXIT" -eq 0 ]; then
88
132
  exit 1
89
133
  fi
90
134
  elif [ "$GITLEAKS_EXIT" -eq 1 ]; then
135
+ # #582: gitleaks exits 1 for BOTH "leaks found" and "I could not start" (unreadable
136
+ # config, bad path, IO error). Treating every exit 1 as a finding produced the
137
+ # loudest possible wrong answer: "Secrets detected in staged files", plus
138
+ # instructions to rotate a key, on a repository where nothing had been scanned --
139
+ # because WSL handed a POSIX path to the native binary (#582's own case).
140
+ #
141
+ # #592 established this distinction for exit 0 + FTL. The same test applies here,
142
+ # and the order matters as much: a scanner that could not start must never be
143
+ # reported as having found something, or the gate teaches people to disable it.
144
+ if printf '%s\n' "$GITLEAKS_OUTPUT" | grep -qiE '(^|[[:space:]])(FTL|fatal error)[[:space:]:]|unable to load|cannot find the path'; then
145
+ echo " ❌ BLOCKED - ENVIRONMENT: gitleaks exited 1 because it could not start, so"
146
+ echo " NO secret scan took place. This is a tooling problem, not a verdict"
147
+ echo " about the staged code."
148
+ printf '%s\n' "$GITLEAKS_OUTPUT" | grep -iE 'FTL|fatal|unable|cannot find' | head -3 | sed 's/^/ /'
149
+ echo " Fix the gitleaks config/invocation above and commit again."
150
+ exit 1
151
+ fi
91
152
  # Secrets found — output details
92
153
  echo "$GITLEAKS_OUTPUT"
93
154
  echo ""
package/adapters/go.sh CHANGED
@@ -4,7 +4,24 @@
4
4
  # Tools: go vet, golangci-lint, arch-go (architecture), go test
5
5
 
6
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
- source "$SCRIPT_DIR/../adapter-common.sh" 2>/dev/null || true
7
+ # #597: the library can be beside the adapters or one level up, depending on how
8
+ # the package was installed:
9
+ # global install ~/.config/xp-gate/adapters/adapter-common.sh (inside)
10
+ # npm package src/npm-package/adapters/../adapter-common.sh (above)
11
+ # The old single "$SCRIPT_DIR/../adapter-common.sh" only ever matched the second,
12
+ # so in the global layout the library never loaded - and "2>/dev/null || true"
13
+ # made that indistinguishable from success, leaving every shared function
14
+ # undefined with no warning.
15
+ for _ac_candidate in \
16
+ "$SCRIPT_DIR/adapter-common.sh" \
17
+ "$SCRIPT_DIR/../adapter-common.sh"; do
18
+ if [ -f "$_ac_candidate" ]; then
19
+ # shellcheck source=/dev/null
20
+ source "$_ac_candidate"
21
+ break
22
+ fi
23
+ done
24
+ unset _ac_candidate
8
25
 
9
26
  run_static_analysis() {
10
27
  require_tool go "Go SDK" || return 1
package/adapters/iac.sh CHANGED
@@ -5,7 +5,24 @@
5
5
  # Tools: checkov (primary), hadolint (Docker), kube-score (K8s)
6
6
 
7
7
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
- source "$SCRIPT_DIR/../adapter-common.sh" 2>/dev/null || true
8
+ # #597: the library can be beside the adapters or one level up, depending on how
9
+ # the package was installed:
10
+ # global install ~/.config/xp-gate/adapters/adapter-common.sh (inside)
11
+ # npm package src/npm-package/adapters/../adapter-common.sh (above)
12
+ # The old single "$SCRIPT_DIR/../adapter-common.sh" only ever matched the second,
13
+ # so in the global layout the library never loaded - and "2>/dev/null || true"
14
+ # made that indistinguishable from success, leaving every shared function
15
+ # undefined with no warning.
16
+ for _ac_candidate in \
17
+ "$SCRIPT_DIR/adapter-common.sh" \
18
+ "$SCRIPT_DIR/../adapter-common.sh"; do
19
+ if [ -f "$_ac_candidate" ]; then
20
+ # shellcheck source=/dev/null
21
+ source "$_ac_candidate"
22
+ break
23
+ fi
24
+ done
25
+ unset _ac_candidate
9
26
 
10
27
  # Detect IaC files in changed files
11
28
  detect_iac_files() {
@@ -81,6 +81,11 @@ _find_powershell_files() {
81
81
  local entry
82
82
  while IFS= read -r entry; do
83
83
  entry="${entry%$'\r'}"
84
+ # #578: strip a UTF-8 BOM before anything else. PowerShell 5.1's
85
+ # `Set-Content -Encoding UTF8` writes one, and it glues itself to the first
86
+ # line -- so the first directory name would silently never match, and a
87
+ # leading '#' comment would stop looking like a comment.
88
+ entry="${entry#$'\xEF\xBB\xBF'}"
84
89
  # Strip surrounding whitespace, then the ./ prefix and trailing / a repo
85
90
  # may reasonably write; -name matches a bare directory name.
86
91
  entry="${entry#"${entry%%[![:space:]]*}"}"
@@ -89,6 +94,30 @@ _find_powershell_files() {
89
94
  entry="${entry#./}"
90
95
  entry="${entry%/}"
91
96
  [ -n "$entry" ] || continue
97
+
98
+ # #574: discovery feeds THREE denominators at once (test discovery,
99
+ # coverage, static-analysis scope), so a bad entry does not merely fail to
100
+ # prune -- it silently shrinks all three while the file still looks
101
+ # reasonable.
102
+ # -name '*' matches EVERY directory -> prunes the whole tree -> the
103
+ # gate analyses nothing and still exits 0.
104
+ # -name 'a/b' matches nothing (names have no separators) -> a silent
105
+ # no-op that reads as working config.
106
+ # Only bare directory NAMES are honoured; anything else is reported and
107
+ # ignored. Reported, not silently dropped: an ignored entry is a config
108
+ # error the operator has to see.
109
+ case "$entry" in
110
+ */*|*\\*|*'*'*|*'?'*|*'['*)
111
+ echo " ⚠️ $prune_file: ignoring invalid entry '$entry' (must be a bare directory name, no separators or glob metacharacters)" >&2
112
+ continue
113
+ ;;
114
+ .|..)
115
+ echo " ⚠️ $prune_file: ignoring invalid entry '$entry'" >&2
116
+ continue
117
+ ;;
118
+ esac
119
+
120
+ echo " ℹ️ pruning directory name '$entry' (from $prune_file)" >&2
92
121
  prune_args[${#prune_args[@]}]=-o
93
122
  prune_args[${#prune_args[@]}]=-name
94
123
  prune_args[${#prune_args[@]}]="$entry"
@@ -109,6 +138,10 @@ _find_powershell_files() {
109
138
  -name .workbuddy -o \
110
139
  -name .sprint-state -o \
111
140
  -name wrc-drill -o \
141
+ -name .xp-gate -o \
142
+ -name .vscode -o \
143
+ -name .idea -o \
144
+ -name .qoder -o \
112
145
  -path './plugins/claude-code/skills' -o \
113
146
  -path './plugins/opencode/skills' -o \
114
147
  -path './plugins/qoder/skills' -o \
@@ -155,6 +188,7 @@ run_static_analysis() {
155
188
  local PSSA_OUT
156
189
  local scope_file=".xp-gate-powershell-analysis-paths"
157
190
  local analysis_targets=()
191
+ local scope_missing=0
158
192
 
159
193
  # #571: repo-local analysis scope. Pester test files legitimately trip
160
194
  # dozens of rule warnings under the Pester execution model, so a whole-repo
@@ -175,6 +209,12 @@ run_static_analysis() {
175
209
  local declared=0
176
210
  while IFS= read -r target_line; do
177
211
  target_line="${target_line%$'\r'}"
212
+ # #578: a UTF-8 BOM (PowerShell 5.1's `Set-Content -Encoding UTF8` writes
213
+ # one) glues itself to the FIRST line. On a path that manufactures a
214
+ # nonexistent entry -- and since #576 that returns 3, so a perfectly valid
215
+ # scope file would fail closed on every commit. On the '#' header it turns
216
+ # a comment into a phantom path. Strip it before classifying.
217
+ target_line="${target_line#$'\xEF\xBB\xBF'}"
178
218
  # Strip surrounding whitespace before classifying: a line of spaces is
179
219
  # not a path, and `case` alone would treat it as one — measured, a
180
220
  # whitespace-only line became an "analysis path not found" entry.
@@ -186,6 +226,7 @@ run_static_analysis() {
186
226
  # shellcheck disable=SC2004 # explicit index arithmetic on an array
187
227
  analysis_targets[${#analysis_targets[@]}]="$target_line"
188
228
  else
229
+ scope_missing=$((scope_missing + 1))
189
230
  echo " ℹ️ analysis path not found (skipped): $target_line"
190
231
  echo "XP_G1_REASON: analysis-path-missing:$target_line"
191
232
  fi
@@ -218,13 +259,18 @@ run_static_analysis() {
218
259
  try { Import-Module PSScriptAnalyzer -ErrorAction Stop | Out-Null } catch { exit 3 }
219
260
  \$settings = Join-Path (Get-Location) 'PSScriptAnalyzerSettings.psd1'
220
261
  \$all = @()
221
- foreach (\$p in $target_array) {
222
- \$recurse = [System.IO.Directory]::Exists(\$p)
223
- if (Test-Path -LiteralPath \$settings) {
224
- \$all += @(Invoke-ScriptAnalyzer -Path \$p -Recurse:\$recurse -Severity Error,Warning -Settings \$settings -ErrorAction Stop)
225
- } else {
226
- \$all += @(Invoke-ScriptAnalyzer -Path \$p -Recurse:\$recurse -Severity Error,Warning -ErrorAction Stop)
262
+ try {
263
+ foreach (\$p in $target_array) {
264
+ \$recurse = [System.IO.Directory]::Exists(\$p)
265
+ if (Test-Path -LiteralPath \$settings) {
266
+ \$all += @(Invoke-ScriptAnalyzer -Path \$p -Recurse:\$recurse -Severity Error,Warning -Settings \$settings -ErrorAction Stop)
267
+ } else {
268
+ \$all += @(Invoke-ScriptAnalyzer -Path \$p -Recurse:\$recurse -Severity Error,Warning -ErrorAction Stop)
269
+ }
227
270
  }
271
+ } catch {
272
+ Write-Output ('PSSA_RUN_ERROR: ' + \$_.Exception.Message)
273
+ exit 3
228
274
  }
229
275
  if (\$all) {
230
276
  \$all | Format-Table -AutoSize
@@ -233,12 +279,48 @@ run_static_analysis() {
233
279
  exit 0
234
280
  " 2>&1) || PSSA_RC=$?
235
281
  else
282
+ # #595: the unscoped path used to be a bare `-Path . -Recurse`, which walks
283
+ # node_modules. In a repo with Playwright installed that analyses
284
+ # playwright-core/bin/*.ps1 -- vendored files that use Write-Host and WMI
285
+ # cmdlets by design, that `npm ci` overwrites, and that the author cannot
286
+ # edit. Gate 1 then blocked EVERY commit, permanently. A gate that can never
287
+ # pass is not strict; it is one people learn to bypass.
288
+ #
289
+ # The adapter already had the right discovery: _find_powershell_files prunes
290
+ # .git, node_modules, dist, coverage and the rest. The two paths disagreed
291
+ # only because this one never called it. Now both use it, so "what this
292
+ # adapter refuses to analyse" has one definition.
293
+ local legacy_files
294
+ legacy_files=$(_find_powershell_files '*.ps1')
295
+ if [ -z "$legacy_files" ]; then
296
+ # Nothing to analyse is NOT a pass: say so via the reason protocol the
297
+ # module-missing branch uses, so it stays visible in the gate output.
298
+ echo "XP_G1_REASON: no PowerShell files found outside the pruned directories"
299
+ return 3
300
+ fi
301
+ local legacy_array
302
+ legacy_array=$(_powershell_path_array $legacy_files)
236
303
  PSSA_OUT=$("$PWSH" "${POWERSHELL_ARGS[@]}" "
237
304
  if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
238
305
  Write-Output 'PSSA_MODULE_MISSING'
239
306
  exit 0
240
307
  }
241
- \$results = Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning
308
+ \$ErrorActionPreference = 'Stop'
309
+ try { Import-Module PSScriptAnalyzer -ErrorAction Stop | Out-Null } catch { exit 3 }
310
+ \$settings = Join-Path (Get-Location) 'PSScriptAnalyzerSettings.psd1'
311
+ \$results = @()
312
+ try {
313
+ foreach (\$p in $legacy_array) {
314
+ if (Test-Path -LiteralPath \$settings) {
315
+ \$results += @(Invoke-ScriptAnalyzer -Path \$p -Severity Error,Warning -Settings \$settings -ErrorAction Stop)
316
+ } else {
317
+ \$results += @(Invoke-ScriptAnalyzer -Path \$p -Severity Error,Warning -ErrorAction Stop)
318
+ }
319
+ }
320
+ } catch {
321
+ Write-Output ('PSSA_RUN_ERROR: ' + \$_.Exception.Message)
322
+ exit 3
323
+ }
242
324
  if (\$results) {
243
325
  \$results | Format-Table -AutoSize
244
326
  exit 1
@@ -258,11 +340,32 @@ run_static_analysis() {
258
340
  echo "XP_G1_REASON: PSScriptAnalyzer module not installed (Install-Module PSScriptAnalyzer)"
259
341
  return 3
260
342
  fi
343
+ # #576: a scope entry that vanished means product code may have ESCAPED
344
+ # the gate. The reason line was emitted above, but pre-commit switches on
345
+ # the RETURN CODE and only uses the reason for the message text -- so
346
+ # returning 0 here made the reason dead weight and Gate 1 printed
347
+ # PASSED. Measured before this fix: "RETURN CODE = 0". The scan being
348
+ # clean does not make an incomplete scope a pass.
349
+ if [ "$scope_missing" -gt 0 ]; then
350
+ echo " ⚠️ $scope_missing scope entry(ies) did not exist - product code may have escaped the gate"
351
+ return 3
352
+ fi
261
353
  return 0
262
354
  ;;
263
355
  1)
264
356
  return 1 # real findings -> BLOCK upstream
265
357
  ;;
358
+ 3)
359
+ # #576: the PowerShell fragment exits 3 when the scan itself raised. That
360
+ # is "could not verify", not "found violations" -- pwsh would exit 1 for
361
+ # findings, so mapping this to 1 would report an analysis crash as lint
362
+ # errors. Surface the actual exception where the fragment captured one.
363
+ echo "XP_G1_REASON: PSScriptAnalyzer raised an error during the run"
364
+ printf '%s\n' "$PSSA_OUT" | grep '^PSSA_RUN_ERROR: ' | sed 's/^PSSA_RUN_ERROR: //' | head -3 | while IFS= read -r _ps_err; do
365
+ echo " ⚠️ $_ps_err"
366
+ done
367
+ return 2 # unusable -> fail closed upstream
368
+ ;;
266
369
  *)
267
370
  echo "XP_G1_REASON: PSScriptAnalyzer run failed (exit $PSSA_RC)"
268
371
  return 2 # unusable -> fail closed upstream
@@ -4,10 +4,24 @@
4
4
  # Tools: mypy, ruff/flake8, pytest, import-linter (architecture)
5
5
 
6
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
- # SC1091:adapter-common.sh 由调用方按查找顺序提供,路径靠变量拼接,shellcheck 无法静态跟随。
8
- # `source … || true` 是既有容错口径(仓库存档时该文件可能不在),不要改成硬依赖。
9
- # shellcheck source=/dev/null
10
- source "$SCRIPT_DIR/../adapter-common.sh" 2>/dev/null || true
7
+ # #597: the library can be beside the adapters or one level up, depending on how
8
+ # the package was installed:
9
+ # global install ~/.config/xp-gate/adapters/adapter-common.sh (inside)
10
+ # npm package src/npm-package/adapters/../adapter-common.sh (above)
11
+ # The old single "$SCRIPT_DIR/../adapter-common.sh" only ever matched the second,
12
+ # so in the global layout the library never loaded - and "2>/dev/null || true"
13
+ # made that indistinguishable from success, leaving every shared function
14
+ # undefined with no warning.
15
+ for _ac_candidate in \
16
+ "$SCRIPT_DIR/adapter-common.sh" \
17
+ "$SCRIPT_DIR/../adapter-common.sh"; do
18
+ if [ -f "$_ac_candidate" ]; then
19
+ # shellcheck source=/dev/null
20
+ source "$_ac_candidate"
21
+ break
22
+ fi
23
+ done
24
+ unset _ac_candidate
11
25
 
12
26
  # A mypy target must exist AND actually contain .py files: a bare directory
13
27
  # check lets through TS repos' src/ (mypy exits 2 with "There are no .py[i]
package/gate-3.sh CHANGED
@@ -233,7 +233,16 @@ else
233
233
  fi
234
234
  fi
235
235
  else
236
- echo "⏭️ SKIPPED - Complexity (no source files to check)."
236
+ # #589: "no source files to check" described an empty PROJECT when the real
237
+ # cause is an empty CHANGED_FILES — an empty DIFF, or a `--all` run whose
238
+ # tree genuinely has no sources. The old wording read like a pass, which is
239
+ # why the reporter worked around the gate by running lizard by hand.
240
+ if [ -n "${GATE_SCOPE:-}" ] && [ "$GATE_SCOPE" = "all" ]; then
241
+ echo "⏭️ SKIPPED - Complexity: no tracked source files in this repository (scope: --all, whole tree)."
242
+ else
243
+ echo "⏭️ SKIPPED - Complexity: no changed source files in this run (scope: diff)."
244
+ echo " Run 'xp-gate check . --all' to scan the whole tree."
245
+ fi
237
246
  GATE_3_STATUS="SKIP"
238
247
  # Consumed by the sourcing parent (pre-commit report JSON), not here.
239
248
  # shellcheck disable=SC2034
package/gate-8.sh CHANGED
@@ -33,10 +33,23 @@ fi
33
33
 
34
34
  GITLEAKS_CONFIG=""
35
35
  if [ -f ".gitleaks.toml" ]; then
36
- # gitleaks is a native binary and may not understand MSYS paths, so pass an
36
+ # gitleaks is a native binary and may not understand MSYS/WSL paths, so pass an
37
37
  # absolute path in the host's own format when we can derive one (#449).
38
- if command -v cygpath >/dev/null 2>&1; then
38
+ #
39
+ # #582 found this the hard way: `cygpath` exists only in Git-for-Windows/MSYS, and on
40
+ # WSL its absence meant the fallback handed `/mnt/d/projects/<repo>/.gitleaks.toml`
41
+ # to the native gitleaks.exe. It could not open the file, exited 1, and Gate 8
42
+ # reported "Secrets detected". The conversion now lives in a shared library so the
43
+ # next gate handing a path to a native binary does not re-derive it (#581).
44
+ #
45
+ # This script may also run standalone (archived/copied hook) without the library, so
46
+ # the converter is used when present and the inline branch kept as the fallback.
47
+ if declare -F to_native_path >/dev/null 2>&1; then
48
+ GITLEAKS_CONFIG="--config=$(to_native_path "$(pwd)/.gitleaks.toml")"
49
+ elif command -v cygpath >/dev/null 2>&1; then
39
50
  GITLEAKS_CONFIG="--config=$(cygpath -w "$(pwd)/.gitleaks.toml")"
51
+ elif command -v wslpath >/dev/null 2>&1; then
52
+ GITLEAKS_CONFIG="--config=$(wslpath -w "$(pwd)/.gitleaks.toml")"
40
53
  else
41
54
  GITLEAKS_CONFIG="--config=$(pwd)/.gitleaks.toml"
42
55
  fi
@@ -75,10 +88,41 @@ if [ "$GITLEAKS_EXIT" -eq 0 ]; then
75
88
  echo " ✅ PASSED - No secrets detected."
76
89
  GATE_8_STATUS="PASS"
77
90
  elif [ "$GITLEAKS_FINDINGS" = "?" ]; then
78
- # Exit 0 but no readable report: we have no evidence the scan happened.
79
- # Fail closed on the *claim*: report SKIP, never PASS.
80
- echo " ⚠️ gitleaks exited 0 but produced no readable report"
81
- echo " ⏭️ SKIPPED - secret scanning produced no evidence (not a pass)"
91
+ # #592: exit 0 with no usable report has TWO very different causes, and the
92
+ # operator can only act if we tell them which one happened:
93
+ #
94
+ # (a) the scan never ran — config unreadable, FTL, bad --report-path.
95
+ # Nothing was checked, so this must fail closed with the tool's own
96
+ # words, not be filed as "no evidence about the code".
97
+ # (b) the scan ran and found nothing, but the tool wrote no file (reported
98
+ # for gitleaks 8.30.1 on WSL; measured NOT to happen in git-bash with
99
+ # the same version, so the code must handle both). Only stdout is left,
100
+ # and stdout is weaker evidence than an artifact, so it still may not
101
+ # produce a PASS.
102
+ #
103
+ # The order matters and is the point: artifact first, tool failure second,
104
+ # tool output last (#449 established the principle; #592 re-opens it).
105
+ if printf '%s\n' "$GITLEAKS_OUTPUT" | grep -qiE '(^|[[:space:]])(FTL|fatal error)[[:space:]:]|unable to load'; then
106
+ echo " ❌ BLOCKED - ENVIRONMENT: gitleaks exited 0 but reported a failure, so"
107
+ echo " NO secret scan took place. This is a tooling problem, not a verdict"
108
+ echo " about the staged code."
109
+ printf '%s\n' "$GITLEAKS_OUTPUT" | grep -iE 'FTL|fatal|unable' | head -3 | sed 's/^/ /'
110
+ echo " Fix the gitleaks config/invocation above and commit again."
111
+ exit 1
112
+ fi
113
+
114
+ if printf '%s\n' "$GITLEAKS_OUTPUT" | grep -q 'no leaks found'; then
115
+ # The scan demonstrably ran and found nothing, but there is no artifact to
116
+ # prove it beyond this line. Reporting PASS from log wording is exactly what
117
+ # #449 forbade, so this stays SKIP — and says WHY, which the old message did
118
+ # not: without that, SKIP is indistinguishable from "tool missing".
119
+ echo " ⚠️ gitleaks scanned and reported no leaks, but wrote no report file"
120
+ echo " (${GITLEAKS_REPORT} missing). No artifact means no PASS (#449)."
121
+ echo " ⏭️ SKIPPED - secret scanning produced no artifact (scan reported clean)."
122
+ else
123
+ echo " ⚠️ gitleaks exited 0 but produced no readable report and no scan summary"
124
+ echo " ⏭️ SKIPPED - secret scanning produced no evidence (not a pass)"
125
+ fi
82
126
  GATE_8_STATUS="SKIP"
83
127
  else
84
128
  echo " ❌ BLOCKED - gitleaks reported $GITLEAKS_FINDINGS finding(s)"
@@ -88,6 +132,23 @@ if [ "$GITLEAKS_EXIT" -eq 0 ]; then
88
132
  exit 1
89
133
  fi
90
134
  elif [ "$GITLEAKS_EXIT" -eq 1 ]; then
135
+ # #582: gitleaks exits 1 for BOTH "leaks found" and "I could not start" (unreadable
136
+ # config, bad path, IO error). Treating every exit 1 as a finding produced the
137
+ # loudest possible wrong answer: "Secrets detected in staged files", plus
138
+ # instructions to rotate a key, on a repository where nothing had been scanned --
139
+ # because WSL handed a POSIX path to the native binary (#582's own case).
140
+ #
141
+ # #592 established this distinction for exit 0 + FTL. The same test applies here,
142
+ # and the order matters as much: a scanner that could not start must never be
143
+ # reported as having found something, or the gate teaches people to disable it.
144
+ if printf '%s\n' "$GITLEAKS_OUTPUT" | grep -qiE '(^|[[:space:]])(FTL|fatal error)[[:space:]:]|unable to load|cannot find the path'; then
145
+ echo " ❌ BLOCKED - ENVIRONMENT: gitleaks exited 1 because it could not start, so"
146
+ echo " NO secret scan took place. This is a tooling problem, not a verdict"
147
+ echo " about the staged code."
148
+ printf '%s\n' "$GITLEAKS_OUTPUT" | grep -iE 'FTL|fatal|unable|cannot find' | head -3 | sed 's/^/ /'
149
+ echo " Fix the gitleaks config/invocation above and commit again."
150
+ exit 1
151
+ fi
91
152
  # Secrets found — output details
92
153
  echo "$GITLEAKS_OUTPUT"
93
154
  echo ""
@@ -155,19 +155,57 @@ require_tool() {
155
155
  local tool_name="$1"
156
156
  local gate_name="${2:-Gate}"
157
157
  local install_hint="${3:-}"
158
-
158
+
159
+ # #593: on Windows the hook runs under git-bash, which does not see the
160
+ # toolchains installed on the Windows side (%APPDATA%\npm, the node dir,
161
+ # pip --user Scripts, ~/bin). Bootstrapping before probing turns "not on this
162
+ # shell's PATH" back into "found", instead of reporting an installed tool as
163
+ # missing. lib/toolchain-path.sh is sourced by pre-commit before this runs; the
164
+ # guard keeps require_tool usable from adapters that load on their own.
165
+ if command -v bootstrap_toolchain_path >/dev/null 2>&1; then
166
+ bootstrap_toolchain_path
167
+ fi
168
+
169
+ # #580: on WSL (appendWindowsPath=false) and in git-bash, `command -v node`
170
+ # returns 1 while node.exe sits in the same directory and runs. One root cause
171
+ # gave Gate 1 a silent SKIP, Gate 1 a false "Biome check failed" and Gate 8 a
172
+ # false "gitleaks not installed" — an environment fault reported as a code
173
+ # verdict in all three. tool_is_available also counts the .exe suffix and the
174
+ # XP_GATE_<TOOL>_BIN override.
175
+ if command -v tool_is_available >/dev/null 2>&1 && tool_is_available "$tool_name"; then
176
+ return 0
177
+ fi
178
+
159
179
  if command -v "$tool_name" >/dev/null 2>&1; then
160
180
  return 0
161
181
  fi
162
-
182
+
183
+ if command -v "${tool_name}.exe" >/dev/null 2>&1; then
184
+ return 0
185
+ fi
186
+
163
187
  if command -v npx >/dev/null 2>&1 && npx --no-install "$tool_name" --version >/dev/null 2>&1; then
164
188
  return 0
165
189
  fi
166
-
190
+
191
+ # #593: monorepos keep the tool in a subproject's node_modules/.bin, where
192
+ # neither PATH nor `npx` from the repo root can see it.
193
+ if command -v resolve_tool_path >/dev/null 2>&1 && resolve_tool_path "$tool_name" >/dev/null 2>&1; then
194
+ if command -v ensure_tool_dir_on_path >/dev/null 2>&1; then
195
+ ensure_tool_dir_on_path "$tool_name" || true
196
+ fi
197
+ return 0
198
+ fi
199
+
167
200
  echo "❌ BLOCKED - Required tool '$tool_name' not available for $gate_name"
168
201
  if [ -n "$install_hint" ]; then
169
202
  echo " Install: $install_hint"
170
203
  fi
204
+ # #593 point 3: "not installed" and "installed but not found" need different
205
+ # fixes, so name every location that was searched.
206
+ if command -v describe_tool_search >/dev/null 2>&1; then
207
+ describe_tool_search "$tool_name"
208
+ fi
171
209
  echo " Per QUALITY-GATES-CODE-OF-CONDUCT.md: tool unavailable = BLOCK, not SKIP"
172
210
  return 1
173
211
  }
package/hooks/gate-3.sh CHANGED
@@ -233,7 +233,16 @@ else
233
233
  fi
234
234
  fi
235
235
  else
236
- echo "⏭️ SKIPPED - Complexity (no source files to check)."
236
+ # #589: "no source files to check" described an empty PROJECT when the real
237
+ # cause is an empty CHANGED_FILES — an empty DIFF, or a `--all` run whose
238
+ # tree genuinely has no sources. The old wording read like a pass, which is
239
+ # why the reporter worked around the gate by running lizard by hand.
240
+ if [ -n "${GATE_SCOPE:-}" ] && [ "$GATE_SCOPE" = "all" ]; then
241
+ echo "⏭️ SKIPPED - Complexity: no tracked source files in this repository (scope: --all, whole tree)."
242
+ else
243
+ echo "⏭️ SKIPPED - Complexity: no changed source files in this run (scope: diff)."
244
+ echo " Run 'xp-gate check . --all' to scan the whole tree."
245
+ fi
237
246
  GATE_3_STATUS="SKIP"
238
247
  # Consumed by the sourcing parent (pre-commit report JSON), not here.
239
248
  # shellcheck disable=SC2034