@boyingliu01/xp-gate 0.29.0 → 0.30.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/gate-3.sh +18 -1
- package/hooks/gate-3.sh +18 -1
- package/hooks/hook-module-resolution.sh +39 -0
- package/hooks/pre-commit +118 -39
- package/hooks/pre-push +26 -2
- package/lib/__tests__/doctor-module-resolution-parity.test.js +79 -11
- package/lib/__tests__/sprint-state-manager.test.js +31 -0
- package/lib/doctor.js +60 -27
- package/lib/hook-module-resolution.js +127 -0
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/retro.js +24 -7
- package/lib/sprint-state-manager.js +14 -6
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +3 -3
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/grill-with-docs/SKILL.md +1 -0
- package/plugins/claude-code/skills/grilling/SKILL.md +1 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/grill-with-docs/SKILL.md +1 -0
- package/plugins/dsh/skills/grilling/SKILL.md +1 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/grill-with-docs/SKILL.md +1 -0
- package/plugins/opencode/skills/grilling/SKILL.md +1 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/batch-grill-me/SKILL.md +9 -2
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/grill-with-docs/SKILL.md +2 -2
- package/plugins/qoder/skills/grilling/SKILL.md +23 -5
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/sync-package-content.js +3 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/grill-with-docs/SKILL.md +1 -0
- package/skills/grilling/SKILL.md +1 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/gate-3.sh
CHANGED
|
@@ -77,7 +77,24 @@ else
|
|
|
77
77
|
|
|
78
78
|
# Get changed source files for complexity check (includes test files)
|
|
79
79
|
CC_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx|py|go|java|swift|cpp|c|hpp|h|m|mm|kt)$' || true)
|
|
80
|
-
|
|
80
|
+
|
|
81
|
+
# Grade exactly the population the baseline was written from. The two must
|
|
82
|
+
# match or the ratchet reports pre-existing code as "new", which is a false
|
|
83
|
+
# BLOCK no repair can clear (the exact failure #495 Round 3 called out, in a
|
|
84
|
+
# different place). scripts/ccn-report.cjs --write-baseline records only
|
|
85
|
+
# bucket === 'production' rows; its bucketFor() classifies two other buckets:
|
|
86
|
+
# mirror src/npm-package/**, plugins/*/src/** — regenerated by prepack
|
|
87
|
+
# from a canonical source that IS graded here. Editing a mirror
|
|
88
|
+
# is reverted at the next publish, so grading it cannot be acted
|
|
89
|
+
# on (doctorDelphi CCN=106 blocked every src/npm-package commit,
|
|
90
|
+
# on HEAD too — no baseline entry could ever exist for it).
|
|
91
|
+
# test __tests__/, *.test.*, *_test.{py,go}, *Test(s).java — recorded
|
|
92
|
+
# in the report's test bucket, never in the baseline.
|
|
93
|
+
# Both prefixes and the test pattern mirror ccn-report.cjs so the gate and
|
|
94
|
+
# the writer cannot drift apart again (pinned by __tests__/gate-3-*.bats).
|
|
95
|
+
CC_FILES=$(echo "$CC_FILES" | grep -vE \
|
|
96
|
+
'^"?src/npm-package/|^"?plugins/[^/]+/src/|(^|/)__tests__?/|\.test\.[cm]?[jt]sx?$|_test\.(py|go)$|Test(s)?\.java$' || true)
|
|
97
|
+
|
|
81
98
|
if [ -n "$CC_FILES" ]; then
|
|
82
99
|
echo "Checking complexity for source files..."
|
|
83
100
|
|
package/hooks/gate-3.sh
CHANGED
|
@@ -77,7 +77,24 @@ else
|
|
|
77
77
|
|
|
78
78
|
# Get changed source files for complexity check (includes test files)
|
|
79
79
|
CC_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx|py|go|java|swift|cpp|c|hpp|h|m|mm|kt)$' || true)
|
|
80
|
-
|
|
80
|
+
|
|
81
|
+
# Grade exactly the population the baseline was written from. The two must
|
|
82
|
+
# match or the ratchet reports pre-existing code as "new", which is a false
|
|
83
|
+
# BLOCK no repair can clear (the exact failure #495 Round 3 called out, in a
|
|
84
|
+
# different place). scripts/ccn-report.cjs --write-baseline records only
|
|
85
|
+
# bucket === 'production' rows; its bucketFor() classifies two other buckets:
|
|
86
|
+
# mirror src/npm-package/**, plugins/*/src/** — regenerated by prepack
|
|
87
|
+
# from a canonical source that IS graded here. Editing a mirror
|
|
88
|
+
# is reverted at the next publish, so grading it cannot be acted
|
|
89
|
+
# on (doctorDelphi CCN=106 blocked every src/npm-package commit,
|
|
90
|
+
# on HEAD too — no baseline entry could ever exist for it).
|
|
91
|
+
# test __tests__/, *.test.*, *_test.{py,go}, *Test(s).java — recorded
|
|
92
|
+
# in the report's test bucket, never in the baseline.
|
|
93
|
+
# Both prefixes and the test pattern mirror ccn-report.cjs so the gate and
|
|
94
|
+
# the writer cannot drift apart again (pinned by __tests__/gate-3-*.bats).
|
|
95
|
+
CC_FILES=$(echo "$CC_FILES" | grep -vE \
|
|
96
|
+
'^"?src/npm-package/|^"?plugins/[^/]+/src/|(^|/)__tests__?/|\.test\.[cm]?[jt]sx?$|_test\.(py|go)$|Test(s)?\.java$' || true)
|
|
97
|
+
|
|
81
98
|
if [ -n "$CC_FILES" ]; then
|
|
82
99
|
echo "Checking complexity for source files..."
|
|
83
100
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# GENERATED FILE — DO NOT EDIT.
|
|
3
|
+
# Source of truth: src/npm-package/lib/hook-module-resolution.js
|
|
4
|
+
# Regenerate: node scripts/generate-hook-resolution.cjs
|
|
5
|
+
#
|
|
6
|
+
# The module resolution order (#505) lives in one place, as data. This file
|
|
7
|
+
# is its shell projection. Editing it by hand makes the hook disagree with
|
|
8
|
+
# doctor.js, which reads the JS module directly — the exact divergence
|
|
9
|
+
# #495/#500 were about.
|
|
10
|
+
|
|
11
|
+
# Probe file used to pick the gate tier.
|
|
12
|
+
HOOK_GATE_PROBE="gate-3.sh"
|
|
13
|
+
|
|
14
|
+
# ADAPTER_DIR cascade, highest priority first.
|
|
15
|
+
# Each entry is "tier_id|base_var|probe_file".
|
|
16
|
+
HOOK_ADAPTER_DIR_TIERS=(
|
|
17
|
+
'global|GLOBAL_ADAPTER_DIR|adapter-common.sh'
|
|
18
|
+
'project|PROJECT_GITHOOKS|adapter-common.sh'
|
|
19
|
+
'script|SCRIPT_DIR|adapter-common.sh'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Adapter resolution tiers, highest priority first.
|
|
23
|
+
# Each entry is "tier_id|base_var|template" with {name} as the basename.
|
|
24
|
+
HOOK_ADAPTER_TIERS=(
|
|
25
|
+
'adapter_flat|ADAPTER_DIR|{name}.sh'
|
|
26
|
+
'adapter_nested|ADAPTER_DIR|adapters/{name}.sh'
|
|
27
|
+
'project_nested|PROJECT_GITHOOKS|adapters/{name}.sh'
|
|
28
|
+
'script_nested|SCRIPT_DIR|adapters/{name}.sh'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Gate resolution tiers, highest priority first.
|
|
32
|
+
# Semantics differ from adapter tiers: each entry names the DIRECTORY that
|
|
33
|
+
# would hold the gate probe, so a match yields the directory (GATE_DIR), not
|
|
34
|
+
# the probe file itself. Entry format: "tier_id|base_var|(empty suffix)".
|
|
35
|
+
HOOK_GATE_TIERS=(
|
|
36
|
+
'gate_adapter_dir|ADAPTER_DIR|'
|
|
37
|
+
'gate_project|PROJECT_GITHOOKS|'
|
|
38
|
+
'gate_script_dir|SCRIPT_DIR|'
|
|
39
|
+
)
|
package/hooks/pre-commit
CHANGED
|
@@ -15,6 +15,89 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
15
15
|
ADAPTER_DIR=""
|
|
16
16
|
GLOBAL_ADAPTER_DIR="$HOME/.config/xp-gate/adapters"
|
|
17
17
|
|
|
18
|
+
# #505: load the resolution-order data BEFORE any tier decision runs. The file is
|
|
19
|
+
# generated from src/npm-package/lib/hook-module-resolution.js, which doctor.js
|
|
20
|
+
# also reads, so the hook and the diagnostic share one order instead of two
|
|
21
|
+
# hand-written copies that a parity test had to keep honest.
|
|
22
|
+
#
|
|
23
|
+
# It is looked up beside the hook (SCRIPT_DIR — always present, since this file
|
|
24
|
+
# IS the hook) and beside adapter-common.sh (ADAPTER_DIR — where `xp-gate init`
|
|
25
|
+
# and setup-global install the supporting modules). Either location works; the
|
|
26
|
+
# hook-dir copy is what `doctor --sync-hooks` and sync-package-content maintain.
|
|
27
|
+
# Note this runs BEFORE ADAPTER_DIR is resolved, which is why the script dir is
|
|
28
|
+
# the primary location: reading the order cannot depend on the order.
|
|
29
|
+
HOOK_RESOLUTION_SH="${SCRIPT_DIR}/hook-module-resolution.sh"
|
|
30
|
+
if [ ! -f "$HOOK_RESOLUTION_SH" ] && [ -f "$GLOBAL_ADAPTER_DIR/hook-module-resolution.sh" ]; then
|
|
31
|
+
HOOK_RESOLUTION_SH="$GLOBAL_ADAPTER_DIR/hook-module-resolution.sh"
|
|
32
|
+
fi
|
|
33
|
+
if [ ! -f "$HOOK_RESOLUTION_SH" ]; then
|
|
34
|
+
# Fail closed: a hook that cannot load its resolution order must not fall back
|
|
35
|
+
# to an invented one — that would silently resurrect the duplication removed
|
|
36
|
+
# here, and doctor would report a different surface than the hook executes.
|
|
37
|
+
echo "FATAL: cannot load hook-module-resolution.sh (module resolution order is unavailable)" >&2
|
|
38
|
+
echo "Fix: reinstall the hooks (xp-gate init, or xp-gate doctor --sync-hooks in the xp-gate repo)." >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
# shellcheck source=/dev/null
|
|
42
|
+
. "$HOOK_RESOLUTION_SH"
|
|
43
|
+
|
|
44
|
+
# Look up one tier's directory variable by name without eval-ing arbitrary input:
|
|
45
|
+
# the base names are a closed set baked into the generated file.
|
|
46
|
+
_hook_tier_base() {
|
|
47
|
+
case "$1" in
|
|
48
|
+
ADAPTER_DIR) printf '%s' "$ADAPTER_DIR" ;;
|
|
49
|
+
GLOBAL_ADAPTER_DIR) printf '%s' "$GLOBAL_ADAPTER_DIR" ;;
|
|
50
|
+
PROJECT_GITHOOKS) printf '%s' "$PROJECT_GITHOOKS" ;;
|
|
51
|
+
SCRIPT_DIR) printf '%s' "$SCRIPT_DIR" ;;
|
|
52
|
+
*) printf '%s' "" ;;
|
|
53
|
+
esac
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Walk a "tier_id|base|suffix" table in order and print the first existing match.
|
|
57
|
+
# Usage: _hook_resolve <module_filename> <tiers_array_name>
|
|
58
|
+
_hook_resolve() {
|
|
59
|
+
local module_name="$1"
|
|
60
|
+
local tiers_var="$2"
|
|
61
|
+
local entry base suffix base_dir candidate
|
|
62
|
+
eval "set -- \"\${${tiers_var}[@]}\""
|
|
63
|
+
for entry in "$@"; do
|
|
64
|
+
entry="${entry#*|}"
|
|
65
|
+
base="${entry%%|*}"
|
|
66
|
+
suffix="${entry#*|}"
|
|
67
|
+
base_dir="$(_hook_tier_base "$base")"
|
|
68
|
+
[ -n "$base_dir" ] || continue
|
|
69
|
+
candidate="$base_dir/${suffix/\{name\}/$module_name}"
|
|
70
|
+
if [ -f "$candidate" ]; then
|
|
71
|
+
printf '%s' "$candidate"
|
|
72
|
+
return 0
|
|
73
|
+
fi
|
|
74
|
+
done
|
|
75
|
+
return 1
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Walk a gate tier table and print the first DIRECTORY that contains the probe.
|
|
79
|
+
# The adapter table yields a file; this one yields the directory the gate suite
|
|
80
|
+
# lives in (GATE_DIR), which is why it probes rather than substitutes.
|
|
81
|
+
# Usage: _hook_resolve_dir <probe_filename> <tiers_array_name>
|
|
82
|
+
_hook_resolve_dir() {
|
|
83
|
+
local probe="$1"
|
|
84
|
+
local tiers_var="$2"
|
|
85
|
+
local entry base suffix base_dir
|
|
86
|
+
eval "set -- \"\${${tiers_var}[@]}\""
|
|
87
|
+
for entry in "$@"; do
|
|
88
|
+
entry="${entry#*|}"
|
|
89
|
+
base="${entry%%|*}"
|
|
90
|
+
suffix="${entry#*|}"
|
|
91
|
+
base_dir="$(_hook_tier_base "$base")"
|
|
92
|
+
[ -n "$base_dir" ] || continue
|
|
93
|
+
if [ -f "$base_dir${suffix}/$probe" ]; then
|
|
94
|
+
printf '%s' "$base_dir${suffix}"
|
|
95
|
+
return 0
|
|
96
|
+
fi
|
|
97
|
+
done
|
|
98
|
+
return 1
|
|
99
|
+
}
|
|
100
|
+
|
|
18
101
|
# BEGIN GIT CONTEXT FALLBACK
|
|
19
102
|
if ! declare -F run_without_git_context >/dev/null 2>&1; then
|
|
20
103
|
run_without_git_context() (
|
|
@@ -51,39 +134,25 @@ source "$ADAPTER_DIR/adapter-common.sh" 2>/dev/null || {
|
|
|
51
134
|
# ADAPTER_DIR points to the base directory that contains either
|
|
52
135
|
# adapter-common.sh directly (global) or adapter-common.sh in a flat layout.
|
|
53
136
|
# Gate scripts live alongside adapter-common.sh.
|
|
137
|
+
#
|
|
138
|
+
# #505: the ORDER is not written here. HOOK_ADAPTER_TIERS is generated from
|
|
139
|
+
# src/npm-package/lib/hook-module-resolution.js, which doctor.js reads directly,
|
|
140
|
+
# so the hook and the diagnostic cannot disagree about which file wins.
|
|
54
141
|
resolve_adapter_path() {
|
|
55
|
-
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
echo "$ADAPTER_DIR/${lang}.sh"
|
|
59
|
-
return 0
|
|
60
|
-
fi
|
|
61
|
-
# Try nested layout (project: adapters/typescript.sh)
|
|
62
|
-
if [ -f "$ADAPTER_DIR/adapters/${lang}.sh" ]; then
|
|
63
|
-
echo "$ADAPTER_DIR/adapters/${lang}.sh"
|
|
64
|
-
return 0
|
|
65
|
-
fi
|
|
66
|
-
# Try project githooks as fallback
|
|
67
|
-
if [ -f "$PROJECT_GITHOOKS/adapters/${lang}.sh" ]; then
|
|
68
|
-
echo "$PROJECT_GITHOOKS/adapters/${lang}.sh"
|
|
69
|
-
return 0
|
|
70
|
-
fi
|
|
71
|
-
# Try script dir as last resort
|
|
72
|
-
if [ -f "$SCRIPT_DIR/adapters/${lang}.sh" ]; then
|
|
73
|
-
echo "$SCRIPT_DIR/adapters/${lang}.sh"
|
|
74
|
-
return 0
|
|
75
|
-
fi
|
|
76
|
-
return 1
|
|
142
|
+
# ADAPTER templates carry the extension ("{name}.sh"), so pass the bare language
|
|
143
|
+
# name here. GATE templates are "{name}", so their call passes the full filename.
|
|
144
|
+
_hook_resolve "${1}" HOOK_ADAPTER_TIERS
|
|
77
145
|
}
|
|
78
146
|
|
|
79
|
-
# Gate scripts directory — falls back to project githooks if ADAPTER_DIR doesn't have them
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
147
|
+
# Gate scripts directory — falls back to project githooks if ADAPTER_DIR doesn't have them.
|
|
148
|
+
# #505: the probe file and the tier order come from the generated data file, so
|
|
149
|
+
# this cascade cannot drift from what doctor.js reports.
|
|
150
|
+
GATE_DIR="$(_hook_resolve_dir "$HOOK_GATE_PROBE" HOOK_GATE_TIERS)" || GATE_DIR=""
|
|
151
|
+
# Deliberate last resort: when no tier carries the gate suite, point at
|
|
152
|
+
# ADAPTER_DIR anyway rather than leaving GATE_DIR empty. The gate-N.sh sources
|
|
153
|
+
# further down check for the file before sourcing, so an absent suite still
|
|
154
|
+
# degrades to a reported skip instead of a path built from an empty string.
|
|
155
|
+
if [ -z "$GATE_DIR" ]; then
|
|
87
156
|
GATE_DIR="$ADAPTER_DIR"
|
|
88
157
|
fi
|
|
89
158
|
|
|
@@ -3296,7 +3365,14 @@ else
|
|
|
3296
3365
|
echo " ⏭️ SKIPPED - Boy Scout Rule (not available in project)"
|
|
3297
3366
|
fi
|
|
3298
3367
|
fi
|
|
3299
|
-
|
|
3368
|
+
# A PASS here must mean 'the checks ran and found nothing'. The two
|
|
3369
|
+
# branches above print 'SKIPPED - not configured/available'; stamping PASS
|
|
3370
|
+
# after them turned 'nothing was checked' into a scored success.
|
|
3371
|
+
if [ -n "${GATE_6_STATUS:-}" ]; then
|
|
3372
|
+
: # a branch that really ran already decided the verdict
|
|
3373
|
+
else
|
|
3374
|
+
GATE_6_STATUS="SKIP"
|
|
3375
|
+
fi
|
|
3300
3376
|
record_gate_audit "gate-6" "architecture-boy-scout" "$GATE_6_STATUS" "${BS_BLOCKED:-0}" "$GATE_6_START"
|
|
3301
3377
|
|
|
3302
3378
|
# ============================================================================
|
|
@@ -3653,7 +3729,10 @@ generate_quality_report() {
|
|
|
3653
3729
|
# applicable" and must render and score identically. A previous scoring
|
|
3654
3730
|
# default of FAIL produced self-contradictory reports -- the gate showed
|
|
3655
3731
|
# PASS in JSON but scored as FAIL (#507 Delphi round-1 A-MAJOR-2).
|
|
3656
|
-
|
|
3732
|
+
# Default direction is "did not run", never "passed": an unassigned status
|
|
3733
|
+
# is a gate that never reached a verdict, and scoring it PASS both claims a
|
|
3734
|
+
# success that did not happen and hides the gap in the effective rate.
|
|
3735
|
+
if [ "$gate" -eq 12 ]; then gate_default="WARN"; else gate_default="SKIP"; fi
|
|
3657
3736
|
gate_status="${!status_var:-$gate_default}"
|
|
3658
3737
|
case "$gate_status" in
|
|
3659
3738
|
PASS) PASSED_COUNT=$((PASSED_COUNT + 1)) ;;
|
|
@@ -3750,7 +3829,7 @@ generate_quality_report() {
|
|
|
3750
3829
|
# Gate 6 reason for this language — extracted so the rule is testable
|
|
3751
3830
|
# rather than inlined (see coverage_g6_reason_for below).
|
|
3752
3831
|
_cm_g6_reason=$(coverage_g6_reason_for "$_cm_lang")
|
|
3753
|
-
_cm_g6=$(classify_coverage_reason "$_cm_g6_reason" "${GATE_6_STATUS:-
|
|
3832
|
+
_cm_g6=$(classify_coverage_reason "$_cm_g6_reason" "${GATE_6_STATUS:-SKIP}")
|
|
3754
3833
|
_cm_entry="\"${_cm_lang}\": {\"gate3_complexity\": \"${_cm_g3}\", \"gate4_principles\": \"${_cm_g4}\", \"gate6_architecture\": \"${_cm_g6}\"}"
|
|
3755
3834
|
COVERAGE_MATRIX_JSON="${COVERAGE_MATRIX_JSON:+$COVERAGE_MATRIX_JSON, }${_cm_entry}"
|
|
3756
3835
|
done
|
|
@@ -3786,7 +3865,7 @@ generate_quality_report() {
|
|
|
3786
3865
|
},
|
|
3787
3866
|
"gate2_dup_code": {
|
|
3788
3867
|
"name": "Duplicate Code",
|
|
3789
|
-
"status": "${GATE_2_STATUS:-
|
|
3868
|
+
"status": "${GATE_2_STATUS:-SKIP}",
|
|
3790
3869
|
"metric": "similarity <= 5%"
|
|
3791
3870
|
},
|
|
3792
3871
|
"gate3_complexity": {
|
|
@@ -3812,7 +3891,7 @@ generate_quality_report() {
|
|
|
3812
3891
|
},
|
|
3813
3892
|
"gate6_arch_boyscout": {
|
|
3814
3893
|
"name": "Architecture + Boy Scout Rule",
|
|
3815
|
-
"status": "${GATE_6_STATUS:-
|
|
3894
|
+
"status": "${GATE_6_STATUS:-SKIP}",
|
|
3816
3895
|
"reason": "${GATE_6_REASON:-}",
|
|
3817
3896
|
"boyScoutPassed": $BS_PASSED,
|
|
3818
3897
|
"boyScoutBlocked": $BS_BLOCKED
|
|
@@ -3865,11 +3944,11 @@ ENDJSON
|
|
|
3865
3944
|
# ── History append (unchanged — append-only for trend) ────────────────────────────
|
|
3866
3945
|
local GATES_JSON="{"
|
|
3867
3946
|
GATES_JSON="${GATES_JSON}\"gate1\":{\"status\":\"${GATE_1_STATUS:-PASS}\",\"name\":\"Code Quality\"},"
|
|
3868
|
-
GATES_JSON="${GATES_JSON}\"gate2\":{\"status\":\"${GATE_2_STATUS:-
|
|
3947
|
+
GATES_JSON="${GATES_JSON}\"gate2\":{\"status\":\"${GATE_2_STATUS:-SKIP}\",\"name\":\"Duplicate Code\"},"
|
|
3869
3948
|
GATES_JSON="${GATES_JSON}\"gate3\":{\"status\":\"${GATE_3_STATUS:-PASS}\",\"name\":\"Complexity\",\"warnings\":${CC_WARNINGS:-0}},"
|
|
3870
3949
|
GATES_JSON="${GATES_JSON}\"gate4\":{\"status\":\"${GATE_4_STATUS:-PASS}\",\"name\":\"Principles\",\"warnings\":${WARNING_COUNT:-0}},"
|
|
3871
3950
|
GATES_JSON="${GATES_JSON}\"gate5\":{\"status\":\"${GATE_5_STATUS:-PASS}\",\"name\":\"Tests+Coverage\",\"coverage\":\"${COV_PCT}\"},"
|
|
3872
|
-
GATES_JSON="${GATES_JSON}\"gate6\":{\"status\":\"${GATE_6_STATUS:-
|
|
3951
|
+
GATES_JSON="${GATES_JSON}\"gate6\":{\"status\":\"${GATE_6_STATUS:-SKIP}\",\"name\":\"Architecture+BoyScout\",\"bsBlocked\":${BS_BLOCKED}},"
|
|
3873
3952
|
GATES_JSON="${GATES_JSON}\"gate7\":{\"status\":\"${GATE_7_STATUS:-PASS}\",\"name\":\"IaC Security\"},"
|
|
3874
3953
|
GATES_JSON="${GATES_JSON}\"gate8\":{\"status\":\"${GATE_8_STATUS:-PASS}\",\"name\":\"Secret Scanning\"},"
|
|
3875
3954
|
GATES_JSON="${GATES_JSON}\"gate9\":{\"status\":\"${GATE_9_STATUS:-PASS}\",\"name\":\"Build Integrity\"},"
|
|
@@ -3886,11 +3965,11 @@ ENDJSON
|
|
|
3886
3965
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
3887
3966
|
echo ""
|
|
3888
3967
|
printf " %-45s %s\n" "Gate 1: Code Quality" "${GATE_1_STATUS:-PASS}"
|
|
3889
|
-
printf " %-45s %s\n" "Gate 2: Duplicate Code" "${GATE_2_STATUS:-
|
|
3968
|
+
printf " %-45s %s\n" "Gate 2: Duplicate Code" "${GATE_2_STATUS:-SKIP}"
|
|
3890
3969
|
printf " %-45s %s (warnings: ${CC_WARNINGS:-0})\n" "Gate 3: Complexity" "${GATE_3_STATUS:-PASS}"
|
|
3891
3970
|
printf " %-45s %s (warnings: ${WARNING_COUNT:-0})\n" "Gate 4: Principles" "${GATE_4_STATUS:-PASS}"
|
|
3892
3971
|
printf " %-45s %s (coverage: ${COV_PCT}%%)\n" "Gate 5: Tests + Coverage" "${GATE_5_STATUS:-PASS}"
|
|
3893
|
-
printf " %-45s %s\n" "Gate 6: Architecture + Boy Scout" "${GATE_6_STATUS:-
|
|
3972
|
+
printf " %-45s %s\n" "Gate 6: Architecture + Boy Scout" "${GATE_6_STATUS:-SKIP}"
|
|
3894
3973
|
printf " %-45s %s\n" "Gate 7: IaC Security" "${GATE_7_STATUS:-PASS}"
|
|
3895
3974
|
printf " %-45s %s\n" "Gate 8: Secret Scanning" "${GATE_8_STATUS:-PASS}"
|
|
3896
3975
|
printf " %-45s %s\n" "Gate 9: Build Integrity" "${GATE_9_STATUS:-PASS}"
|
package/hooks/pre-push
CHANGED
|
@@ -51,7 +51,7 @@ report = {
|
|
|
51
51
|
'gates': [
|
|
52
52
|
{'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {'duration_ms': '${MP_DURATION_MS:-0}'}},
|
|
53
53
|
{'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},
|
|
54
|
-
{'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': {}},
|
|
54
|
+
{'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': {'skip_reason': '${GATE_M_SKIP_REASON:-}', 'duration_ms': '${M_DURATION_MS:-0}', 'mutation_score': '${M_SCORE:-}'}},
|
|
55
55
|
{'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PYTHON_STATUS:-SKIP}', 'details': {}},
|
|
56
56
|
{'id': 'M-Go', 'name': 'Mutation Testing (Go)', 'status': '${GATE_M_GO_STATUS:-SKIP}', 'details': {}},
|
|
57
57
|
{'id': 'M-Java', 'name': 'Mutation Testing (Java)', 'status': '${GATE_M_JAVA_STATUS:-SKIP}', 'details': {}},
|
|
@@ -373,16 +373,22 @@ fi
|
|
|
373
373
|
# shellcheck source=githooks/adapter-common.sh
|
|
374
374
|
source "$ADAPTER_COMMON" 2>/dev/null || {
|
|
375
375
|
echo "⚠️ Could not source adapter-common.sh. SKIP — Gate M."
|
|
376
|
+
GATE_M_SKIP_REASON="adapter-common.sh could not be sourced"
|
|
376
377
|
}
|
|
377
378
|
|
|
378
|
-
#
|
|
379
|
+
# #404 P0: every Gate M skip records WHY. Without this the journals carried
|
|
380
|
+
# `details: {}` on 62 of 68 runs, so the RFC's "SKIP rate" question was
|
|
381
|
+
# unanswerable — a missing tool and "no mutable file in this push" are opposite
|
|
382
|
+
# findings that looked identical after the fact.
|
|
379
383
|
if [[ ! -f "package.json" ]] || [[ ! -f "tsconfig.json" ]]; then
|
|
380
384
|
echo "📚 Not a TypeScript project. SKIP — Gate M."
|
|
385
|
+
GATE_M_SKIP_REASON="not a TypeScript project (package.json+tsconfig.json required)"
|
|
381
386
|
else
|
|
382
387
|
# Check if mutation testing is configured
|
|
383
388
|
if ! detect_mutation_testable 2>/dev/null; then
|
|
384
389
|
echo "⚠️ Stryker config not found or @stryker-mutator not installed."
|
|
385
390
|
echo " SKIP — Gate M mutation testing."
|
|
391
|
+
GATE_M_SKIP_REASON="stryker config missing or @stryker-mutator not installed"
|
|
386
392
|
else
|
|
387
393
|
# Filter to source files (exclude tests). The tree-segment grep excludes
|
|
388
394
|
# test infrastructure (helpers/fixtures under tests/, test/, __tests__/)
|
|
@@ -392,6 +398,7 @@ else
|
|
|
392
398
|
|
|
393
399
|
if [ -z "$CHANGED_SOURCE_FILES" ]; then
|
|
394
400
|
echo "📚 No changed TypeScript source files. SKIP — Gate M."
|
|
401
|
+
GATE_M_SKIP_REASON="no changed TypeScript source files in this push"
|
|
395
402
|
else
|
|
396
403
|
echo "🧬 Running mutation tests on changed files..."
|
|
397
404
|
echo "$CHANGED_SOURCE_FILES"
|
|
@@ -408,15 +415,28 @@ else
|
|
|
408
415
|
|
|
409
416
|
if [[ -z "$MUTATION_SCRIPT" ]]; then
|
|
410
417
|
echo "⚠️ Gate M script not found. SKIP — Gate M."
|
|
418
|
+
GATE_M_SKIP_REASON="gate-m.ts not found in .xp-gate/modules, src/mutation, or global modules"
|
|
411
419
|
else
|
|
412
420
|
# Run mutation gate with timeout matching stryker.conf.json (timeoutMS: 600000 = 10min)
|
|
413
421
|
MUTATION_OUTPUT=$(mktemp)
|
|
422
|
+
M_START_MS=$(date +%s%3N 2>/dev/null || python -c 'import time;print(int(time.time()*1000))' 2>/dev/null || echo 0)
|
|
414
423
|
RUN_TSX_TIMEOUT=600s run_tsx $MUTATION_SCRIPT --changed-files "$CHANGED_SOURCE_FILES" > "$MUTATION_OUTPUT" 2>&1
|
|
415
424
|
MUTATION_EXIT=$?
|
|
425
|
+
M_END_MS=$(date +%s%3N 2>/dev/null || python -c 'import time;print(int(time.time()*1000))' 2>/dev/null || echo 0)
|
|
426
|
+
if [ "$M_START_MS" != "0" ] && [ "$M_END_MS" != "0" ]; then
|
|
427
|
+
M_DURATION_MS=$((M_END_MS - M_START_MS))
|
|
428
|
+
[ "$M_DURATION_MS" -ge 0 ] || M_DURATION_MS=0
|
|
429
|
+
else
|
|
430
|
+
M_DURATION_MS=0
|
|
431
|
+
fi
|
|
432
|
+
# #404: the mutation score is the number the RFC wants a distribution for.
|
|
433
|
+
# It is printed by gate-m.ts; take the last percentage it reported.
|
|
434
|
+
M_SCORE=$(sed -n 's/.*[Mm]utation score[: ]*\([0-9][0-9]*\(\.[0-9][0-9]*\)\?\)%.*/\1/p' "$MUTATION_OUTPUT" 2>/dev/null | tail -1)
|
|
416
435
|
|
|
417
436
|
case $MUTATION_EXIT in
|
|
418
437
|
0)
|
|
419
438
|
echo "✅ Gate M: PASS"
|
|
439
|
+
GATE_M_STATUS="PASS"
|
|
420
440
|
# Update baseline after successful mutation test
|
|
421
441
|
if [ -f ".stryker-baseline.json" ]; then
|
|
422
442
|
echo "📝 Updating mutation baseline..."
|
|
@@ -437,11 +457,15 @@ else
|
|
|
437
457
|
124)
|
|
438
458
|
echo "⏱️ Gate M: TIMEOUT (120s). Mutation testing incomplete."
|
|
439
459
|
echo " Allowing push with warning — review mutation coverage manually."
|
|
460
|
+
GATE_M_STATUS="TIMEOUT"
|
|
461
|
+
GATE_M_SKIP_REASON="mutation run exceeded the 600s timeout; score is unknown"
|
|
440
462
|
;;
|
|
441
463
|
*)
|
|
442
464
|
echo "⚠️ Gate M: Unexpected exit code $MUTATION_EXIT"
|
|
443
465
|
cat "$MUTATION_OUTPUT"
|
|
444
466
|
echo " Allowing push with warning."
|
|
467
|
+
GATE_M_STATUS="WARN"
|
|
468
|
+
GATE_M_SKIP_REASON="gate-m.ts exited $MUTATION_EXIT (unexpected); score is unknown"
|
|
445
469
|
;;
|
|
446
470
|
esac
|
|
447
471
|
|
|
@@ -16,10 +16,20 @@
|
|
|
16
16
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { join, resolve } from 'node:path';
|
|
19
|
-
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { spawnSync, execFileSync } from 'node:child_process';
|
|
20
20
|
import { describe, expect, it } from 'vitest';
|
|
21
21
|
|
|
22
22
|
const PRE_COMMIT = readFileSync(resolve(__dirname, '..', '..', '..', '..', 'githooks', 'pre-commit'), 'utf8');
|
|
23
|
+
// #505 replaced the two hand-written shell chains with one data file generated
|
|
24
|
+
// from lib/hook-module-resolution.js — the same module doctor.js requires. The
|
|
25
|
+
// parity test therefore verifies the NEW contract: the generated data is current,
|
|
26
|
+
// and the hook + doctor both honour it. The scenarios below still drive the hook's
|
|
27
|
+
// real text through bash, so agreement is measured, not asserted.
|
|
28
|
+
const RESOLUTION_DATA = readFileSync(
|
|
29
|
+
resolve(__dirname, '..', '..', '..', '..', 'githooks', 'hook-module-resolution.sh'),
|
|
30
|
+
'utf8',
|
|
31
|
+
);
|
|
32
|
+
const { RESOLUTION_ORDER, GATE_RESOLUTION_ORDER, ADAPTER_DIR_ORDER } = require('../hook-module-resolution.js');
|
|
23
33
|
|
|
24
34
|
/**
|
|
25
35
|
* Extract one bash function's full text, brace-balanced from the declaration.
|
|
@@ -149,13 +159,28 @@ function hookResolvesAll(blocks, resolverText, t, lang, gateModule) {
|
|
|
149
159
|
`GLOBAL_ADAPTER_DIR='${t.globalAdapters}'`,
|
|
150
160
|
`PROJECT_GITHOOKS='${t.projectGithooks}'`,
|
|
151
161
|
`SCRIPT_DIR='${t.scriptDir}'`,
|
|
152
|
-
|
|
162
|
+
`ADAPTER_DIR=''`,
|
|
163
|
+
resolverText,
|
|
153
164
|
blocks.gateDir,
|
|
165
|
+
// The real chains, driven by the same data the hook loads.
|
|
166
|
+
'_hook_adapter_dir() {',
|
|
167
|
+
' local entry base suffix base_dir',
|
|
168
|
+
' eval "set -- \\"\\${HOOK_ADAPTER_DIR_TIERS[@]}\\""',
|
|
169
|
+
' for entry in "$@"; do',
|
|
170
|
+
' entry="${entry#*|}"; base="${entry%%|*}"; suffix="${entry#*|}"',
|
|
171
|
+
' base_dir="$(_hook_tier_base "$base")"',
|
|
172
|
+
' [ -n "$base_dir" ] || continue',
|
|
173
|
+
' if [ -f "$base_dir/$suffix" ]; then printf "%s" "$base_dir"; return 0; fi',
|
|
174
|
+
' done',
|
|
175
|
+
' return 1',
|
|
176
|
+
'}',
|
|
177
|
+
'ADAPTER_DIR="$(_hook_adapter_dir)" || ADAPTER_DIR="$SCRIPT_DIR"',
|
|
178
|
+
'GATE_DIR="$(_hook_resolve_dir "$HOOK_GATE_PROBE" HOOK_GATE_TIERS)" || GATE_DIR=""',
|
|
179
|
+
'if [ -z "$GATE_DIR" ]; then GATE_DIR="$ADAPTER_DIR"; fi',
|
|
154
180
|
'echo "ADAPTER=$ADAPTER_DIR"',
|
|
155
181
|
'echo "GATE=$GATE_DIR"',
|
|
156
182
|
`if [ -f "$ADAPTER_DIR/adapter-common.sh" ]; then echo "COMMON=$ADAPTER_DIR/adapter-common.sh"; else echo "COMMON="; fi`,
|
|
157
183
|
`if [ -f "$GATE_DIR/${gateModule}" ]; then echo "MODULE=$GATE_DIR/${gateModule}"; else echo "MODULE="; fi`,
|
|
158
|
-
resolverText,
|
|
159
184
|
// Captured by substitution rather than read off the last stdout line: the
|
|
160
185
|
// script ends with a newline, so "last line" is always the empty one.
|
|
161
186
|
`echo "LANG=$(resolve_adapter_path ${lang} || true)"`,
|
|
@@ -194,14 +219,53 @@ function hookResolves(fnText, tree, lang) {
|
|
|
194
219
|
}
|
|
195
220
|
|
|
196
221
|
describe('doctor module resolution parity with pre-commit (#495)', () => {
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
222
|
+
// #505: the resolver helpers come from the hook, the tier DATA from the
|
|
223
|
+
// generated file. Concatenating them is what the hook itself does, so the
|
|
224
|
+
// bash side here is the production code path, not a paraphrase of it.
|
|
225
|
+
const helpers = [
|
|
226
|
+
extractFunction(PRE_COMMIT, '_hook_tier_base() {'),
|
|
227
|
+
extractFunction(PRE_COMMIT, '_hook_resolve() {'),
|
|
228
|
+
extractFunction(PRE_COMMIT, '_hook_resolve_dir() {'),
|
|
229
|
+
extractFunction(PRE_COMMIT, 'resolve_adapter_path() {'),
|
|
230
|
+
].join('\n');
|
|
231
|
+
// `blocks` keeps the names the scenarios use: the data file supplies the
|
|
232
|
+
// ADAPTER_DIR/GATE_DIR chains that used to be matched out of the hook text.
|
|
201
233
|
const blocks = {
|
|
202
|
-
adapterDir:
|
|
203
|
-
gateDir:
|
|
234
|
+
adapterDir: '',
|
|
235
|
+
gateDir: '',
|
|
204
236
|
};
|
|
237
|
+
const resolverText = `${RESOLUTION_DATA}\n${helpers}`;
|
|
238
|
+
|
|
239
|
+
it('AC-505-01: the generated shell data is current with its JS source', () => {
|
|
240
|
+
// Stale data would silently run a different order than doctor reports — the
|
|
241
|
+
// exact failure mode #505 exists to make impossible.
|
|
242
|
+
expect(() => execFileSync(
|
|
243
|
+
process.execPath,
|
|
244
|
+
[resolve(__dirname, '..', '..', '..', '..', 'scripts', 'generate-hook-resolution.cjs'), '--check'],
|
|
245
|
+
{ stdio: 'pipe' },
|
|
246
|
+
)).not.toThrow();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('AC-505-02: the hook loads the generated data instead of carrying its own order', () => {
|
|
250
|
+
// Anti-vacuity for the whole feature: if the hook re-grew a literal cascade,
|
|
251
|
+
// the single-source-of-truth claim would be false even while scenarios pass.
|
|
252
|
+
expect(PRE_COMMIT).toContain('hook-module-resolution.sh');
|
|
253
|
+
expect(PRE_COMMIT).toContain('HOOK_ADAPTER_TIERS');
|
|
254
|
+
expect(PRE_COMMIT).toContain('HOOK_GATE_TIERS');
|
|
255
|
+
// The old hand-written chains must be gone.
|
|
256
|
+
expect(PRE_COMMIT).not.toContain('$ADAPTER_DIR/${lang}.sh');
|
|
257
|
+
expect(PRE_COMMIT).not.toMatch(/if \[ -f "\$ADAPTER_DIR\/gate-3\.sh" \]; then/);
|
|
258
|
+
// And every tier the JS declares must appear in the emitted shell data.
|
|
259
|
+
for (const t of RESOLUTION_ORDER) {
|
|
260
|
+
expect(RESOLUTION_DATA).toContain(`${t.tier_id}|${t.base}|${t.template}`);
|
|
261
|
+
}
|
|
262
|
+
for (const t of GATE_RESOLUTION_ORDER) {
|
|
263
|
+
expect(RESOLUTION_DATA).toContain(`'${t.tier_id}|${t.base}|'`);
|
|
264
|
+
}
|
|
265
|
+
for (const t of ADAPTER_DIR_ORDER) {
|
|
266
|
+
expect(RESOLUTION_DATA).toContain(`${t.tier_id}|${t.base}|${t.probe}`);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
205
269
|
|
|
206
270
|
it('AC-495-11: flat and nested present -> both languages pick the flat copy', () => {
|
|
207
271
|
const { listExecutedModules } = require('../doctor');
|
|
@@ -241,8 +305,12 @@ describe('doctor module resolution parity with pre-commit (#495)', () => {
|
|
|
241
305
|
}
|
|
242
306
|
|
|
243
307
|
// Anti-vacuity: the guard executed the hook's real text, not a paraphrase.
|
|
244
|
-
|
|
245
|
-
|
|
308
|
+
// #505 moved the literal paths out of the hook, so assert that the tiers it
|
|
309
|
+
// now reads still describe the two layouts those paths used to spell out.
|
|
310
|
+
expect(resolverText).toContain('HOOK_ADAPTER_TIERS');
|
|
311
|
+
const adapterTemplates = RESOLUTION_ORDER.map((t) => t.template);
|
|
312
|
+
expect(adapterTemplates).toContain('{name}.sh');
|
|
313
|
+
expect(adapterTemplates).toContain('adapters/{name}.sh');
|
|
246
314
|
});
|
|
247
315
|
|
|
248
316
|
// The scenarios below put a copy of the same module in EVERY tier the hook can
|
|
@@ -143,6 +143,37 @@ describe('SprintStateManager', () => {
|
|
|
143
143
|
expect(result.phase_history[0].duration_seconds).toBeGreaterThanOrEqual(0);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it('stamps a duration when a phase goes straight from pending to completed', () => {
|
|
147
|
+
// Regression: `xp-gate phase-transition N completed --render` never emits
|
|
148
|
+
// an in_progress step, so gating started_at on in_progress left
|
|
149
|
+
// duration_seconds permanently null — the blank duration column on the
|
|
150
|
+
// status panel. Observed on sprint-20261019-47 phases 2-6.
|
|
151
|
+
const initialState = createInitialState(tmpDir);
|
|
152
|
+
manager.write(initialState);
|
|
153
|
+
|
|
154
|
+
const result = manager.transitionPhase(2, 'completed');
|
|
155
|
+
|
|
156
|
+
expect(result.phase_history[0].status).toBe('completed');
|
|
157
|
+
expect(result.phase_history[0].started_at).toBeDefined();
|
|
158
|
+
expect(result.phase_history[0].completed_at).toBeDefined();
|
|
159
|
+
expect(result.phase_history[0].duration_seconds).not.toBeNull();
|
|
160
|
+
expect(result.phase_history[0].duration_seconds).toBeGreaterThanOrEqual(0);
|
|
161
|
+
expect(Number.isNaN(result.phase_history[0].duration_seconds)).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('never writes a NaN duration, even for an unparseable started_at', () => {
|
|
165
|
+
const state = createInitialState(tmpDir);
|
|
166
|
+
state.phase_history = [
|
|
167
|
+
{ phase: 2, phase_name: 'DESIGN', status: 'pending', started_at: 'not-a-date' },
|
|
168
|
+
];
|
|
169
|
+
manager.write(state);
|
|
170
|
+
|
|
171
|
+
const result = manager.transitionPhase(2, 'completed');
|
|
172
|
+
|
|
173
|
+
expect(Number.isNaN(result.phase_history[0].duration_seconds)).toBe(false);
|
|
174
|
+
expect(result.phase_history[0].duration_seconds).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
146
177
|
it('calls onTransition callback', () => {
|
|
147
178
|
const initialState = createInitialState(tmpDir);
|
|
148
179
|
manager.write(initialState);
|