@boyingliu01/xp-gate 0.34.1 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/bin/xp-gate.js +8 -0
  2. package/gate-3.sh +46 -2
  3. package/gate-4.sh +20 -3
  4. package/hooks/gate-3.sh +46 -2
  5. package/hooks/gate-4.sh +20 -3
  6. package/hooks/lib/ccn-ratchet.sh +109 -0
  7. package/hooks/pre-commit +92 -0
  8. package/lib/__tests__/doctor-timeout-partial.test.js +79 -0
  9. package/lib/__tests__/rework.test.js +127 -0
  10. package/lib/doctor.js +60 -3
  11. package/lib/gate-audit.ts +8 -0
  12. package/lib/mattpocock-license-verdict.json +1 -1
  13. package/lib/rework.js +262 -0
  14. package/mock-policy/AGENTS.md +2 -2
  15. package/mutation/AGENTS.md +2 -2
  16. package/package.json +1 -1
  17. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  18. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  19. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  20. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  21. package/plugins/dsh/package.json +1 -1
  22. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  23. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  24. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  25. package/plugins/opencode/package.json +1 -1
  26. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  27. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/plugins/qoder/plugin.json +1 -1
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  31. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  32. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/principles/AGENTS.md +2 -2
  34. package/scripts/__tests__/sync-package-content-isolation.test.js +73 -0
  35. package/scripts/__tests__/sync-package-content.test.js +109 -2
  36. package/skills/delphi-review/AGENTS.md +2 -2
  37. package/skills/sprint-flow/AGENTS.md +2 -2
  38. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -350,6 +350,14 @@ const COMMANDS = {
350
350
  },
351
351
  usage: 'xp-gate retro [--days N] [--all] [--json] [--sprint <id>]'
352
352
  },
353
+ 'rework': {
354
+ description: 'Report rework: first-pass rate, rounds before landing, top blocking reasons (#651)',
355
+ run: subargs => {
356
+ const { main } = require('../lib/rework.js');
357
+ process.exit(main(subargs));
358
+ },
359
+ usage: 'xp-gate rework [--top N] [--dir <path>] [--json]'
360
+ },
353
361
  'crap': {
354
362
  description: 'Rank functions by CRAP score (complexity x weak coverage) — advisory, never blocks (#404)',
355
363
  run: subargs => {
package/gate-3.sh CHANGED
@@ -461,6 +461,52 @@ else
461
461
  GATE_3_DETAIL="mode=ratchet verifier=MISSING"
462
462
  exit 1
463
463
  fi
464
+ RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
465
+ [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
466
+ # #617: grade exactly the population the baseline was written from.
467
+ #
468
+ # The comment below has always said so, but only as a comment: a repo whose
469
+ # baseline came from a narrower scan (a Java-only ccn-report, say) had its
470
+ # vendored minified JS graded anyway, and --csv-verified then disagreed with
471
+ # lizard on single-line code and fail-closed with "could not be
472
+ # span-verified". That BLOCK has no repair: the file has no baseline entry
473
+ # to ratchet, so there is nothing the user could add to clear it.
474
+ #
475
+ # The population filter runs BEFORE verification on purpose. Domain
476
+ # restriction is decidable from the path alone, so asking the expensive
477
+ # span re-derivation about a file that will not be graded anyway only
478
+ # manufactures failures for code the ratchet never claims to cover.
479
+ if [ -f "$RATCHET_LIB" ]; then
480
+ # shellcheck source=/dev/null
481
+ . "$RATCHET_LIB" 2>/dev/null || true
482
+ if declare -F ccn_file_in_population >/dev/null 2>&1; then
483
+ _cc_pop_kept=""
484
+ _cc_pop_skipped=""
485
+ while IFS= read -r _cc_row; do
486
+ [ -n "$_cc_row" ] || continue
487
+ _cc_row_path=$(printf '%s' "$_cc_row" | awk -F, '{print $1}')
488
+ if ccn_file_in_population ".ccn-baseline.json" "$_cc_row_path"; then
489
+ _cc_pop_kept="${_cc_pop_kept}${_cc_row}
490
+ "
491
+ else
492
+ _cc_pop_skipped="${_cc_pop_skipped}${_cc_row_path}
493
+ "
494
+ fi
495
+ done <<CC_POP_EOF
496
+ $CC_CSV
497
+ CC_POP_EOF
498
+ CC_CSV=$(printf '%s' "$_cc_pop_kept")
499
+ if [ -n "$_cc_pop_skipped" ]; then
500
+ _cc_pop_skip_n=$(printf '%s' "$_cc_pop_skipped" | grep -c . || true)
501
+ echo "ℹ️ Gate 3: ${_cc_pop_skip_n:-0} file(s) outside the baseline population — not graded, not ratcheted:"
502
+ printf '%s' "$_cc_pop_skipped" | sed 's/^/ /' | sed -n '1,5p'
503
+ if [ "${_cc_pop_skip_n:-0}" -gt 5 ]; then
504
+ echo " ... and $((_cc_pop_skip_n - 5)) more"
505
+ fi
506
+ echo " (declare or widen the population in .ccn-baseline.json to grade them)"
507
+ fi
508
+ fi
509
+ fi
464
510
  if [ -f "$CC_REPORT" ] && command -v node >/dev/null 2>&1; then
465
511
  _cc_verify_failed=0
466
512
  CC_CSV_VERIFIED=$(printf '%s\n' "$CC_CSV" | node "$CC_REPORT" --csv-verified batch --root "$(git rev-parse --show-toplevel 2>/dev/null)") || _cc_verify_failed=1
@@ -471,8 +517,6 @@ else
471
517
  fi
472
518
  CC_CSV="$CC_CSV_VERIFIED"
473
519
  fi
474
- RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
475
- [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
476
520
  # Fail closed when the ratchet library is missing. Sourcing a nonexistent
477
521
  # file only prints "No such file or directory" and leaves
478
522
  # evaluate_ccn_ratchet undefined; the verdict then came back empty, every
package/gate-4.sh CHANGED
@@ -172,9 +172,26 @@ else
172
172
  rm -f "$PRINCIPLES_STDERR"
173
173
 
174
174
  if [ "$PRINCIPLES_EXIT" -ge 2 ]; then
175
- echo "⚠️ Warning: Principles checker execution failed"
176
- echo "⏭️ SKIPPED - Principles check (execution issue)"
177
- GATE_4_STATUS="SKIP"
175
+ # #661: exit>=2 means the checker did not RUN (crash, module not found,
176
+ # bad invocation). This used to record SKIP, and pre-commit treats SKIP
177
+ # as non-blocking, so a tool failure was absorbed into the green light --
178
+ # the reproduction printed a module-not-found stack trace and then
179
+ # "✅ PASSED - Principles checker (no errors found)".
180
+ #
181
+ # The distinction the gate must keep:
182
+ # - tool NOT INSTALLED -> SKIP is honest; nothing is being claimed.
183
+ # - tool PRESENT, CRASH -> WARN. Not a pass (nothing was checked) and
184
+ # not a silent skip (the user must know their code went unaudited).
185
+ #
186
+ # WARN rather than FAIL on purpose: the commit itself is not necessarily
187
+ # bad, the TOOL is broken, and blocking every commit until node/tsx is
188
+ # repaired would blame the change for an environment fault. What must
189
+ # never happen is the green light.
190
+ echo "⚠️ Principles checker did not run (exit ${PRINCIPLES_EXIT}) — this code is NOT verified."
191
+ echo " Gate 4 cannot claim a pass for a check that never executed."
192
+ echo " Fix the checker invocation above (or run 'npx xp-gate check principles .')."
193
+ echo "⏭️ WARN - Principles check (checker execution error)"
194
+ GATE_4_STATUS="WARN"
178
195
  # Consumed by the sourcing parent, not here (see #548).
179
196
  # shellcheck disable=SC2034
180
197
  GATE_4_REASON="checker_execution_error"
package/hooks/gate-3.sh CHANGED
@@ -461,6 +461,52 @@ else
461
461
  GATE_3_DETAIL="mode=ratchet verifier=MISSING"
462
462
  exit 1
463
463
  fi
464
+ RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
465
+ [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
466
+ # #617: grade exactly the population the baseline was written from.
467
+ #
468
+ # The comment below has always said so, but only as a comment: a repo whose
469
+ # baseline came from a narrower scan (a Java-only ccn-report, say) had its
470
+ # vendored minified JS graded anyway, and --csv-verified then disagreed with
471
+ # lizard on single-line code and fail-closed with "could not be
472
+ # span-verified". That BLOCK has no repair: the file has no baseline entry
473
+ # to ratchet, so there is nothing the user could add to clear it.
474
+ #
475
+ # The population filter runs BEFORE verification on purpose. Domain
476
+ # restriction is decidable from the path alone, so asking the expensive
477
+ # span re-derivation about a file that will not be graded anyway only
478
+ # manufactures failures for code the ratchet never claims to cover.
479
+ if [ -f "$RATCHET_LIB" ]; then
480
+ # shellcheck source=/dev/null
481
+ . "$RATCHET_LIB" 2>/dev/null || true
482
+ if declare -F ccn_file_in_population >/dev/null 2>&1; then
483
+ _cc_pop_kept=""
484
+ _cc_pop_skipped=""
485
+ while IFS= read -r _cc_row; do
486
+ [ -n "$_cc_row" ] || continue
487
+ _cc_row_path=$(printf '%s' "$_cc_row" | awk -F, '{print $1}')
488
+ if ccn_file_in_population ".ccn-baseline.json" "$_cc_row_path"; then
489
+ _cc_pop_kept="${_cc_pop_kept}${_cc_row}
490
+ "
491
+ else
492
+ _cc_pop_skipped="${_cc_pop_skipped}${_cc_row_path}
493
+ "
494
+ fi
495
+ done <<CC_POP_EOF
496
+ $CC_CSV
497
+ CC_POP_EOF
498
+ CC_CSV=$(printf '%s' "$_cc_pop_kept")
499
+ if [ -n "$_cc_pop_skipped" ]; then
500
+ _cc_pop_skip_n=$(printf '%s' "$_cc_pop_skipped" | grep -c . || true)
501
+ echo "ℹ️ Gate 3: ${_cc_pop_skip_n:-0} file(s) outside the baseline population — not graded, not ratcheted:"
502
+ printf '%s' "$_cc_pop_skipped" | sed 's/^/ /' | sed -n '1,5p'
503
+ if [ "${_cc_pop_skip_n:-0}" -gt 5 ]; then
504
+ echo " ... and $((_cc_pop_skip_n - 5)) more"
505
+ fi
506
+ echo " (declare or widen the population in .ccn-baseline.json to grade them)"
507
+ fi
508
+ fi
509
+ fi
464
510
  if [ -f "$CC_REPORT" ] && command -v node >/dev/null 2>&1; then
465
511
  _cc_verify_failed=0
466
512
  CC_CSV_VERIFIED=$(printf '%s\n' "$CC_CSV" | node "$CC_REPORT" --csv-verified batch --root "$(git rev-parse --show-toplevel 2>/dev/null)") || _cc_verify_failed=1
@@ -471,8 +517,6 @@ else
471
517
  fi
472
518
  CC_CSV="$CC_CSV_VERIFIED"
473
519
  fi
474
- RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
475
- [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
476
520
  # Fail closed when the ratchet library is missing. Sourcing a nonexistent
477
521
  # file only prints "No such file or directory" and leaves
478
522
  # evaluate_ccn_ratchet undefined; the verdict then came back empty, every
package/hooks/gate-4.sh CHANGED
@@ -172,9 +172,26 @@ else
172
172
  rm -f "$PRINCIPLES_STDERR"
173
173
 
174
174
  if [ "$PRINCIPLES_EXIT" -ge 2 ]; then
175
- echo "⚠️ Warning: Principles checker execution failed"
176
- echo "⏭️ SKIPPED - Principles check (execution issue)"
177
- GATE_4_STATUS="SKIP"
175
+ # #661: exit>=2 means the checker did not RUN (crash, module not found,
176
+ # bad invocation). This used to record SKIP, and pre-commit treats SKIP
177
+ # as non-blocking, so a tool failure was absorbed into the green light --
178
+ # the reproduction printed a module-not-found stack trace and then
179
+ # "✅ PASSED - Principles checker (no errors found)".
180
+ #
181
+ # The distinction the gate must keep:
182
+ # - tool NOT INSTALLED -> SKIP is honest; nothing is being claimed.
183
+ # - tool PRESENT, CRASH -> WARN. Not a pass (nothing was checked) and
184
+ # not a silent skip (the user must know their code went unaudited).
185
+ #
186
+ # WARN rather than FAIL on purpose: the commit itself is not necessarily
187
+ # bad, the TOOL is broken, and blocking every commit until node/tsx is
188
+ # repaired would blame the change for an environment fault. What must
189
+ # never happen is the green light.
190
+ echo "⚠️ Principles checker did not run (exit ${PRINCIPLES_EXIT}) — this code is NOT verified."
191
+ echo " Gate 4 cannot claim a pass for a check that never executed."
192
+ echo " Fix the checker invocation above (or run 'npx xp-gate check principles .')."
193
+ echo "⏭️ WARN - Principles check (checker execution error)"
194
+ GATE_4_STATUS="WARN"
178
195
  # Consumed by the sourcing parent, not here (see #548).
179
196
  # shellcheck disable=SC2034
180
197
  GATE_4_REASON="checker_execution_error"
@@ -11,6 +11,23 @@
11
11
  # improves we say so and suggest refreshing the baseline, because a stale
12
12
  # ceiling invites nobody to finish the job.
13
13
  #
14
+ # ── Population (#617) ───────────────────────────────────────────────────────
15
+ # A ratchet is only meaningful over the population it was recorded from. Gate 3
16
+ # grades every extension it recognises; a repo whose baseline came from a
17
+ # narrower scan then reports pre-existing code as "new", which is a false BLOCK
18
+ # no repair can clear — the file has no baseline entry to fix.
19
+ #
20
+ # The baseline may therefore DECLARE its population:
21
+ #
22
+ # { "population": { "roots": ["src/main/java"], "extensions": [".java"] },
23
+ # "entries": { "src/main/java/A.java::f": 4 } }
24
+ #
25
+ # A baseline without that key is LEGACY and is treated as unrestricted, which is
26
+ # exactly the historical behaviour: every baseline already in the field has the
27
+ # flat `{ "path::fn": ccn }` shape, and refusing to load it would break every
28
+ # install on upgrade. Absent metadata must never be read as an empty population
29
+ # -- that would silently grade nothing at all, the opposite failure.
30
+ #
14
31
  # Key = "<relative/path>::<functionName>". Rename or move = new key = treated
15
32
  # as a new function. That is deliberate: fuzzy matching would hide debt moves.
16
33
  # `node scripts/ccn-report.cjs --write-baseline` regenerates the file after a
@@ -41,6 +58,98 @@
41
58
  # every row is read as a single field).
42
59
  CSV_FS=$'\x1f'
43
60
 
61
+ # ---------------------------------------------------------------------------
62
+ # #617: baseline population.
63
+ #
64
+ # Prints the declared population as `<root>|<ext>|<ext>...` on one line, or
65
+ # NOTHING when the baseline carries no `population` key (legacy => unrestricted).
66
+ # Printing nothing is deliberate: "no declaration" and "empty population" are
67
+ # opposite meanings, and a caller that cannot tell them apart would either grade
68
+ # everything (wrong for a Java-only repo) or nothing (catastrophic -- the ratchet
69
+ # would report a clean run while comparing zero functions).
70
+ # ---------------------------------------------------------------------------
71
+ ccn_baseline_population() {
72
+ local _bl="${1:-.ccn-baseline.json}"
73
+ [ -f "$_bl" ] || return 0
74
+ command -v node >/dev/null 2>&1 || return 0
75
+ node -e '
76
+ const fs = require("fs");
77
+ let data;
78
+ try { data = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); }
79
+ catch { process.exit(0); }
80
+ // Legacy shape: a flat map of "path::fn" -> ccn. No declaration.
81
+ if (!data || typeof data !== "object" || !data.population) process.exit(0);
82
+ const pop = data.population || {};
83
+ const roots = Array.isArray(pop.roots) ? pop.roots : [];
84
+ const exts = Array.isArray(pop.extensions) ? pop.extensions : [];
85
+ // A declared-but-empty population is not a legitimate declaration: it would
86
+ // excuse every file. Treat it as no declaration so the gate keeps grading.
87
+ if (roots.length === 0 && exts.length === 0) process.exit(0);
88
+ // One line: "<roots csv>|<exts csv>". Callers split on the single pipe.
89
+ process.stdout.write(roots.join(",") + "|" + exts.join(","));
90
+ ' "$_bl" 2>/dev/null
91
+ return 0
92
+ }
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # #617: is this file inside the baseline's declared population?
96
+ #
97
+ # Returns 0 when the file may be graded, 1 when it is outside the population.
98
+ # A legacy baseline (no declaration) permits everything, preserving the
99
+ # behaviour every existing install already relies on.
100
+ #
101
+ # Both axes are ANDed only when both are declared; a baseline declaring just
102
+ # roots (or just extensions) narrows on that axis alone.
103
+ # ---------------------------------------------------------------------------
104
+ ccn_file_in_population() {
105
+ local _bl="${1:-.ccn-baseline.json}"
106
+ local _file="${2:-}"
107
+ [ -n "$_file" ] || return 1
108
+
109
+ local _pop
110
+ _pop=$(ccn_baseline_population "$_bl")
111
+ # No declaration => unrestricted (legacy baseline).
112
+ [ -n "$_pop" ] || return 0
113
+
114
+ local _roots="${_pop%%|*}"
115
+ local _exts="${_pop#*|}"
116
+ # Normalise separators so a Windows path and a POSIX path compare the same way.
117
+ # sed, not `tr '\' '/'`: a lone backslash is a portability trap in tr's SET
118
+ # argument (GNU tr warns "unescaped backslash at end of string" and MSYS tr
119
+ # behaviour differs), while the sed regex below is unambiguous everywhere.
120
+ _norm=$(printf '%s' "$_file" | sed 's|\\|/|g')
121
+
122
+ if [ -n "$_roots" ]; then
123
+ local _hit=0 _r
124
+ local _oldifs="$IFS"
125
+ IFS=','
126
+ for _r in $_roots; do
127
+ IFS="$_oldifs"
128
+ case "$_norm" in
129
+ "$_r"|"$_r"/*) _hit=1; break ;;
130
+ esac
131
+ done
132
+ IFS="$_oldifs"
133
+ [ "$_hit" -eq 1 ] || return 1
134
+ fi
135
+
136
+ if [ -n "$_exts" ]; then
137
+ local _match=0 _e
138
+ local _oldifs2="$IFS"
139
+ IFS=','
140
+ for _e in $_exts; do
141
+ IFS="$_oldifs2"
142
+ case "$_norm" in
143
+ *"$_e") _match=1; break ;;
144
+ esac
145
+ done
146
+ IFS="$_oldifs2"
147
+ [ "$_match" -eq 1 ] || return 1
148
+ fi
149
+
150
+ return 0
151
+ }
152
+
44
153
  csv_to_unit_separated() {
45
154
  awk '
46
155
  function emit(line, cur, i, ch, q, nf) {
package/hooks/pre-commit CHANGED
@@ -627,6 +627,98 @@ record_gate_audit() {
627
627
  disown 2>/dev/null || true
628
628
  }
629
629
 
630
+ # ── #651: journal a blocking exit ──────────────────────────────────────────
631
+ #
632
+ # Failure was invisible in this repo's own history: audit.jsonl had 8,258 records
633
+ # and essentially zero gate FAILs, because pre-commit has ~76 `exit 1` blocking
634
+ # paths and record_gate_audit() is only reached AFTER a gate settles. The moment a
635
+ # gate FAILs and exits, the record that would have said so is never written — so
636
+ # `xp-gate retro` and `sprint-status --rework-check` computed rework over a
637
+ # denominator with no failures in it, and rework trended to zero by construction.
638
+ #
639
+ # The trap catches the failure on its way OUT rather than adding a record call to
640
+ # all 76 branches, which would rot the first time someone adds branch 77.
641
+ _BLOCKING_RECORDED=""
642
+ _record_blocking_exit() {
643
+ local rc=$?
644
+ # 0 = success, nothing to journal.
645
+ [ "$rc" -eq 0 ] && return 0
646
+ # One record per run. Without the latch, a gate that already recorded and then
647
+ # exited non-zero would be counted twice, halving the reported first-pass rate.
648
+ [ -n "$_BLOCKING_RECORDED" ] && return 0
649
+ _BLOCKING_RECORDED="1"
650
+
651
+ # Identify the gate that was running. Status vars are NO*-gradeable: any of
652
+ # FAIL/BLOCK means this gate is the reason. Latest failing gate wins, which is
653
+ # the one that actually called exit.
654
+ local _bg_id="" _bg_name="" _bg_status="" _bg_detail=""
655
+ local _i _st_var _st
656
+ for _i in 0 1 2 3 4 5 6 7 8 9 10 11 12 13; do
657
+ _st_var="GATE_${_i}_STATUS"
658
+ _st="${!_st_var:-}"
659
+ case "$_st" in
660
+ FAIL|BLOCK)
661
+ # Latest failing gate wins: it is the one that actually called exit.
662
+ _bg_id="$_i"
663
+ _bg_status="$_st"
664
+ ;;
665
+ esac
666
+ done
667
+ local _bg_detail_var="GATE_${_bg_id}_DETAIL"
668
+ _bg_detail="${!_bg_detail_var:-}"
669
+
670
+ # Gate names for the journal. Deliberately short: this runs during shutdown.
671
+ case "$_bg_id" in
672
+ 0) _bg_name="Version Consistency" ;;
673
+ 1) _bg_name="Code Quality" ;;
674
+ 2) _bg_name="Duplicate Code" ;;
675
+ 3) _bg_name="Cyclomatic Complexity" ;;
676
+ 4) _bg_name="Principles" ;;
677
+ 5) _bg_name="Tests + Coverage" ;;
678
+ 6) _bg_name="Architecture + Boy Scout" ;;
679
+ 7) _bg_name="IaC Security" ;;
680
+ 8) _bg_name="Secret Scanning" ;;
681
+ 9) _bg_name="Build Integrity" ;;
682
+ 10) _bg_name="SAST Security" ;;
683
+ 11) _bg_name="Sprint Flow" ;;
684
+ 12) _bg_name="File Hygiene" ;;
685
+ 13) _bg_name="Compiler Strictness" ;;
686
+ *) _bg_name="unknown" ;;
687
+ esac
688
+ [ -n "$_bg_id" ] || _bg_id="unknown"
689
+
690
+ # The reason must reach the journal or "top-N failing rules" cannot exist.
691
+ # Prefer the gate's own detail (it carries the rule/threshold), else the status.
692
+ local _bg_reason="${_bg_detail:-gate_exit_${rc}}"
693
+
694
+ local _bg_start_var="GATE_${_bg_id}_START"
695
+ local _bg_start="${!_bg_start_var:-}"
696
+ local _bg_dur="null"
697
+ if [ -n "$_bg_start" ]; then
698
+ local _bg_end
699
+ _bg_end=$(now_ms 2>/dev/null || echo "")
700
+ if [ -n "$_bg_end" ]; then
701
+ _bg_dur=$((_bg_end - _bg_start))
702
+ [ "$_bg_dur" -ge 0 ] 2>/dev/null || _bg_dur="null"
703
+ fi
704
+ fi
705
+
706
+ # Fire-and-forget, and never let journaling change the exit code.
707
+ {
708
+ run_tsx "${PROJECT_ROOT_FOR_AUDIT}/src/npm-package/lib/gate-audit.ts" record \
709
+ --gate-id "$_bg_id" \
710
+ --gate-name "$_bg_name" \
711
+ --passed false \
712
+ --issues-found 1 \
713
+ --duration-ms "$_bg_dur" \
714
+ --trigger commit \
715
+ --failure-reason "$_bg_reason" \
716
+ 2>/dev/null
717
+ } >/dev/null 2>&1 || true
718
+ return 0
719
+ }
720
+ trap _record_blocking_exit EXIT
721
+
630
722
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
631
723
  echo " QUALITY GATES - PRE-COMMIT CHECK"
632
724
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @test REQ-629
3
+ * @intent `xp-gate doctor` is non-deterministic on Windows. Against an identical,
4
+ * freshly-installed, fully healthy installation it printed
5
+ * "All checks passed" on one run and "Timed out after 10s — some checks
6
+ * incomplete" on the next two. The timeout is a deliberate safety net
7
+ * (#348, "43s on Windows"); the defect is what happens when it fires.
8
+ *
9
+ * `Promise.race` discarded the entire `diagnoseAsync()` result and
10
+ * substituted a single placeholder check, so everything that had already
11
+ * finished was thrown away. Consequence: doctor can report LESS
12
+ * information the LONGER it runs — the opposite of what a diagnostic
13
+ * should do — and a WARN from the timeout made the skills section vanish
14
+ * entirely, so tooling that scrapes `Skill:.*Up to date` read 0/12.
15
+ *
16
+ * The fix: reach a verdict at the deadline (never hang — #348 stays fixed)
17
+ * but keep whatever finished. A diagnostic that says "these 9 checks
18
+ * passed, 2 did not report in time" is strictly more useful than one that
19
+ * says "timed out".
20
+ *
21
+ * The tests below pin the merge semantics directly rather than waiting on
22
+ * a real 10s timer: the behaviour that matters is "partial results are
23
+ * preserved and reported as partial", not the clock.
24
+ * @covers AC-670-43, AC-670-44, AC-670-45
25
+ */
26
+ 'use strict';
27
+
28
+ // describe/it/expect are vitest globals in this repo's config; requiring them from
29
+ // 'vitest' fails because this file is CommonJS. (Hooks are not needed here.)
30
+
31
+ /** A completed check, shaped like diagnoseAsync()'s output. */
32
+ function check(name, status) {
33
+ return { name, status, detail: `${name} detail` };
34
+ }
35
+
36
+ describe('doctor keeps completed checks when the global timeout fires (#629)', () => {
37
+ it('AC-670-43: completed checks are merged with the timeout notice, not replaced', () => {
38
+ const { mergeTimedOutDiagnosis } = require('../doctor');
39
+ expect(typeof mergeTimedOutDiagnosis).toBe('function');
40
+
41
+ const completed = {
42
+ checks: [check('Hooks', 'PASS'), check('Adapters', 'PASS'), check('Skills', 'WARN')],
43
+ issues: 1,
44
+ };
45
+ const merged = mergeTimedOutDiagnosis(completed, ['Gate env', 'OpenCode TUI']);
46
+
47
+ // Nothing that finished may be dropped -- that is the whole defect.
48
+ for (const name of ['Hooks', 'Adapters', 'Skills']) {
49
+ expect(merged.checks.map((c) => c.name)).toContain(name);
50
+ }
51
+ // The deadline itself is still reported, and still not a pass.
52
+ const notice = merged.checks.find((c) => c.name === 'Diagnosis');
53
+ expect(notice).toBeDefined();
54
+ expect(notice.status).toBe('WARN');
55
+ // Named individually, so the reader knows which surface is unknown.
56
+ expect(notice.detail).toContain('Gate env');
57
+ expect(notice.detail).toContain('OpenCode TUI');
58
+ expect(merged.timedOut).toBe(true);
59
+ // A partial run is never clean.
60
+ expect(merged.issues).toBeGreaterThanOrEqual(1);
61
+ });
62
+
63
+ it('AC-670-44: an empty partial result still yields the honest timeout notice', () => {
64
+ const { mergeTimedOutDiagnosis } = require('../doctor');
65
+ const merged = mergeTimedOutDiagnosis({ checks: [], issues: 0 }, ['Gate env']);
66
+ expect(merged.checks).toHaveLength(1);
67
+ expect(merged.checks[0].name).toBe('Diagnosis');
68
+ expect(merged.timedOut).toBe(true);
69
+ });
70
+
71
+ it('AC-670-45: nothing that already reported is ever lost, and a real issue count survives', () => {
72
+ const { mergeTimedOutDiagnosis } = require('../doctor');
73
+ const completed = { checks: [check('Hooks', 'FAIL'), check('Templates', 'PASS')], issues: 4 };
74
+ const merged = mergeTimedOutDiagnosis(completed, []);
75
+ // Pre-existing issues are not reset by the timeout.
76
+ expect(merged.issues).toBeGreaterThanOrEqual(4);
77
+ expect(merged.checks.map((c) => c.name)).toEqual(expect.arrayContaining(['Hooks', 'Templates']));
78
+ });
79
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @test REQ-651 rework
3
+ * @intent #651 asks for a command that turns the gate journal into the three
4
+ * numbers rework management actually needs:
5
+ *
6
+ * 1. first-pass rate -- how often a round clears the gates on try 1
7
+ * 2. rework round stats -- median / mean / worst rounds per landing
8
+ * 3. top-N failing rules -- which rule_id blocks most often
9
+ *
10
+ * Without (3) the only thing you can do is "try harder". With it, the
11
+ * answer to "why is development slow" becomes a ranked list.
12
+ *
13
+ * Two data hazards decide the design:
14
+ *
15
+ * - Before #651 the journal contained NO failures, so every derived rate
16
+ * was 100% by construction. A command that reports "first-pass rate: 100%"
17
+ * from an empty failure set is worse than no command: it manufactures
18
+ * confidence. So an input with zero FAIL records must report that the
19
+ * figure is NOT MEASURABLE, not that it is perfect.
20
+ *
21
+ * - ~401 historical records carry epoch-sized `duration_ms` (flagged
22
+ * `duration_anomaly`). Any duration aggregate must exclude them rather
23
+ * than average them in -- one of them once produced "average 430 million
24
+ * seconds".
25
+ *
26
+ * These tests exercise the pure aggregation function, so the numbers are
27
+ * pinned independently of file layout.
28
+ * @covers AC-670-59, AC-670-60, AC-670-61, AC-670-62
29
+ */
30
+ 'use strict';
31
+
32
+ const { computeReworkStats } = require('../rework');
33
+
34
+ /** One journal record, defaulting to a pass. */
35
+ function rec(overrides) {
36
+ return {
37
+ timestamp: '2026-10-30T10:00:00.000Z',
38
+ gate_id: '1',
39
+ gate_name: 'Code Quality',
40
+ passed: true,
41
+ issues_found: 0,
42
+ duration_ms: 1000,
43
+ trigger: 'commit',
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ describe('computeReworkStats (#651)', () => {
49
+ it('AC-670-59: reports an unmeasurable first-pass rate when the journal has no failures', () => {
50
+ // This is exactly the pre-#651 journal: thousands of passes, zero fails.
51
+ const entries = Array.from({ length: 50 }, () => rec());
52
+ const stats = computeReworkStats(entries);
53
+ // A rate derived from an empty failure set is not 100% -- it is unknown.
54
+ expect(stats.firstPassRate).toBeNull();
55
+ expect(stats.measurable).toBe(false);
56
+ expect(stats.reason).toMatch(/no failure/i);
57
+ });
58
+
59
+ it('AC-670-60: counts failing rules and ranks them, so the worst rule is actionable', () => {
60
+ const entries = [
61
+ rec({ passed: false, failure_reason: 'long-function' }),
62
+ rec({ passed: false, failure_reason: 'long-function' }),
63
+ rec({ passed: false, failure_reason: 'long-function' }),
64
+ rec({ passed: false, failure_reason: 'tests_failed' }),
65
+ rec(),
66
+ rec(),
67
+ ];
68
+ const stats = computeReworkStats(entries);
69
+ expect(stats.measurable).toBe(true);
70
+ expect(stats.topFailures[0]).toEqual({ reason: 'long-function', count: 3 });
71
+ expect(stats.topFailures[1]).toEqual({ reason: 'tests_failed', count: 1 });
72
+ // 4 failures out of 6 attempts -> 33% first-pass.
73
+ expect(stats.firstPassRate).toBeCloseTo(33.3, 1);
74
+ });
75
+
76
+ it('AC-670-61: excludes anomaly-flagged durations instead of averaging them in', () => {
77
+ // A real epoch-sized value from the corpus; averaging it in produced
78
+ // "average 430 million seconds" on gate-1.
79
+ const entries = [
80
+ rec({ duration_ms: 1000 }),
81
+ rec({ duration_ms: 3000 }),
82
+ rec({ duration_ms: 1784536915891, duration_anomaly: true }),
83
+ ];
84
+ const stats = computeReworkStats(entries);
85
+ expect(stats.duration).not.toBeNull();
86
+ // Median over the two sane values (1000, 3000) is 2000, not ~5.9e11.
87
+ expect(stats.duration.medianMs).toBe(2000);
88
+ expect(stats.duration.samples).toBe(2);
89
+ expect(stats.duration.excludedAnomalies).toBe(1);
90
+ });
91
+
92
+ it('AC-670-62: a failure with no reason is bucketed, never silently dropped', () => {
93
+ const entries = [
94
+ rec({ passed: false }), // no failure_reason
95
+ rec({ passed: false, failure_reason: '' }), // empty
96
+ rec({ passed: false, failure_reason: 'short-circuit' }),
97
+ ];
98
+ const stats = computeReworkStats(entries);
99
+ // Dropping unlabelled failures would understate the failure count and push
100
+ // the first-pass rate UP -- the optimistic direction is the dangerous one.
101
+ expect(stats.failures).toBe(3);
102
+ const unattributed = stats.topFailures.find((f) => /unattributed|unknown/i.test(f.reason));
103
+ expect(unattributed, 'unlabelled failures must appear in the ranking').toBeDefined();
104
+ expect(unattributed.count).toBe(2);
105
+ });
106
+ });
107
+
108
+ describe('rework duration sampling (#651)', () => {
109
+ it('AC-670-61: an UNFLAGGED epoch-sized duration is still excluded', () => {
110
+ // Measured on the real journal: 391 records carry duration_anomaly, but 6
111
+ // more epoch-sized values do not. Trusting the flag alone left the mean at
112
+ // 1.3e9 ms -- the "average 430 million seconds" artifact returning through
113
+ // the back door. Plausibility must be checked independently of the flag.
114
+ const entries = [
115
+ rec({ duration_ms: 2000 }),
116
+ rec({ duration_ms: 4000 }),
117
+ rec({ duration_ms: 1785001784992 }), // no duration_anomaly flag
118
+ ];
119
+ const stats = computeReworkStats(entries);
120
+ expect(stats.duration).not.toBeNull();
121
+ expect(stats.duration.medianMs).toBe(3000);
122
+ expect(stats.duration.samples).toBe(2);
123
+ // Excluded count covers BOTH flagged and implausible, or the sample size
124
+ // looks closer to the record count than it really is.
125
+ expect(stats.duration.excludedAnomalies).toBe(1);
126
+ });
127
+ });