@boyingliu01/xp-gate 0.35.24 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/adapter-common.sh +55 -2
  2. package/adapters/csharp.sh +245 -0
  3. package/adapters/gate-3.sh +1 -1
  4. package/adapters/gate-4.sh +1 -1
  5. package/adapters/gate-9.sh +37 -2
  6. package/adapters/iac.sh +45 -5
  7. package/bin/xp-gate.js +18 -11
  8. package/gate-10.sh +18 -3
  9. package/gate-13-compiler-strictness.sh +181 -0
  10. package/gate-3.sh +1 -1
  11. package/gate-4.sh +1 -1
  12. package/gate-9.sh +37 -2
  13. package/gates/common.ts +9 -2
  14. package/hooks/adapter-common.sh +55 -2
  15. package/hooks/gate-10.sh +18 -3
  16. package/hooks/gate-13-compiler-strictness.sh +181 -0
  17. package/hooks/gate-3.sh +1 -1
  18. package/hooks/gate-4.sh +1 -1
  19. package/hooks/gate-9.sh +37 -2
  20. package/hooks/gate-registry.sh +161 -0
  21. package/hooks/lib/test-failure.sh +29 -4
  22. package/hooks/pre-commit +122 -3
  23. package/lib/__tests__/agent-runtime-journal.test.js +42 -18
  24. package/lib/__tests__/check-single-file.test.js +55 -4
  25. package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
  26. package/lib/__tests__/doctor-delphi.test.js +175 -0
  27. package/lib/__tests__/install-skill-full-dir.test.js +45 -2
  28. package/lib/__tests__/install-skill.test.js +84 -3
  29. package/lib/__tests__/mirror-sync.test.js +23 -5
  30. package/lib/__tests__/uninstall-global-residue.test.js +192 -28
  31. package/lib/__tests__/update-skill-flags.test.js +78 -0
  32. package/lib/__tests__/update-skill.test.js +156 -3
  33. package/lib/detect-deps.js +24 -0
  34. package/lib/doctor.js +80 -12
  35. package/lib/install-skill.js +67 -18
  36. package/lib/mattpocock-license-verdict.json +1 -1
  37. package/lib/uninstall.js +96 -2
  38. package/lib/update-skill.js +143 -2
  39. package/mock-policy/AGENTS.md +4 -4
  40. package/mutation/AGENTS.md +4 -4
  41. package/package.json +1 -1
  42. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  43. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  44. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  45. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  46. package/plugins/dsh/lib/index.d.ts.map +1 -1
  47. package/plugins/dsh/lib/index.js +7 -4
  48. package/plugins/dsh/package.json +1 -1
  49. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  50. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  51. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  52. package/plugins/dsh/src/index.test.ts +25 -0
  53. package/plugins/dsh/src/index.ts +7 -4
  54. package/plugins/opencode/package.json +1 -1
  55. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  56. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  57. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  58. package/plugins/qoder/plugin.json +1 -1
  59. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  60. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  61. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  62. package/principles/AGENTS.md +4 -4
  63. package/principles/adapters/csharp.ts +309 -0
  64. package/principles/analyzer.ts +5 -0
  65. package/scripts/delphi-external-review.cjs +130 -101
  66. package/skills/delphi-review/AGENTS.md +4 -4
  67. package/skills/sprint-flow/AGENTS.md +4 -4
  68. package/skills/test-specification-alignment/AGENTS.md +4 -4
package/hooks/gate-9.sh CHANGED
@@ -1,3 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # ⚠️ DEAD CODE — NOT EXECUTED. Read this before changing anything here.
4
+ # ============================================================================
5
+ # This file is NOT sourced by githooks/pre-commit or githooks/pre-push (both
6
+ # source count = 0; verify with: grep -c "gate-9.sh" githooks/pre-commit).
7
+ # It is shipped by githooks/install.sh but never runs.
8
+ #
9
+ # The gate that actually runs semgrep SAST is githooks/gate-10.sh, and it
10
+ # reports as `gate10_sast`. Gate 9 in the report is `gate9_build_integrity`
11
+ # (Build Integrity), a different gate entirely.
12
+ #
13
+ # This mislabelling is not cosmetic: in #717 a C# SAST fix was first written
14
+ # into THIS file, where it changed nothing, while .cs stayed ungoverned. The
15
+ # C# ruleset loading that works lives in gate-10.sh. Do not relocate it here.
16
+ #
17
+ # Kept rather than deleted because githooks/install.sh:GATE_SCRIPTS ships it;
18
+ # removing it is a separate change. See issue #717 walkthrough MC-03.
19
+ # ============================================================================
1
20
  # ============================================================================
2
21
  # GATE 9: Semgrep SAST Security Scan
3
22
  # Detects security vulnerabilities (SQL injection, XSS, etc.) in staged files
@@ -112,8 +131,22 @@ EOF
112
131
  # code is demoted to "did the tool itself run". --error stays OFF: it would
113
132
  # add nothing the JSON does not already provide, and keeping it off leaves
114
133
  # failure exit codes unambiguous for the #475 diagnostics path.
134
+ # #717: p/security-audit carries NO C# rules. Measured: a .cs file with a
135
+ # textbook SQL injection (string-concatenated SqlCommand) and a
136
+ # Process.Start command injection produced 0 findings under
137
+ # p/security-audit and 1 ERROR under p/csharp. Since gate-9.sh:45 lists
138
+ # `cs` in the file filter, .cs was accepted and then scanned by a ruleset
139
+ # that cannot see C# -- the identical "filter looks like coverage" defect
140
+ # this issue removed from Gate 4. p/csharp is loaded IN ADDITION to
141
+ # p/security-audit (not instead of: the security-audit rules still apply
142
+ # to the other languages in a mixed commit).
143
+ SEMGREP_RULES=(--config=p/security-audit)
144
+ if printf "%s
145
+ " "$SEMGREP_FILES" | grep -qE ".cs$"; then
146
+ SEMGREP_RULES+=(--config=p/csharp)
147
+ fi
115
148
  SEMGREP_STDOUT_FILE=$(mktemp)
116
- SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
149
+ SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan "${SEMGREP_RULES[@]}" --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1 >"$SEMGREP_STDOUT_FILE")
117
150
  SEMGREP_EXIT=$?
118
151
 
119
152
  # One node pass over the JSON: first line = verdict counts, remaining lines =
@@ -191,7 +224,9 @@ console.log("");});
191
224
  echo " ❌ BLOCKED — blocking-severity vulnerability found"
192
225
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
193
226
  echo "$FINDING_DETAILS"
194
- echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
227
+ # #717: name BOTH rulesets. A C# finding comes from p/csharp, so
228
+ # pointing the author at p/security-audit alone reproduces zero hits.
229
+ echo " Run 'semgrep scan --config=p/security-audit --config=p/csharp' to review all findings."
195
230
  GATE_9_STATUS="FAIL"
196
231
  exit 1
197
232
  else
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bash
2
+ # ============================================================================
3
+ # GATE REGISTRY — the single source of truth for "which gate is which"
4
+ # ============================================================================
5
+ #
6
+ # WHY THIS FILE EXISTS
7
+ #
8
+ # Gate numbers alone are not self-describing, and the numbers are not stable
9
+ # facts about the repository — they are labels. Three sources disagreed about
10
+ # what "Gate 9" is:
11
+ #
12
+ # root AGENTS.md -> Semgrep SAST (WRONG)
13
+ # githooks/AGENTS.md -> Build Integrity (right)
14
+ # pre-commit actual -> Build Integrity (right)
15
+ #
16
+ # In issue #717 a C# SAST fix was written into githooks/gate-9.sh, because the
17
+ # root document said Gate 9 was SAST. SAST is gate 10. gate-9.sh is not sourced
18
+ # by any hook and never executes, so the fix changed nothing while appearing to
19
+ # land. The defect was not "the number was hard to remember" — it was that
20
+ # nothing connected a gate's NAME to the FILE that runs it.
21
+ #
22
+ # This registry makes that connection explicit and testable. The drift test
23
+ # (githooks/__tests__/gate-registry.test.bats) asserts every `impl` below
24
+ # against the code, so a document that disagrees with reality, or a fix that
25
+ # lands in a file nobody sources, fails immediately.
26
+ #
27
+ # COLUMNS
28
+ # name stable semantic id — use this in prose, commits, and tests
29
+ # number display label only. Mirrors the report key (gateN_*) for backward
30
+ # compatibility; do NOT treat it as the identity of the gate.
31
+ # impl where the gate actually runs. One of:
32
+ # <script>.sh a file sourced/executed by pre-commit
33
+ # inline implemented inside pre-commit itself
34
+ # blocks YES the gate can reject a commit
35
+ # NO the gate reports but cannot reject
36
+ #
37
+ # USAGE
38
+ # source githooks/gate-registry.sh
39
+ # gate_name_for_file gate-10.sh # -> sast
40
+ # gate_impl sast # -> gate-10.sh
41
+ # gate_number sast # -> 10
42
+ # gate_can_block sast # -> YES
43
+ # gate_registry_dump # -> one line per gate
44
+ #
45
+ # ⚠️ Adding a gate: add a row here AND update the drift test's expectations.
46
+ # A gate that exists only in pre-commit and not here is a gate nobody can
47
+ # look up by name — which is how #717's wrong-file patch happened.
48
+ # ============================================================================
49
+
50
+ # name|number|impl|blocks
51
+ GATE_REGISTRY_ROWS=(
52
+ "version-consistency|0|inline|YES"
53
+ "code-quality|1|inline|NO"
54
+ "duplicate-code|2|inline|NO"
55
+ "complexity|3|gate-3.sh|YES"
56
+ "principles|4|gate-4.sh|YES"
57
+ "tests-coverage|5|inline|YES"
58
+ "architecture|6|inline|YES"
59
+ "iac-security|7|gate-7.sh|YES"
60
+ "secret-scanning|8|gate-8.sh|YES"
61
+ "build-integrity|9|inline|NO"
62
+ "sast|10|gate-10.sh|YES"
63
+ "sprint-flow|11|sprint-gate.sh|YES"
64
+ "file-hygiene|12|gate-12-file-hygiene.sh|NO"
65
+ "compiler-strictness|13|gate-13-compiler-strictness.sh|YES"
66
+ )
67
+
68
+ # Human-readable title for each name. Kept separate from the row above so the
69
+ # title can change without touching the lookup keys.
70
+ _gate_title() {
71
+ case "$1" in
72
+ version-consistency) echo "Version Consistency" ;;
73
+ code-quality) echo "Code Quality (Static + Lint)" ;;
74
+ duplicate-code) echo "Duplicate Code" ;;
75
+ complexity) echo "Cyclomatic Complexity" ;;
76
+ principles) echo "Clean Code + SOLID" ;;
77
+ tests-coverage) echo "Tests + Coverage" ;;
78
+ architecture) echo "Architecture + Boy Scout Rule" ;;
79
+ iac-security) echo "IaC Security Scanning" ;;
80
+ secret-scanning) echo "Secret Scanning" ;;
81
+ build-integrity) echo "Build Integrity" ;;
82
+ sast) echo "SAST Security Scan" ;;
83
+ sprint-flow) echo "Sprint Flow Enforcement" ;;
84
+ file-hygiene) echo "File Hygiene Check" ;;
85
+ compiler-strictness) echo "Compiler Strictness" ;;
86
+ *) return 1 ;;
87
+ esac
88
+ }
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Lookups. Each returns non-zero when the name is unknown, so a typo fails
92
+ # loudly instead of silently matching nothing.
93
+ # ---------------------------------------------------------------------------
94
+
95
+ # gate_row <name> -> echoes the raw row, or returns 1.
96
+ gate_row() {
97
+ local want="$1" row
98
+ for row in "${GATE_REGISTRY_ROWS[@]}"; do
99
+ case "$row" in
100
+ "$want"\|*) printf '%s\n' "$row"; return 0 ;;
101
+ esac
102
+ done
103
+ return 1
104
+ }
105
+
106
+ # _gate_field <name> <1-based field index>
107
+ _gate_field() {
108
+ local row
109
+ row=$(gate_row "$1") || return 1
110
+ printf '%s\n' "$row" | cut -d'|' -f"$2"
111
+ }
112
+
113
+ gate_number() { _gate_field "$1" 2; }
114
+ gate_impl() { _gate_field "$1" 3; }
115
+ gate_blocks() { _gate_field "$1" 4; }
116
+ gate_can_block() { [ "$(gate_blocks "$1")" = "YES" ] && echo YES || echo NO; }
117
+ gate_title() { _gate_title "$1"; }
118
+
119
+ # gate_name_for_number <N> -> echoes the semantic name.
120
+ gate_name_for_number() {
121
+ local want="$1" row
122
+ for row in "${GATE_REGISTRY_ROWS[@]}"; do
123
+ case "$row" in
124
+ *\|"$want"\|*) printf '%s\n' "${row%%|*}"; return 0 ;;
125
+ esac
126
+ done
127
+ return 1
128
+ }
129
+
130
+ # gate_name_for_file <file> -> semantic name of the gate implemented by <file>.
131
+ # Returns 1 for `inline` gates and unknown files. This is the lookup that would
132
+ # have caught #717: gate-9.sh resolves to build-integrity, not sast.
133
+ gate_name_for_file() {
134
+ local want="$1" row impl name
135
+ for row in "${GATE_REGISTRY_ROWS[@]}"; do
136
+ name="${row%%|*}"
137
+ impl=$(printf '%s\n' "$row" | cut -d'|' -f3)
138
+ [ "$impl" = "$want" ] && { printf '%s\n' "$name"; return 0; }
139
+ done
140
+ return 1
141
+ }
142
+
143
+ # List every script the registry claims the hook runs.
144
+ gate_all_impls() {
145
+ local row impl
146
+ for row in "${GATE_REGISTRY_ROWS[@]}"; do
147
+ impl=$(printf '%s\n' "$row" | cut -d'|' -f3)
148
+ [ "$impl" != "inline" ] && printf '%s\n' "$impl"
149
+ done
150
+ }
151
+
152
+ gate_registry_dump() {
153
+ local row name num impl blocks
154
+ for row in "${GATE_REGISTRY_ROWS[@]}"; do
155
+ name="${row%%|*}"
156
+ num=$(printf '%s\n' "$row" | cut -d'|' -f2)
157
+ impl=$(printf '%s\n' "$row" | cut -d'|' -f3)
158
+ blocks=$(printf '%s\n' "$row" | cut -d'|' -f4)
159
+ printf '%-3s %-22s %-34s %-6s %s\n' "$num" "$name" "$(_gate_title "$name")" "$blocks" "$impl"
160
+ done
161
+ }
@@ -31,6 +31,20 @@ has_real_failure_marker() {
31
31
  is_runner_infrastructure_error() {
32
32
  local _out="$1"
33
33
  [ -n "$_out" ] || return 1
34
+ # #717: dotnet/VSTest's testhost failing to START is the same class of event
35
+ # -- the runner could not run, so no test was judged. Its signature is a
36
+ # Win32Exception from ProcessManager.OpenProcess (the testhost cannot open a
37
+ # handle to its own parent) followed by "Testhost process exited with error".
38
+ # Without this arm, a .NET repo on a machine with that permission fault would
39
+ # report "Tests FAILED" on every commit for a reason that has nothing to do
40
+ # with its tests.
41
+ #
42
+ # A real dotnet failure ("Failed!", "Failed: 3") never carries these strings,
43
+ # so the has_real_failure_marker guard below still lets genuine failures block.
44
+ if printf '%s' "$_out" | grep -qE 'Win32Exception|Testhost.*(exited with error|已退出)|test host.*(could not|did not) start'; then
45
+ has_real_failure_marker "$_out" && return 1
46
+ return 0
47
+ fi
34
48
  printf '%s' "$_out" | grep -qE 'Unhandled Error' || return 1
35
49
  has_real_failure_marker "$_out" && return 1
36
50
  return 0
@@ -116,10 +130,21 @@ handle_test_failure() {
116
130
  local _ctx="$2"
117
131
  if is_runner_infrastructure_error "$_out"; then
118
132
  echo ""
119
- echo "⏭️ SKIPPED - vitest runner infrastructure error (no test failed)"
120
- echo " Detected: Unhandled Errors with 0 failed tests."
121
- echo " Known Windows issue: EPERM on vitest temp/ssr cache (#454)."
122
- echo " Workaround: npx vitest run --no-file-parallelism <targets>"
133
+ # The advice has to match the runner that failed (#717). A C# developer
134
+ # handed the vitest EPERM workaround would chase a cache problem in a
135
+ # language they are not using, and the real cause -- the testhost could not
136
+ # open a handle to its own parent -- would never be named.
137
+ if printf '%s' "$_out" | grep -qE 'Win32Exception|Testhost.*(exited with error|已退出)|test host.*(could not|did not) start'; then
138
+ echo "⏭️ SKIPPED - dotnet test host could not start (no test was judged)"
139
+ echo " Detected: VSTest testhost exited before running a test."
140
+ echo " This is an environment fault, NOT a test verdict -- it must not block."
141
+ echo " Check: antivirus / endpoint protection blocking process handles, or run the suite outside this path."
142
+ else
143
+ echo "⏭️ SKIPPED - vitest runner infrastructure error (no test failed)"
144
+ echo " Detected: Unhandled Errors with 0 failed tests."
145
+ echo " Known Windows issue: EPERM on vitest temp/ssr cache (#454)."
146
+ echo " Workaround: npx vitest run --no-file-parallelism <targets>"
147
+ fi
123
148
  TESTS_SKIPPED=true
124
149
  TESTS_EXIT_CODE=0
125
150
  return 0
package/hooks/pre-commit CHANGED
@@ -271,6 +271,17 @@ if [ -z "$GATE_DIR" ]; then
271
271
  GATE_DIR="$ADAPTER_DIR"
272
272
  fi
273
273
 
274
+ # Gate registry (#717 walkthrough): binds each gate's NAME to the file that
275
+ # runs it, so a gate can be referred to by what it does rather than by a
276
+ # positional number. Optional by design — a consumer repo installed from an
277
+ # older package will not have it, and the hooks must keep working there.
278
+ _GATE_REGISTRY="$GATE_DIR/gate-registry.sh"
279
+ [ -f "$_GATE_REGISTRY" ] || _GATE_REGISTRY="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/gate-registry.sh"
280
+ if [ -f "$_GATE_REGISTRY" ]; then
281
+ # shellcheck source=/dev/null
282
+ . "$_GATE_REGISTRY"
283
+ fi
284
+
274
285
  # Trap: ensure quality report generated on ANY exit (pass or fail).
275
286
  # Using a guard prevents recursion when 'exit' is called from inside the trap.
276
287
  # IMPORTANT: Captures exit status FIRST so generate_quality_report cannot override it.
@@ -1267,6 +1278,14 @@ if [ -z "$PROJECT_LANGS" ] && [ -z "$_CONFIG_LANGS" ]; then
1267
1278
  if [ "$(_detect_ext_lang "ps1" "psm1")" = "1" ]; then
1268
1279
  PROJECT_LANGS="$PROJECT_LANGS powershell"
1269
1280
  fi
1281
+ # #717: without this arm a staged .cs file never produced a `csharp` entry in
1282
+ # PROJECT_LANGS, so the per-language loop at the adapter-routing site had no
1283
+ # turn in which to resolve adapters/csharp.sh -- the adapter was unreachable
1284
+ # no matter how correct it was. Detection on extensions (not manifests) is why
1285
+ # one arm is enough: a repo staging .cs always lands here.
1286
+ if [ "$(_detect_ext_lang "cs" "csx")" = "1" ]; then
1287
+ PROJECT_LANGS="$PROJECT_LANGS csharp"
1288
+ fi
1270
1289
  # IaC: .tf, .tfvars, Dockerfile, docker-compose*, .yaml/.yml in kube context
1271
1290
  if echo "$CHANGED_FILES" | grep -qE '(\.tf$|\.tfvars$|Dockerfile|docker-compose)' 2>/dev/null; then
1272
1291
  PROJECT_LANGS="$PROJECT_LANGS iac"
@@ -2256,7 +2275,18 @@ else
2256
2275
  if [ "$LINT_EXIT" -ne 0 ]; then
2257
2276
  echo "ℹ️ Lint check had issues (may be non-blocking)"
2258
2277
  fi
2259
-
2278
+
2279
+ # #717: rc=2 is the adapters' "could not execute" channel (no toolchain,
2280
+ # or a tool that refused to start), distinct from rc=1 "ran and found
2281
+ # problems". Collapsing both into one SKIPPED line -- as this branch did
2282
+ # -- makes a permanently-broken analyzer indistinguishable from a clean
2283
+ # one, which is the silent-failure shape #717 is about. The gate still
2284
+ # does not block (tool-missing must not block), but the reason is now
2285
+ # named in the log instead of implied.
2286
+ if [ "$ANALYSIS_EXIT" -eq 2 ] || [ "$LINT_EXIT" -eq 2 ]; then
2287
+ echo "⏭️ SKIPPED - Code quality (${CURRENT_LANG} toolchain could not execute — NOT a pass)"
2288
+ fi
2289
+
2260
2290
  if [ "$ANALYSIS_EXIT" -eq 0 ] && [ "$LINT_EXIT" -eq 0 ]; then
2261
2291
  echo "✅ PASSED - Language-specific code quality checks."
2262
2292
  else
@@ -4307,8 +4337,72 @@ JAVAARCHROOTS
4307
4337
  fi
4308
4338
  fi
4309
4339
  ;;
4340
+ "csharp")
4341
+ # #717: C# had no arm at all, so it fell through to the `*)` default
4342
+ # below and was reported as "not configured for csharp" -- a language
4343
+ # the gate silently could not measure, with no way to configure it
4344
+ # either, because the config file was never looked for.
4345
+ #
4346
+ # Architecture for .NET is normally expressed as *tests* (NetArchTest,
4347
+ # ArchUnitNET, or a plain xUnit project asserting layering). There is no
4348
+ # universal CLI, so this arm keys off a config the project opts into,
4349
+ # exactly like the Go arm's arch-go.yaml.
4350
+ if [ -f "arch-csharp.yaml" ] || [ -f ".xp-gate/arch-csharp.yaml" ]; then
4351
+ _cs_arch_cfg="arch-csharp.yaml"
4352
+ [ -f "$_cs_arch_cfg" ] || _cs_arch_cfg=".xp-gate/arch-csharp.yaml"
4353
+ # Find an architecture test project: a .csproj whose name or contents
4354
+ # mention architecture tests. Guessing one fixed path is the #602
4355
+ # defect, which reported "not configured" for repos that had them.
4356
+ _cs_arch_proj=""
4357
+ while IFS= read -r _p; do
4358
+ [ -n "$_p" ] || continue
4359
+ if grep -qiE "architecture|archunit|netarchtest" "$_p" 2>/dev/null; then
4360
+ _cs_arch_proj="$_p"
4361
+ break
4362
+ fi
4363
+ done <<CSARCHPROJ
4364
+ $(git ls-files '*.csproj' 2>/dev/null)
4365
+ CSARCHPROJ
4366
+
4367
+ if [ -z "$_cs_arch_proj" ]; then
4368
+ echo " ℹ️ No architecture test project found - skipping C# architecture"
4369
+ echo " ⏭️ SKIPPED - C# architecture validation (no arch test project)"
4370
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
4371
+ GATE_6_REASON_BY_LANG["$CURRENT_LANG"]="architecture_not_configured"
4372
+ fi
4373
+ elif ! command -v dotnet >/dev/null 2>&1; then
4374
+ echo " ⏭️ SKIPPED - C# architecture validation (dotnet SDK not installed)"
4375
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
4376
+ GATE_6_REASON_BY_LANG["$CURRENT_LANG"]="tool_missing"
4377
+ fi
4378
+ else
4379
+ echo " Running C# architecture tests ($_cs_arch_proj)..."
4380
+ # Same reading as the Go/Java arms (#630/#602): the checker's own
4381
+ # status decides the verdict. Piping into `tail` and printing
4382
+ # "completed" unconditionally is the fail-open shape those issues
4383
+ # removed; PIPESTATUS[0] is what keeps this arm from repeating it.
4384
+ dotnet test "$_cs_arch_proj" --nologo 2>&1 | tail -20
4385
+ _cs_arch_rc="${PIPESTATUS[0]}"
4386
+ if [ "$_cs_arch_rc" -ne 0 ]; then
4387
+ echo ""
4388
+ echo "❌ BLOCKED - C# architecture violations ($_cs_arch_proj, exit $_cs_arch_rc)"
4389
+ echo "Fix the layering violation(s) above before committing."
4390
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
4391
+ GATE_6_REASON_BY_LANG["$CURRENT_LANG"]="architecture_tests_failed"
4392
+ fi
4393
+ exit 1
4394
+ fi
4395
+ echo " ✅ C# architecture validation completed."
4396
+ fi
4397
+ else
4398
+ echo " ℹ️ No arch-csharp.yaml found - skipping C# architecture"
4399
+ echo " ⏭️ SKIPPED - C# architecture validation (no config)"
4400
+ if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
4401
+ GATE_6_REASON_BY_LANG["$CURRENT_LANG"]="architecture_not_configured"
4402
+ fi
4403
+ fi
4404
+ ;;
4310
4405
  *)
4311
- echo " ℹ️ Architecture validation not configured for $CURRENT_LANG"
4312
4406
  echo " ⏭️ SKIPPED - Architecture validation (not configured for $CURRENT_LANG)"
4313
4407
  # #537: archlint ships no detector for this language. Say so in a way
4314
4408
  # the quality report can read, instead of leaving the dimension silent.
@@ -4987,6 +5081,19 @@ if [ -n "$MIRROR_STAGED" ] && printf '%s\n' "$MIRROR_STAGED" | grep -qE '^(githo
4987
5081
  # each other, and the only way past was to edit a generated file by hand.
4988
5082
  case "$_mf" in
4989
5083
  */__tests__/*) continue ;;
5084
+ # AGENTS.md is documentation, not a hook artifact: sync-package-content.js
5085
+ # copies the hook suite, and no AGENTS.md exists under hooks/. Demanding a
5086
+ # mirror for it reported a BLOCK on every doc edit (harmless only because
5087
+ # GATE_FAILED was never read -- see below).
5088
+ */AGENTS.md) continue ;;
5089
+ # `githooks/adapters/` mirrors to `src/npm-package/adapters/`, NOT under
5090
+ # `hooks/` -- that is what scripts/check-hook-mirror.sh declares (line 6)
5091
+ # and what sync-package-content.js writes. The blanket `githooks/*` rule
5092
+ # below sent it to `src/npm-package/hooks/adapters/<f>`, a path that has
5093
+ # never existed, so ANY edit to an adapter was un-committable with
5094
+ # "has no mirror at". It stayed hidden because nobody had edited an
5095
+ # adapter since this gate was narrowed to diff files.
5096
+ githooks/adapters/*) _mm="src/npm-package/adapters/${_mf#githooks/adapters/}" ;;
4990
5097
  githooks/*) _mm="src/npm-package/hooks/${_mf#githooks/}" ;;
4991
5098
  plugins/*/src/*) _mm=$(printf '%s' "$_mf" | sed 's|^plugins/\([^/]*\)/src/|src/npm-package/plugins/\1/src/|') ;;
4992
5099
  *) continue ;;
@@ -5006,7 +5113,15 @@ if [ -n "$MIRROR_STAGED" ] && printf '%s\n' "$MIRROR_STAGED" | grep -qE '^(githo
5006
5113
  echo "❌ BLOCKED - Gate 12b: $MIRROR_FAIL mirror parity violation(s)."
5007
5114
  echo " The mirror is what gets published; a mirror-only fix is silently"
5008
5115
  echo " reverted by the next prepack (v0.27.3 shipped that bug)."
5009
- GATE_FAILED=1
5116
+ # BLOCKS, and must actually block. This used to set GATE_FAILED=1, a
5117
+ # variable that appears exactly once in this file -- the assignment --
5118
+ # and is read nowhere, so the gate printed BLOCKED on every doc edit and
5119
+ # let the commit through. A gate that reports a verdict nobody consumes is
5120
+ # the same failure as a filter that accepts a file no adapter claims:
5121
+ # it looks like enforcement. exit is the contract every other blocking
5122
+ # gate here uses (see Gate 13: `exit "$_g13_worst"`).
5123
+ GATE_12_STATUS=WARN
5124
+ exit 1
5010
5125
  else
5011
5126
  echo "✅ PASSED - Gate 12b: mirror parity (touched files)"
5012
5127
  fi
@@ -5061,6 +5176,10 @@ elif [ -f "$GATE_DIR/gate-13-compiler-strictness.sh" ] || [ -f "$(git rev-parse
5061
5176
 
5062
5177
  gate13_run_cpp_analysis; _g13_rc=$?; _g13_note "$_g13_rc"
5063
5178
  gate13_run_java_analysis; _g13_rc=$?; _g13_note "$_g13_rc"
5179
+ # #717: C# joins the same reduce-the-worst loop. Without this call the
5180
+ # function would exist but never execute -- the "silently skipped" shape
5181
+ # #717 is about, one level up.
5182
+ gate13_run_csharp_analysis; _g13_rc=$?; _g13_note "$_g13_rc"
5064
5183
  gate13_run_analyzer; _g13_rc=$?; _g13_note "$_g13_rc"
5065
5184
 
5066
5185
  exit "$_g13_worst"
@@ -58,27 +58,41 @@ function journalText(events) {
58
58
  function toolPair(callId, name, args, startT, endT, result = {}, pos = {}) {
59
59
  const turn = pos.turn ?? 1;
60
60
  const step = pos.step ?? 1;
61
- return [
62
- { type: 'tool/call', time: startT, data: { turn, step, callId, name, arguments: JSON.stringify(args) } },
63
- {
64
- type: 'tool/result',
65
- time: endT,
66
- data: {
67
- turn,
68
- step,
69
- message: {
70
- role: 'tool',
71
- toolCallId: callId,
72
- isError: !!result.isError,
73
- content: [{ type: 'text', text: result.text ?? 'ok' }],
74
- },
75
- },
76
- },
77
- ];
61
+ const call = {
62
+ type: 'tool/call',
63
+ time: startT,
64
+ data: { turn, step, callId, name, arguments: JSON.stringify(args) },
65
+ };
66
+ // Built separately so the pair stays flat: inlining it put the event five
67
+ // objects deep, which trips clean-code.deep-nesting (max 4) on every run.
68
+ const reply = {
69
+ type: 'tool/result',
70
+ time: endT,
71
+ data: { turn, step, message: toolResultMessage(callId, result) },
72
+ };
73
+ return [call, reply];
74
+ }
75
+
76
+ /** The `message` payload of a tool/result event. */
77
+ function toolResultMessage(callId, result) {
78
+ return {
79
+ role: 'tool',
80
+ toolCallId: callId,
81
+ isError: !!result.isError,
82
+ content: [{ type: 'text', text: result.text ?? 'ok' }],
83
+ };
78
84
  }
79
85
 
80
86
  describe('parseSessionBuffer', () => {
81
- it('reads a zstd-compressed session journal', () => {
87
+ // `zlib.zstdCompressSync` / `zstdDecompressSync` landed in Node 22. CI pins
88
+ // Node 20.x, so without this guard the test throws "zstdCompressSync is not a
89
+ // function" there and turns Gate 5 red on a machine difference rather than a
90
+ // code defect. Locally (Node 24) it passed, which is exactly why the gap
91
+ // survived. Feature-detected rather than version-sniffed, so a backport or a
92
+ // future rename cannot silently disable the coverage.
93
+ const hasZstd = typeof zlib.zstdCompressSync === 'function' && typeof zlib.zstdDecompressSync === 'function';
94
+
95
+ it.skipIf(!hasZstd)('reads a zstd-compressed session journal', () => {
82
96
  const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
83
97
  const buf = zlib.zstdCompressSync(Buffer.from(journalText(events), 'utf8'));
84
98
  const parsed = parseSessionBuffer(buf);
@@ -86,6 +100,16 @@ describe('parseSessionBuffer', () => {
86
100
  expect(parsed[0].type).toBe('tool/call');
87
101
  });
88
102
 
103
+ it('skips a zstd-magic buffer it cannot decode, rather than throwing', () => {
104
+ // The counterpart to the test above: on a runtime without zstd the magic
105
+ // number still gets recognised, `zstdDecompressSync` is missing, and the
106
+ // catch must degrade to "no events" instead of crashing the whole journal.
107
+ // This runs on every Node version, so the degraded path is never untested.
108
+ const magic = Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x00, 0x00, 0x00, 0x00]);
109
+ expect(() => parseSessionBuffer(magic)).not.toThrow();
110
+ expect(parseSessionBuffer(magic)).toEqual([]);
111
+ });
112
+
89
113
  it('reads an uncompressed journal too, so fixtures need no encoder', () => {
90
114
  const events = toolPair('c1', 'read', { file_path: '/a.ts' }, 1000, 1010);
91
115
  const parsed = parseSessionBuffer(Buffer.from(journalText(events), 'utf8'));
@@ -43,6 +43,32 @@ function runCheck(relPath) {
43
43
  return { stdout, status, elapsed: Date.now() - started };
44
44
  }
45
45
 
46
+ /**
47
+ * 量一次「同一 CLI 做最少的事」要多久——只解析参数并打印版本,不跑任何 gate。
48
+ * 用作 AC-653-05 的相对基线:Node 启动 + bin 模块加载 + 依赖解析的固有成本,
49
+ * 这部分与机器负载同步伸缩,减掉它才能量出 check 自己的开销。
50
+ *
51
+ * 取三次的最小值:最小值受负载尖峰影响最小,比平均值稳定得多。
52
+ */
53
+ function measure(args) {
54
+ let best = Infinity;
55
+ for (let i = 0; i < 3; i += 1) {
56
+ const started = Date.now();
57
+ try {
58
+ execFileSync(process.execPath, [CLI, ...args], {
59
+ cwd: REPO,
60
+ encoding: 'utf8',
61
+ timeout: 120000,
62
+ stdio: 'pipe',
63
+ });
64
+ } catch {
65
+ // --version 不该失败;真失败时这一次不计入,别把错误吞成 0ms。
66
+ }
67
+ best = Math.min(best, Date.now() - started);
68
+ }
69
+ return best;
70
+ }
71
+
46
72
  describe('check --gates 单文件自检 (#653)', () => {
47
73
  it('AC-653-04: 传文件路径时 gate 真的执行,而不是报 spawn 失败', () => {
48
74
  const target = 'src/npm-package/lib/doctor-brief.js';
@@ -54,10 +80,35 @@ describe('check --gates 单文件自检 (#653)', () => {
54
80
  expect(stdout).toMatch(/principles/i);
55
81
  });
56
82
 
57
- it('AC-653-05: 单文件自检在 2 秒内给出结论', () => {
83
+ it('AC-653-05: 单文件自检的增量开销远小于一次完整 pre-commit', () => {
84
+ // #653 的价值主张:提交前自检要快过等一轮完整 pre-commit(几分钟)。
85
+ // "快路径"如果慢就不叫快路径。
86
+ //
87
+ // 但这里不能用绝对墙钟阈值。原实现写 `elapsed < 2000`,实测同一台机器:
88
+ // 裸跑 `node bin/xp-gate.js check ...` 654–822ms(远快于阈值)
89
+ // 在 vitest 里跑同一命令 2191–2352ms(偶尔超阈值)
90
+ // 它测的是「机器此刻有多空」,不是「这个功能够不够快」——并行跑测试时翻红、
91
+ // 空闲时变绿,红绿与功能无关(#722)。一个随负载漂移的断言,既不能证明快,
92
+ // 也不能证明慢。
93
+ //
94
+ // 改成相对判定:先用 `--version` 量出「同一 CLI、同一 Node 启动、同一份模块
95
+ // 加载」的固有成本,再看 check 是它的几倍。两者对机器快慢同步伸缩,比值因此
96
+ // 稳定。实测:
97
+ // 空闲 check 852ms / base 172ms = 4.95x
98
+ // 重负载 check 2563ms / base 445ms = 5.76x (4 个 CPU 密集进程并行)
99
+ // 比值只涨 16%,而绝对时间涨 3 倍——所以比值才是与负载无关的那个量。
100
+ //
101
+ // 门槛取 12x:它意味着 check 花的时间不超过「启动 CLI 本身」的一打倍——
102
+ // 一次完整 pre-commit 是分钟级,12x 启动成本仍是亚秒到一秒级,离「慢」很远;
103
+ // 同时留足余量,不会因为机器一时抖动就翻红。
104
+ const baseline = measure(['--version']);
58
105
  const { elapsed } = runCheck('src/npm-package/lib/doctor-brief.js');
59
- // #653's whole value proposition: self-check before committing beats waiting
60
- // for a 3-minute pre-commit. A slow "fast path" is not a fast path.
61
- expect(elapsed).toBeLessThan(2000);
106
+ const ratio = elapsed / baseline;
107
+
108
+ expect(ratio).toBeLessThan(12);
109
+ // 翻红时把三个数字一起打出来,能立刻分辨是「基线被拖慢」还是「check 真变慢」。
110
+ expect(
111
+ `${elapsed}ms / ${baseline}ms = ${ratio.toFixed(1)}x`,
112
+ ).toMatch(/^\d+ms \/ \d+ms = [\d.]+x$/);
62
113
  });
63
114
  });
@@ -104,3 +104,46 @@ describe('PLATFORM_PROFILES covers dsh (#635)', () => {
104
104
  expect(getSkillsDirs('claude-code')[0]).toBe(path.join(tmpHome, '.claude', 'skills'));
105
105
  });
106
106
  });
107
+
108
+ /**
109
+ * @test REQ-725
110
+ * @intent 多平台共存时 detectPlatform() 按优先级短路返回单个平台,install-skill/
111
+ * update-skill 与 doctor 都只拿它选中的目录,其余平台(含 detectPlatform()
112
+ * 从不返回的 dsh)被永久冻结。detectInstalledPlatforms() 是「检测所有已安装
113
+ * agent」的原始能力:按 PLATFORM_PROFILES 里目录真实存在的平台返回全集。
114
+ * @covers AC-725-01, AC-725-06
115
+ */
116
+ describe('detectInstalledPlatforms (#725 detect-all)', () => {
117
+ let tmpHome;
118
+ let originalHome;
119
+
120
+ beforeEach(() => {
121
+ originalHome = process.env.HOME;
122
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-detectall-'));
123
+ process.env.HOME = tmpHome;
124
+ vi.resetModules();
125
+ delete require.cache[require.resolve('../detect-deps')];
126
+ delete require.cache[require.resolve('../shared-paths')];
127
+ });
128
+
129
+ afterEach(() => {
130
+ process.env.HOME = originalHome;
131
+ fs.rmSync(tmpHome, { recursive: true, force: true });
132
+ });
133
+
134
+ it('returns every platform whose directory exists, including dsh', () => {
135
+ fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
136
+ fs.mkdirSync(path.join(tmpHome, '.config', 'opencode'), { recursive: true });
137
+ fs.mkdirSync(path.join(tmpHome, '.dsh'), { recursive: true });
138
+
139
+ const { detectInstalledPlatforms } = require('../detect-deps');
140
+ const installed = detectInstalledPlatforms();
141
+ expect(installed).toEqual(expect.arrayContaining(['opencode', 'qoder', 'dsh']));
142
+ expect(installed).not.toContain('claude-code');
143
+ });
144
+
145
+ it('returns an empty list when no platform directory exists', () => {
146
+ const { detectInstalledPlatforms } = require('../detect-deps');
147
+ expect(detectInstalledPlatforms()).toEqual([]);
148
+ });
149
+ });