@boyingliu01/xp-gate 0.30.1 → 0.30.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.
- package/adapters/iac.sh +45 -4
- package/adapters/java.sh +82 -16
- package/adapters/powershell.sh +70 -9
- package/bin/xp-gate.js +8 -0
- package/gate-10.sh +32 -2
- package/gate-3.sh +47 -6
- package/gate-4.sh +12 -0
- package/gate-7.sh +4 -0
- package/hooks/gate-10.sh +32 -2
- package/hooks/gate-3.sh +47 -6
- package/hooks/gate-4.sh +12 -0
- package/hooks/gate-7.sh +4 -0
- package/hooks/hook-module-resolution.sh +14 -0
- package/hooks/lib/coverage-total.sh +1 -1
- package/hooks/pre-commit +138 -17
- package/hooks/pre-push +97 -6
- package/hooks/sprint-gate.sh +81 -2
- package/lib/__tests__/crap-cli.test.js +76 -0
- package/lib/__tests__/crap.test.js +137 -0
- package/lib/__tests__/hook-health.test.js +85 -0
- package/lib/__tests__/retro-gate-m.test.js +144 -0
- package/lib/coverage-total.sh +92 -0
- package/lib/crap-cli.js +191 -0
- package/lib/crap.js +196 -0
- package/lib/doctor.js +44 -0
- package/lib/hook-health.js +121 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +139 -2
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
- package/sprint-gate.sh +81 -2
package/hooks/pre-push
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
+
# Line-ending self-check (#546). Must stay on the first executable line: if this
|
|
3
|
+
# file is ever rewritten with CRLF, bash needs to reach this check before the
|
|
4
|
+
# parser chokes somewhere far away and blames an innocent line. `tr` also
|
|
5
|
+
# strips the \r from this very line, so the check runs even when everything
|
|
6
|
+
# around it is broken.
|
|
7
|
+
if tr -d '\r' < "$0" | sed -n '2p' | tr -d '\r' | grep -q 'Line-ending self-check'; then
|
|
8
|
+
if [ "$(tr -cd '\r' < "$0" | wc -c | tr -d '[:space:]')" -gt 0 ]; then
|
|
9
|
+
echo "======================================================================" >&2
|
|
10
|
+
echo "FATAL: hook has CRLF line endings — bash cannot parse it (#546)." >&2
|
|
11
|
+
echo " file: $0" >&2
|
|
12
|
+
echo " fix : tr -d '\\r' < \"$0\" > \"$0.tmp\" && mv \"$0.tmp\" \"$0\" && chmod +x \"$0\"" >&2
|
|
13
|
+
echo "Or re-sync the installed copy: xp-gate doctor --sync-hooks" >&2
|
|
14
|
+
echo "======================================================================" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
fi
|
|
2
18
|
# Pre-push Hook — 8 gates: Gate 10 (Build Integrity) + M/M-Python/M-Go/M-Java/M-Kotlin (Mutation)
|
|
3
19
|
# + M2 (Mock Density, WARNING) + ML (Mock Layering) + UI (UI Sprint Gates) + MW (Code Walkthrough) + S (Sprint Flow)
|
|
4
20
|
#
|
|
@@ -220,6 +236,10 @@ if [ -f "$MP_ROOT/scripts/check-hook-mirror.sh" ]; then
|
|
|
220
236
|
fi
|
|
221
237
|
else
|
|
222
238
|
echo "⚠️ Gate MP: check-hook-mirror.sh not found. SKIP — Gate MP."
|
|
239
|
+
echo " Gate MP did not run; this push was not checked for mirror drift."
|
|
240
|
+
GATE_MP_STATUS="SKIP"
|
|
241
|
+
GATE_MP_SKIP_REASON="mirror_script_not_found"
|
|
242
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate MP (mirror parity: script not found)|"
|
|
223
243
|
MP_DURATION_MS=0
|
|
224
244
|
fi
|
|
225
245
|
echo ""
|
|
@@ -491,6 +511,13 @@ PY_SOURCE_FILES=$(echo "$PY_FILES" | grep -v '/venv/' | grep -v '/\.venv/' || tr
|
|
|
491
511
|
|
|
492
512
|
if [ -z "$PY_SOURCE_FILES" ]; then
|
|
493
513
|
echo "📚 No changed Python source files. SKIP — Gate M (Python)."
|
|
514
|
+
# #557: this outermost branch was the only Gate M (Python) path that assigned
|
|
515
|
+
# no status, while Gate M (TypeScript) records a reason on every path. Without
|
|
516
|
+
# it, "no Python in this push" and "the tool is missing" look identical in the
|
|
517
|
+
# journals, which is the gap #404 P0 spent effort closing for the TS gate.
|
|
518
|
+
GATE_M_PY_STATUS="SKIP"
|
|
519
|
+
GATE_M_PY_SKIP_REASON="no_python_source_files"
|
|
520
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (no changed Python source files)|"
|
|
494
521
|
else
|
|
495
522
|
PYTHON_FILE_LIST=$(echo "$PY_SOURCE_FILES" | tr '\n' ',' | sed 's/,$//')
|
|
496
523
|
echo "🐍 Changed Python files: $PYTHON_FILE_LIST"
|
|
@@ -504,6 +531,7 @@ else
|
|
|
504
531
|
case $MUTATION_EXIT in
|
|
505
532
|
0)
|
|
506
533
|
echo "✅ Gate M (Python): PASS"
|
|
534
|
+
GATE_M_PY_STATUS="PASS"
|
|
507
535
|
;;
|
|
508
536
|
1)
|
|
509
537
|
echo ""
|
|
@@ -519,16 +547,28 @@ else
|
|
|
519
547
|
124)
|
|
520
548
|
cat "$MUTATION_OUTPUT"
|
|
521
549
|
echo "⏱ Gate M (Python): TIMEOUT (120s). Allowing push with warning."
|
|
550
|
+
# A timeout means the mutation score was never measured. Saying
|
|
551
|
+
# nothing here let the closing banner claim success for a gate that
|
|
552
|
+
# started work and could not finish (#557).
|
|
553
|
+
GATE_M_PY_STATUS="TIMEOUT"
|
|
554
|
+
GATE_M_PY_SKIP_REASON="timeout_120s"
|
|
555
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (TIMEOUT — score never measured)|"
|
|
522
556
|
;;
|
|
523
557
|
*)
|
|
524
558
|
cat "$MUTATION_OUTPUT"
|
|
525
559
|
echo "⚠ Gate M (Python): Unexpected exit code $MUTATION_EXIT. Allowing push with warning."
|
|
560
|
+
GATE_M_PY_STATUS="ERROR"
|
|
561
|
+
GATE_M_PY_SKIP_REASON="unexpected_exit_${MUTATION_EXIT}"
|
|
562
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (error, exit ${MUTATION_EXIT} — score never measured)|"
|
|
526
563
|
;;
|
|
527
564
|
esac
|
|
528
565
|
|
|
529
566
|
rm -f "$MUTATION_OUTPUT"
|
|
530
567
|
else
|
|
531
568
|
echo "⚠ Gate M script not found. SKIP — Gate M (Python)."
|
|
569
|
+
GATE_M_PY_STATUS="SKIP"
|
|
570
|
+
GATE_M_PY_SKIP_REASON="gate_m_script_not_found"
|
|
571
|
+
GATES_SKIPPED="${GATES_SKIPPED:-}Gate M Python (gate-m.ts not found)|"
|
|
532
572
|
fi
|
|
533
573
|
else
|
|
534
574
|
echo "⚠ mutmut not installed. SKIP — Gate M (Python)."
|
|
@@ -929,11 +969,51 @@ if [[ "$XP_GATE_SKIP_UI_GATES" == "1" ]]; then
|
|
|
929
969
|
AUDIT_ENTRY="{\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"branch\":\"$CURRENT_BRANCH\",\"commit\":\"$(git rev-parse HEAD)\",\"user\":\"$(git config user.name || echo 'unknown')\",\"reason\":\"$XP_GATE_BYPASS_REASON\",\"bypass_type\":\"ui-gates\"}"
|
|
930
970
|
echo "$AUDIT_ENTRY" >> .audit-log.jsonl
|
|
931
971
|
else
|
|
932
|
-
# Detect UI changes in pushed files
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
972
|
+
# Detect UI changes in pushed files.
|
|
973
|
+
#
|
|
974
|
+
# stderr is kept out of the captured value but its ABSENCE is no longer
|
|
975
|
+
# treated as a clean "no UI here" answer (#553). jq -r prints the empty
|
|
976
|
+
# string and exits 0 on empty input, so the old `|| echo "false"` fallback
|
|
977
|
+
# was unreachable; an empty verdict became "", which is not "true", which
|
|
978
|
+
# silently took the "No UI changes detected — skipping" branch. A push whose
|
|
979
|
+
# only change was src/ui/streamlit_app.py was therefore told there was
|
|
980
|
+
# nothing to review — the failure Gate UI exists to prevent, inverted.
|
|
981
|
+
UI_DETECTION=$(echo "$PUSHED_FILES" | npx -y tsx src/npm-package/lib/ui-detector.ts --push-mode --from-stdin 2>/dev/null)
|
|
982
|
+
UI_DETECT_EXIT=$?
|
|
983
|
+
|
|
984
|
+
if [ "$UI_DETECT_EXIT" -eq 0 ] && [ -n "$UI_DETECTION" ]; then
|
|
985
|
+
IS_UI_SPRINT=$(echo "$UI_DETECTION" | jq -r 'if type == "object" then (.isUiSprint | tostring) else "unknown" end' 2>/dev/null)
|
|
986
|
+
else
|
|
987
|
+
IS_UI_SPRINT=""
|
|
988
|
+
fi
|
|
989
|
+
# Three answers, not two. "" (detector missing, crashed, or emitted nothing)
|
|
990
|
+
# is "unknown", and unknown must never read as "no UI".
|
|
991
|
+
if [ -z "$IS_UI_SPRINT" ] || [ "$IS_UI_SPRINT" = "null" ]; then
|
|
992
|
+
IS_UI_SPRINT="unknown"
|
|
993
|
+
fi
|
|
994
|
+
|
|
995
|
+
if [ "$IS_UI_SPRINT" = "unknown" ]; then
|
|
996
|
+
# A missing detector must not disable UI review. Fall back to a path
|
|
997
|
+
# check over the same file list: a cheap, conservative substitute for a
|
|
998
|
+
# verdict we do not have.
|
|
999
|
+
UI_PATH_HITS=$(echo "$PUSHED_FILES" | grep -Ei '(^|/)(src/ui|ui|components|pages|views|screens)/|[.](tsx|jsx|vue|svelte|css|scss|sass|less)$' || true)
|
|
1000
|
+
if [ -n "$UI_PATH_HITS" ]; then
|
|
1001
|
+
echo ""
|
|
1002
|
+
echo "❌ Gate UI: could not evaluate the UI verdict (detector unavailable or produced no verdict, exit ${UI_DETECT_EXIT})."
|
|
1003
|
+
echo " This push touches UI-looking paths:"
|
|
1004
|
+
echo "$UI_PATH_HITS" | sed 's/^/ /'
|
|
1005
|
+
echo ""
|
|
1006
|
+
echo " The gate cannot claim there is nothing to review when it never got an answer."
|
|
1007
|
+
echo " Fix: install the detector (npx -y tsx must be able to load src/npm-package/lib/ui-detector.ts),"
|
|
1008
|
+
echo " or run the review explicitly: xp-gate ui-review"
|
|
1009
|
+
echo " To accept the risk for this push: XP_GATE_SKIP_UI_GATES=1 XP_GATE_BYPASS_REASON=\"<why>\""
|
|
1010
|
+
echo ""
|
|
1011
|
+
exit 1
|
|
1012
|
+
fi
|
|
1013
|
+
echo "ℹ️ Gate UI: detector unavailable (exit ${UI_DETECT_EXIT}) and no UI-looking paths in this push — nothing to review."
|
|
1014
|
+
IS_UI_SPRINT="false"
|
|
1015
|
+
fi
|
|
1016
|
+
|
|
937
1017
|
if [[ "$IS_UI_SPRINT" == "true" ]]; then
|
|
938
1018
|
UI_RESULT_FILE=".ui-gate-result.json"
|
|
939
1019
|
MISSING_RESULTS=()
|
|
@@ -1067,10 +1147,21 @@ fi
|
|
|
1067
1147
|
fi
|
|
1068
1148
|
|
|
1069
1149
|
# ============================================================================
|
|
1070
|
-
#
|
|
1150
|
+
# Closing banner. States what actually happened (#557): a gate that skipped is
|
|
1151
|
+
# named. The heading used to read "ALL CHECKS PASSED" unconditionally while Gate
|
|
1152
|
+
# MP and Gate M (Python) skipped on a typical push without recording why, so a
|
|
1153
|
+
# reviewer skimming the log had no signal that mutation testing never ran.
|
|
1071
1154
|
# ============================================================================
|
|
1072
1155
|
echo ""
|
|
1073
1156
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
1157
|
+
# Trim the trailing separator and turn it into a displayable list.
|
|
1158
|
+
SKIPPED_DISPLAY=$(printf '%s' "${GATES_SKIPPED:-}" | sed 's/|$//' | sed 's/|/ , /g')
|
|
1159
|
+
if [ -n "$SKIPPED_DISPLAY" ]; then
|
|
1160
|
+
echo " ⚠️ NO BLOCKING FAILURES — but not every gate ran:"
|
|
1161
|
+
echo "$SKIPPED_DISPLAY" | tr ',' '\n' | sed '/^[[:space:]]*$/d' | sed 's/^[[:space:]]*/ - /'
|
|
1162
|
+
else
|
|
1163
|
+
echo " ✅ ALL CHECKS PASSED"
|
|
1164
|
+
fi
|
|
1074
1165
|
# Say what actually happened. Printing "VERIFIED" on a skipped gate would be a
|
|
1075
1166
|
# claim the hook cannot support — the same dishonesty (evidence asserting an
|
|
1076
1167
|
# unperformed check) that Gate MW exists to prevent.
|
package/hooks/sprint-gate.sh
CHANGED
|
@@ -181,6 +181,65 @@ read_verdict() {
|
|
|
181
181
|
json_extract "$APPROVED_FILE" '.verdict // ""' || echo ""
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
# --- Helper: sprint age in days, or empty if unknown ---
|
|
185
|
+
# Reads status + started_at from sprint-state.json. #551: the gate judged only
|
|
186
|
+
# `phase` and the delphi verdict, so a state file left at status=in_progress
|
|
187
|
+
# reported "sprint phase 6 validated" on unrelated commits for as long as
|
|
188
|
+
# nobody fixed it. An advancing sprint and an abandoned one were
|
|
189
|
+
# indistinguishable -- worse than SKIP, because SKIP is honest and PASS lies.
|
|
190
|
+
read_sprint_status() {
|
|
191
|
+
if [ ! -f "$SPRINT_STATE_JSON" ] || [ "$JSON_PARSER" = "none" ]; then
|
|
192
|
+
echo ""
|
|
193
|
+
return
|
|
194
|
+
fi
|
|
195
|
+
json_validate "$SPRINT_STATE_JSON" || { echo ""; return; }
|
|
196
|
+
json_extract "$SPRINT_STATE_JSON" '.status // ""' 2>/dev/null || echo ""
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
read_sprint_age_days() {
|
|
200
|
+
if [ ! -f "$SPRINT_STATE_JSON" ] || [ "$JSON_PARSER" = "none" ]; then
|
|
201
|
+
echo ""
|
|
202
|
+
return
|
|
203
|
+
fi
|
|
204
|
+
json_validate "$SPRINT_STATE_JSON" || { echo ""; return; }
|
|
205
|
+
local started
|
|
206
|
+
started=$(json_extract "$SPRINT_STATE_JSON" '.started_at // ""' 2>/dev/null || echo "")
|
|
207
|
+
# Older state files may carry updated_at only; either is the last sign of life.
|
|
208
|
+
if [ -z "$started" ] || [ "$started" = "null" ]; then
|
|
209
|
+
started=$(json_extract "$SPRINT_STATE_JSON" '.updated_at // ""' 2>/dev/null || echo "")
|
|
210
|
+
fi
|
|
211
|
+
if [ -z "$started" ] || [ "$started" = "null" ]; then
|
|
212
|
+
echo ""
|
|
213
|
+
return
|
|
214
|
+
fi
|
|
215
|
+
# Portable ISO-8601 -> epoch. GNU date first, BSD date second, node last; if
|
|
216
|
+
# all fail the age is unknown and the caller must not invent one.
|
|
217
|
+
local epoch=""
|
|
218
|
+
epoch=$(date -u -d "$started" +%s 2>/dev/null || true)
|
|
219
|
+
if [ -z "$epoch" ]; then
|
|
220
|
+
epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$started" +%s 2>/dev/null || true)
|
|
221
|
+
fi
|
|
222
|
+
if [ -z "$epoch" ] && command -v node >/dev/null 2>&1; then
|
|
223
|
+
epoch=$(node -e "const t=Date.parse(process.argv[1]);if(Number.isFinite(t))process.stdout.write(String(Math.floor(t/1000)))" "$started" 2>/dev/null || true)
|
|
224
|
+
fi
|
|
225
|
+
if [ -z "$epoch" ]; then
|
|
226
|
+
echo ""
|
|
227
|
+
return
|
|
228
|
+
fi
|
|
229
|
+
local now
|
|
230
|
+
now=$(date -u +%s)
|
|
231
|
+
local age=$(( (now - epoch) / 86400 ))
|
|
232
|
+
[ "$age" -lt 0 ] && age=0
|
|
233
|
+
echo "$age"
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
# Threshold in days beyond which an in_progress sprint is considered abandoned.
|
|
237
|
+
# Configurable because the right number is project-specific: a weekly cadence
|
|
238
|
+
# wants 14, a monthly one wants more. Default 14 = two full weeks without
|
|
239
|
+
# progress: enough that a sprint paused over a holiday is not called dead, tight
|
|
240
|
+
# enough that the reported 12-day case is caught within two days of it firing.
|
|
241
|
+
SPRINT_STALE_DAYS="${SPRINT_STALE_DAYS:-14}"
|
|
242
|
+
|
|
184
243
|
# --- Pre-commit mode ---
|
|
185
244
|
if [ "$MODE" = "pre-commit" ]; then
|
|
186
245
|
PHASE=$(read_phase)
|
|
@@ -222,8 +281,28 @@ if [ "$MODE" = "pre-commit" ]; then
|
|
|
222
281
|
exit 1
|
|
223
282
|
fi
|
|
224
283
|
|
|
225
|
-
#
|
|
226
|
-
|
|
284
|
+
# #551: staleness. A sprint left at status=in_progress past the threshold is
|
|
285
|
+
# abandoned, not validated -- reporting PASS for it means the gate cannot
|
|
286
|
+
# distinguish an advancing sprint from a forgotten one, and it injects
|
|
287
|
+
# ancient phase_history into every unrelated commit's audit trail. SKIP is
|
|
288
|
+
# the honest answer: the gate declines to certify a sprint nobody is
|
|
289
|
+
# driving, without blocking work that has nothing to do with it.
|
|
290
|
+
SPRINT_STATUS=$(read_sprint_status)
|
|
291
|
+
SPRINT_AGE_DAYS=$(read_sprint_age_days)
|
|
292
|
+
if [ "$SPRINT_STATUS" = "in_progress" ] && [ -n "$SPRINT_AGE_DAYS" ] \
|
|
293
|
+
&& [ "$SPRINT_AGE_DAYS" -ge "$SPRINT_STALE_DAYS" ] 2>/dev/null; then
|
|
294
|
+
echo "{\"decision\":\"skip\",\"reason\":\"STALE SPRINT: status=in_progress for ${SPRINT_AGE_DAYS} days (threshold ${SPRINT_STALE_DAYS}) — not certifying it. Finish it (sprint-flow close) or clear .sprint-state/sprint-state.json\"}"
|
|
295
|
+
exit 0
|
|
296
|
+
fi
|
|
297
|
+
|
|
298
|
+
# All checks passed. Name the age so "validated" is not an unconditional
|
|
299
|
+
# claim: a 29-day-old sprint under a 30-day threshold is a very different
|
|
300
|
+
# situation from one opened today, and the log should say which it is.
|
|
301
|
+
if [ -n "$SPRINT_AGE_DAYS" ]; then
|
|
302
|
+
echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint open ${SPRINT_AGE_DAYS} day(s), staleness threshold ${SPRINT_STALE_DAYS})\"}"
|
|
303
|
+
else
|
|
304
|
+
echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint age unknown — started_at unreadable)\"}"
|
|
305
|
+
fi
|
|
227
306
|
exit 0
|
|
228
307
|
fi
|
|
229
308
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-404 CRAP advisory CLI
|
|
3
|
+
* @intent Verify `xp-gate crap` renders a ranking, stays advisory (exit 0 no
|
|
4
|
+
* matter what it finds), and refuses to invent a ranking when its
|
|
5
|
+
* inputs are missing.
|
|
6
|
+
* @covers AC-404
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const { handleCrap, parseArgs } = require('../crap-cli.js');
|
|
13
|
+
|
|
14
|
+
function capture(fn) {
|
|
15
|
+
const logs = [];
|
|
16
|
+
const errs = [];
|
|
17
|
+
const origLog = console.log;
|
|
18
|
+
const origErr = console.error;
|
|
19
|
+
console.log = (...a) => logs.push(a.join(' '));
|
|
20
|
+
console.error = (...a) => errs.push(a.join(' '));
|
|
21
|
+
return fn()
|
|
22
|
+
.then((code) => ({ code, out: logs.join('\n'), err: errs.join('\n') }))
|
|
23
|
+
.finally(() => {
|
|
24
|
+
console.log = origLog;
|
|
25
|
+
console.error = origErr;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('crap-cli.js - argument parsing', () => {
|
|
30
|
+
it('defaults to top 20, threshold 6, mirrors excluded', () => {
|
|
31
|
+
const opts = parseArgs([]);
|
|
32
|
+
expect(opts.top).toBe(20);
|
|
33
|
+
expect(opts.threshold).toBe(6);
|
|
34
|
+
expect(opts.includeMirrors).toBe(false);
|
|
35
|
+
expect(opts.json).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('parses the documented flags', () => {
|
|
39
|
+
const opts = parseArgs(['--top', '5', '--threshold', '12', '--json', '--include-mirrors']);
|
|
40
|
+
expect(opts.top).toBe(5);
|
|
41
|
+
expect(opts.threshold).toBe(12);
|
|
42
|
+
expect(opts.json).toBe(true);
|
|
43
|
+
expect(opts.includeMirrors).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('collects multiple --paths values', () => {
|
|
47
|
+
expect(parseArgs(['--paths', 'src', 'scripts']).paths).toEqual(['src', 'scripts']);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('crap-cli.js - advisory contract', () => {
|
|
52
|
+
let tmpDir;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crap-cli-'));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('prints usage and exits 0 on --help', async () => {
|
|
63
|
+
const res = await capture(() => handleCrap(['--help']));
|
|
64
|
+
expect(res.code).toBe(0);
|
|
65
|
+
expect(res.out).toContain('ADVISORY ONLY');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('refuses to rank when there is no coverage artifact', async () => {
|
|
69
|
+
// Without coverage every function is unmeasured; printing an empty ranking
|
|
70
|
+
// would read as "nothing to fix", which is the opposite of the truth.
|
|
71
|
+
const missing = path.join(tmpDir, 'does-not-exist.json');
|
|
72
|
+
const res = await capture(() => handleCrap(['--coverage', missing]));
|
|
73
|
+
expect(res.code).toBe(1);
|
|
74
|
+
expect(res.err).toContain('No coverage artifact');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-404 CRAP advisory ranking
|
|
3
|
+
* @intent Verify the CRAP indicator ranks "complex and weakly tested" functions
|
|
4
|
+
* without silently treating missing coverage as either perfect or zero,
|
|
5
|
+
* and that it never presents itself as a blocking gate (#404 P1-2).
|
|
6
|
+
* @covers AC-404
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
computeCrap,
|
|
14
|
+
joinCoverage,
|
|
15
|
+
buildCrapReport,
|
|
16
|
+
isMirrorPath,
|
|
17
|
+
} = require('../crap.js');
|
|
18
|
+
|
|
19
|
+
describe('crap.js - CRAP formula', () => {
|
|
20
|
+
it('computes comp^2 * (1 - cov) + comp', () => {
|
|
21
|
+
// CCN 10, 50% covered -> 100 * 0.5 + 10 = 60
|
|
22
|
+
expect(computeCrap(10, 0.5)).toBeCloseTo(60, 5);
|
|
23
|
+
// CCN 4, fully covered -> 0 + 4 = 4
|
|
24
|
+
expect(computeCrap(4, 1)).toBeCloseTo(4, 5);
|
|
25
|
+
// CCN 12, no coverage -> 144 + 12 = 156
|
|
26
|
+
expect(computeCrap(12, 0)).toBeCloseTo(156, 5);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns null when coverage is unknown rather than guessing', () => {
|
|
30
|
+
// The whole reason this function exists: an unmeasured function must not be
|
|
31
|
+
// scored as if it were untested (that inflates it to the top of the ranking)
|
|
32
|
+
// nor as if it were fully tested (that hides it). It is simply unmeasured.
|
|
33
|
+
expect(computeCrap(10, null)).toBeNull();
|
|
34
|
+
expect(computeCrap(10, undefined)).toBeNull();
|
|
35
|
+
expect(computeCrap(10, NaN)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('clamps out-of-range coverage instead of producing negative scores', () => {
|
|
39
|
+
// Istanbul occasionally reports slightly out-of-range values; a negative
|
|
40
|
+
// CRAP would sort below genuinely clean functions and hide a hotspot.
|
|
41
|
+
expect(computeCrap(5, 1.2)).toBeCloseTo(5, 5);
|
|
42
|
+
expect(computeCrap(5, -0.1)).toBeCloseTo(30, 5);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('crap.js - coverage joining', () => {
|
|
47
|
+
it('joins per-file coverage using a repo-relative path', () => {
|
|
48
|
+
const coverage = new Map([['src/a.ts', 0.5]]);
|
|
49
|
+
const functions = [{ file: 'src/a.ts', name: 'f', ccn: 10 }];
|
|
50
|
+
const joined = joinCoverage(functions, coverage);
|
|
51
|
+
expect(joined).toHaveLength(1);
|
|
52
|
+
expect(joined[0].cov).toBeCloseTo(0.5, 5);
|
|
53
|
+
expect(joined[0].crap).toBeCloseTo(60, 5);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('marks functions whose file has no coverage as unmeasured', () => {
|
|
57
|
+
const coverage = new Map([['src/a.ts', 0.5]]);
|
|
58
|
+
const functions = [{ file: 'src/never-loaded.ts', name: 'g', ccn: 20 }];
|
|
59
|
+
const joined = joinCoverage(functions, coverage);
|
|
60
|
+
expect(joined[0].cov).toBeNull();
|
|
61
|
+
expect(joined[0].crap).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('crap.js - report', () => {
|
|
66
|
+
const functions = [
|
|
67
|
+
{ file: 'src/gates/gate-7.ts', name: 'runGate7', ccn: 14, cov: 0 },
|
|
68
|
+
{ file: 'src/gates/ok.ts', name: 'clean', ccn: 3, cov: 1 },
|
|
69
|
+
{ file: 'src/gates/mid.ts', name: 'mid', ccn: 9, cov: 0.8 },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
it('ranks by CRAP descending and drops functions below the bar', () => {
|
|
73
|
+
const report = buildCrapReport(functions, { threshold: 6 });
|
|
74
|
+
// clean: 9 * 0 + 3 = 3 -> below the bar, excluded
|
|
75
|
+
// mid: 81 * 0.2 + 9 = 25.2
|
|
76
|
+
// runGate7: 196 + 14 = 210
|
|
77
|
+
expect(report.hotspots.map((h) => h.name)).toEqual(['runGate7', 'mid']);
|
|
78
|
+
expect(report.hotspots[0].crap).toBeCloseTo(210, 5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reports how many functions could not be measured', () => {
|
|
82
|
+
const withUnknown = [
|
|
83
|
+
...functions,
|
|
84
|
+
{ file: 'src/gates/never.ts', name: 'nope', ccn: 30, cov: null },
|
|
85
|
+
];
|
|
86
|
+
const report = buildCrapReport(withUnknown, { threshold: 6 });
|
|
87
|
+
expect(report.unmeasured).toBe(1);
|
|
88
|
+
expect(report.measured).toBe(3);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('labels itself advisory', () => {
|
|
92
|
+
const report = buildCrapReport(functions, { threshold: 6 });
|
|
93
|
+
// A ranking nobody is blocked by must say so; the RFC is explicit that this
|
|
94
|
+
// navigates refactoring and does not replace security/performance review.
|
|
95
|
+
expect(report.advisory).toBe(true);
|
|
96
|
+
expect(report.blocking).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('honours an explicit top-N limit', () => {
|
|
100
|
+
const report = buildCrapReport(functions, { threshold: 6, top: 1 });
|
|
101
|
+
expect(report.hotspots).toHaveLength(1);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('crap.js - mirror exclusion', () => {
|
|
106
|
+
it('recognises the generated npm-package mirror', () => {
|
|
107
|
+
// Refactoring a mirror is refactoring a copy that prepack overwrites.
|
|
108
|
+
expect(isMirrorPath('src/npm-package/lib/doctor.js')).toBe(true);
|
|
109
|
+
expect(isMirrorPath('src/npm-package/principles/config.ts')).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not treat real source as a mirror', () => {
|
|
113
|
+
expect(isMirrorPath('src/gates/gate-7.ts')).toBe(false);
|
|
114
|
+
expect(isMirrorPath('plugins/qoder/src/index.ts')).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('excludes mirror-tree functions from the ranking by default', () => {
|
|
118
|
+
// A mirror hotspot outranks everything real (measured: findBodyBrace at
|
|
119
|
+
// CRAP 4830 for being a big untested generated copy), so the default has to
|
|
120
|
+
// be exclusion or the ranking is useless out of the box.
|
|
121
|
+
const mixed = [
|
|
122
|
+
{ file: 'src/npm-package/lib/huge.js', name: 'mirrorFn', ccn: 60, cov: 0 },
|
|
123
|
+
{ file: 'src/gates/real.ts', name: 'realFn', ccn: 10, cov: 0 },
|
|
124
|
+
];
|
|
125
|
+
const report = buildCrapReport(mixed, { threshold: 6 });
|
|
126
|
+
expect(report.hotspots.map((h) => h.name)).toEqual(['realFn']);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('includes mirrors only when explicitly asked', () => {
|
|
130
|
+
const mixed = [
|
|
131
|
+
{ file: 'src/npm-package/lib/huge.js', name: 'mirrorFn', ccn: 60, cov: 0 },
|
|
132
|
+
{ file: 'src/gates/real.ts', name: 'realFn', ccn: 10, cov: 0 },
|
|
133
|
+
];
|
|
134
|
+
const report = buildCrapReport(mixed, { threshold: 6, mirrors: true });
|
|
135
|
+
expect(report.hotspots.map((h) => h.name)).toEqual(['mirrorFn', 'realFn']);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-546 hook health: line endings and bash parseability
|
|
3
|
+
* @intent Verify doctor can tell a hook that merely EXISTS from a hook git can
|
|
4
|
+
* actually run. #546: a pre-commit rewritten with CRLF line endings
|
|
5
|
+
* failed every commit while `xp-gate doctor` reported it healthy,
|
|
6
|
+
* because the check only asked whether the file was there.
|
|
7
|
+
* @covers AC-05, AC-08
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
|
|
13
|
+
const { inspectHookFile } = require('../hook-health');
|
|
14
|
+
|
|
15
|
+
let TMP_DIR;
|
|
16
|
+
|
|
17
|
+
beforeAll(() => {
|
|
18
|
+
TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-health-'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
fs.rmSync(TMP_DIR, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function write(name, content) {
|
|
26
|
+
const p = path.join(TMP_DIR, name);
|
|
27
|
+
fs.writeFileSync(p, content);
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('inspectHookFile', () => {
|
|
32
|
+
test('a healthy LF hook reports ok', () => {
|
|
33
|
+
const p = write('good.sh', '#!/bin/bash\nfoo() {\n echo hi\n}\nfoo\n');
|
|
34
|
+
const r = inspectHookFile(p);
|
|
35
|
+
expect(r.ok).toBe(true);
|
|
36
|
+
expect(r.crlfLines).toBe(0);
|
|
37
|
+
expect(r.problems).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an all-CRLF hook is reported with a CRLF count', () => {
|
|
41
|
+
const p = write('crlf.sh', '#!/bin/bash\r\nfoo() {\r\n echo hi\r\n}\r\nfoo\r\n');
|
|
42
|
+
const r = inspectHookFile(p);
|
|
43
|
+
expect(r.ok).toBe(false);
|
|
44
|
+
expect(r.crlfLines).toBe(5);
|
|
45
|
+
expect(r.problems.join(' ')).toMatch(/CRLF/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('a single stray CRLF is enough to fail', () => {
|
|
49
|
+
// One bad line is all bash needs to choke on, and it is the case an
|
|
50
|
+
// "is the file mostly fine" heuristic would wave through.
|
|
51
|
+
const p = write('one.sh', '#!/bin/bash\necho one\necho two\r\necho three\n');
|
|
52
|
+
const r = inspectHookFile(p);
|
|
53
|
+
expect(r.ok).toBe(false);
|
|
54
|
+
expect(r.crlfLines).toBe(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('CRLF in the middle of a multi-line construct is flagged', () => {
|
|
58
|
+
// The exact shape from #546: the \r lands inside a construct bash is
|
|
59
|
+
// mid-way through parsing, so the reported line number drifts far from
|
|
60
|
+
// the real cause.
|
|
61
|
+
const p = write('mid.sh', 'case "$x" in\r\n a) echo a ;;\r\nesac\n');
|
|
62
|
+
const r = inspectHookFile(p);
|
|
63
|
+
expect(r.ok).toBe(false);
|
|
64
|
+
expect(r.crlfLines).toBe(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a syntax error with clean line endings is reported as unparseable', () => {
|
|
68
|
+
const p = write('bad.sh', '#!/bin/bash\nif [ 1 -eq 1 ]; then\n echo x\n');
|
|
69
|
+
const r = inspectHookFile(p);
|
|
70
|
+
expect(r.ok).toBe(false);
|
|
71
|
+
expect(r.problems.join(' ')).toMatch(/not parseable|syntax/i);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a binary-looking file does not crash the inspector', () => {
|
|
75
|
+
const p = path.join(TMP_DIR, 'bin.sh');
|
|
76
|
+
fs.writeFileSync(p, Buffer.from([0x00, 0xff, 0xfe, 0x0a]));
|
|
77
|
+
expect(() => inspectHookFile(p)).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a missing file reports missing rather than throwing', () => {
|
|
81
|
+
const r = inspectHookFile(path.join(TMP_DIR, 'nope.sh'));
|
|
82
|
+
expect(r.ok).toBe(false);
|
|
83
|
+
expect(r.problems.join(' ')).toMatch(/missing/i);
|
|
84
|
+
});
|
|
85
|
+
});
|