@boyingliu01/xp-gate 0.29.0 → 0.30.0

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 (47) hide show
  1. package/gate-3.sh +18 -1
  2. package/hooks/gate-3.sh +18 -1
  3. package/hooks/hook-module-resolution.sh +39 -0
  4. package/hooks/pre-commit +118 -39
  5. package/hooks/pre-push +26 -2
  6. package/lib/__tests__/doctor-module-resolution-parity.test.js +79 -11
  7. package/lib/__tests__/sprint-state-manager.test.js +31 -0
  8. package/lib/doctor.js +60 -27
  9. package/lib/hook-module-resolution.js +127 -0
  10. package/lib/mattpocock-license-verdict.json +1 -1
  11. package/lib/retro.js +24 -7
  12. package/lib/sprint-state-manager.js +14 -6
  13. package/mock-policy/AGENTS.md +3 -3
  14. package/mutation/AGENTS.md +3 -3
  15. package/package.json +1 -1
  16. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  17. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  18. package/plugins/claude-code/skills/grill-with-docs/SKILL.md +1 -0
  19. package/plugins/claude-code/skills/grilling/SKILL.md +1 -0
  20. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  21. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  22. package/plugins/dsh/package.json +1 -1
  23. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/dsh/skills/grill-with-docs/SKILL.md +1 -0
  25. package/plugins/dsh/skills/grilling/SKILL.md +1 -0
  26. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  27. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  28. package/plugins/opencode/package.json +1 -1
  29. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  30. package/plugins/opencode/skills/grill-with-docs/SKILL.md +1 -0
  31. package/plugins/opencode/skills/grilling/SKILL.md +1 -0
  32. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  34. package/plugins/qoder/plugin.json +1 -1
  35. package/plugins/qoder/skills/batch-grill-me/SKILL.md +9 -2
  36. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/qoder/skills/grill-with-docs/SKILL.md +2 -2
  38. package/plugins/qoder/skills/grilling/SKILL.md +23 -5
  39. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  40. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  41. package/principles/AGENTS.md +3 -3
  42. package/scripts/sync-package-content.js +3 -1
  43. package/skills/delphi-review/AGENTS.md +3 -3
  44. package/skills/grill-with-docs/SKILL.md +1 -0
  45. package/skills/grilling/SKILL.md +1 -0
  46. package/skills/sprint-flow/AGENTS.md +3 -3
  47. 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
- local lang="$1"
56
- # Try flat layout first (global: typescript.sh next to adapter-common.sh)
57
- if [ -f "$ADAPTER_DIR/${lang}.sh" ]; then
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
- if [ -f "$ADAPTER_DIR/gate-3.sh" ]; then
81
- GATE_DIR="$ADAPTER_DIR"
82
- elif [ -f "$PROJECT_GITHOOKS/gate-3.sh" ]; then
83
- GATE_DIR="$PROJECT_GITHOOKS"
84
- elif [ -f "$SCRIPT_DIR/gate-3.sh" ]; then
85
- GATE_DIR="$SCRIPT_DIR"
86
- else
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
- GATE_6_STATUS="PASS"
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
- if [ "$gate" -eq 12 ]; then gate_default="WARN"; else gate_default="PASS"; fi
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:-PASS}")
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:-PASS}",
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:-PASS}",
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:-PASS}\",\"name\":\"Duplicate Code\"},"
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:-PASS}\",\"name\":\"Architecture+BoyScout\",\"bsBlocked\":${BS_BLOCKED}},"
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:-PASS}"
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:-PASS}"
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
- # Only run for TypeScript projects
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
- blocks.adapterDir,
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
- const resolverText = extractFunction(PRE_COMMIT, 'resolve_adapter_path() {');
198
- // The two tier chains the hook runs before it ever calls the resolver. Read
199
- // from the hook's text, not restated here: #495 Round 2's project-tier label
200
- // was wrong precisely because doctor carried a second, hand-written tier list.
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: matchBlock(PRE_COMMIT, /if \[ -f "\$GLOBAL_ADAPTER_DIR\/adapter-common\.sh" \]; then[\s\S]*?\nfi/, 'the ADAPTER_DIR tier chain'),
203
- gateDir: matchBlock(PRE_COMMIT, /if \[ -f "\$ADAPTER_DIR\/gate-3\.sh" \]; then[\s\S]*?\nfi/, 'the GATE_DIR tier chain'),
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
- expect(resolverText).toMatch(/\$ADAPTER_DIR\/\$\{lang\}\.sh/);
245
- expect(resolverText).toMatch(/\$ADAPTER_DIR\/adapters\/\$\{lang\}\.sh/);
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);