@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.
- package/bin/xp-gate.js +8 -0
- package/gate-3.sh +46 -2
- package/gate-4.sh +20 -3
- package/hooks/gate-3.sh +46 -2
- package/hooks/gate-4.sh +20 -3
- package/hooks/lib/ccn-ratchet.sh +109 -0
- package/hooks/pre-commit +92 -0
- package/lib/__tests__/doctor-timeout-partial.test.js +79 -0
- package/lib/__tests__/rework.test.js +127 -0
- package/lib/doctor.js +60 -3
- package/lib/gate-audit.ts +8 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/rework.js +262 -0
- 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/scripts/__tests__/sync-package-content-isolation.test.js +73 -0
- package/scripts/__tests__/sync-package-content.test.js +109 -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/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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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/lib/ccn-ratchet.sh
CHANGED
|
@@ -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
|
+
});
|