@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.
- package/adapter-common.sh +41 -3
- package/adapters/gate-3.sh +8 -1
- package/adapters/gate-8.sh +67 -6
- package/adapters/go.sh +18 -1
- package/adapters/iac.sh +18 -1
- package/adapters/powershell.sh +37 -1
- package/adapters/python.sh +18 -4
- package/gate-3.sh +10 -1
- package/gate-8.sh +67 -6
- package/hooks/adapter-common.sh +41 -3
- package/hooks/gate-3.sh +10 -1
- package/hooks/gate-8.sh +67 -6
- package/hooks/lib/coverage-total.sh +20 -5
- package/hooks/lib/gate-5c-alignment.sh +145 -0
- package/hooks/lib/jscpd-run.sh +40 -1
- package/hooks/lib/native-path.sh +106 -0
- package/hooks/lib/prepush-score.py +143 -0
- package/hooks/lib/sprint-gate-report.sh +23 -0
- package/hooks/lib/toolchain-path.sh +370 -0
- package/hooks/lib/validate-code-walkthrough.cjs +224 -23
- package/hooks/pre-commit +371 -163
- package/hooks/pre-push +192 -74
- package/hooks/sprint-gate.sh +14 -3
- package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
- package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
- package/lib/__tests__/doctor-delphi.test.js +46 -0
- package/lib/__tests__/doctor-exec-retry.test.js +20 -10
- package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
- package/lib/__tests__/doctor.test.js +7 -1
- package/lib/__tests__/hook-health-path.test.js +9 -1
- package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
- package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
- package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
- package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
- package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
- package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
- package/lib/arch.js +261 -44
- package/lib/check-alignment-runner.cjs +23 -0
- package/lib/check.js +4 -1
- package/lib/doctor.js +194 -49
- package/lib/gate-runner.js +265 -58
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +228 -6
- package/lib/shared-paths.js +53 -19
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
- package/plugins/dsh/src/delphi-config.ts +151 -9
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
- package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
- package/principles/adapters/java.ts +127 -11
- package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
- package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
- package/principles/rules/solid/lsp.ts +348 -23
- package/scripts/__tests__/sync-package-content.test.js +32 -1
- package/scripts/sync-package-content.js +6 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +50 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- package/sprint-gate.sh +14 -3
package/hooks/pre-push
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
report
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
-
|
|
1329
|
+
printf '%s\n' "$SKIPPED_LIST" | sed 's/^[[:space:]]*/ - /'
|
|
1212
1330
|
else
|
|
1213
1331
|
echo " ✅ ALL CHECKS PASSED"
|
|
1214
1332
|
fi
|
package/hooks/sprint-gate.sh
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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([
|