@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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Toolchain PATH bootstrap and tool resolution (#593).
|
|
3
|
+
#
|
|
4
|
+
# On Windows the hook runs under git-bash, which does NOT inherit the PATH entries
|
|
5
|
+
# the caller added on the Windows side, and none of the standard install locations
|
|
6
|
+
# for the toolchains these gates need are on git-bash's default PATH:
|
|
7
|
+
#
|
|
8
|
+
# npm global CLIs %APPDATA%\npm
|
|
9
|
+
# the node binary C:\Program Files\nodejs
|
|
10
|
+
# pip --user scripts %APPDATA%\Python\PythonNNN\Scripts
|
|
11
|
+
# hand-placed tools ~/bin, ~/.local/bin
|
|
12
|
+
#
|
|
13
|
+
# Every gate that needs one of those probed only `command -v` / `npx --no-install`
|
|
14
|
+
# / `~/.local/bin`, so "not on this shell's PATH" was reported as "not installed".
|
|
15
|
+
# Four gates did that for tools that were installed, and two of the reports were
|
|
16
|
+
# false GREENS (a gate that never ran, reported as a WARN that reads like an
|
|
17
|
+
# optional tool missing).
|
|
18
|
+
#
|
|
19
|
+
# The fix belongs here, once, rather than in each gate: one bootstrap that appends
|
|
20
|
+
# — never prepends — so the caller's PATH order stays authoritative.
|
|
21
|
+
|
|
22
|
+
# Directories to append, one per line. Empty when nothing applies.
|
|
23
|
+
#
|
|
24
|
+
# Globbing the Python Scripts dirs instead of guessing a version is deliberate:
|
|
25
|
+
# the version in the path is the interpreter's, not the tool's, and a repo may
|
|
26
|
+
# have several.
|
|
27
|
+
toolchain_path_candidates() {
|
|
28
|
+
local _home="${HOME:-}"
|
|
29
|
+
local _appdata="${APPDATA:-}"
|
|
30
|
+
local _node="${XP_GATE_NODE_DIR:-}"
|
|
31
|
+
local _d
|
|
32
|
+
|
|
33
|
+
# npm global CLIs. APPDATA is the Windows default; the unix-shaped locations are
|
|
34
|
+
# harmless on Linux/macOS because the loop below skips anything not a directory.
|
|
35
|
+
[ -n "$_appdata" ] && [ -d "$_appdata/npm" ] && printf '%s\n' "$_appdata/npm"
|
|
36
|
+
[ -n "$_home" ] && [ -d "$_home/.npm-global/bin" ] && printf '%s\n' "$_home/.npm-global/bin"
|
|
37
|
+
|
|
38
|
+
# The node install dir. Needed not only to run node CLIs but to run the npm POSIX
|
|
39
|
+
# shims, which exec `node` by name (#593 symptom 2).
|
|
40
|
+
if [ -n "$_node" ] && [ -d "$_node" ]; then
|
|
41
|
+
printf '%s\n' "$_node"
|
|
42
|
+
fi
|
|
43
|
+
if [ -n "$_appdata" ] && [ -d "$_appdata/npm/node_modules/.bin" ]; then
|
|
44
|
+
printf '%s\n' "$_appdata/npm/node_modules/.bin"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# pip --user Scripts dirs, one per Python version found.
|
|
48
|
+
if [ -n "$_appdata" ] && [ -d "$_appdata/Python" ]; then
|
|
49
|
+
for _d in "$_appdata"/Python/Python*/Scripts; do
|
|
50
|
+
[ -d "$_d" ] && printf '%s\n' "$_d"
|
|
51
|
+
done
|
|
52
|
+
fi
|
|
53
|
+
# Unix-side pip --user and hand-placed tools.
|
|
54
|
+
[ -n "$_home" ] && [ -d "$_home/.local/bin" ] && printf '%s\n' "$_home/.local/bin"
|
|
55
|
+
[ -n "$_home" ] && [ -d "$_home/bin" ] && printf '%s\n' "$_home/bin"
|
|
56
|
+
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Append the toolchain locations to PATH. Idempotent: a second call is a no-op.
|
|
61
|
+
#
|
|
62
|
+
# APPENDS rather than prepends. A project that pins its own tool version must keep
|
|
63
|
+
# winning; putting a global CLI in front would silently override it.
|
|
64
|
+
bootstrap_toolchain_path() {
|
|
65
|
+
local _dir _added=0
|
|
66
|
+
while IFS= read -r _dir; do
|
|
67
|
+
[ -n "$_dir" ] || continue
|
|
68
|
+
case ":${PATH}:" in
|
|
69
|
+
*":${_dir}:"*) continue ;;
|
|
70
|
+
esac
|
|
71
|
+
PATH="${PATH}:${_dir}"
|
|
72
|
+
_added=$((_added + 1))
|
|
73
|
+
done <<EOF_TOOLCHAIN
|
|
74
|
+
$(toolchain_path_candidates)
|
|
75
|
+
EOF_TOOLCHAIN
|
|
76
|
+
|
|
77
|
+
# #596 point 2: the authoritative source is the ORIGINAL Windows PATH, not a list
|
|
78
|
+
# of guessed standard locations. A hook shell can run with a shrunken PATH while
|
|
79
|
+
# ORIGINAL_PATH still carries everything, and a custom prefix (D:\npm) shows up
|
|
80
|
+
# only there. Appended after the standard candidates, so an explicitly pinned
|
|
81
|
+
# project toolchain still wins.
|
|
82
|
+
while IFS= read -r _dir; do
|
|
83
|
+
[ -n "$_dir" ] || continue
|
|
84
|
+
case ":${PATH}:" in
|
|
85
|
+
*":${_dir}:"*) continue ;;
|
|
86
|
+
esac
|
|
87
|
+
[ -d "$_dir" ] || continue
|
|
88
|
+
PATH="${PATH}:${_dir}"
|
|
89
|
+
_added=$((_added + 1))
|
|
90
|
+
done <<EOF_ORIGINAL
|
|
91
|
+
$(original_path_dirs)
|
|
92
|
+
EOF_ORIGINAL
|
|
93
|
+
|
|
94
|
+
export PATH
|
|
95
|
+
_XP_GATE_TOOLCHAIN_BOOTSTRAPPED=1
|
|
96
|
+
return 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Environment variable that names a tool binary directly, in priority order.
|
|
100
|
+
#
|
|
101
|
+
# #580's first suggestion: a user whose toolchain lives somewhere unusual has no
|
|
102
|
+
# way to say so today, and "put it on PATH" is not available to everyone — WSL with
|
|
103
|
+
# appendWindowsPath=false has no Windows PATH at all.
|
|
104
|
+
tool_env_override() {
|
|
105
|
+
case "$1" in
|
|
106
|
+
node) printf '%s\n' "${XP_GATE_NODE_BIN:-}" ;;
|
|
107
|
+
npx) printf '%s\n' "${XP_GATE_NPX_BIN:-}" ;;
|
|
108
|
+
npm) printf '%s\n' "${XP_GATE_NPM_BIN:-}" ;;
|
|
109
|
+
*) printf '%s\n' "" ;;
|
|
110
|
+
esac
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
# Is a tool available, counting the Windows `.exe` suffix and an env override?
|
|
114
|
+
#
|
|
115
|
+
# `command -v node` returns 1 on WSL and in git-bash even when
|
|
116
|
+
# /mnt/c/Program Files/nodejs/node.exe exists and runs, because bash does not
|
|
117
|
+
# append a suffix the way Windows does (#580). That single fact gave one gate a
|
|
118
|
+
# silent SKIP and another a false BLOCK on the same machine:
|
|
119
|
+
#
|
|
120
|
+
# Gate 1 "SKIP - npx not available" fail-OPEN, reads like "nothing
|
|
121
|
+
# to check here"
|
|
122
|
+
# Gate 1 "BLOCKED - Biome check failed" fail-CLOSED, wrong cause
|
|
123
|
+
# Gate 8 "BLOCKED - gitleaks not installed" a security gate blocked by its
|
|
124
|
+
# own environment
|
|
125
|
+
#
|
|
126
|
+
# Both described an environment problem as a code problem.
|
|
127
|
+
#
|
|
128
|
+
# #596 adds the third and costliest variant: PRESENCE IS NOT EXECUTABILITY.
|
|
129
|
+
# `command -v checkov` succeeds on a POSIX shim whose shebang execs `python`, on a
|
|
130
|
+
# shell where `python` is not resolvable. The file is there, it is executable, and
|
|
131
|
+
# running it dies at the `exec` with 127. Gate 7 counted zero usable scanners and
|
|
132
|
+
# BLOCKED a commit under "no scanning tools available" — on a repo where checkov
|
|
133
|
+
# 3.3.30 worked. So the probe now proves it can run.
|
|
134
|
+
tool_is_available() {
|
|
135
|
+
local _tool="$1" _override _hit
|
|
136
|
+
_override=$(tool_env_override "$_tool")
|
|
137
|
+
if [ -n "$_override" ] && [ -x "$_override" ]; then
|
|
138
|
+
tool_can_execute "$_override"
|
|
139
|
+
return $?
|
|
140
|
+
fi
|
|
141
|
+
|
|
142
|
+
_hit=$(command -v "$_tool" 2>/dev/null || true)
|
|
143
|
+
if [ -z "$_hit" ]; then
|
|
144
|
+
# Harmless on Linux/macOS: no such file exists there.
|
|
145
|
+
_hit=$(command -v "${_tool}.exe" 2>/dev/null || true)
|
|
146
|
+
fi
|
|
147
|
+
if [ -z "$_hit" ]; then
|
|
148
|
+
# #596: a tool reachable only through the un-shrunk PATH still counts. Callers
|
|
149
|
+
# may probe before bootstrapping, so this cannot rely on the bootstrap having
|
|
150
|
+
# run.
|
|
151
|
+
_hit=$(find_tool_in_original_path "$_tool")
|
|
152
|
+
fi
|
|
153
|
+
[ -n "$_hit" ] || return 1
|
|
154
|
+
|
|
155
|
+
# Found — now prove it is not a shim pointing at an unreachable interpreter.
|
|
156
|
+
# A tool that exists but cannot run is NOT available; the caller reports it as a
|
|
157
|
+
# toolchain failure, which is a different and true statement from "not installed".
|
|
158
|
+
tool_can_execute "$_hit"
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# The path of a tool found in ORIGINAL_PATH, or the empty string.
|
|
162
|
+
#
|
|
163
|
+
# @param {string} tool tool name
|
|
164
|
+
# @returns {string}
|
|
165
|
+
find_tool_in_original_path() {
|
|
166
|
+
local _tool="$1" _dir
|
|
167
|
+
while IFS= read -r _dir; do
|
|
168
|
+
[ -n "$_dir" ] || continue
|
|
169
|
+
if [ -x "$_dir/$_tool" ]; then
|
|
170
|
+
printf '%s' "$_dir/$_tool"
|
|
171
|
+
return 0
|
|
172
|
+
fi
|
|
173
|
+
if [ -x "$_dir/${_tool}.exe" ]; then
|
|
174
|
+
printf '%s' "$_dir/${_tool}.exe"
|
|
175
|
+
return 0
|
|
176
|
+
fi
|
|
177
|
+
done <<EOF_ORIGINAL_TOOL
|
|
178
|
+
$(original_path_dirs)
|
|
179
|
+
EOF_ORIGINAL_TOOL
|
|
180
|
+
return 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# Can this resolved tool be executed?
|
|
184
|
+
#
|
|
185
|
+
# Runs `<tool> --version` under a timeout. Exit 127 is the shell's own "interpreter
|
|
186
|
+
# not found" and is the precise signal for the broken-shim case. Any other non-zero
|
|
187
|
+
# exit still counts as executable: a tool that runs and rejects `--version` exists
|
|
188
|
+
# and works, and calling it missing would be the same defect in the other direction.
|
|
189
|
+
#
|
|
190
|
+
# @param {string} path resolved tool path
|
|
191
|
+
# @returns 0 when the tool can be executed
|
|
192
|
+
tool_can_execute() {
|
|
193
|
+
local _path="$1" _status=0
|
|
194
|
+
[ -n "$_path" ] && [ -x "$_path" ] || return 1
|
|
195
|
+
if command -v timeout >/dev/null 2>&1; then
|
|
196
|
+
timeout 20 "$_path" --version >/dev/null 2>&1 || _status=$?
|
|
197
|
+
else
|
|
198
|
+
"$_path" --version >/dev/null 2>&1 || _status=$?
|
|
199
|
+
fi
|
|
200
|
+
[ "$_status" -ne 127 ]
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# Directories listed in ORIGINAL_PATH, one per line.
|
|
204
|
+
#
|
|
205
|
+
# #596 point 2: inside a hook shell $PATH can be a handful of entries (measured: 7)
|
|
206
|
+
# while ORIGINAL_PATH still carries the full Windows PATH (measured: 3002 chars), and
|
|
207
|
+
# a tool installed to a custom prefix appears only in the latter. Guessing standard
|
|
208
|
+
# install locations — all #593's bootstrap does — cannot find a custom prefix.
|
|
209
|
+
#
|
|
210
|
+
# Normally unset on Linux/macOS, which yields nothing and is correct: there is no
|
|
211
|
+
# shrunken-PATH problem there.
|
|
212
|
+
original_path_dirs() {
|
|
213
|
+
local _raw="${ORIGINAL_PATH:-}"
|
|
214
|
+
[ -n "$_raw" ] || return 0
|
|
215
|
+
printf '%s' "$_raw" | tr ':' '\n' | sed -n '/./p'
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Resolve a tool to an absolute path, or return 1.
|
|
219
|
+
#
|
|
220
|
+
# Priority order, matching #580's suggestion:
|
|
221
|
+
# 1. XP_GATE_<TOOL>_BIN override
|
|
222
|
+
# 2. command -v <tool>
|
|
223
|
+
# 3. command -v <tool>.exe (a Windows binary seen from a *NIX* shell)
|
|
224
|
+
# 4. npx --no-install
|
|
225
|
+
# 5. subproject node_modules/.bin
|
|
226
|
+
#
|
|
227
|
+
# Covers the shapes `command -v` cannot see:
|
|
228
|
+
# * the `.exe` suffix (#580)
|
|
229
|
+
# * subproject node_modules/.bin (monorepos: jscpd is a devDependency of ui/)
|
|
230
|
+
# * the standard Windows toolchain locations, via the bootstrap
|
|
231
|
+
#
|
|
232
|
+
# Echoes the path on success.
|
|
233
|
+
resolve_tool_path() {
|
|
234
|
+
local _tool="$1" _hit _d _override
|
|
235
|
+
local _base="${PROJECT_ROOT:-$(pwd)}"
|
|
236
|
+
|
|
237
|
+
# #580: an explicit override wins over every probe.
|
|
238
|
+
_override=$(tool_env_override "$_tool")
|
|
239
|
+
if [ -n "$_override" ] && [ -x "$_override" ]; then
|
|
240
|
+
printf '%s\n' "$_override"
|
|
241
|
+
return 0
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
_hit=$(command -v "$_tool" 2>/dev/null || true)
|
|
245
|
+
if [ -z "$_hit" ]; then
|
|
246
|
+
# #580: the .exe suffix. bash does not append it the way Windows does, so a
|
|
247
|
+
# bootstrapped Windows toolchain stays invisible without this second probe —
|
|
248
|
+
# and every gate keeps reporting an installed tool as missing.
|
|
249
|
+
_hit=$(command -v "${_tool}.exe" 2>/dev/null || true)
|
|
250
|
+
fi
|
|
251
|
+
if [ -n "$_hit" ]; then
|
|
252
|
+
printf '%s\n' "$_hit"
|
|
253
|
+
return 0
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
# npx knows about the project's own node_modules.
|
|
257
|
+
if tool_is_available npx; then
|
|
258
|
+
if npx --no-install "$_tool" --version >/dev/null 2>&1; then
|
|
259
|
+
printf '%s\n' "$_tool"
|
|
260
|
+
return 0
|
|
261
|
+
fi
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
# Subproject node_modules/.bin, shallow then one level deep. A monorepo's ui/ or
|
|
265
|
+
# server/ package is the normal case; deeper nesting is rare enough that a full
|
|
266
|
+
# find would cost more than it returns.
|
|
267
|
+
for _d in "$_base/node_modules/.bin/$_tool" "$_base"/*/node_modules/.bin/"$_tool"; do
|
|
268
|
+
if [ -x "$_d" ]; then
|
|
269
|
+
printf '%s\n' "$_d"
|
|
270
|
+
return 0
|
|
271
|
+
fi
|
|
272
|
+
done
|
|
273
|
+
|
|
274
|
+
return 1
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
# Put the directory of a resolved tool on PATH, so the tool's own siblings are
|
|
278
|
+
# reachable, and print that directory.
|
|
279
|
+
#
|
|
280
|
+
# semgrep(.exe) is a zipapp wrapper that execs the sibling `pysemgrep`. Resolving
|
|
281
|
+
# the wrapper is not enough: if its directory is not on PATH the wrapper dies with
|
|
282
|
+
# `executing pysemgrep failed: No such file or directory`, which Gate 10 then
|
|
283
|
+
# reported as "tool not installed" (#593 symptom 4).
|
|
284
|
+
#
|
|
285
|
+
# WARNING: call this directly, never as `$(resolve_tool_dir x)`. A command
|
|
286
|
+
# substitution runs in a subshell, so the PATH change would be discarded and the
|
|
287
|
+
# caller would get the directory name while the tool stayed broken — which is
|
|
288
|
+
# exactly how this bit its own first test. Read the directory from
|
|
289
|
+
# `$(resolve_tool_dir ...)` only if you do not need the PATH side effect; otherwise
|
|
290
|
+
# call it and capture with a plain assignment from a file or use
|
|
291
|
+
# `ensure_tool_dir_on_path`.
|
|
292
|
+
resolve_tool_dir() {
|
|
293
|
+
local _tool="$1" _path _dir
|
|
294
|
+
bootstrap_toolchain_path
|
|
295
|
+
_path=$(resolve_tool_path "$_tool") || return 1
|
|
296
|
+
if [ -f "$_path" ]; then
|
|
297
|
+
_dir=$(cd "$(dirname "$_path")" && pwd)
|
|
298
|
+
else
|
|
299
|
+
_dir=$(pwd)
|
|
300
|
+
fi
|
|
301
|
+
case ":${PATH}:" in
|
|
302
|
+
*":${_dir}:"*) ;;
|
|
303
|
+
*) PATH="${_dir}:${PATH}"; export PATH ;;
|
|
304
|
+
esac
|
|
305
|
+
printf '%s\n' "$_dir"
|
|
306
|
+
return 0
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
# Side-effect-only variant: put the tool's directory on PATH in the CALLER's
|
|
310
|
+
# shell. Returns 0 on success, 1 when the tool cannot be resolved.
|
|
311
|
+
#
|
|
312
|
+
# This is the one to call from a gate that is about to run the tool.
|
|
313
|
+
ensure_tool_dir_on_path() {
|
|
314
|
+
local _tool="$1" _dir
|
|
315
|
+
bootstrap_toolchain_path
|
|
316
|
+
_dir=$(resolve_tool_path "$_tool") || return 1
|
|
317
|
+
if [ -f "$_dir" ]; then
|
|
318
|
+
_dir=$(cd "$(dirname "$_dir")" && pwd)
|
|
319
|
+
else
|
|
320
|
+
_dir=$(pwd)
|
|
321
|
+
fi
|
|
322
|
+
case ":${PATH}:" in
|
|
323
|
+
*":${_dir}:"*) ;;
|
|
324
|
+
*) PATH="${_dir}:${PATH}"; export PATH ;;
|
|
325
|
+
esac
|
|
326
|
+
return 0
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# Human-readable list of everywhere a tool is looked for, for the "not available"
|
|
330
|
+
# message.
|
|
331
|
+
#
|
|
332
|
+
# "not installed" and "installed but not found" need different fixes, and the old
|
|
333
|
+
# message could only say the first (#593 point 3).
|
|
334
|
+
describe_tool_search() {
|
|
335
|
+
local _tool="$1" _base="${PROJECT_ROOT:-$(pwd)}"
|
|
336
|
+
printf ' Searched: PATH entries'
|
|
337
|
+
printf ', %s' "$_base/node_modules/.bin/$_tool" "$_base"/*/node_modules/.bin/"$_tool"
|
|
338
|
+
local _extra
|
|
339
|
+
_extra=$(toolchain_path_candidates | tr '\n' ',' | sed 's/,$//')
|
|
340
|
+
if [ -n "$_extra" ]; then
|
|
341
|
+
printf ', toolchain dirs (%s)' "$_extra"
|
|
342
|
+
fi
|
|
343
|
+
printf '\n'
|
|
344
|
+
printf ' If the tool IS installed, this is a PATH problem, not a missing install.\n'
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
# Classify a non-zero exit from a tool: TOOLCHAIN or FINDING.
|
|
348
|
+
#
|
|
349
|
+
# $1 -- the tool's exit status
|
|
350
|
+
# $2 -- everything it printed
|
|
351
|
+
#
|
|
352
|
+
# Exit 127 plus an exec/"not found" line means the shim resolved but its
|
|
353
|
+
# interpreter did not. Gate 6 printed
|
|
354
|
+
# ❌ BLOCKED - Architecture violations detected
|
|
355
|
+
# for exactly that, pointing the reader at code that was fine (#593 symptom 2).
|
|
356
|
+
#
|
|
357
|
+
# The FINDING default matters as much: if everything looked like a toolchain
|
|
358
|
+
# problem, no gate could ever block on a real violation.
|
|
359
|
+
classify_tool_failure() {
|
|
360
|
+
local _exit="$1" _out="$2"
|
|
361
|
+
if [ "$_exit" -eq 127 ] 2>/dev/null; then
|
|
362
|
+
printf 'TOOLCHAIN\n'
|
|
363
|
+
return 0
|
|
364
|
+
fi
|
|
365
|
+
if printf '%s' "$_out" | grep -qiE 'exec: [^ ]+: not found|command not found|No such file or directory.*exec|pysemgrep failed'; then
|
|
366
|
+
printf 'TOOLCHAIN\n'
|
|
367
|
+
return 0
|
|
368
|
+
fi
|
|
369
|
+
printf 'FINDING\n'
|
|
370
|
+
}
|
|
@@ -2,10 +2,21 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
5
6
|
|
|
6
7
|
const REQUIRED_ROLES = ['architecture', 'technical', 'feasibility'];
|
|
7
8
|
const VALIDITY_MS = 60 * 60 * 1000;
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Project-level Delphi seat configuration.
|
|
12
|
+
*
|
|
13
|
+
* Present => evidence is BOUND to it (#574): each expert's `requested_model`
|
|
14
|
+
* must be exactly the seat the config assigns to that expert's role.
|
|
15
|
+
* Absent => evidence stays unbound (the #423 behaviour), so projects that
|
|
16
|
+
* review through another platform's channel are unaffected.
|
|
17
|
+
*/
|
|
18
|
+
const DELPHI_CONFIG_FILE = '.delphi-config.json';
|
|
19
|
+
|
|
9
20
|
/**
|
|
10
21
|
* Allowed provenance values for an expert record.
|
|
11
22
|
*
|
|
@@ -58,7 +69,126 @@ function parseTimestamp(value, field) {
|
|
|
58
69
|
return date.getTime();
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Record one seat into `seats` if the entry carries a usable model id.
|
|
74
|
+
* Shared by both `experts` shapes so the acceptance rule lives in exactly one
|
|
75
|
+
* place (#599: the two branches were byte-for-byte copies, which is what pushed
|
|
76
|
+
* this function over the complexity block threshold).
|
|
77
|
+
*
|
|
78
|
+
* @param {Map<string, string>} seats role -> trimmed model id, mutated
|
|
79
|
+
* @param {string} role configured role name
|
|
80
|
+
* @param {unknown} entry candidate expert record
|
|
81
|
+
*/
|
|
82
|
+
function addSeat(seats, role, entry) {
|
|
83
|
+
if (!isPlainObject(entry)) return;
|
|
84
|
+
const model = entry.model ?? entry.requested_model;
|
|
85
|
+
if (typeof model === 'string' && model.trim() !== '') seats.set(role, model.trim());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalise the two `experts` shapes `.delphi-config.json` appears in — an
|
|
90
|
+
* object keyed by role (what xp-gate writes) and an array carrying `role` —
|
|
91
|
+
* into a plain role -> model map. Mirrors `normalizeExperts()` in
|
|
92
|
+
* `plugins/dsh/src/delphi-config.ts` and `iter_seats()` in
|
|
93
|
+
* `scripts/check_delphi_local_models.py`: one config shape, one reading.
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} experts raw `experts` value
|
|
96
|
+
* @returns {Map<string, string>} role -> trimmed model id
|
|
97
|
+
*/
|
|
98
|
+
function seatsFromExperts(experts) {
|
|
99
|
+
const seats = new Map();
|
|
100
|
+
if (Array.isArray(experts)) {
|
|
101
|
+
for (const entry of experts) {
|
|
102
|
+
if (isPlainObject(entry) && typeof entry.role === 'string') {
|
|
103
|
+
addSeat(seats, entry.role, entry);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} else if (isPlainObject(experts)) {
|
|
107
|
+
for (const [role, entry] of Object.entries(experts)) {
|
|
108
|
+
addSeat(seats, role, entry);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return seats;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the binding seats from a parsed `.delphi-config.json`.
|
|
116
|
+
*
|
|
117
|
+
* Only the ACTIVE profile binds, exactly like every other Delphi loader. A
|
|
118
|
+
* config that names profiles but leaves no resolvable active one is rejected
|
|
119
|
+
* rather than ignored: silently falling back to "unbound" would turn a broken
|
|
120
|
+
* config into a bypass.
|
|
121
|
+
*
|
|
122
|
+
* @param {unknown} config parsed config document
|
|
123
|
+
* @returns {Map<string, string>} role -> model id that evidence must match
|
|
124
|
+
*/
|
|
125
|
+
function resolveBindingSeats(config) {
|
|
126
|
+
if (!isPlainObject(config)) fail(`${DELPHI_CONFIG_FILE} must contain a JSON object.`);
|
|
127
|
+
|
|
128
|
+
const profiles = config.profiles;
|
|
129
|
+
if (!isPlainObject(profiles) || Object.keys(profiles).length === 0) {
|
|
130
|
+
// Legacy/flat shape: experts live at the top level.
|
|
131
|
+
return seatsFromExperts(config.experts);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const active = config.active_profile;
|
|
135
|
+
if (typeof active !== 'string' || !isPlainObject(profiles[active])) {
|
|
136
|
+
const available = Object.keys(profiles).join(', ');
|
|
137
|
+
fail(
|
|
138
|
+
`${DELPHI_CONFIG_FILE} has no resolvable active_profile (active_profile=${JSON.stringify(active)}; `
|
|
139
|
+
+ `available: ${available}). Set one so the gate knows which seats bind.`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return seatsFromExperts(profiles[active].experts);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read the binding seats for the project rooted at `projectRoot`.
|
|
147
|
+
*
|
|
148
|
+
* The root is passed EXPLICITLY by the caller, never derived from
|
|
149
|
+
* `process.cwd()`. Deriving it from cwd made the binding depend on where the
|
|
150
|
+
* validator was launched from: running the test suite from inside xp-gate
|
|
151
|
+
* itself picked up xp-gate's own `.delphi-config.json` and re-bound every
|
|
152
|
+
* fixture to xp-gate's seats. The gate's subject is the repository being
|
|
153
|
+
* pushed, so that is what must be named.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} projectRoot repository root whose config binds the evidence
|
|
156
|
+
* @returns {Map<string, string> | null} seats, or null when no config exists
|
|
157
|
+
*/
|
|
158
|
+
function loadBindingSeats(projectRoot) {
|
|
159
|
+
const configPath = path.join(projectRoot, DELPHI_CONFIG_FILE);
|
|
160
|
+
let raw;
|
|
161
|
+
try {
|
|
162
|
+
raw = fs.readFileSync(configPath, 'utf8');
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error && error.code === 'ENOENT') return null;
|
|
165
|
+
fail(`cannot read ${DELPHI_CONFIG_FILE}: ${error && error.message ? error.message : String(error)}`);
|
|
166
|
+
}
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(raw);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
fail(`${DELPHI_CONFIG_FILE} is not valid JSON: ${error.message}`);
|
|
172
|
+
}
|
|
173
|
+
const seats = resolveBindingSeats(parsed);
|
|
174
|
+
for (const role of REQUIRED_ROLES) {
|
|
175
|
+
if (!seats.has(role)) {
|
|
176
|
+
fail(`${DELPHI_CONFIG_FILE} declares no seat for the ${role} role; all three roles must be configured.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return seats;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Validate an expert's identity fields: shape, role, approval status, and
|
|
184
|
+
* channel provenance. Split out of `validateExpert`, which exceeded the
|
|
185
|
+
* complexity block threshold (#599).
|
|
186
|
+
*
|
|
187
|
+
* @param {object} expert expert record
|
|
188
|
+
* @param {number} index position in the experts array
|
|
189
|
+
* @param {Set<string>} roles roles seen so far; the role is added
|
|
190
|
+
*/
|
|
191
|
+
function validateExpertIdentity(expert, index, roles) {
|
|
62
192
|
if (!isPlainObject(expert)) fail(`experts[${index}] must be a plain object.`);
|
|
63
193
|
if (!REQUIRED_ROLES.includes(expert.role)) fail(`experts[${index}].role is invalid.`);
|
|
64
194
|
if (roles.has(expert.role)) fail(`expert role ${expert.role} is duplicated.`);
|
|
@@ -80,14 +210,33 @@ function validateExpert(expert, index, roles, models, resolvedModels) {
|
|
|
80
210
|
`expert ${expert.role} channel must be one of: ${VALID_CHANNELS.join(', ')}.`
|
|
81
211
|
);
|
|
82
212
|
}
|
|
213
|
+
}
|
|
83
214
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Validate the requested model: non-empty, distinct across experts, and — when
|
|
217
|
+
* the project binds seats — exactly the model configured for this role.
|
|
218
|
+
*
|
|
219
|
+
* requested_model is compared EXACTLY (after trimming) while resolved_model is
|
|
220
|
+
* compared case-INSENSITIVELY. The asymmetry is intentional, not an oversight: a
|
|
221
|
+
* requested ID is a case-sensitive gateway identifier and AGENTS.md requires
|
|
222
|
+
* "three distinct trimmed requested model IDs", whereas resolved_model answers
|
|
223
|
+
* "did these actually run on one model?" -- and gateways do report the same model
|
|
224
|
+
* with different casing per call, which is how #423 slipped through. Pinned by
|
|
225
|
+
* scripts/__tests__/gate-mw-provenance.test.cjs (AC-423-08).
|
|
226
|
+
*
|
|
227
|
+
* #574: when the project ships a `.delphi-config.json`, the seat this role was
|
|
228
|
+
* configured with is the ONLY model allowed to report for it. Distinctness alone
|
|
229
|
+
* was satisfiable by any three of the gateway's ~138 models -- measured
|
|
230
|
+
* 2026-10-22, a walkthrough ran on s-deepseek-v4.1-flash / g-deepseek-v4-pro /
|
|
231
|
+
* doubao-seed-2.0-pro while the config named three local-* seats, and this
|
|
232
|
+
* validator passed it. Membership is checked per ROLE (not as a set) so a swapped
|
|
233
|
+
* pair is rejected too: the role attribution would otherwise be a lie.
|
|
234
|
+
*
|
|
235
|
+
* @param {object} expert expert record
|
|
236
|
+
* @param {Set<string>} models requested models seen so far; this one is added
|
|
237
|
+
* @param {Map<string, string>|null} bindingSeats configured seats, or null
|
|
238
|
+
*/
|
|
239
|
+
function validateRequestedModel(expert, models, bindingSeats) {
|
|
91
240
|
if (typeof expert.requested_model !== 'string' || expert.requested_model.trim() === '') {
|
|
92
241
|
fail(`expert ${expert.role} requested_model must be non-empty.`);
|
|
93
242
|
}
|
|
@@ -95,10 +244,28 @@ function validateExpert(expert, index, roles, models, resolvedModels) {
|
|
|
95
244
|
if (models.has(model)) fail(`requested_model ${model} is duplicated.`);
|
|
96
245
|
models.add(model);
|
|
97
246
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
247
|
+
if (bindingSeats !== null) {
|
|
248
|
+
const configured = bindingSeats.get(expert.role);
|
|
249
|
+
if (model !== configured) {
|
|
250
|
+
fail(
|
|
251
|
+
`expert ${expert.role} requested_model ${model} does not match the configured seat `
|
|
252
|
+
+ `${configured} from ${DELPHI_CONFIG_FILE}.`
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Validate the resolved model: a non-empty string, distinct ignoring case.
|
|
260
|
+
*
|
|
261
|
+
* Previously null was allowed, so an expert that never resolved a model still
|
|
262
|
+
* counted as successful; and nothing compared resolved models, so all three could
|
|
263
|
+
* report the same one while `requested_model` differed.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} expert expert record
|
|
266
|
+
* @param {Set<string>} resolvedModels resolved models seen so far; added here
|
|
267
|
+
*/
|
|
268
|
+
function validateResolvedModel(expert, resolvedModels) {
|
|
102
269
|
if (typeof expert.resolved_model !== 'string' || expert.resolved_model.trim() === '') {
|
|
103
270
|
fail(`expert ${expert.role} resolved_model must be a non-empty string.`);
|
|
104
271
|
}
|
|
@@ -109,7 +276,13 @@ function validateExpert(expert, index, roles, models, resolvedModels) {
|
|
|
109
276
|
resolvedModels.add(resolved);
|
|
110
277
|
}
|
|
111
278
|
|
|
112
|
-
function
|
|
279
|
+
function validateExpert(expert, index, roles, models, resolvedModels, bindingSeats) {
|
|
280
|
+
validateExpertIdentity(expert, index, roles);
|
|
281
|
+
validateRequestedModel(expert, models, bindingSeats);
|
|
282
|
+
validateResolvedModel(expert, resolvedModels);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function validateEvidence(evidence, expectedCommit, expectedBranch, now, bindingSeats) {
|
|
113
286
|
if (!isPlainObject(evidence)) fail('top level must be a plain object.');
|
|
114
287
|
if (evidence.commit !== expectedCommit) fail('commit does not match HEAD.');
|
|
115
288
|
if (evidence.branch !== expectedBranch) fail('branch does not match the current branch.');
|
|
@@ -139,25 +312,53 @@ function validateEvidence(evidence, expectedCommit, expectedBranch, now) {
|
|
|
139
312
|
// next (a long-lived process validating several pushes).
|
|
140
313
|
const resolvedModels = new Set();
|
|
141
314
|
evidence.experts.forEach((expert, index) => {
|
|
142
|
-
validateExpert(expert, index, roles, models, resolvedModels);
|
|
315
|
+
validateExpert(expert, index, roles, models, resolvedModels, bindingSeats);
|
|
143
316
|
});
|
|
144
317
|
if (roles.size !== REQUIRED_ROLES.length) fail('all required expert roles must be present.');
|
|
145
318
|
}
|
|
146
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Read and parse the evidence document, failing with a readable message rather
|
|
322
|
+
* than letting a raw ENOENT/JSON.parse error escape.
|
|
323
|
+
*
|
|
324
|
+
* @param {string} file path to the evidence JSON
|
|
325
|
+
* @returns {unknown} parsed document
|
|
326
|
+
*/
|
|
327
|
+
function readEvidence(file) {
|
|
328
|
+
try {
|
|
329
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
330
|
+
} catch {
|
|
331
|
+
return fail('file must exist and contain valid JSON.');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Resolve the project root the caller named, if any.
|
|
337
|
+
*
|
|
338
|
+
* #574: the seats that bind are the ones configured in the repository being
|
|
339
|
+
* pushed. The caller names that root; a missing argument means "no project
|
|
340
|
+
* context", which keeps evidence unbound rather than guessing from cwd.
|
|
341
|
+
*
|
|
342
|
+
* @param {string|undefined} projectRoot raw argv value
|
|
343
|
+
* @returns {string|null} absolute root, or null for "no project context"
|
|
344
|
+
*/
|
|
345
|
+
function resolveProjectRoot(projectRoot) {
|
|
346
|
+
if (projectRoot === undefined || projectRoot === '') return null;
|
|
347
|
+
return path.resolve(projectRoot);
|
|
348
|
+
}
|
|
349
|
+
|
|
147
350
|
function main() {
|
|
148
|
-
const [file, expectedCommit, expectedBranch, nowValue] = process.argv.slice(2);
|
|
351
|
+
const [file, expectedCommit, expectedBranch, nowValue, projectRoot] = process.argv.slice(2);
|
|
149
352
|
if (!file || !expectedCommit || !expectedBranch) {
|
|
150
|
-
fail(
|
|
353
|
+
fail(`usage: validate-code-walkthrough.cjs <file> <commit> <branch> [now] [project-root].`);
|
|
151
354
|
}
|
|
152
355
|
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
evidence = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
156
|
-
} catch {
|
|
157
|
-
fail('file must exist and contain valid JSON.');
|
|
158
|
-
}
|
|
356
|
+
const evidence = readEvidence(file);
|
|
159
357
|
const now = nowValue === undefined ? Date.now() : parseTimestamp(nowValue, 'current time');
|
|
160
|
-
|
|
358
|
+
|
|
359
|
+
const root = resolveProjectRoot(projectRoot);
|
|
360
|
+
const bindingSeats = root === null ? null : loadBindingSeats(root);
|
|
361
|
+
validateEvidence(evidence, expectedCommit, expectedBranch, now, bindingSeats);
|
|
161
362
|
}
|
|
162
363
|
|
|
163
364
|
main();
|