@boyingliu01/xp-gate 0.30.5 → 0.31.1

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 (37) hide show
  1. package/adapters/powershell.sh +195 -12
  2. package/gate-10.sh +16 -0
  3. package/hooks/gate-10.sh +16 -0
  4. package/hooks/pre-commit +28 -1
  5. package/lib/__tests__/check-all-skip-accounting.test.js +171 -0
  6. package/lib/__tests__/find-bash-wsl.test.js +121 -0
  7. package/lib/__tests__/gate-runner-verdict.test.js +105 -0
  8. package/lib/__tests__/hook-health-path.test.js +101 -0
  9. package/lib/__tests__/principles-directory.test.js +87 -0
  10. package/lib/check.js +62 -8
  11. package/lib/gate-runner.js +8 -1
  12. package/lib/hook-health.js +65 -5
  13. package/lib/mattpocock-license-verdict.json +1 -1
  14. package/lib/principles.js +105 -2
  15. package/mock-policy/AGENTS.md +3 -3
  16. package/mutation/AGENTS.md +3 -3
  17. package/package.json +1 -1
  18. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  19. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  20. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  21. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  22. package/plugins/dsh/package.json +1 -1
  23. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  25. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  26. package/plugins/opencode/package.json +1 -1
  27. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  28. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  29. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  30. package/plugins/qoder/plugin.json +1 -1
  31. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  34. package/principles/AGENTS.md +3 -3
  35. package/skills/delphi-review/AGENTS.md +3 -3
  36. package/skills/sprint-flow/AGENTS.md +3 -3
  37. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -61,6 +61,69 @@ _detect_pwsh() {
61
61
 
62
62
  _find_powershell_files() {
63
63
  local name_pattern="$1"
64
+
65
+ # #573: repo-local extra prune entries. The list below covers the artifact
66
+ # directories xp-gate itself produces, but a consuming repo can have its own
67
+ # (a generated obj/, a vendored third_party/, a samples/ tree of
68
+ # intentionally broken scripts) and had no way to exclude them -- so its Gate 1
69
+ # stayed the warning wall #538 fixed for xp-gate's own layout.
70
+ #
71
+ # Entries are appended to the built-in list, never replace it: `wrc-drill` is
72
+ # pinned here for xp-gate's own admin drills. Matched by -name, so a listed
73
+ # directory is pruned at any depth.
74
+ #
75
+ # Built as an ARRAY, not a string spliced into an eval: the entries come from
76
+ # a file in the checked-out repo, and a directory name is not something to
77
+ # hand to a shell parser.
78
+ local -a prune_args=()
79
+ local prune_file=".xp-gate-powershell-prune-dirs"
80
+ if [ -f "$prune_file" ]; then
81
+ local entry
82
+ while IFS= read -r entry; do
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'}"
89
+ # Strip surrounding whitespace, then the ./ prefix and trailing / a repo
90
+ # may reasonably write; -name matches a bare directory name.
91
+ entry="${entry#"${entry%%[![:space:]]*}"}"
92
+ entry="${entry%"${entry##*[![:space:]]}"}"
93
+ case "$entry" in ''|'#'*) continue ;; esac
94
+ entry="${entry#./}"
95
+ entry="${entry%/}"
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
121
+ prune_args[${#prune_args[@]}]=-o
122
+ prune_args[${#prune_args[@]}]=-name
123
+ prune_args[${#prune_args[@]}]="$entry"
124
+ done < "$prune_file"
125
+ fi
126
+
64
127
  # #538: gitignored drill/review artifact dirs hold hundreds of non-product
65
128
  # .ps1 files (admin drills, Delphi review copies). Collecting them turned
66
129
  # Gate 1 into an 800+ warning wall and made the pwsh gates unusable.
@@ -75,11 +138,16 @@ _find_powershell_files() {
75
138
  -name .workbuddy -o \
76
139
  -name .sprint-state -o \
77
140
  -name wrc-drill -o \
141
+ -name .xp-gate -o \
142
+ -name .vscode -o \
143
+ -name .idea -o \
144
+ -name .qoder -o \
78
145
  -path './plugins/claude-code/skills' -o \
79
146
  -path './plugins/opencode/skills' -o \
80
147
  -path './plugins/qoder/skills' -o \
81
148
  -path './src/npm-package/skills' -o \
82
149
  -path './src/npm-package/plugins' \
150
+ "${prune_args[@]+"${prune_args[@]}"}" \
83
151
  \) -prune \) -o \
84
152
  \( -type f -name "$name_pattern" -print \) 2>/dev/null
85
153
  }
@@ -115,22 +183,116 @@ run_static_analysis() {
115
183
  local POWERSHELL_ARGS
116
184
  _powershell_invocation "$PWSH"
117
185
  echo "Running PSScriptAnalyzer static analysis on PowerShell scripts..."
118
- # Recursively analyze all .ps1 files from repo root
119
186
  # Exit with non-zero if Error or Warning severity issues found
120
187
  local PSSA_RC=0
121
188
  local PSSA_OUT
122
- PSSA_OUT=$("$PWSH" "${POWERSHELL_ARGS[@]}" "
123
- if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
124
- Write-Output 'PSSA_MODULE_MISSING'
189
+ local scope_file=".xp-gate-powershell-analysis-paths"
190
+ local analysis_targets=()
191
+ local scope_missing=0
192
+
193
+ # #571: repo-local analysis scope. Pester test files legitimately trip
194
+ # dozens of rule warnings under the Pester execution model, so a whole-repo
195
+ # scan either blocks every PowerShell commit or forces the rule set open —
196
+ # both end in PowerShell not being gated at all. A repo may list its PRODUCT
197
+ # scope (directories and/or single files, one per line, `#` comments) and
198
+ # the SAME rules then apply to just that code.
199
+ #
200
+ # The load-bearing half is staleness: a listed path that no longer exists
201
+ # means product code ESCAPED the gate (renamed, moved, or a new script added
202
+ # without updating the file). That emits a reason so the pre-commit arm maps
203
+ # it to WARN inside the scoring denominator, instead of silently analysing
204
+ # less and reporting PASS. Same for a scope file with no usable entries.
205
+ # Without the file the legacy whole-repo behavior is kept, so existing repos
206
+ # are unaffected.
207
+ if [ -f "$scope_file" ]; then
208
+ local target_line
209
+ local declared=0
210
+ while IFS= read -r target_line; do
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'}"
218
+ # Strip surrounding whitespace before classifying: a line of spaces is
219
+ # not a path, and `case` alone would treat it as one — measured, a
220
+ # whitespace-only line became an "analysis path not found" entry.
221
+ target_line="${target_line#"${target_line%%[![:space:]]*}"}"
222
+ target_line="${target_line%"${target_line##*[![:space:]]}"}"
223
+ case "$target_line" in ''|'#'*) continue ;; esac
224
+ declared=$((declared + 1))
225
+ if [ -e "$target_line" ]; then
226
+ # shellcheck disable=SC2004 # explicit index arithmetic on an array
227
+ analysis_targets[${#analysis_targets[@]}]="$target_line"
228
+ else
229
+ scope_missing=$((scope_missing + 1))
230
+ echo " ℹ️ analysis path not found (skipped): $target_line"
231
+ echo "XP_G1_REASON: analysis-path-missing:$target_line"
232
+ fi
233
+ done < "$scope_file"
234
+ if [ "$declared" -eq 0 ]; then
235
+ # The file exists but declares nothing: analysing zero paths would exit 0
236
+ # and read as a clean PASS. A file whose entries are all MISSING takes
237
+ # the per-entry branch above instead, so the two causes stay distinct.
238
+ echo "XP_G1_REASON: analysis-paths-empty"
239
+ echo " ℹ️ $scope_file present but declares no paths - skipping analysis"
240
+ return 3
241
+ fi
242
+ fi
243
+
244
+ if [ "${#analysis_targets[@]}" -gt 0 ]; then
245
+ # Scoped per-path run. -Path binds ONE string only (an array argument
246
+ # throws "Cannot convert 'System.Object[]'..."), so each target is
247
+ # analyzed separately and findings are aggregated.
248
+ local target_array
249
+ target_array=$(_powershell_path_array "${analysis_targets[@]}")
250
+ PSSA_OUT=$("$PWSH" "${POWERSHELL_ARGS[@]}" "
251
+ if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
252
+ Write-Output 'PSSA_MODULE_MISSING'
253
+ exit 0
254
+ }
255
+ # Non-terminating errors must become terminating: otherwise a broken
256
+ # settings file or unreadable path yields an empty result set that LOOKS
257
+ # like a clean pass.
258
+ \$ErrorActionPreference = 'Stop'
259
+ try { Import-Module PSScriptAnalyzer -ErrorAction Stop | Out-Null } catch { exit 3 }
260
+ \$settings = Join-Path (Get-Location) 'PSScriptAnalyzerSettings.psd1'
261
+ \$all = @()
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
+ }
270
+ }
271
+ } catch {
272
+ Write-Output ('PSSA_RUN_ERROR: ' + \$_.Exception.Message)
273
+ exit 3
274
+ }
275
+ if (\$all) {
276
+ \$all | Format-Table -AutoSize
277
+ exit 1
278
+ }
125
279
  exit 0
126
- }
127
- \$results = Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning
128
- if (\$results) {
129
- \$results | Format-Table -AutoSize
130
- exit 1
131
- }
132
- exit 0
133
- " 2>&1) || PSSA_RC=$?
280
+ " 2>&1) || PSSA_RC=$?
281
+ else
282
+ PSSA_OUT=$("$PWSH" "${POWERSHELL_ARGS[@]}" "
283
+ if (-not (Get-Module -ListAvailable -Name PSScriptAnalyzer)) {
284
+ Write-Output 'PSSA_MODULE_MISSING'
285
+ exit 0
286
+ }
287
+ \$results = Invoke-ScriptAnalyzer -Path . -Recurse -Severity Error,Warning
288
+ if (\$results) {
289
+ \$results | Format-Table -AutoSize
290
+ exit 1
291
+ }
292
+ exit 0
293
+ " 2>&1) || PSSA_RC=$?
294
+ fi
295
+
134
296
  printf '%s\n' "$PSSA_OUT"
135
297
  case "$PSSA_RC" in
136
298
  0)
@@ -142,11 +304,32 @@ run_static_analysis() {
142
304
  echo "XP_G1_REASON: PSScriptAnalyzer module not installed (Install-Module PSScriptAnalyzer)"
143
305
  return 3
144
306
  fi
307
+ # #576: a scope entry that vanished means product code may have ESCAPED
308
+ # the gate. The reason line was emitted above, but pre-commit switches on
309
+ # the RETURN CODE and only uses the reason for the message text -- so
310
+ # returning 0 here made the reason dead weight and Gate 1 printed
311
+ # PASSED. Measured before this fix: "RETURN CODE = 0". The scan being
312
+ # clean does not make an incomplete scope a pass.
313
+ if [ "$scope_missing" -gt 0 ]; then
314
+ echo " ⚠️ $scope_missing scope entry(ies) did not exist - product code may have escaped the gate"
315
+ return 3
316
+ fi
145
317
  return 0
146
318
  ;;
147
319
  1)
148
320
  return 1 # real findings -> BLOCK upstream
149
321
  ;;
322
+ 3)
323
+ # #576: the PowerShell fragment exits 3 when the scan itself raised. That
324
+ # is "could not verify", not "found violations" -- pwsh would exit 1 for
325
+ # findings, so mapping this to 1 would report an analysis crash as lint
326
+ # errors. Surface the actual exception where the fragment captured one.
327
+ echo "XP_G1_REASON: PSScriptAnalyzer raised an error during the run"
328
+ printf '%s\n' "$PSSA_OUT" | grep '^PSSA_RUN_ERROR: ' | sed 's/^PSSA_RUN_ERROR: //' | head -3 | while IFS= read -r _ps_err; do
329
+ echo " ⚠️ $_ps_err"
330
+ done
331
+ return 2 # unusable -> fail closed upstream
332
+ ;;
150
333
  *)
151
334
  echo "XP_G1_REASON: PSScriptAnalyzer run failed (exit $PSSA_RC)"
152
335
  return 2 # unusable -> fail closed upstream
package/gate-10.sh CHANGED
@@ -34,6 +34,22 @@ if command -v semgrep >/dev/null 2>&1; then
34
34
  SEMGREP_CMD="semgrep"
35
35
  elif [ -f "$HOME/.local/bin/semgrep" ]; then
36
36
  SEMGREP_CMD="$HOME/.local/bin/semgrep"
37
+ else
38
+ # #572: Windows pip --user installs land in
39
+ # %APPDATA%\Python\<ver>\Scripts\semgrep.exe, which git-bash does NOT inherit
40
+ # on PATH -- so a working, installed semgrep read as "not installed" and this
41
+ # SAST gate degraded to WARN on the platform the project is developed on.
42
+ # The glob is expanded by the shell (one candidate per Python version) and
43
+ # probed by EXIT CODE, never by parsing `semgrep --version` output: a broken
44
+ # install can still print a plausible version string, and the probe must
45
+ # distinguish "runnable" from "present".
46
+ for candidate in "$HOME"/AppData/Roaming/Python/*/Scripts/semgrep.exe; do
47
+ [ -f "$candidate" ] || continue
48
+ if "$candidate" --version >/dev/null 2>&1; then
49
+ SEMGREP_CMD="$candidate"
50
+ break
51
+ fi
52
+ done
37
53
  fi
38
54
 
39
55
  if [ -z "$SEMGREP_CMD" ]; then
package/hooks/gate-10.sh CHANGED
@@ -34,6 +34,22 @@ if command -v semgrep >/dev/null 2>&1; then
34
34
  SEMGREP_CMD="semgrep"
35
35
  elif [ -f "$HOME/.local/bin/semgrep" ]; then
36
36
  SEMGREP_CMD="$HOME/.local/bin/semgrep"
37
+ else
38
+ # #572: Windows pip --user installs land in
39
+ # %APPDATA%\Python\<ver>\Scripts\semgrep.exe, which git-bash does NOT inherit
40
+ # on PATH -- so a working, installed semgrep read as "not installed" and this
41
+ # SAST gate degraded to WARN on the platform the project is developed on.
42
+ # The glob is expanded by the shell (one candidate per Python version) and
43
+ # probed by EXIT CODE, never by parsing `semgrep --version` output: a broken
44
+ # install can still print a plausible version string, and the probe must
45
+ # distinguish "runnable" from "present".
46
+ for candidate in "$HOME"/AppData/Roaming/Python/*/Scripts/semgrep.exe; do
47
+ [ -f "$candidate" ] || continue
48
+ if "$candidate" --version >/dev/null 2>&1; then
49
+ SEMGREP_CMD="$candidate"
50
+ break
51
+ fi
52
+ done
37
53
  fi
38
54
 
39
55
  if [ -z "$SEMGREP_CMD" ]; then
package/hooks/pre-commit CHANGED
@@ -3889,6 +3889,28 @@ generate_quality_report() {
3889
3889
  if [ "$EFFECTIVE_TOTAL" -gt 0 ]; then
3890
3890
  EFFECTIVE_PASS_RATE=$(awk "BEGIN {printf \"%.1f\", ($PASSED_COUNT / $EFFECTIVE_TOTAL) * 100}")
3891
3891
  fi
3892
+
3893
+ # #564: `score` above divides by the EFFECTIVE total, so a SKIPped gate leaves
3894
+ # the denominator instead of counting against it. Arithmetic on that formula:
3895
+ # 1 passed / 11 skipped is 1/1 -> 10.0, identical to 10 passed / 2 skipped.
3896
+ # .quality-history.jsonl shows Gate 3 running in 7% of runs and Gate 4 in 8%,
3897
+ # every one of them recording score 10 -- skipping work raised the score.
3898
+ #
3899
+ # The effective view answers a real question ("of the gates that could run,
3900
+ # how many passed?"), so it stays. What was missing is the denominator being
3901
+ # visible, and a score that counts skips against the run. `scoreAllGates`
3902
+ # divides by TOTAL_GATES, so a skipped gate costs exactly what a failed one
3903
+ # costs in that view; `gatesRan` says how much of the suite was exercised.
3904
+ # `score` keeps its current value and meaning so dashboard/ and
3905
+ # .quality-history.jsonl consumers do not break (#507 already made it
3906
+ # number-or-null; this stays within that contract).
3907
+ local GATES_RAN=$EFFECTIVE_TOTAL
3908
+ local SCORE_ALL="N/A"
3909
+ if [ "$TOTAL_GATES" -gt 0 ]; then
3910
+ SCORE_ALL=$(awk "BEGIN {printf \"%.1f\", ($PASSED_COUNT / $TOTAL_GATES) * 10}")
3911
+ fi
3912
+ local SCORE_ALL_JSON="$SCORE_ALL"
3913
+ [ "$SCORE_ALL_JSON" = "N/A" ] && SCORE_ALL_JSON="null"
3892
3914
  # JSON bodies need a number or null -- a literal N/A breaks every consumer.
3893
3915
  local SCORE_JSON="$SCORE"
3894
3916
  [ "$SCORE_JSON" = "N/A" ] && SCORE_JSON="null"
@@ -3971,6 +3993,8 @@ generate_quality_report() {
3971
3993
  "degradedGates": [$DEGRADED_JSON],
3972
3994
  "gatesEffective": $EFFECTIVE_TOTAL,
3973
3995
  "effectivePassRate": $EFFECTIVE_PASS_RATE_JSON,
3996
+ "gatesRan": $GATES_RAN,
3997
+ "scoreAllGates": $SCORE_ALL_JSON,
3974
3998
  "score": $SCORE_JSON,
3975
3999
  "verdict": "$VERDICT"
3976
4000
  },
@@ -4098,7 +4122,10 @@ ENDJSON
4098
4122
  printf " %-45s %s\n" "Gate 11: Sprint Flow" "${GATE_11_STATUS:-PASS}"
4099
4123
  printf " %-45s %s\n" "Gate 12: File Hygiene" "${GATE_12_STATUS:-WARN}"
4100
4124
  echo ""
4101
- echo " Overall Score: $SCORE/10 | $PASSED_COUNT/$TOTAL_GATES gates passed (effective: $PASSED_COUNT/$EFFECTIVE_TOTAL, skipped: $SKIP_COUNT, verdict: $VERDICT)"
4125
+ # #564: both views in one line. The effective score answers "of the gates that
4126
+ # could run, how many passed"; the all-gates score counts a skip against the
4127
+ # run. Printing only the first let a 1-of-12 run read as 10.0/10.
4128
+ echo " Overall Score: $SCORE/10 effective | $SCORE_ALL/10 of all gates ($GATES_RAN/$TOTAL_GATES ran, $SKIP_COUNT skipped, verdict: $VERDICT)"
4102
4129
  echo " Branch status: $REPORT_FILE"
4103
4130
  echo " Run report: $PERSISTED_REPORT_FILE"
4104
4131
  echo " History saved: $HISTORY_FILE"
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @test REQ-568 `check --all` must not count a gate it never ran as passed
3
+ * @intent Gate 5's own output already distinguishes four states — PASS, WARN,
4
+ * SKIP and FAIL — and `xp-gate check --all` collapses them into two:
5
+ * `code !== 0` is a failure, everything else is a pass. A gate whose
6
+ * registry entry is `preCommitOnly` prints
7
+ *
8
+ * ⏭️ SKIPPED - <reason>
9
+ * return 0;
10
+ *
11
+ * and that 0 is counted as a pass, so 15 registered gates report as
12
+ * `10/10 gate(s) passed` with exit 0. Measured on the registry:
13
+ *
14
+ * total gates : 15
15
+ * preCommitOnly : 5 (ids 0, 1, 5, 10, 11)
16
+ * check --all runs : 10
17
+ *
18
+ * The user-visible number is therefore true about the ten gates that
19
+ * ran and silent about the five that did not. `verdict: PARTIAL` in the
20
+ * hook's own report exists for the same reason; the CLI had no such
21
+ * notion. This is report §5 phase-0 action 0.2.
22
+ * @covers AC-568-01, AC-568-02, AC-568-03
23
+ */
24
+ const gateRunner = require('../gate-runner.js');
25
+ const { checkAllGates, formatGateTally } = require('../check.js');
26
+
27
+ const { GATE_REGISTRY } = gateRunner;
28
+
29
+ /** Original registry contents, restored after every test. */
30
+ let ORIGINAL_REGISTRY;
31
+
32
+ // Gate ids the CLI's `check --all` path should never treat as "passed" without
33
+ // saying so. The five real preCommitOnly ids from the shipped registry.
34
+ const PRECOMMIT_ONLY = ['0', '1', '5', '10', '11'];
35
+
36
+ /**
37
+ * Swap every key in GATE_REGISTRY for the supplied object's keys.
38
+ * @param {object} next - registry contents to install
39
+ */
40
+ function replaceRegistry(next) {
41
+ for (const id of Object.keys(GATE_REGISTRY)) delete GATE_REGISTRY[id];
42
+ for (const [id, info] of Object.entries(next)) GATE_REGISTRY[id] = info;
43
+ }
44
+
45
+ /**
46
+ * Replace the registry with a controlled set of stub gates.
47
+ *
48
+ * The registry is swapped WHOLESALE, not added to: `getAllGates()` maps every
49
+ * entry, so leaving the real fifteen in place would run them too — they spawn
50
+ * processes and take minutes — and the tally would be about the shipped
51
+ * registry rather than the fixture. Measured: the first draft of this test ran
52
+ * the real gates and reported "8 passed / 2 failed / 5 skipped / 15 total".
53
+ *
54
+ * @param {Array<{id: string, preCommitOnly?: boolean, code?: number}>} specs
55
+ * @returns {Function} cleanup that restores the original registry
56
+ */
57
+ function installGates(specs) {
58
+ const stubs = {};
59
+ for (const spec of specs) {
60
+ stubs[spec.id] = spec.preCommitOnly
61
+ ? {
62
+ name: `Stub ${spec.id}`,
63
+ description: 'test double',
64
+ aliases: [],
65
+ preCommitOnly: true,
66
+ reason: 'runs during commit',
67
+ }
68
+ : {
69
+ name: `Stub ${spec.id}`,
70
+ description: 'test double',
71
+ aliases: [],
72
+ run: () => (typeof spec.code === 'number' ? spec.code : 0),
73
+ };
74
+ }
75
+ replaceRegistry(stubs);
76
+ return () => replaceRegistry(ORIGINAL_REGISTRY);
77
+ }
78
+
79
+ let cleanup = null;
80
+ let logSpy;
81
+
82
+ beforeAll(() => {
83
+ ORIGINAL_REGISTRY = { ...GATE_REGISTRY };
84
+ });
85
+
86
+ beforeEach(() => {
87
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
88
+ vi.spyOn(console, 'error').mockImplementation(() => {});
89
+ });
90
+
91
+ afterEach(() => {
92
+ if (cleanup) {
93
+ cleanup();
94
+ cleanup = null;
95
+ }
96
+ vi.restoreAllMocks();
97
+ });
98
+
99
+ /**
100
+ * Read everything the CLI printed during the run.
101
+ * @returns {string} joined output
102
+ */
103
+ function printed() {
104
+ return logSpy.mock.calls.map(args => args.join(' ')).join('\n');
105
+ }
106
+
107
+ describe('check --all skip accounting', () => {
108
+ test('AC-568-01: a preCommitOnly gate is reported as skipped, not passed', async () => {
109
+ cleanup = installGates([
110
+ ...PRECOMMIT_ONLY.map(id => ({ id, preCommitOnly: true })),
111
+ { id: '2', code: 0 },
112
+ { id: '3', code: 0 },
113
+ ]);
114
+
115
+ const code = await checkAllGates('.');
116
+
117
+ const out = printed();
118
+ // The summary must name the skips and must not claim a bare N/N.
119
+ expect(out, 'the tally must distinguish skipped from passed').toMatch(/skipped/i);
120
+ expect(out).toMatch(/2 passed/);
121
+ expect(out).toMatch(/5 skipped/);
122
+ // A clean run with skips still exits 0 — skips are not failures.
123
+ expect(code).toBe(0);
124
+ });
125
+
126
+ test('AC-568-02: skipped gates never inflate the passed count', async () => {
127
+ // The defect in one assertion: before the fix this printed "2/2 gate(s)
128
+ // passed" for a run where zero gates were actually executed.
129
+ cleanup = installGates([...PRECOMMIT_ONLY.map(id => ({ id, preCommitOnly: true }))]);
130
+
131
+ await checkAllGates('.');
132
+
133
+ const out = printed();
134
+ expect(out).not.toMatch(/5\/5/);
135
+ expect(out).toMatch(/0 passed/);
136
+ expect(out).toMatch(/5 skipped/);
137
+ });
138
+
139
+ test('AC-568-03: a real failure still fails the run and is counted apart from skips', async () => {
140
+ // Anti-vacuity: the tally must not have been made honest by making it
141
+ // never fail. One failing gate among skips and passes fails the run.
142
+ cleanup = installGates([
143
+ { id: '0', preCommitOnly: true },
144
+ { id: '1', preCommitOnly: true },
145
+ { id: '2', code: 0 },
146
+ { id: '3', code: 1 },
147
+ ]);
148
+
149
+ const code = await checkAllGates('.');
150
+
151
+ const out = printed();
152
+ expect(code, 'a failing gate must still exit non-zero').toBe(1);
153
+ expect(out).toMatch(/1 passed/);
154
+ expect(out).toMatch(/1 failed/);
155
+ expect(out).toMatch(/2 skipped/);
156
+ });
157
+
158
+ test('AC-568-04: formatGateTally renders all four states without loss', () => {
159
+ // The pure formatter, exercised directly so the wording contract is pinned
160
+ // independently of the gate loop.
161
+ expect(formatGateTally({ passed: 3, skipped: 4, failed: 0, warned: 0, total: 7 })).toBe(
162
+ '3 passed / 4 skipped / 7 total'
163
+ );
164
+ expect(formatGateTally({ passed: 2, skipped: 1, failed: 1, warned: 0, total: 4 })).toBe(
165
+ '2 passed / 1 failed / 1 skipped / 4 total'
166
+ );
167
+ expect(formatGateTally({ passed: 1, skipped: 0, failed: 0, warned: 2, total: 3 })).toBe(
168
+ '1 passed / 2 warned / 3 total'
169
+ );
170
+ });
171
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @test REQ-570 findBash must reject a WSL bash and prefer Git for Windows
3
+ * @intent `findBash()` puts the bare name `bash` first in its candidate list, and
4
+ * accepts whatever PATH resolves it to as long as `bash --version` exits
5
+ * 0. On a machine with WSL installed, `bash` on PATH IS WSL:
6
+ *
7
+ * > (Get-Command bash -All).Source
8
+ * C:\WINDOWS\system32\bash.exe
9
+ * C:\Users\think\AppData\Local\Microsoft\WindowsApps\bash.exe
10
+ * > bash -c "uname -s"
11
+ * Linux
12
+ * > bash --version ; echo $LASTEXITCODE
13
+ * GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)
14
+ * (exit 0)
15
+ *
16
+ * Exit code 0 is exactly the probe's acceptance condition, so `findBash`
17
+ * returns the string `"bash"` — WSL — and every subsequent `bash -n
18
+ * <windows path>` fails with "No such file or directory", because a
19
+ * Linux bash cannot open `C:/...`. That is where doctor's "hook is not
20
+ * parseable" came from.
21
+ *
22
+ * This corrupts my own audit trail, so it is worth stating plainly: in
23
+ * issue #563 I wrote that the §2.2 WSL-stub false positive "does not
24
+ * reproduce" and downgraded it to robustness debt. I had measured
25
+ * `where bash` (which lists the WSL stubs) and the stubs' `--version`
26
+ * (status null / 1 with UTF-16 garbage) and concluded the probe already
27
+ * rejected them. Both measurements were about DIFFERENT bash binaries
28
+ * than the one `findBash` actually reaches: `spawnSync('bash', ...)`
29
+ * from Node resolves through PATH the same way the shell does, and here
30
+ * that reaches a healthy WSL bash which exits 0. The finding was real;
31
+ * the retraction was wrong.
32
+ *
33
+ * Related: the POSIX-path conversion in #565 is still correct and still
34
+ * needed — Git bash accepts `C:/...` while a backslash path gets eaten by
35
+ * escape processing. But with the wrong bash selected, no path form
36
+ * works, which is why #565 alone did not fix doctor.
37
+ * @covers AC-570-01, AC-570-02, AC-570-03
38
+ */
39
+ const path = require('path');
40
+
41
+ const { findBash } = require('../hook-health');
42
+
43
+ /** Windows-only: every assertion here is about Windows bash selection. */
44
+ const itWin = process.platform === 'win32' ? test : test.skip;
45
+
46
+ /**
47
+ * Run `bash -s` against a command and return trimmed stdout.
48
+ * @param {string} bashPath - binary to interrogate
49
+ * @param {string} script - shell snippet
50
+ * @returns {{status: number|null, out: string}} result
51
+ */
52
+ function runBash(bashPath, script) {
53
+ const { spawnSync } = require('child_process');
54
+ const res = spawnSync(bashPath, ['-c', script], { encoding: 'utf8', timeout: 20000 });
55
+ return { status: res.status, out: String(res.stdout || '').trim() };
56
+ }
57
+
58
+ describe('findBash platform selection', () => {
59
+ itWin('AC-570-01: the resolved bash is a Windows bash, not WSL', () => {
60
+ const bin = findBash();
61
+
62
+ expect(bin, 'findBash returned nothing; every hook-health probe is skipped').not.toBeNull();
63
+
64
+ // `uname -s` is the discriminator the project already uses elsewhere
65
+ // (detect-deps.resolveWindowsBashCandidates): Git bash reports MINGW*/MSYS*/
66
+ // CYGWIN*, WSL reports Linux.
67
+ const { out } = runBash(bin, 'uname -s');
68
+ expect(out, `findBash() selected "${bin}" which reports uname -s = "${out}"`).toMatch(
69
+ /MINGW|MSYS|CYGWIN/i
70
+ );
71
+ });
72
+
73
+ itWin('AC-570-02: a WSL bash on PATH does not win over Git for Windows', () => {
74
+ // The defect, stated as a property rather than as a specific fix: whatever
75
+ // PATH offers first, a Git for Windows bash exists on this machine (the
76
+ // whole test suite only runs because it does), so findBash must reach it.
77
+ const gitBash = path.join(
78
+ process.env.ProgramFiles || 'C:\\Program Files',
79
+ 'Git',
80
+ 'bin',
81
+ 'bash.exe'
82
+ );
83
+
84
+ const bin = findBash();
85
+
86
+ if (require('fs').existsSync(gitBash)) {
87
+ expect(
88
+ path.resolve(bin),
89
+ 'a Git for Windows bash is installed but findBash picked something else'
90
+ ).toBe(path.resolve(gitBash));
91
+ } else {
92
+ // No Git bash here: the WSL bash would still be wrong, so requiring a
93
+ // MINGW report is the honest fallback rather than accepting "bash".
94
+ expect(runBash(bin, 'uname -s').out).toMatch(/MINGW|MSYS|CYGWIN/i);
95
+ }
96
+ });
97
+
98
+ itWin('AC-570-03: the selected bash can actually open a Windows hook path', () => {
99
+ // End to end, and the reason any of this matters: the probe that doctor runs
100
+ // is `bash -n <path>`, so the selected binary must be able to see a Windows
101
+ // file at all. This is the assertion that would have caught #563's
102
+ // retraction being wrong.
103
+ const fs = require('fs');
104
+ const os = require('os');
105
+
106
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'findbash-probe-'));
107
+ const hook = path.join(dir, 'pre-commit');
108
+ try {
109
+ fs.writeFileSync(hook, '#!/bin/bash\necho hi\n');
110
+ const bin = findBash();
111
+ const posix = hook.replace(/\\/g, '/');
112
+
113
+ // Both forms are tried: a correct bash accepts both on Windows, so this
114
+ // asserts the binary, not the path convention (that is #565's subject).
115
+ expect(runBash(bin, `bash -n '${posix}' && echo PARSE_OK`).out).toContain('PARSE_OK');
116
+ expect(runBash(bin, `bash -n '${hook}' && echo PARSE_OK`).out).toContain('PARSE_OK');
117
+ } finally {
118
+ fs.rmSync(dir, { recursive: true, force: true });
119
+ }
120
+ });
121
+ });