@boyingliu01/xp-gate 0.31.1 → 0.31.2

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 (85) hide show
  1. package/adapter-common.sh +41 -3
  2. package/adapters/gate-3.sh +8 -1
  3. package/adapters/gate-8.sh +67 -6
  4. package/adapters/go.sh +18 -1
  5. package/adapters/iac.sh +18 -1
  6. package/adapters/powershell.sh +37 -1
  7. package/adapters/python.sh +18 -4
  8. package/gate-3.sh +10 -1
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +10 -1
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/coverage-total.sh +20 -5
  14. package/hooks/lib/gate-5c-alignment.sh +145 -0
  15. package/hooks/lib/jscpd-run.sh +40 -1
  16. package/hooks/lib/native-path.sh +106 -0
  17. package/hooks/lib/prepush-score.py +143 -0
  18. package/hooks/lib/sprint-gate-report.sh +23 -0
  19. package/hooks/lib/toolchain-path.sh +370 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  21. package/hooks/pre-commit +371 -163
  22. package/hooks/pre-push +192 -74
  23. package/hooks/sprint-gate.sh +14 -3
  24. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  25. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  26. package/lib/__tests__/doctor-delphi.test.js +46 -0
  27. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  28. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  29. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  30. package/lib/__tests__/doctor.test.js +7 -1
  31. package/lib/__tests__/hook-health-path.test.js +9 -1
  32. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  33. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  34. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  35. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  36. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  37. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  38. package/lib/arch.js +261 -44
  39. package/lib/check-alignment-runner.cjs +23 -0
  40. package/lib/check.js +4 -1
  41. package/lib/doctor.js +194 -49
  42. package/lib/gate-runner.js +265 -58
  43. package/lib/mattpocock-license-verdict.json +1 -1
  44. package/lib/principles.js +228 -6
  45. package/lib/shared-paths.js +53 -19
  46. package/mock-policy/AGENTS.md +3 -3
  47. package/mutation/AGENTS.md +3 -3
  48. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  49. package/package.json +1 -1
  50. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  51. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  53. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  54. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  55. package/plugins/dsh/package.json +1 -1
  56. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  57. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  58. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  59. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  60. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  61. package/plugins/dsh/src/delphi-config.ts +151 -9
  62. package/plugins/opencode/package.json +1 -1
  63. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  64. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  65. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  66. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  67. package/plugins/qoder/plugin.json +1 -1
  68. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  69. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  70. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  71. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  72. package/principles/AGENTS.md +3 -3
  73. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  74. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  75. package/principles/adapters/java.ts +127 -11
  76. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  77. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  78. package/principles/rules/solid/lsp.ts +348 -23
  79. package/scripts/__tests__/sync-package-content.test.js +32 -1
  80. package/scripts/sync-package-content.js +6 -1
  81. package/skills/delphi-review/AGENTS.md +3 -3
  82. package/skills/delphi-review/SKILL.md +50 -0
  83. package/skills/sprint-flow/AGENTS.md +3 -3
  84. package/skills/test-specification-alignment/AGENTS.md +3 -3
  85. package/sprint-gate.sh +14 -3
package/hooks/pre-push CHANGED
@@ -1,20 +1,29 @@
1
1
  #!/bin/bash
2
- # Line-ending self-check (#546). Must stay on the first executable line: if this
3
- # file is ever rewritten with CRLF, bash needs to reach this check before the
4
- # parser chokes somewhere far away and blames an innocent line. `tr` also
5
- # strips the \r from this very line, so the check runs even when everything
6
- # around it is broken.
7
- if tr -d '\r' < "$0" | sed -n '2p' | tr -d '\r' | grep -q 'Line-ending self-check'; then
8
- if [ "$(tr -cd '\r' < "$0" | wc -c | tr -d '[:space:]')" -gt 0 ]; then
9
- echo "======================================================================" >&2
10
- echo "FATAL: hook has CRLF line endings — bash cannot parse it (#546)." >&2
11
- echo " file: $0" >&2
12
- echo " fix : tr -d '\\r' < \"$0\" > \"$0.tmp\" && mv \"$0.tmp\" \"$0\" && chmod +x \"$0\"" >&2
13
- echo "Or re-sync the installed copy: xp-gate doctor --sync-hooks" >&2
14
- echo "======================================================================" >&2
15
- exit 1
16
- fi
17
- fi
2
+ _LE_CR=$(tr -cd '\r' < "$0" | wc -c | tr -d '[:space:]'); test "${_LE_CR:-0}" -eq 0 || { printf '%s\n' "======================================================================" "FATAL: hook has CRLF line endings -- bash cannot parse it (#546)." " file: $0" " fix : tr -d '\\r' < \"$0\" > \"$0.tmp\" && mv \"$0.tmp\" \"$0\" && chmod +x \"$0\"" "Or re-sync the installed copy: xp-gate doctor --sync-hooks" "======================================================================" >&2; exit 1; }
3
+ #
4
+ # The line above is the #546 line-ending self-check. It MUST stay on the first
5
+ # executable line, as a SINGLE physical line, using no construct whose keyword
6
+ # needs a line boundary. All three are load-bearing:
7
+ #
8
+ # * A CRLF rewrite puts \r at the end of EVERY line. Any construct whose
9
+ # keywords must match across lines -- if/then/fi, case/esac, for/do/done --
10
+ # then fails to PARSE and none of it runs. The original #546 implementation
11
+ # was a nested if/fi spanning 11 lines: it could not survive the very
12
+ # condition it detects. On a CRLF copy bash reported
13
+ # line 76: syntax error near unexpected token `$'{\r''
14
+ # blaming an innocent function far below -- which is exactly the failure #546
15
+ # set out to prevent. It looked correct locally only because that bash build
16
+ # deferred the error past the first command.
17
+ # * Even on one line, `if ... then ... fi` ends in `fi\r`, and `then\r` / `fi\r`
18
+ # are ordinary words to the tokenizer. So the check uses only
19
+ # `test ... || { ...; exit 1; }`: the `{` already follows `; `, so a trailing
20
+ # \r on the line cannot change how it tokenizes.
21
+ # * The CR count is byte-wise (`tr -cd`). `grep -c $'\r'` reports 0 matches
22
+ # through the Git-Bash/MSYS layer even on files that carry CR bytes, which is
23
+ # what made earlier attempts at this fixture look green while CI stayed red.
24
+ #
25
+ # Verified: an LF copy runs through (0 CR bytes); a CRLF copy prints the FATAL
26
+ # diagnosis and exits 1 with no syntax error.
18
27
  # Pre-push Hook — 8 gates: Gate 10 (Build Integrity) + M/M-Python/M-Go/M-Java/M-Kotlin (Mutation)
19
28
  # + M2 (Mock Density, WARNING) + ML (Mock Layering) + UI (UI Sprint Gates) + MW (Code Walkthrough) + S (Sprint Flow)
20
29
  #
@@ -53,58 +62,144 @@ write_pre_push_report() {
53
62
  # store it serialised back into the `details` string domain (every value there
54
63
  # is a string; the journal has broken before over mixed types).
55
64
  export XP_GATE_M_SURVIVORS_JSON="${M_SURVIVORS_JSON:-}"
56
- python3 - <<PY 2>/dev/null || true
57
- import json
58
- import os
59
- from pathlib import Path
60
- pushed_files = '''${PUSHED_FILES:-}'''.splitlines()
61
- passed = 1 if '$verdict' == 'PASS' else 0
62
- # Absent stays absent: no key at all when nothing was measured, so a reader can
63
- # tell "not measured" from "measured: zero survivors" (#544).
64
- try:
65
- _survivors_raw = os.environ.get('XP_GATE_M_SURVIVORS_JSON', '').strip()
66
- survivors_detail = json.loads(_survivors_raw) if _survivors_raw else None
67
- except (ValueError, TypeError):
68
- survivors_detail = None
69
- _m_details = {'skip_reason': '${GATE_M_SKIP_REASON:-}', 'duration_ms': '${M_DURATION_MS:-0}', 'mutation_score': '${M_SCORE:-}'}
70
- if survivors_detail is not None:
71
- _m_details['survivors'] = json.dumps(survivors_detail, sort_keys=True)
72
- report = {
73
- 'timestamp': '$timestamp',
74
- 'trigger': 'pre-push',
75
- 'branch': '$CURRENT_BRANCH',
76
- 'commit': '$commit_hash',
77
- 'changed_files': [f for f in pushed_files if f],
78
- 'overall': {
79
- 'score': '10.0/10' if '$verdict' == 'PASS' else '0.0/10',
80
- 'gates_passed': passed,
81
- 'gates_total': 8,
82
- 'verdict': '$verdict',
83
- },
84
- 'gates': [
85
- {'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {'duration_ms': '${MP_DURATION_MS:-0}'}},
86
- {'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},
87
- {'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': _m_details},
88
- # #557 follow-up: this line read GATE_M_PYTHON_STATUS while the code assigns
89
- # GATE_M_PY_STATUS, so the field always fell back to its SKIP default -- the
90
- # "never write the real state" bug #557 set out to fix, still present for
91
- # this one gate. The skip reason now travels with it, so a retro reader can
92
- # tell "no Python here" from "timed out" from "mutmut missing".
93
- {'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PY_STATUS:-SKIP}', 'details': {'skip_reason': '${GATE_M_PY_SKIP_REASON:-}'}},
94
- {'id': 'M-Go', 'name': 'Mutation Testing (Go)', 'status': '${GATE_M_GO_STATUS:-SKIP}', 'details': {}},
95
- {'id': 'M-Java', 'name': 'Mutation Testing (Java)', 'status': '${GATE_M_JAVA_STATUS:-SKIP}', 'details': {}},
96
- {'id': 'M-Kotlin', 'name': 'Mutation Testing (Kotlin)', 'status': '${GATE_M_KOTLIN_STATUS:-SKIP}', 'details': {}},
97
- {'id': 'M2', 'name': 'Mock Density (WARNING only)', 'status': '${GATE_M2_STATUS:-SKIP}', 'details': {}},
98
- {'id': 'ML', 'name': 'Mock Layering', 'status': '${GATE_M3_STATUS:-SKIP}', 'details': {}},
99
- {'id': 'UI', 'name': 'UI Sprint Gates', 'status': '${GATE_UI_STATUS:-SKIP}', 'details': {}},
100
- {'id': 'MW', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL', 'skip_reason': '${GATE_DELPHI_SKIP_REASON:-}'}},
101
- {'id': 'S', 'name': 'Sprint Flow Enforcement', 'status': '${GATE_S_STATUS:-SKIP}', 'details': {}}
102
- ],
103
- 'warnings': [],
104
- 'errors': [] if '$verdict' == 'PASS' else ['pre-push hook exited with code $exit_code'],
105
- }
106
- Path('$report_file').write_text(json.dumps(report, indent=2) + '\n')
107
- PY
65
+ # #597: resolve a WORKING interpreter instead of assuming `python3`.
66
+ #
67
+ # Measured on Windows Git Bash: `python3` resolves to the Microsoft Store
68
+ # execution-alias stub at
69
+ # /c/Users/think/AppData/Local/Microsoft/WindowsApps/python3
70
+ # which exits non-zero and prints nothing (`python3 --version` is empty). The
71
+ # old `python3 - <<PY 2>/dev/null || true` then swallowed the failure, so:
72
+ # - the JSON journal was never written;
73
+ # - bash went on to execute the heredoc BODY as shell commands, producing
74
+ # python3: line 56: None: command not found
75
+ # python3: line 56: verdict: command not found
76
+ # A `|| true` around a whole script body cannot distinguish "python missing"
77
+ # from "script failed", and the journal is the only audit trail a push leaves.
78
+ #
79
+ # `python` is the real interpreter on Windows (3.12.x) while `python3` is the
80
+ # stub; on Linux `python3` is real and `python` may be absent. Probe both and
81
+ # require the candidate to actually report a version.
82
+ local py_bin=""
83
+ for _candidate in python3 python py; do
84
+ if command -v "$_candidate" >/dev/null 2>&1 && \
85
+ "$_candidate" --version >/dev/null 2>&1; then
86
+ py_bin="$_candidate"
87
+ break
88
+ fi
89
+ done
90
+ if [ -z "$py_bin" ]; then
91
+ echo "⚠️ Gate M journal: no working Python interpreter found (tried python3, python, py) — report NOT written for $report_file." >&2
92
+ return 0
93
+ fi
94
+ # #577: the scoring rule lives in its own module so it can be tested directly.
95
+ # Install layout: <hooks dir>/lib/prepush-score.py, with the deprecated copy
96
+ # under <hooks dir>/hooks/lib/ for older installs.
97
+ local score_lib=""
98
+ for _cand in \
99
+ "$(dirname "${BASH_SOURCE[0]}")/lib/prepush-score.py" \
100
+ "$(dirname "${BASH_SOURCE[0]}")/hooks/lib/prepush-score.py" \
101
+ ../githooks/lib/prepush-score.py; do
102
+ if [ -f "$_cand" ]; then
103
+ score_lib="$_cand"
104
+ break
105
+ fi
106
+ done
107
+ if [ -z "$score_lib" ]; then
108
+ # #569 follow-up: a missing scorer used to only warn, and the journal then
109
+ # carried `score: null, gates_run: null` — a document that LOOKS written but
110
+ # answers none of the questions it exists for. The score rule lives in
111
+ # prepush-score.py and nowhere else, so "no scorer" means the summary was
112
+ # never computed, not that the run had nothing to summarise. Fail closed.
113
+ echo "❌ Gate M journal: prepush-score.py not found — cannot compute the score for $report_file." >&2
114
+ echo " Expected it beside the hook (lib/prepush-score.py) or under hooks/lib/. Reinstall with: xp-gate doctor --sync-hooks" >&2
115
+ return 1
116
+ fi
117
+ # No blanket `|| true`: a failing interpreter must be visible. The journal is
118
+ # advisory, so still return 0, but say so instead of dying silently.
119
+ #
120
+ # #597: the Python body goes to a TEMP FILE, not a `<<PY` heredoc.
121
+ #
122
+ # This function runs from the EXIT trap, i.e. AFTER the main body's
123
+ # while read local_ref local_sha remote_ref remote_sha; do ...
124
+ # loop has consumed the hook's stdin. A heredoc is read from stdin, so by then
125
+ # `python - <<PY` receives NOTHING — bash discards the body and executes its
126
+ # lines as commands instead:
127
+ # .git/hooks/pre-push: line 92: None: command not found
128
+ # .git/hooks/pre-push: line 92: verdict: command not found
129
+ # The old form (`python3 - <<PY 2>/dev/null || true`) hid all of it, so the
130
+ # JSON journal was silently never written on any platform. Writing the script
131
+ # to a file removes the stdin dependency entirely.
132
+ local py_script py_rc
133
+ py_script="$(mktemp)" || {
134
+ echo "⚠️ Gate M journal: could not create a temp file — report NOT written for $report_file." >&2
135
+ return 0
136
+ }
137
+ # Values are injected here (they are expanded by bash before Python parses the
138
+ # file), exactly as the heredoc did.
139
+ {
140
+ printf '%s\n' \
141
+ 'import json' \
142
+ 'import os' \
143
+ 'from pathlib import Path'
144
+ printf '%s\n' "pushed_files = '''${PUSHED_FILES:-}'''.splitlines()"
145
+ printf '%s\n' "passed = 1 if '$verdict' == 'PASS' else 0"
146
+ printf '%s\n' 'try:'
147
+ printf '%s\n' " _survivors_raw = os.environ.get('XP_GATE_M_SURVIVORS_JSON', '').strip()"
148
+ printf '%s\n' ' survivors_detail = json.loads(_survivors_raw) if _survivors_raw else None'
149
+ printf '%s\n' 'except (ValueError, TypeError):'
150
+ printf '%s\n' ' survivors_detail = None'
151
+ printf '%s\n' "_m_details = {'skip_reason': '${GATE_M_SKIP_REASON:-}', 'duration_ms': '${M_DURATION_MS:-0}', 'mutation_score': '${M_SCORE:-}'}"
152
+ printf '%s\n' 'if survivors_detail is not None:'
153
+ printf '%s\n' " _m_details['survivors'] = json.dumps(survivors_detail, sort_keys=True)"
154
+ printf '%s\n' 'report = {'
155
+ printf '%s\n' " 'timestamp': '$timestamp',"
156
+ printf '%s\n' " 'trigger': 'pre-push',"
157
+ printf '%s\n' " 'branch': '$CURRENT_BRANCH',"
158
+ printf '%s\n' " 'commit': '$commit_hash',"
159
+ printf '%s\n' " 'changed_files': [f for f in pushed_files if f],"
160
+ printf '%s\n' " 'overall': {"
161
+ printf '%s\n' " 'score': None,"
162
+ printf '%s\n' " 'score_scope': None,"
163
+ printf '%s\n' " 'gates_passed': None,"
164
+ printf '%s\n' " 'gates_total': None,"
165
+ printf '%s\n' " 'verdict': '$verdict',"
166
+ printf '%s\n' " },"
167
+ printf '%s\n' " 'gates': ["
168
+ printf '%s\n' " {'id': 'MP', 'name': 'Gate-Script Mirror Parity', 'status': '${GATE_MP_STATUS:-SKIP}', 'details': {'duration_ms': '${MP_DURATION_MS:-0}'}},"
169
+ printf '%s\n' " {'id': '10', 'name': 'Build Integrity', 'status': '${GATE_10_STATUS:-SKIP}', 'details': {}},"
170
+ printf '%s\n' " {'id': 'M', 'name': 'Mutation Testing', 'status': '${GATE_M_STATUS:-SKIP}', 'details': _m_details},"
171
+ printf '%s\n' " {'id': 'M-Python', 'name': 'Mutation Testing (Python)', 'status': '${GATE_M_PY_STATUS:-SKIP}', 'details': {'skip_reason': '${GATE_M_PY_SKIP_REASON:-}'}},"
172
+ printf '%s\n' " {'id': 'M-Go', 'name': 'Mutation Testing (Go)', 'status': '${GATE_M_GO_STATUS:-SKIP}', 'details': {}},"
173
+ printf '%s\n' " {'id': 'M-Java', 'name': 'Mutation Testing (Java)', 'status': '${GATE_M_JAVA_STATUS:-SKIP}', 'details': {}},"
174
+ printf '%s\n' " {'id': 'M-Kotlin', 'name': 'Mutation Testing (Kotlin)', 'status': '${GATE_M_KOTLIN_STATUS:-SKIP}', 'details': {}},"
175
+ printf '%s\n' " {'id': 'M2', 'name': 'Mock Density (WARNING only)', 'status': '${GATE_M2_STATUS:-SKIP}', 'details': {}},"
176
+ printf '%s\n' " {'id': 'ML', 'name': 'Mock Layering', 'status': '${GATE_M3_STATUS:-SKIP}', 'details': {}},"
177
+ printf '%s\n' " {'id': 'UI', 'name': 'UI Sprint Gates', 'status': '${GATE_UI_STATUS:-SKIP}', 'details': {}},"
178
+ printf '%s\n' " {'id': 'MW', 'name': 'Code Walkthrough', 'status': '${GATE_DELPHI_STATUS:-SKIP}', 'details': {'remote': '$REMOTE', 'url': '$URL', 'skip_reason': '${GATE_DELPHI_SKIP_REASON:-}'}},"
179
+ printf '%s\n' " {'id': 'S', 'name': 'Sprint Flow Enforcement', 'status': '${GATE_S_STATUS:-SKIP}', 'details': {}}"
180
+ printf '%s\n' " ],"
181
+ printf '%s\n' " 'warnings': [],"
182
+ printf '%s\n' " 'errors': [] if '$verdict' == 'PASS' else ['pre-push hook exited with code $exit_code'],"
183
+ printf '%s\n' '}'
184
+ printf '%s\n' "Path('$report_file').write_text(json.dumps(report, indent=2) + '\n')"
185
+ } > "$py_script"
186
+
187
+ "$py_bin" "$py_script"
188
+ py_rc=$?
189
+ rm -f "$py_script"
190
+ if [ "$py_rc" -ne 0 ]; then
191
+ echo "⚠️ Gate M journal: $py_bin exited $py_rc — the push's audit record at $report_file is missing." >&2
192
+ return 0
193
+ fi
194
+
195
+ # #577: the score rule is NOT re-derived here. It lives in
196
+ # githooks/lib/prepush-score.py as a real module with a real interface, so the
197
+ # rule can be exercised directly by tests instead of being asserted through a
198
+ # copied-and-pasted implementation. It refuses to emit a number when the gates
199
+ # that ran cannot summarise the report (none ran, or fewer than half did).
200
+ if ! "$py_bin" "$score_lib" --report "$report_file"; then
201
+ echo "⚠️ Gate M journal: could not score $report_file — score left unset." >&2
202
+ fi
108
203
  }
109
204
 
110
205
  _pre_push_report_on_exit() {
@@ -992,8 +1087,16 @@ echo " GATE UI: UI SPRINT QUALITY GATES"
992
1087
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
993
1088
 
994
1089
  if [[ "$XP_GATE_SKIP_UI_GATES" == "1" ]]; then
995
- if [[ -z "$XP_GATE_BYPASS_REASON" || ${#XP_GATE_BYPASS_REASON// /} -lt 10 ]]; then
1090
+ # #578: this guard used `${#XP_GATE_BYPASS_REASON// /}`, which stacks "length"
1091
+ # and "substitution" — bash rejects it with `bad substitution` for the whole
1092
+ # condition line, so NEITHER branch ran. The bypass therefore skipped Gate UI
1093
+ # with an empty or one-character reason, and the audit entry below was lost
1094
+ # too. Strip the whitespace into a variable first, then take its length: two
1095
+ # ordinary expansions, each of them valid on its own.
1096
+ XP_GATE_BYPASS_REASON_COMPACT="${XP_GATE_BYPASS_REASON//[[:space:]]/}"
1097
+ if [[ -z "$XP_GATE_BYPASS_REASON" || ${#XP_GATE_BYPASS_REASON_COMPACT} -lt 10 ]]; then
996
1098
  echo "❌ XP_GATE_BYPASS_REASON required (min 10 non-whitespace chars)"
1099
+ echo " got: ${#XP_GATE_BYPASS_REASON_COMPACT} non-whitespace character(s)"
997
1100
  exit 1
998
1101
  fi
999
1102
  echo "⚠️ UI Gate BYPASSED — reason: $XP_GATE_BYPASS_REASON"
@@ -1175,7 +1278,12 @@ if [ ! -f "$MW_VALIDATOR" ]; then
1175
1278
  fi
1176
1279
 
1177
1280
  CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
1178
- if ! VALIDATION_ERROR=$(node "$MW_VALIDATOR" "$RESULT_FILE" "$EXPECTED_COMMIT" "$CURRENT_BRANCH" "$CURRENT_TIME" 2>&1); then
1281
+ # The 5th argument names the repository whose `.delphi-config.json` BINDS the
1282
+ # evidence (#574). It is passed explicitly instead of letting the validator
1283
+ # infer it from cwd: the gate's subject is the repo being pushed, and a cwd-
1284
+ # derived lookup would bind xp-gate's own config whenever the suite runs here.
1285
+ MW_PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
1286
+ if ! VALIDATION_ERROR=$(node "$MW_VALIDATOR" "$RESULT_FILE" "$EXPECTED_COMMIT" "$CURRENT_BRANCH" "$CURRENT_TIME" "$MW_PROJECT_ROOT" 2>&1); then
1179
1287
  echo ""
1180
1288
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1181
1289
  echo " ❌ RESULT FILE OUTDATED - PUSH BLOCKED"
@@ -1204,11 +1312,21 @@ fi
1204
1312
  # ============================================================================
1205
1313
  echo ""
1206
1314
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
1207
- # Trim the trailing separator and turn it into a displayable list.
1208
- SKIPPED_DISPLAY=$(printf '%s' "${GATES_SKIPPED:-}" | sed 's/|$//' | sed 's/|/ , /g')
1209
- if [ -n "$SKIPPED_DISPLAY" ]; then
1315
+ # Turn the recorded skips into a displayable list.
1316
+ #
1317
+ # Split on the SEPARATOR (`|`), never on anything that can occur inside a reason.
1318
+ # The previous form replaced `|` with " , " and then split on `,`, so a reason
1319
+ # that merely contained a comma was torn in two and its second half printed as
1320
+ # its own gate:
1321
+ # Gate MP: script not found, parity NOT verified
1322
+ # - Gate MP: script not found
1323
+ # - parity NOT verified <-- invented gate, and the real entry lost
1324
+ # the half that said what was verified (#572)
1325
+ # A trailing separator is dropped, and blank segments are skipped.
1326
+ SKIPPED_LIST=$(printf '%s' "${GATES_SKIPPED:-}" | tr '|' '\n' | sed '/^[[:space:]]*$/d')
1327
+ if [ -n "$SKIPPED_LIST" ]; then
1210
1328
  echo " ⚠️ NO BLOCKING FAILURES — but not every gate ran:"
1211
- echo "$SKIPPED_DISPLAY" | tr ',' '\n' | sed '/^[[:space:]]*$/d' | sed 's/^[[:space:]]*/ - /'
1329
+ printf '%s\n' "$SKIPPED_LIST" | sed 's/^[[:space:]]*/ - /'
1212
1330
  else
1213
1331
  echo " ✅ ALL CHECKS PASSED"
1214
1332
  fi
@@ -35,6 +35,17 @@
35
35
  # - JSON parser fallback chain: jq → Node.js → explicit degradation message
36
36
  #
37
37
  # OUTPUT: JSON to stdout — {"decision":"allow|deny|skip",...}
38
+ #
39
+ # #584: every `allow` also carries `"allow_reason"`, because `allow` alone was
40
+ # ambiguous. It meant three different things and pre-commit mapped all of them to
41
+ # Gate 11 PASS:
42
+ #
43
+ # checks-passed the sprint was actually validated -> PASS is honest
44
+ # phase-not-reached the sprint has not reached this gate's phase
45
+ # no-json-parser nothing could be read at all
46
+ #
47
+ # The last two examine nothing, so reporting them as PASS inflated the score.
48
+ # `checks-passed` is the only reason that licenses a PASS.
38
49
  # EXIT: 0 = pass/skip, 1 = block
39
50
 
40
51
  set -euo pipefail
@@ -264,7 +275,7 @@ if [ "$MODE" = "pre-commit" ]; then
264
275
 
265
276
  # If no JSON parser available, warn but allow (graceful degradation)
266
277
  if [ "$JSON_PARSER" = "none" ]; then
267
- echo '{"decision":"allow","warning":"neither jq nor node available, cannot verify delphi-review verdict. Install jq or node for full protection."}'
278
+ echo '{"decision":"allow","allow_reason":"no-json-parser","warning":"neither jq nor node available, cannot verify delphi-review verdict. Install jq or node for full protection."}'
268
279
  exit 0
269
280
  fi
270
281
 
@@ -299,7 +310,7 @@ if [ "$MODE" = "pre-commit" ]; then
299
310
  # claim: a 29-day-old sprint under a 30-day threshold is a very different
300
311
  # situation from one opened today, and the log should say which it is.
301
312
  if [ -n "$SPRINT_AGE_DAYS" ]; then
302
- echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint open ${SPRINT_AGE_DAYS} day(s), staleness threshold ${SPRINT_STALE_DAYS})\"}"
313
+ echo "{\"decision\":\"allow\",\"allow_reason\":\"checks-passed\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint open ${SPRINT_AGE_DAYS} day(s), staleness threshold ${SPRINT_STALE_DAYS})\"}"
303
314
  else
304
315
  echo "{\"decision\":\"allow\",\"message\":\"delphi-review APPROVED, sprint phase ${PHASE} validated (sprint age unknown — started_at unreadable)\"}"
305
316
  fi
@@ -307,7 +318,7 @@ if [ "$MODE" = "pre-commit" ]; then
307
318
  fi
308
319
 
309
320
  # Phase < 1 (pre-PLAN) — no delphi enforcement needed
310
- echo "{\"decision\":\"allow\",\"reason\":\"sprint phase ${PHASE} (pre-PLAN), no delphi enforcement required\"}"
321
+ echo "{\"decision\":\"allow\",\"allow_reason\":\"phase-not-reached\",\"reason\":\"sprint phase ${PHASE} (pre-PLAN), no delphi enforcement required\"}"
311
322
  exit 0
312
323
  fi
313
324
 
@@ -57,10 +57,27 @@ describe('check-alignment CLI injection surface (#392-#6)', () => {
57
57
  describe('check-alignment runner output integrity', () => {
58
58
  it('emits a complete parseable JSON document with --json', () => {
59
59
  const spec = join(here, 'test-alignment.test.ts');
60
- const result = spawnSync(process.execPath, [RUNNER, '--spec', spec, '--json'], {
61
- encoding: 'utf8',
62
- });
60
+ // Run the runner under the SAME loader the CLI uses. `check-alignment-runner.cjs`
61
+ // does `require('./test-alignment.ts')`, and plain `node` only tolerates that
62
+ // from Node 22.6+ (type stripping). CI pins Node 20.x, where it throws
63
+ // "Unknown file extension" — the runner printed nothing, and the empty string
64
+ // surfaced as "Unexpected end of JSON input", which reads exactly like the
65
+ // truncation this test guards against (#586). bin/xp-gate.js has always loaded
66
+ // this file through tsx; the test was the only caller that did not.
67
+ const result = spawnSync(
68
+ process.execPath,
69
+ [join(here, '..', '..', '..', '..', 'node_modules', 'tsx', 'dist', 'cli.mjs'), RUNNER, '--spec', spec, '--json'],
70
+ { encoding: 'utf8' }
71
+ );
63
72
  expect(result.error).toBeUndefined();
73
+ // An EMPTY stdout also parses to "Unexpected end of JSON input", which is
74
+ // indistinguishable from the truncation this test exists for. Report what the
75
+ // runner actually did first, so a crash is not mistaken for a truncation.
76
+ expect(
77
+ result.stdout,
78
+ `runner produced no stdout at all (status=${result.status}, ` +
79
+ `stderr=${JSON.stringify(result.stderr)}) — that is a crash, not a truncation`
80
+ ).not.toBe('');
64
81
  // The whole stdout stream must be one valid JSON document. A process.exit()
65
82
  // immediately after console.log would truncate it on some platforms.
66
83
  const parsed = JSON.parse(result.stdout);
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @test REQ-599
3
+ * @intent `check-alignment` printed only its status and counters when it blocked:
4
+ *
5
+ * Status: BLOCKED
6
+ * Score: 0
7
+ * Misaligned: 0
8
+ *
9
+ * Those two lines contradict each other — zero misaligned tests, yet
10
+ * blocked — and the actual reason was in `report.errors`, which the
11
+ * runner never printed at all. A gate that blocks without saying why is
12
+ * not actionable, and the standard response to an unactionable red (by any
13
+ * name: --no-verify, deleting the check) is exactly how dead gates get
14
+ * built. This is a recurrence of #57.
15
+ *
16
+ * These cases pin that a non-PASS verdict always explains itself, and
17
+ * that a non-PASS verdict carrying NO reason is reported as the producer
18
+ * bug it is rather than printed as an empty list.
19
+ * @covers AC-599-09
20
+ */
21
+ import { describe, it, expect } from 'vitest';
22
+ import { readFileSync } from 'fs';
23
+ import { join, dirname } from 'path';
24
+ import { spawnSync } from 'child_process';
25
+ import { fileURLToPath } from 'url';
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const RUNNER = join(here, '..', 'check-alignment-runner.cjs');
29
+ const TSX = join(here, '..', '..', '..', '..', 'node_modules', 'tsx', 'dist', 'cli.mjs');
30
+
31
+ const source = readFileSync(RUNNER, 'utf8');
32
+
33
+ describe('check-alignment explains a non-PASS verdict (#573)', () => {
34
+ it('anti-vacuity: the runner does read report.errors at all', () => {
35
+ // Before the fix this exact grep returned nothing, which is the whole issue.
36
+ expect(source).toMatch(/report\.errors|\.errors\b/);
37
+ });
38
+
39
+ it('the human-readable path prints a reasons block', () => {
40
+ // Not just "mentions errors somewhere": the non-JSON branch must render them.
41
+ expect(source).toMatch(/Reasons:/);
42
+ });
43
+
44
+ it('a missing reason is reported as a producer bug, not printed as empty', () => {
45
+ // A non-PASS verdict with no entries in report.errors is a defect in whoever
46
+ // produced the report. Saying so is different from printing nothing.
47
+ expect(source).toMatch(/none recorded|no reasons|producer/i);
48
+ });
49
+
50
+ it('the reasons are printed only when the verdict is not PASS', () => {
51
+ // Printing "Reasons:" on a PASS run would train readers to ignore it.
52
+ const idx = source.indexOf('Reasons:');
53
+ expect(idx).toBeGreaterThan(-1);
54
+ // The rendering must sit inside a non-PASS guard.
55
+ expect(source).toMatch(/alignment_status\s*!==\s*['"]PASS['"]/);
56
+ });
57
+ });
58
+
59
+ describe('check-alignment reasons survive the real CLI surface (#573)', () => {
60
+ it('--json still emits report.errors so a machine can read the reason', () => {
61
+ const spec = join(here, 'test-alignment.test.ts');
62
+ const result = spawnSync(process.execPath, [TSX, RUNNER, '--spec', spec, '--json'], {
63
+ encoding: 'utf8',
64
+ timeout: 120000,
65
+ });
66
+ expect(result.error).toBeUndefined();
67
+ expect(result.stdout).not.toBe('');
68
+ const parsed = JSON.parse(result.stdout);
69
+ // The field must exist on every report, PASS or not, so a consumer never has
70
+ // to distinguish "absent" from "empty".
71
+ expect(parsed).toHaveProperty('errors');
72
+ expect(Array.isArray(parsed.errors)).toBe(true);
73
+ }, 180000);
74
+
75
+ it('the human-readable path names the status and still lists no phantom reasons on PASS', () => {
76
+ const spec = join(here, 'test-alignment.test.ts');
77
+ const result = spawnSync(process.execPath, [TSX, RUNNER, '--spec', spec], {
78
+ encoding: 'utf8',
79
+ timeout: 120000,
80
+ });
81
+ expect(result.error).toBeUndefined();
82
+ // Whatever the verdict, the status line is always there.
83
+ expect(result.stdout).toMatch(/Status:/);
84
+
85
+ const blocked = /Status:\s*(?!PASS)/.test(result.stdout);
86
+ if (!blocked) {
87
+ // A passing run must not print a reasons block.
88
+ expect(result.stdout).not.toMatch(/Reasons:/);
89
+ }
90
+ }, 180000);
91
+ });
@@ -206,6 +206,52 @@ describe('doctor delphi (#414 REQ-3/REQ-6)', () => {
206
206
  expect(code).toBe(0);
207
207
  });
208
208
 
209
+ // #574: `scripts/check_delphi_local_models.py` encoded the local-only seat
210
+ // rule but nothing ever called it, so the guard existed only in its own
211
+ // docstring. The doctor now reports the same verdict, which is what gives the
212
+ // rule a caller. The rule stays a WARN: a deliberately hosted seat is legal,
213
+ // and the HARD enforcement is Gate MW's evidence-to-config binding.
214
+ it('#574 hosted (non local-*) seats are reported as a WARN, not a failure', async () => {
215
+ healthyHome();
216
+ const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
217
+ const logs = [];
218
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
219
+ const { doctorDelphi } = require('../doctor');
220
+ const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
221
+ logSpy.mockRestore();
222
+ const output = logs.join('\n');
223
+ expect(code).toBe(0);
224
+ expect(output).toMatch(/local-model guard/i);
225
+ expect(output).toMatch(/model-a|model-b|model-c/);
226
+ });
227
+
228
+ it('#574 three local-* seats PASS the local-model guard', async () => {
229
+ healthyHome();
230
+ const configPath = path.join(tmpHome, '.delphi-config.json');
231
+ fs.writeFileSync(configPath, JSON.stringify({
232
+ active_profile: 'local',
233
+ profiles: {
234
+ local: {
235
+ providers: { gw: { base_url: 'https://gw.example/v1', api_key: 'k' } },
236
+ experts: {
237
+ architecture: { provider: 'gw', model: 'local-deepseek-v4-flash' },
238
+ technical: { provider: 'gw', model: 'local-qwen3.8-flash' },
239
+ feasibility: { provider: 'gw', model: 'local-qwen3.8-27b' },
240
+ },
241
+ },
242
+ },
243
+ }));
244
+ const logs = [];
245
+ const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
246
+ const { doctorDelphi } = require('../doctor');
247
+ const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
248
+ logSpy.mockRestore();
249
+ const output = logs.join('\n');
250
+ expect(code).toBe(0);
251
+ expect(output).toMatch(/local-model guard/i);
252
+ expect(output).toMatch(/all 3 seats are local-\*/i);
253
+ });
254
+
209
255
  // R1 FC-07: the switch shipping off is the platform default, not a defect —
210
256
  // only enabled-but-underprovisioned is a real FAIL.
211
257
  it('DSH with subagentModelSelection absent is SKIP, not FAIL', async () => {
@@ -67,16 +67,26 @@ describe('execWithTimeout empty-stream retry (#525 follow-up)', () => {
67
67
  return require(doctorPath).execWithTimeout;
68
68
  }
69
69
 
70
- it('AC-DOCTOR-RETRY-01: retries an exit-0 empty-stdout probe and keeps its own timeout', async () => {
71
- stub = stubExec([
72
- (cb) => cb(null, '', ''), // the lost-stream failure mode
73
- (cb) => cb(null, 'v24.19.0\n', ''), // the same probe, stream intact
74
- ]);
75
- const execWithTimeout = freshDoctor();
76
- const result = await execWithTimeout('node --version', { stdio: ['ignore', 'pipe', 'pipe'] });
77
- expect(stub.calls.length).toBe(2);
78
- expect(result).toEqual({ stdout: 'v24.19.0', stderr: '' });
79
- });
70
+ // Windows-only by construction, as a whole: the RETRY it asserts does not exist
71
+ // off win32. The retry compensates for cmd.exe returning exit 0 with an EMPTY
72
+ // stream; elsewhere the first empty probe is final, so `result` comes back null
73
+ // and there is nothing to retry. Asserting the attempt count alone is not enough
74
+ // — measured under a faked `process.platform = 'linux'`, the count assertion
75
+ // passed while `result` failed with "expected null to deeply equal { stdout:
76
+ // 'v24.19.0' }". The premise of the whole case is win32 (#586).
77
+ it.skipIf(process.platform !== 'win32')(
78
+ 'AC-DOCTOR-RETRY-01: retries an exit-0 empty-stdout probe and keeps its own timeout',
79
+ async () => {
80
+ stub = stubExec([
81
+ (cb) => cb(null, '', ''), // the lost-stream failure mode
82
+ (cb) => cb(null, 'v24.19.0\n', ''), // the same probe, stream intact
83
+ ]);
84
+ const execWithTimeout = freshDoctor();
85
+ const result = await execWithTimeout('node --version', { stdio: ['ignore', 'pipe', 'pipe'] });
86
+ expect(stub.calls.length).toBe(2);
87
+ expect(result).toEqual({ stdout: 'v24.19.0', stderr: '' });
88
+ }
89
+ );
80
90
 
81
91
  it('AC-DOCTOR-RETRY-01: returns null after exhausting attempts on a persistently empty probe', async () => {
82
92
  stub = stubExec([