@boyingliu01/xp-gate 0.31.1 → 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 +37 -1
  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() {
@@ -279,12 +279,48 @@ run_static_analysis() {
279
279
  exit 0
280
280
  " 2>&1) || PSSA_RC=$?
281
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)
282
303
  PSSA_OUT=$("$PWSH" "${POWERSHELL_ARGS[@]}" "
283
304
  if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
284
305
  Write-Output 'PSSA_MODULE_MISSING'
285
306
  exit 0
286
307
  }
287
- \$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
+ }
288
324
  if (\$results) {
289
325
  \$results | Format-Table -AutoSize
290
326
  exit 1
@@ -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
package/hooks/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 ""
@@ -81,12 +81,27 @@ enforce_python_coverage() {
81
81
  echo " Run the full suite for a real Python coverage check."
82
82
  return 0
83
83
  fi
84
- echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below 80% threshold"
84
+ echo "❌ BLOCKED - Python coverage ${COVERAGE_PERCENT}% below ${COVERAGE_THRESHOLD:-80}% threshold"
85
85
  return 1
86
86
  fi
87
- echo "Python coverage: ${COVERAGE_PERCENT}% ✅ (≥ 80%)"
88
- else
89
- echo "⚠️ Could not determine Python coverage percentage. Ensure pytest-cov or coverage.py data exists."
87
+ echo "Python coverage: ${COVERAGE_PERCENT}% ✅ (≥ ${COVERAGE_THRESHOLD:-80}%)"
88
+ return 0
89
+ fi
90
+
91
+ # #570: reaching here means the gate produced NO measurement. That is a FAILED
92
+ # answer to "is coverage at or above the floor?", not a lenient one — the gate
93
+ # exists to answer that question and answered nothing. Warning and returning 0
94
+ # made the gate fail-open: deleting .coverage (or a pytest run that died before
95
+ # writing it) disabled the coverage check while the summary still looked clean.
96
+ #
97
+ # The one legitimate exception is a subset run: nothing was measured because
98
+ # only part of the suite was run, so there is no total to compare.
99
+ if [ "${PARTIAL_TEST_RUN:-false}" = "true" ]; then
100
+ echo "⚠️ Could not determine Python coverage percentage (subset test run) — not blocking."
101
+ return 0
90
102
  fi
91
- return 0
103
+ echo "❌ BLOCKED - Could not determine Python coverage percentage."
104
+ echo " No .coverage / coverage report was produced, so the ${COVERAGE_THRESHOLD:-80}% floor"
105
+ echo " cannot be verified. Run the suite with coverage (pytest --cov) and commit again."
106
+ return 1
92
107
  }