@boyingliu01/xp-gate 0.31.1 → 0.31.3

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 (86) 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 +254 -10
  9. package/gate-8.sh +67 -6
  10. package/hooks/adapter-common.sh +41 -3
  11. package/hooks/gate-3.sh +254 -10
  12. package/hooks/gate-8.sh +67 -6
  13. package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
  14. package/hooks/lib/coverage-total.sh +20 -5
  15. package/hooks/lib/gate-5c-alignment.sh +145 -0
  16. package/hooks/lib/jscpd-run.sh +40 -1
  17. package/hooks/lib/native-path.sh +106 -0
  18. package/hooks/lib/prepush-score.py +143 -0
  19. package/hooks/lib/sprint-gate-report.sh +23 -0
  20. package/hooks/lib/toolchain-path.sh +370 -0
  21. package/hooks/lib/validate-code-walkthrough.cjs +224 -23
  22. package/hooks/pre-commit +403 -165
  23. package/hooks/pre-push +192 -74
  24. package/hooks/sprint-gate.sh +14 -3
  25. package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
  26. package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
  27. package/lib/__tests__/doctor-delphi.test.js +46 -0
  28. package/lib/__tests__/doctor-exec-retry.test.js +20 -10
  29. package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
  30. package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
  31. package/lib/__tests__/doctor.test.js +7 -1
  32. package/lib/__tests__/hook-health-path.test.js +9 -1
  33. package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
  34. package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
  35. package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
  36. package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
  37. package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
  38. package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
  39. package/lib/arch.js +261 -44
  40. package/lib/check-alignment-runner.cjs +23 -0
  41. package/lib/check.js +4 -1
  42. package/lib/doctor.js +194 -49
  43. package/lib/gate-runner.js +265 -58
  44. package/lib/mattpocock-license-verdict.json +1 -1
  45. package/lib/principles.js +228 -6
  46. package/lib/shared-paths.js +53 -19
  47. package/mock-policy/AGENTS.md +3 -3
  48. package/mutation/AGENTS.md +3 -3
  49. package/mutation/__tests__/gremlins-runner.test.ts +3 -3
  50. package/package.json +1 -1
  51. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  52. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  53. package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
  54. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  55. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  56. package/plugins/dsh/package.json +1 -1
  57. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  58. package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
  59. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  60. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  61. package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
  62. package/plugins/dsh/src/delphi-config.ts +151 -9
  63. package/plugins/opencode/package.json +1 -1
  64. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  65. package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
  66. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  67. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/plugins/qoder/plugin.json +1 -1
  69. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  70. package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
  71. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  72. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  73. package/principles/AGENTS.md +3 -3
  74. package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
  75. package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
  76. package/principles/adapters/java.ts +127 -11
  77. package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
  78. package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
  79. package/principles/rules/solid/lsp.ts +348 -23
  80. package/scripts/__tests__/sync-package-content.test.js +32 -1
  81. package/scripts/sync-package-content.js +6 -1
  82. package/skills/delphi-review/AGENTS.md +3 -3
  83. package/skills/delphi-review/SKILL.md +50 -0
  84. package/skills/sprint-flow/AGENTS.md +3 -3
  85. package/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/sprint-gate.sh +14 -3
@@ -56,3 +56,26 @@ sprint_gate_detail() {
56
56
  done
57
57
  return 0
58
58
  }
59
+
60
+ # Echo WHY sprint-gate.sh allowed the commit (#584).
61
+ #
62
+ # `allow` on its own is ambiguous: it certifies a validated sprint, but it also
63
+ # covers "the sprint has not reached this gate's phase" and "no JSON parser, so
64
+ # nothing could be read". pre-commit mapped every one of those to Gate 11 PASS,
65
+ # so a gate that examined nothing raised the score and left the verdict alone.
66
+ #
67
+ # Only `checks-passed` licenses a PASS. Anything else -- including an `allow`
68
+ # from an older sprint-gate.sh that carries no reason at all -- means "not
69
+ # evaluated", and the caller must report SKIP. Defaulting the other way would
70
+ # reinstate the defect for every consumer who has not re-installed.
71
+ sprint_gate_allow_reason() {
72
+ local _out="${1:-}" _reason
73
+ _reason=$(printf '%s' "$_out" | sed -n 's/.*"allow_reason"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tail -1)
74
+ if [ -n "$_reason" ]; then
75
+ printf '%s\n' "$_reason"
76
+ return 0
77
+ fi
78
+ # No reason given. The decision may still be an `allow`; the caller decides.
79
+ printf 'unspecified\n'
80
+ return 0
81
+ }
@@ -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
+ }