@boyingliu01/xp-gate 0.31.1 → 0.31.3

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 (86) 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 +254 -10
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +254 -10
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
  14. package/hooks/lib/coverage-total.sh +20 -5
  15. package/hooks/lib/gate-5c-alignment.sh +145 -0
  16. package/hooks/lib/jscpd-run.sh +40 -1
  17. package/hooks/lib/native-path.sh +106 -0
  18. package/hooks/lib/prepush-score.py +143 -0
  19. package/hooks/lib/sprint-gate-report.sh +23 -0
  20. package/hooks/lib/toolchain-path.sh +370 -0
  21. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  22. package/hooks/pre-commit +403 -165
  23. package/hooks/pre-push +192 -74
  24. package/hooks/sprint-gate.sh +14 -3
  25. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  26. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  27. package/lib/__tests__/doctor-delphi.test.js +46 -0
  28. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  29. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  30. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  31. package/lib/__tests__/doctor.test.js +7 -1
  32. package/lib/__tests__/hook-health-path.test.js +9 -1
  33. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  34. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  35. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  36. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  37. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  38. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  39. package/lib/arch.js +261 -44
  40. package/lib/check-alignment-runner.cjs +23 -0
  41. package/lib/check.js +4 -1
  42. package/lib/doctor.js +194 -49
  43. package/lib/gate-runner.js +265 -58
  44. package/lib/mattpocock-license-verdict.json +1 -1
  45. package/lib/principles.js +228 -6
  46. package/lib/shared-paths.js +53 -19
  47. package/mock-policy/AGENTS.md +3 -3
  48. package/mutation/AGENTS.md +3 -3
  49. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  50. package/package.json +1 -1
  51. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  52. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  53. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  54. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  55. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  56. package/plugins/dsh/package.json +1 -1
  57. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  58. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  59. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  60. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  61. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  62. package/plugins/dsh/src/delphi-config.ts +151 -9
  63. package/plugins/opencode/package.json +1 -1
  64. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  65. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  66. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  67. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/plugins/qoder/plugin.json +1 -1
  69. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  70. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  71. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  72. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  73. package/principles/AGENTS.md +3 -3
  74. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  75. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  76. package/principles/adapters/java.ts +127 -11
  77. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  78. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  79. package/principles/rules/solid/lsp.ts +348 -23
  80. package/scripts/__tests__/sync-package-content.test.js +32 -1
  81. package/scripts/sync-package-content.js +6 -1
  82. package/skills/delphi-review/AGENTS.md +3 -3
  83. package/skills/delphi-review/SKILL.md +50 -0
  84. package/skills/sprint-flow/AGENTS.md +3 -3
  85. package/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/sprint-gate.sh +14 -3
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
@@ -20,15 +20,250 @@ if [ "$PROJECT_LANG" = "documentation-only" ]; then
20
20
  GATE_3_REASON="documentation_project"
21
21
 
22
22
  elif [ "$PROJECT_LANG" = "powershell" ]; then
23
- echo "ℹ️ No PowerShell Clean Code / SOLID tool available"
24
- echo "⏭️ SKIPPED - Complexity (no PowerShell tool)"
25
- GATE_3_STATUS="SKIP"
26
- # #537: a PowerShell-primary repo was invisible to this dimension while the
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
30
- GATE_3_REASON="no_powershell_tool"
31
-
23
+ # #537/#598: this arm used to be a hard SKIP with "no PowerShell tool". That
24
+ # was true of lizard (it cannot parse .ps1) but NOT of the platform:
25
+ # PowerShell's own AST gives an exact decision-point count, which is the same
26
+ # quantity lizard measures for other languages. A PowerShell-primary repo
27
+ # therefore had a permanently unmeasured quality dimension while the score
28
+ # implied full coverage.
29
+ #
30
+ # The measurement lives in lib/Measure-PowerShellComplexity.ps1 because the
31
+ # hook cannot know a project's refactor history; the adapter only runs it and
32
+ # translates the verdict.
33
+ #
34
+ # The threshold resolution is duplicated from the lizard arm on purpose: the
35
+ # arms are read independently of each other, and a shared helper here would
36
+ # make the PowerShell path depend on the code path it replaces.
37
+ _gate3_ps_threshold() {
38
+ local default=5 val
39
+ if [ -f ".xp-gate/ccn-threshold" ]; then
40
+ val=$(sed -n '1s/[[:space:]]*$//p' ".xp-gate/ccn-threshold" 2>/dev/null || true)
41
+ case "$val" in
42
+ ''|*[!0-9]*) echo "WARN - invalid .xp-gate/ccn-threshold value '${val}' (expected a positive integer), falling back to ${default}" >&2 ;;
43
+ 0) echo "WARN - invalid .xp-gate/ccn-threshold value '0' (expected a positive integer), falling back to ${default}" >&2 ;;
44
+ *) echo "$val"; return 0 ;;
45
+ esac
46
+ fi
47
+ val=${XP_GATE_CCN_THRESHOLD:-}
48
+ case "$val" in
49
+ '') echo "$default" ;;
50
+ *[!0-9]*) echo "WARN - invalid XP_GATE_CCN_THRESHOLD value '${val}' (expected a positive integer), falling back to ${default}" >&2; echo "$default" ;;
51
+ 0) echo "WARN - invalid XP_GATE_CCN_THRESHOLD value '0' (expected a positive integer), falling back to ${default}" >&2; echo "$default" ;;
52
+ *) echo "$val" ;;
53
+ esac
54
+ }
55
+
56
+ # Resolve a PowerShell engine LOCALLY.
57
+ #
58
+ # This arm must NOT call _detect_pwsh, which is defined in
59
+ # adapters/powershell.sh: the hook sources that adapter inside a SUBSHELL for
60
+ # Gate 1 --
61
+ # PS_G1_OUTPUT=$(source "$POWERSHELL_ADAPTER"; run_static_analysis)
62
+ # -- so its functions never reach this gate. Depending on it printed
63
+ # "no PowerShell engine found" on a machine with both pwsh 7 AND PS 5.1
64
+ # installed, i.e. the verdict was about bash scoping, not about the engine.
65
+ # A gate must not depend on another gate having sourced a library first.
66
+ _gate3_detect_pwsh() {
67
+ if command -v pwsh >/dev/null 2>&1; then echo pwsh; return 0; fi
68
+ if command -v powershell.exe >/dev/null 2>&1; then echo powershell.exe; return 0; fi
69
+ local _c
70
+ for _c in \
71
+ "${LOCALAPPDATA:-$HOME/AppData/Local}/Microsoft/WindowsApps/pwsh.exe" \
72
+ "/c/Program Files/PowerShell/7/pwsh.exe" \
73
+ "/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"; do
74
+ [ -x "$_c" ] && { echo "$_c"; return 0; }
75
+ done
76
+ return 1
77
+ }
78
+
79
+ PS_CC_SCRIPT="${AUDIT_SCRIPT_DIR}/lib/Measure-PowerShellComplexity.ps1"
80
+ if [ ! -f "$PS_CC_SCRIPT" ]; then
81
+ echo "ℹ️ No PowerShell complexity tool available"
82
+ echo " (expected at lib/Measure-PowerShellComplexity.ps1)"
83
+ echo "⏭️ SKIPPED - Complexity (no PowerShell tool)"
84
+ GATE_3_STATUS="SKIP"
85
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
86
+ # shellcheck disable=SC2034
87
+ GATE_3_REASON="no_powershell_tool"
88
+ else
89
+ # #581: pwsh.exe is a NATIVE binary and cannot open the POSIX path this hook
90
+ # holds (`/c/Users/...` or `/tmp/...`). Handed one unchanged it resolves the
91
+ # leading `/` to `<current drive>:\`, silently measures zero files, and the
92
+ # gate then reports "no functions" -- an environment fault reported as a
93
+ # verdict about the repository. Convert the script path once, here.
94
+ PS_CC_SCRIPT_NATIVE="$PS_CC_SCRIPT"
95
+ if command -v to_native_path >/dev/null 2>&1; then
96
+ PS_CC_SCRIPT_NATIVE=$(to_native_path "$PS_CC_SCRIPT" 2>/dev/null || echo "$PS_CC_SCRIPT")
97
+ fi
98
+ PS_CC_SCRIPT="$PS_CC_SCRIPT_NATIVE"
99
+ PS_CC_PWSH=$(_gate3_detect_pwsh 2>/dev/null || true)
100
+ if [ -z "$PS_CC_PWSH" ]; then
101
+ echo "⚠️ WARN - no PowerShell engine found; complexity not measured"
102
+ GATE_3_STATUS="WARN"
103
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
104
+ # shellcheck disable=SC2034
105
+ GATE_3_REASON="no_powershell_engine"
106
+ else
107
+ PS_CC_THRESHOLD=$(_gate3_ps_threshold)
108
+ PS_ADDED=$(git diff --cached --name-only --diff-filter=A 2>/dev/null | grep -E '\.ps(m)?1$' || true)
109
+ PS_BASELINE=".xp-gate-powershell-ccn-baseline.json"
110
+
111
+ if [ -z "$PS_ADDED" ]; then
112
+ # No added files, but modified ones can still get MORE complex. That is
113
+ # what the ratchet baseline is for; without a baseline there is nothing
114
+ # to compare, and silently passing would be the #584 failure mode.
115
+ PS_TOUCHED=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.ps(m)?1$' || true)
116
+ PS_TOUCHED=$(printf '%s\n' "$PS_TOUCHED" | grep -vE '(^|/)tests/' || true)
117
+ if [ -z "$PS_TOUCHED" ] || [ ! -f "$PS_BASELINE" ]; then
118
+ echo "⏭️ SKIPPED - Complexity (no added PowerShell files to measure)."
119
+ GATE_3_STATUS="SKIP"
120
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
121
+ # shellcheck disable=SC2034
122
+ GATE_3_REASON="no_added_powershell_files"
123
+ else
124
+ PS_CC_ARGS=()
125
+ for _f in $PS_TOUCHED; do PS_CC_ARGS[${#PS_CC_ARGS[@]}]="'$_f'"; done
126
+
127
+ # #598: the ratchet branch must use the SAME threshold as the baseline
128
+ # collector. -Threshold 1 used to make every function with CCN >= 2
129
+ # print OVER, so a brand-new well-factored function (CCN 2-4, below the
130
+ # real threshold) was diffed against a baseline that can never contain
131
+ # it and reported as a "regression": the gate blocked exactly the shape
132
+ # it exists to encourage. -List additionally emits the full population,
133
+ # so the comparison covers functions that are below the threshold on
134
+ # both sides.
135
+ PS_CC_OUT=$("$PS_CC_PWSH" -NoProfile -NonInteractive -ExecutionPolicy Bypass \
136
+ -Command "& '$PS_CC_SCRIPT' -Path @($(IFS=','; echo "${PS_CC_ARGS[*]}")) -Threshold ${PS_CC_THRESHOLD} -List" 2>&1 || true)
137
+
138
+ PS_CC_MEASURED=$(printf '%s\n' "$PS_CC_OUT" | sed -n 's/^functions_measured=//p' | tail -1)
139
+ if [ -z "$PS_CC_MEASURED" ] || [ "${PS_CC_MEASURED:-0}" -eq 0 ]; then
140
+ echo "⏭️ SKIPPED - Complexity (no functions in changed PowerShell files)."
141
+ GATE_3_STATUS="SKIP"
142
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
143
+ # shellcheck disable=SC2034
144
+ GATE_3_REASON="no_functions_found"
145
+ else
146
+ PS_CC_NODE=""
147
+ if command -v node >/dev/null 2>&1; then PS_CC_NODE="node"; fi
148
+ if [ -z "$PS_CC_NODE" ]; then
149
+ echo "ℹ️ node unavailable — complexity measured (${PS_CC_MEASURED} function(s)) but ratchet not compared"
150
+ echo "✅ PASSED - PowerShell complexity measured (ratchet unavailable)."
151
+ GATE_3_DETAIL="mode=measured_no_ratchet"
152
+ else
153
+ # Compare every measured function against the baseline. A function
154
+ # absent from the baseline is NEW: it is only a regression if it is
155
+ # above the threshold, which is exactly what the old -Threshold 1
156
+ # comparison got wrong. An unreadable baseline is ERR, not a pass.
157
+ PS_CC_VERDICT=$(printf '%s\n' "$PS_CC_OUT" | "$PS_CC_NODE" -e '
158
+ let raw = "";
159
+ process.stdin.on("data", (c) => { raw += c; });
160
+ process.stdin.on("end", () => {
161
+ const fs = require("fs");
162
+ const fsPath = process.argv[1];
163
+ const threshold = Number(process.argv[2]);
164
+ let baseline;
165
+ try {
166
+ baseline = JSON.parse(fs.readFileSync(fsPath, "utf8"));
167
+ } catch (e) {
168
+ console.log("ERR " + e.message);
169
+ return;
170
+ }
171
+ const known = new Map();
172
+ const entries = baseline.functions || baseline.entries || baseline;
173
+ if (Array.isArray(entries)) {
174
+ for (const e of entries) {
175
+ if (!e || !e.file || !e.name) continue;
176
+ known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
177
+ }
178
+ }
179
+ function norm(p) { return String(p).replace(/\\\\/g, "/").replace(/^\.\//, ""); }
180
+ const seen = new Set();
181
+ const regressions = [];
182
+ const improved = [];
183
+ for (const line of raw.split(/\r?\n/)) {
184
+ const m = line.match(/^(?:OVER|FUNC) (.+):(\d+) (\S+) ccn=(\d+)$/);
185
+ if (!m) continue;
186
+ const key = norm(m[1]) + "::" + m[3];
187
+ const now = Number(m[4]);
188
+ seen.add(key);
189
+ const was = known.has(key) ? known.get(key) : null;
190
+ if (was === null) {
191
+ if (now > threshold) {
192
+ regressions.push(m[1] + ":" + m[2] + " " + m[3] + " new function at ccn=" + now);
193
+ }
194
+ continue;
195
+ }
196
+ if (now > was) {
197
+ regressions.push(m[1] + ":" + m[2] + " " + m[3] + " ccn " + was + " -> " + now);
198
+ } else if (now < was) {
199
+ improved.push(m[1] + ":" + m[2] + " " + m[3] + " ccn " + was + " -> " + now);
200
+ }
201
+ }
202
+ for (const r of regressions) console.log("REGRESSION " + r);
203
+ for (const i of improved) console.log("IMPROVED " + i);
204
+ if (regressions.length > 0) {
205
+ console.log("FAIL " + regressions.length + " function(s) increased cyclomatic complexity vs " + fsPath + ".");
206
+ }
207
+ });
208
+ ' "$PS_BASELINE" "$PS_CC_THRESHOLD" 2>&1 || true)
209
+
210
+ if printf '%s\n' "$PS_CC_VERDICT" | grep -q '^FAIL '; then
211
+ PS_CC_REG=$(printf '%s\n' "$PS_CC_VERDICT" | sed -n 's/^FAIL //p' | tail -1)
212
+ printf '%s\n' "$PS_CC_VERDICT" | grep '^REGRESSION ' | sed 's/^REGRESSION / ❌ /'
213
+ echo "❌ BLOCKED - ${PS_CC_REG}"
214
+ GATE_3_STATUS="BLOCK"
215
+ GATE_3_REASON="ccn_ratchet_regression"
216
+ exit 1
217
+ elif printf '%s\n' "$PS_CC_VERDICT" | grep -q '^ERR '; then
218
+ echo "⚠️ WARN - complexity ratchet baseline unreadable: $(printf '%s\n' "$PS_CC_VERDICT" | sed -n 's/^ERR //p' | head -1)"
219
+ GATE_3_STATUS="WARN"
220
+ GATE_3_REASON="baseline_unreadable"
221
+ else
222
+ PS_CC_IMPROVED=$(printf '%s\n' "$PS_CC_VERDICT" | grep -c '^IMPROVED ' || true)
223
+ printf '%s\n' "$PS_CC_VERDICT" | grep '^IMPROVED ' | sed 's/^IMPROVED / 📉 /'
224
+ echo "✅ PASSED - PowerShell complexity within ratchet (${PS_CC_MEASURED} function(s), ${PS_CC_IMPROVED} improved, threshold ${PS_CC_THRESHOLD})."
225
+ GATE_3_STATUS="PASS"
226
+ GATE_3_DETAIL="powerShell_ratchet improved=${PS_CC_IMPROVED}"
227
+ fi
228
+ fi
229
+ fi
230
+ fi
231
+ else
232
+ # Freshly added files: this arm has no history to compare, so it judges
233
+ # each file's functions against the threshold. A file that is merely
234
+ # being touched keeps its history (that is what the baseline is for);
235
+ # measuring only ADDED files means the arm can never retroactively block
236
+ # a cleanup commit.
237
+ PS_CC_ARGS=()
238
+ for _f in $PS_ADDED; do PS_CC_ARGS[${#PS_CC_ARGS[@]}]="'$_f'"; done
239
+ PS_CC_OUT=$("$PS_CC_PWSH" -NoProfile -NonInteractive -ExecutionPolicy Bypass \
240
+ -Command "& '$PS_CC_SCRIPT' -Path @($(IFS=','; echo "${PS_CC_ARGS[*]}")) -Threshold ${PS_CC_THRESHOLD}" 2>&1 || true)
241
+ PS_CC_MEASURED=$(printf '%s\n' "$PS_CC_OUT" | sed -n 's/^functions_measured=//p' | tail -1)
242
+ PS_CC_OVER=$(printf '%s\n' "$PS_CC_OUT" | sed -n 's/^over_threshold=//p' | tail -1)
243
+ PS_CC_MAX=$(printf '%s\n' "$PS_CC_OUT" | sed -n 's/^max_ccn=//p' | tail -1)
244
+ if [ -z "$PS_CC_MEASURED" ] || [ "${PS_CC_MEASURED:-0}" -eq 0 ]; then
245
+ echo "⏭️ SKIPPED - Complexity (no functions in added PowerShell files)."
246
+ GATE_3_STATUS="SKIP"
247
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
248
+ # shellcheck disable=SC2034
249
+ GATE_3_REASON="no_functions_found"
250
+ elif [ "${PS_CC_OVER:-0}" -gt 0 ]; then
251
+ echo " Measured ${PS_CC_MEASURED} function(s) via AST; max CCN ${PS_CC_MAX} (threshold ${PS_CC_THRESHOLD})"
252
+ printf '%s\n' "$PS_CC_OUT" | grep '^OVER ' | sed 's/^/ /'
253
+ echo "❌ BLOCKED - ${PS_CC_OVER} newly added function(s) with CCN > ${PS_CC_THRESHOLD}."
254
+ echo "Refactor high-complexity functions, or raise the threshold deliberately."
255
+ GATE_3_STATUS="BLOCK"
256
+ GATE_3_REASON="ccn_over_threshold"
257
+ exit 1
258
+ else
259
+ echo "✅ PASSED - All added PowerShell functions within complexity threshold (${PS_CC_THRESHOLD})."
260
+ echo " Measured ${PS_CC_MEASURED} function(s) via AST; max CCN ${PS_CC_MAX}."
261
+ GATE_3_STATUS="PASS"
262
+ fi
263
+ fi
264
+ fi
265
+ fi
266
+
32
267
  else
33
268
  # S2 (#507): three-level CCN threshold override --
34
269
  # .xp-gate/ccn-threshold (positive integer) > XP_GATE_CCN_THRESHOLD > 5.
@@ -233,7 +468,16 @@ else
233
468
  fi
234
469
  fi
235
470
  else
236
- echo "⏭️ SKIPPED - Complexity (no source files to check)."
471
+ # #589: "no source files to check" described an empty PROJECT when the real
472
+ # cause is an empty CHANGED_FILES — an empty DIFF, or a `--all` run whose
473
+ # tree genuinely has no sources. The old wording read like a pass, which is
474
+ # why the reporter worked around the gate by running lizard by hand.
475
+ if [ -n "${GATE_SCOPE:-}" ] && [ "$GATE_SCOPE" = "all" ]; then
476
+ echo "⏭️ SKIPPED - Complexity: no tracked source files in this repository (scope: --all, whole tree)."
477
+ else
478
+ echo "⏭️ SKIPPED - Complexity: no changed source files in this run (scope: diff)."
479
+ echo " Run 'xp-gate check . --all' to scan the whole tree."
480
+ fi
237
481
  GATE_3_STATUS="SKIP"
238
482
  # Consumed by the sourcing parent (pre-commit report JSON), not here.
239
483
  # 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 ""