@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.
- package/adapters/powershell.sh +195 -12
- package/gate-10.sh +16 -0
- package/hooks/gate-10.sh +16 -0
- package/hooks/pre-commit +28 -1
- package/lib/__tests__/check-all-skip-accounting.test.js +171 -0
- package/lib/__tests__/find-bash-wsl.test.js +121 -0
- package/lib/__tests__/gate-runner-verdict.test.js +105 -0
- package/lib/__tests__/hook-health-path.test.js +101 -0
- package/lib/__tests__/principles-directory.test.js +87 -0
- package/lib/check.js +62 -8
- package/lib/gate-runner.js +8 -1
- package/lib/hook-health.js +65 -5
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +105 -2
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/adapters/powershell.sh
CHANGED
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
+
});
|