@boyingliu01/xp-gate 0.28.1 → 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 +315 -38
  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
 
@@ -285,6 +354,104 @@ gate_start_ms() {
285
354
  now_ms
286
355
  }
287
356
 
357
+ # ---------------------------------------------------------------------------
358
+ # #537: per-language coverage classification.
359
+ #
360
+ # A gate that SKIPs tells the reader nothing on its own — "documentation
361
+ # project", "tool not installed" and "nothing changed" are wildly different
362
+ # facts that used to collapse into one word. A PowerShell-primary repo could
363
+ # therefore look fully covered while 3.3k lines were measured by nothing.
364
+ #
365
+ # classify_coverage_reason REASON GATE_STATUS
366
+ # -> covered | not_applicable | no_changes | tool_missing | unknown
367
+ #
368
+ # FAIL CLOSED: an unrecognised reason is `unknown`, never `covered`. `covered`
369
+ # is reachable ONLY by a gate that actually passed (PASS / WARN /
370
+ # PASS_WITH_WARNINGS) with no skip reason attached.
371
+ # ---------------------------------------------------------------------------
372
+ classify_coverage_reason() {
373
+ local reason="${1:-}"
374
+ local gate_status="${2:-}"
375
+
376
+ case "$reason" in
377
+ documentation_project) echo "not_applicable" ;;
378
+ architecture_skip_exemption) echo "not_applicable" ;;
379
+ no_source_files_changed) echo "no_changes" ;;
380
+ # Gate 4 names the same concept "no_matching_files_changed"; Gate 3/6 use
381
+ # the shorter form. Both mean "the tool exists, nothing to look at".
382
+ no_matching_files_changed) echo "no_changes" ;;
383
+ tool_missing|tool_not_installed|no_powershell_tool|no_archlint_detector|checker_not_found)
384
+ echo "tool_missing" ;;
385
+ # archlint / import-linter / arch-go / Java arch tests exist as concepts but
386
+ # this repo carries no config for the language, so nothing was measured.
387
+ architecture_not_configured) echo "tool_missing" ;;
388
+ # Gate 4's checker ran but crashed. Not covered, not "tool missing" — the
389
+ # measurement was attempted and failed, so the honest answer is unknown.
390
+ checker_execution_error) echo "unknown" ;;
391
+ "")
392
+ case "$gate_status" in
393
+ PASS|WARN|PASS_WITH_WARNINGS|PASSED) echo "covered" ;;
394
+ *) echo "unknown" ;;
395
+ esac
396
+ ;;
397
+ *) echo "unknown" ;;
398
+ esac
399
+ }
400
+
401
+ # #537: record where the language key set came from. PROJECT_LANGS is derived
402
+ # from the CURRENTLY CHANGED files, not from a static inventory of the repo, so
403
+ # a docs-only commit legitimately yields an empty set. Reporting the matrix
404
+ # without this provenance would repeat the sin the matrix exists to fix.
405
+ resolve_languages_source() {
406
+ local override_langs="${1:-}"
407
+ local config_langs="${2:-}"
408
+ local resolved_langs="${3:-}"
409
+ if [ -n "$override_langs" ]; then
410
+ echo "override"
411
+ elif [ -n "$config_langs" ]; then
412
+ echo "config"
413
+ elif [ -n "$resolved_langs" ]; then
414
+ echo "changed_files"
415
+ else
416
+ echo "none"
417
+ fi
418
+ }
419
+
420
+ # #537: resolve Gate 6's reason for ONE language.
421
+ #
422
+ # Reads the flattened GATE_6_LANG_REASONS ("lang=reason,lang=reason") because
423
+ # the associate array that produced it is function-local to the gate and
424
+ # invisible to generate_quality_report().
425
+ #
426
+ # The scalar GATE_6_REASON is a SUMMARY across languages and must NOT be used
427
+ # as a per-language fallback when per-language detail exists: with langs
428
+ # "typescript iac" where only iac lacks a detector, the scalar reads
429
+ # no_archlint_detector and would relabel typescript — the exact language
430
+ # archlint had just measured. A real probe run exposed this:
431
+ # langs=[typescript iac] reasons=[iac=no_archlint_detector]
432
+ #
433
+ # So: an explicit pair wins; otherwise there is no skip reason for this
434
+ # language, and "no reason" + PASS status is precisely what `covered` means.
435
+ # The scalar only speaks for every language when there is no detail at all
436
+ # (config-missing / bash<4 paths).
437
+ coverage_g6_reason_for() {
438
+ local lang="${1:-}"
439
+ local detail="${GATE_6_LANG_REASONS:-}"
440
+ local scalar="${GATE_6_REASON:-}"
441
+ local pairs=",${detail},"
442
+ local reason=""
443
+ case "$pairs" in
444
+ *",${lang}="*)
445
+ reason="${pairs#*",${lang}="}"
446
+ reason="${reason%%,*}"
447
+ ;;
448
+ esac
449
+ if [ -z "$reason" ] && [ -z "$detail" ] && [ -n "$scalar" ]; then
450
+ reason="$scalar"
451
+ fi
452
+ echo "$reason"
453
+ }
454
+
288
455
  # Audit helper — records gate execution to .xp-gate/audit.jsonl
289
456
  # NEVER blocks commit on failure (try/catch around npx tsx).
290
457
  # Usage: record_gate_audit <gate_id> <gate_name> <passed> <issues_found> <start_ms>
@@ -2902,6 +3069,15 @@ else
2902
3069
 
2903
3070
  # Check if architecture config exists (use $PROJECT_ROOT — hook may have cd'd to a subdir via ARCH-03)
2904
3071
  if [ -f "$PROJECT_ROOT/architecture.yaml" ] || [ -f "$PROJECT_ROOT/.architecturerc" ] || [ -f "$PROJECT_ROOT/.archlint.yaml" ]; then
3072
+ # #537: per-language reason bookkeeping. Gate 3/4 are single-language gates
3073
+ # and get away with one scalar reason; Gate 6 loops, so a scalar would lose
3074
+ # information ("TS passed, PS has no detector" cannot be one string).
3075
+ # Bash 4 associative array, with a scalar aggregate for the legacy field.
3076
+ GATE_6_REASON=""
3077
+ GATE_6_LANG_REASONS=""
3078
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
3079
+ declare -A GATE_6_REASON_BY_LANG=()
3080
+ fi
2905
3081
  # Different architecture tools per language
2906
3082
  for CURRENT_LANG in $PROJECT_LANGS; do
2907
3083
  case "$CURRENT_LANG" in
@@ -2947,6 +3123,8 @@ else
2947
3123
  else
2948
3124
  echo " ✅ TypeScript architecture validation completed (zero-tolerance mode). no baseline found."
2949
3125
  fi
3126
+ # #537: archlint actually ran here, so there is no skip reason. The
3127
+ # matrix reads an absent reason + PASS status as `covered`.
2950
3128
  ;;
2951
3129
  "python")
2952
3130
  # detect any import-linter carrier (incl. pyproject.toml [tool.importlinter])
@@ -2972,6 +3150,12 @@ else
2972
3150
  else
2973
3151
  echo " ℹ️ No import-linter config found (.import-linter.yml/.importlinter/[tool.importlinter]) - skipping"
2974
3152
  echo " ⏭️ SKIPPED - Python architecture validation (no config)"
3153
+ # #537: no config means Python architecture was NOT measured. Without
3154
+ # this the matrix would call it `covered` simply because the branch
3155
+ # recorded nothing — the exact false-confidence the matrix prevents.
3156
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
3157
+ GATE_6_REASON_BY_LANG["$CURRENT_LANG"]="architecture_not_configured"
3158
+ fi
2975
3159
  fi
2976
3160
  ;;
2977
3161
  "go")
@@ -2988,6 +3172,10 @@ else
2988
3172
  else
2989
3173
  echo " ℹ️ No arch-go.yaml found - skipping Go architecture"
2990
3174
  echo " ⏭️ SKIPPED - Go architecture validation (no config)"
3175
+ # #537: same reasoning as the Python branch above.
3176
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
3177
+ GATE_6_REASON_BY_LANG["$CURRENT_LANG"]="architecture_not_configured"
3178
+ fi
2991
3179
  fi
2992
3180
  ;;
2993
3181
  "java")
@@ -3004,19 +3192,63 @@ else
3004
3192
  else
3005
3193
  echo " ℹ️ No architecture test files found - skipping"
3006
3194
  echo " ⏭️ SKIPPED - Architecture validation (no tests)"
3195
+ # #537: no test files means Java architecture was NOT measured.
3196
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
3197
+ GATE_6_REASON_BY_LANG["$CURRENT_LANG"]="architecture_not_configured"
3198
+ fi
3007
3199
  fi
3008
3200
  ;;
3009
3201
  *)
3010
3202
  echo " ℹ️ Architecture validation not configured for $CURRENT_LANG"
3011
3203
  echo " ⏭️ SKIPPED - Architecture validation (not configured for $CURRENT_LANG)"
3204
+ # #537: archlint ships no detector for this language. Say so in a way
3205
+ # the quality report can read, instead of leaving the dimension silent.
3206
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
3207
+ GATE_6_REASON_BY_LANG["$CURRENT_LANG"]="no_archlint_detector"
3208
+ fi
3012
3209
  ;;
3013
3210
  esac
3014
3211
  done # end for CURRENT_LANG (architecture)
3212
+
3213
+ # #537: fold the per-language reasons into the legacy scalar. Homogeneous
3214
+ # reasons survive as-is; a genuine mix becomes the `mixed` sentinel, which
3215
+ # classify_coverage_reason() maps to `unknown` (fail closed) rather than
3216
+ # pretending one language's verdict covers all of them.
3217
+ #
3218
+ # `declare -A` inside a function is FUNCTION-LOCAL in bash, and the report
3219
+ # is written by generate_quality_report() — a different function. So the
3220
+ # assoc array is flattened into this global string, which the report parses.
3221
+ # (Found by running the hook: the matrix reported tool_missing for
3222
+ # typescript because the lookup saw an empty array.)
3223
+ GATE_6_LANG_REASONS=""
3224
+ _g6_seen=""
3225
+ _g6_mixed=0
3226
+ for _g6_lang in $PROJECT_LANGS; do
3227
+ _g6_r=""
3228
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
3229
+ _g6_r="${GATE_6_REASON_BY_LANG[$_g6_lang]:-}"
3230
+ fi
3231
+ if [ -n "$_g6_r" ]; then
3232
+ GATE_6_LANG_REASONS="${GATE_6_LANG_REASONS:+$GATE_6_LANG_REASONS,}${_g6_lang}=${_g6_r}"
3233
+ if [ -z "$_g6_seen" ]; then
3234
+ _g6_seen="$_g6_r"
3235
+ elif [ "$_g6_seen" != "$_g6_r" ]; then
3236
+ _g6_mixed=1
3237
+ fi
3238
+ fi
3239
+ done
3240
+ if [ "$_g6_mixed" = "1" ]; then
3241
+ GATE_6_REASON="mixed"
3242
+ else
3243
+ GATE_6_REASON="$_g6_seen"
3244
+ fi
3015
3245
  else
3016
3246
  # Architecture config is REQUIRED — BLOCK if missing
3017
3247
  if [ -f "$PROJECT_ROOT/.architecture-skip" ]; then
3018
3248
  echo " ⚠️ Architecture config missing but .architecture-skip present — allowed to skip"
3019
3249
  echo " ⏭️ SKIPPED - Architecture validation (.architecture-skip exemption)"
3250
+ # #537: an explicit exemption is "not applicable", not "tool missing".
3251
+ GATE_6_REASON="architecture_skip_exemption"
3020
3252
  else
3021
3253
  echo ""
3022
3254
  echo "❌ ARCHITECTURE CONFIG MISSING - COMMIT BLOCKED"
@@ -3133,7 +3365,14 @@ else
3133
3365
  echo " ⏭️ SKIPPED - Boy Scout Rule (not available in project)"
3134
3366
  fi
3135
3367
  fi
3136
- 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
3137
3376
  record_gate_audit "gate-6" "architecture-boy-scout" "$GATE_6_STATUS" "${BS_BLOCKED:-0}" "$GATE_6_START"
3138
3377
 
3139
3378
  # ============================================================================
@@ -3490,7 +3729,10 @@ generate_quality_report() {
3490
3729
  # applicable" and must render and score identically. A previous scoring
3491
3730
  # default of FAIL produced self-contradictory reports -- the gate showed
3492
3731
  # PASS in JSON but scored as FAIL (#507 Delphi round-1 A-MAJOR-2).
3493
- 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
3494
3736
  gate_status="${!status_var:-$gate_default}"
3495
3737
  case "$gate_status" in
3496
3738
  PASS) PASSED_COUNT=$((PASSED_COUNT + 1)) ;;
@@ -3563,6 +3805,37 @@ generate_quality_report() {
3563
3805
  local REPORT_FILE="${STATUS_DIR}/${BRANCH}.json"
3564
3806
  mkdir -p "$(dirname "$REPORT_FILE")"
3565
3807
 
3808
+ # ── #537: per-language coverage matrix ───────────────────────────────────────
3809
+ # Answers "was this dimension actually measured for this language?" instead of
3810
+ # letting every SKIP read as a pass. Built by whitelist splicing (the same
3811
+ # technique as DEGRADED_JSON above) rather than jq, because jq is an OPTIONAL
3812
+ # dependency here (see the `command -v jq` guard earlier in this file) and a
3813
+ # missing jq must not silently cost us the whole matrix.
3814
+ #
3815
+ # Safety: every spliced value is one of the five enum literals below — no
3816
+ # quotes, backslashes or newlines can reach the JSON. Language names are
3817
+ # whitelisted too, and a name that fails the whitelist is reported in
3818
+ # coverageWarnings instead of being silently dropped.
3819
+ local COVERAGE_MATRIX_JSON=""
3820
+ local COVERAGE_WARNINGS_JSON=""
3821
+ local _cm_lang _cm_g3 _cm_g4 _cm_g6 _cm_entry
3822
+ for _cm_lang in $PROJECT_LANGS; do
3823
+ if ! echo "$_cm_lang" | grep -qE '^[a-z][a-z0-9+]*$'; then
3824
+ COVERAGE_WARNINGS_JSON="${COVERAGE_WARNINGS_JSON:+$COVERAGE_WARNINGS_JSON, }\"unsupported_language_name\""
3825
+ continue
3826
+ fi
3827
+ _cm_g3=$(classify_coverage_reason "${GATE_3_REASON:-}" "${GATE_3_STATUS:-PASS}")
3828
+ _cm_g4=$(classify_coverage_reason "${GATE_4_REASON:-}" "${GATE_4_STATUS:-PASS}")
3829
+ # Gate 6 reason for this language — extracted so the rule is testable
3830
+ # rather than inlined (see coverage_g6_reason_for below).
3831
+ _cm_g6_reason=$(coverage_g6_reason_for "$_cm_lang")
3832
+ _cm_g6=$(classify_coverage_reason "$_cm_g6_reason" "${GATE_6_STATUS:-SKIP}")
3833
+ _cm_entry="\"${_cm_lang}\": {\"gate3_complexity\": \"${_cm_g3}\", \"gate4_principles\": \"${_cm_g4}\", \"gate6_architecture\": \"${_cm_g6}\"}"
3834
+ COVERAGE_MATRIX_JSON="${COVERAGE_MATRIX_JSON:+$COVERAGE_MATRIX_JSON, }${_cm_entry}"
3835
+ done
3836
+ local LANGUAGES_SOURCE
3837
+ LANGUAGES_SOURCE=$(resolve_languages_source "${_RESOLVED_LANG_OVERRIDE:-}" "${_CONFIG_LANGS:-}" "${PROJECT_LANGS:-}")
3838
+
3566
3839
  cat > "$REPORT_FILE" << ENDJSON
3567
3840
  {
3568
3841
  "reportVersion": "2.0",
@@ -3581,6 +3854,9 @@ generate_quality_report() {
3581
3854
  "score": $SCORE_JSON,
3582
3855
  "verdict": "$VERDICT"
3583
3856
  },
3857
+ "languagesSource": "${LANGUAGES_SOURCE}",
3858
+ "coverageMatrix": {${COVERAGE_MATRIX_JSON}},
3859
+ "coverageWarnings": [${COVERAGE_WARNINGS_JSON}],
3584
3860
  "gates": {
3585
3861
  "gate1_static_analysis": {
3586
3862
  "name": "Code Quality (Static + Lint + Shell)",
@@ -3589,7 +3865,7 @@ generate_quality_report() {
3589
3865
  },
3590
3866
  "gate2_dup_code": {
3591
3867
  "name": "Duplicate Code",
3592
- "status": "${GATE_2_STATUS:-PASS}",
3868
+ "status": "${GATE_2_STATUS:-SKIP}",
3593
3869
  "metric": "similarity <= 5%"
3594
3870
  },
3595
3871
  "gate3_complexity": {
@@ -3615,7 +3891,8 @@ generate_quality_report() {
3615
3891
  },
3616
3892
  "gate6_arch_boyscout": {
3617
3893
  "name": "Architecture + Boy Scout Rule",
3618
- "status": "${GATE_6_STATUS:-PASS}",
3894
+ "status": "${GATE_6_STATUS:-SKIP}",
3895
+ "reason": "${GATE_6_REASON:-}",
3619
3896
  "boyScoutPassed": $BS_PASSED,
3620
3897
  "boyScoutBlocked": $BS_BLOCKED
3621
3898
  },
@@ -3667,11 +3944,11 @@ ENDJSON
3667
3944
  # ── History append (unchanged — append-only for trend) ────────────────────────────
3668
3945
  local GATES_JSON="{"
3669
3946
  GATES_JSON="${GATES_JSON}\"gate1\":{\"status\":\"${GATE_1_STATUS:-PASS}\",\"name\":\"Code Quality\"},"
3670
- 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\"},"
3671
3948
  GATES_JSON="${GATES_JSON}\"gate3\":{\"status\":\"${GATE_3_STATUS:-PASS}\",\"name\":\"Complexity\",\"warnings\":${CC_WARNINGS:-0}},"
3672
3949
  GATES_JSON="${GATES_JSON}\"gate4\":{\"status\":\"${GATE_4_STATUS:-PASS}\",\"name\":\"Principles\",\"warnings\":${WARNING_COUNT:-0}},"
3673
3950
  GATES_JSON="${GATES_JSON}\"gate5\":{\"status\":\"${GATE_5_STATUS:-PASS}\",\"name\":\"Tests+Coverage\",\"coverage\":\"${COV_PCT}\"},"
3674
- 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}},"
3675
3952
  GATES_JSON="${GATES_JSON}\"gate7\":{\"status\":\"${GATE_7_STATUS:-PASS}\",\"name\":\"IaC Security\"},"
3676
3953
  GATES_JSON="${GATES_JSON}\"gate8\":{\"status\":\"${GATE_8_STATUS:-PASS}\",\"name\":\"Secret Scanning\"},"
3677
3954
  GATES_JSON="${GATES_JSON}\"gate9\":{\"status\":\"${GATE_9_STATUS:-PASS}\",\"name\":\"Build Integrity\"},"
@@ -3688,11 +3965,11 @@ ENDJSON
3688
3965
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
3689
3966
  echo ""
3690
3967
  printf " %-45s %s\n" "Gate 1: Code Quality" "${GATE_1_STATUS:-PASS}"
3691
- 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}"
3692
3969
  printf " %-45s %s (warnings: ${CC_WARNINGS:-0})\n" "Gate 3: Complexity" "${GATE_3_STATUS:-PASS}"
3693
3970
  printf " %-45s %s (warnings: ${WARNING_COUNT:-0})\n" "Gate 4: Principles" "${GATE_4_STATUS:-PASS}"
3694
3971
  printf " %-45s %s (coverage: ${COV_PCT}%%)\n" "Gate 5: Tests + Coverage" "${GATE_5_STATUS:-PASS}"
3695
- 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}"
3696
3973
  printf " %-45s %s\n" "Gate 7: IaC Security" "${GATE_7_STATUS:-PASS}"
3697
3974
  printf " %-45s %s\n" "Gate 8: Secret Scanning" "${GATE_8_STATUS:-PASS}"
3698
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