@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.
Files changed (52) hide show
  1. package/adapters/iac.sh +45 -4
  2. package/adapters/java.sh +82 -16
  3. package/adapters/powershell.sh +70 -9
  4. package/bin/xp-gate.js +8 -0
  5. package/gate-10.sh +32 -2
  6. package/gate-3.sh +47 -6
  7. package/gate-4.sh +12 -0
  8. package/gate-7.sh +4 -0
  9. package/hooks/gate-10.sh +32 -2
  10. package/hooks/gate-3.sh +47 -6
  11. package/hooks/gate-4.sh +12 -0
  12. package/hooks/gate-7.sh +4 -0
  13. package/hooks/hook-module-resolution.sh +14 -0
  14. package/hooks/lib/coverage-total.sh +1 -1
  15. package/hooks/pre-commit +138 -17
  16. package/hooks/pre-push +97 -6
  17. package/hooks/sprint-gate.sh +81 -2
  18. package/lib/__tests__/crap-cli.test.js +76 -0
  19. package/lib/__tests__/crap.test.js +137 -0
  20. package/lib/__tests__/hook-health.test.js +85 -0
  21. package/lib/__tests__/retro-gate-m.test.js +144 -0
  22. package/lib/coverage-total.sh +92 -0
  23. package/lib/crap-cli.js +191 -0
  24. package/lib/crap.js +196 -0
  25. package/lib/doctor.js +44 -0
  26. package/lib/hook-health.js +121 -0
  27. package/lib/mattpocock-license-verdict.json +1 -1
  28. package/lib/retro.js +139 -2
  29. package/mock-policy/AGENTS.md +2 -2
  30. package/mutation/AGENTS.md +2 -2
  31. package/package.json +1 -1
  32. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  33. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  34. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  35. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/plugins/dsh/package.json +1 -1
  37. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  39. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  40. package/plugins/opencode/package.json +1 -1
  41. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  42. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  43. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/plugins/qoder/plugin.json +1 -1
  45. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  46. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  47. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  48. package/principles/AGENTS.md +2 -2
  49. package/skills/delphi-review/AGENTS.md +2 -2
  50. package/skills/sprint-flow/AGENTS.md +2 -2
  51. package/skills/test-specification-alignment/AGENTS.md +2 -2
  52. 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
- UI_DETECTION=$(echo "$PUSHED_FILES" | npx -y tsx src/npm-package/lib/ui-detector.ts --push-mode --from-stdin 2>/dev/null) || true
934
-
935
- IS_UI_SPRINT=$(echo "$UI_DETECTION" | jq -r '.isUiSprint' 2>/dev/null || echo "false")
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
- # ALL CHECKS PASSED
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.
@@ -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
- # All checks passed
226
- echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated\"}"
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
+ });