@boyingliu01/xp-gate 0.35.17 → 0.35.19

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 (78) hide show
  1. package/adapters/gate-3.sh +94 -9
  2. package/adapters/gate-4.sh +27 -32
  3. package/adapters/gate-8.sh +32 -3
  4. package/adapters/gate-9.sh +14 -2
  5. package/gate-12-file-hygiene.sh +26 -4
  6. package/gate-13-compiler-strictness.sh +12 -0
  7. package/gates/gate-3.ts +77 -21
  8. package/gates/gate-8.ts +39 -26
  9. package/gates/gate-9.ts +4 -4
  10. package/hooks/gate-12-file-hygiene.sh +26 -4
  11. package/hooks/gate-13-compiler-strictness.sh +12 -0
  12. package/hooks/pre-commit +75 -11
  13. package/hooks/pre-push +11 -2
  14. package/lib/__tests__/doctor-delphi.test.js +114 -0
  15. package/lib/__tests__/doctor-tui-skill-freshness.test.js +74 -0
  16. package/lib/__tests__/doctor.test.js +9 -0
  17. package/lib/__tests__/gate-numbering.test.js +52 -0
  18. package/lib/__tests__/gate-runner.test.js +59 -14
  19. package/lib/__tests__/shared-utils.test.js +47 -0
  20. package/lib/__tests__/update-skill.test.js +77 -0
  21. package/lib/doctor-tui.js +23 -2
  22. package/lib/doctor.js +28 -7
  23. package/lib/gate-runner.js +98 -18
  24. package/lib/mattpocock-license-verdict.json +1 -1
  25. package/lib/shared-utils.js +27 -1
  26. package/lib/update-skill.js +59 -3
  27. package/mock-policy/AGENTS.md +3 -3
  28. package/mutation/AGENTS.md +3 -3
  29. package/package.json +1 -1
  30. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  31. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  32. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/claude-code/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  34. package/plugins/claude-code/skills/sprint-flow/references/definition-of-done.md +47 -0
  35. package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +16 -0
  36. package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +14 -0
  37. package/plugins/claude-code/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  38. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  39. package/plugins/dsh/package.json +1 -1
  40. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  41. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/dsh/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  43. package/plugins/dsh/skills/sprint-flow/references/definition-of-done.md +47 -0
  44. package/plugins/dsh/skills/sprint-flow/references/phase-4-verify.md +16 -0
  45. package/plugins/dsh/skills/sprint-flow/references/phase-5-ship.md +14 -0
  46. package/plugins/dsh/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  47. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  48. package/plugins/dsh/src/delphi-findings.test.ts +32 -6
  49. package/plugins/dsh/src/exit-code.test.ts +6 -0
  50. package/plugins/opencode/package.json +1 -1
  51. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  52. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  53. package/plugins/opencode/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  54. package/plugins/opencode/skills/sprint-flow/references/definition-of-done.md +47 -0
  55. package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +16 -0
  56. package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +14 -0
  57. package/plugins/opencode/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  58. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  59. package/plugins/qoder/plugin.json +1 -1
  60. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  61. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  62. package/plugins/qoder/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  63. package/plugins/qoder/skills/sprint-flow/references/definition-of-done.md +47 -0
  64. package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +16 -0
  65. package/plugins/qoder/skills/sprint-flow/references/phase-5-ship.md +14 -0
  66. package/plugins/qoder/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  67. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  68. package/principles/AGENTS.md +3 -3
  69. package/scripts/__tests__/sync-package-content.test.js +114 -46
  70. package/scripts/sync-package-content.js +32 -1
  71. package/skills/delphi-review/AGENTS.md +3 -3
  72. package/skills/sprint-flow/AGENTS.md +3 -3
  73. package/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  74. package/skills/sprint-flow/references/definition-of-done.md +47 -0
  75. package/skills/sprint-flow/references/phase-4-verify.md +16 -0
  76. package/skills/sprint-flow/references/phase-5-ship.md +14 -0
  77. package/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  78. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -36,13 +36,35 @@ fi
36
36
  HYGIENE_ISSUES=0
37
37
  HYGIENE_FILES=""
38
38
 
39
+ # Is this staged file binary (so line-based checks do not apply)?
40
+ #
41
+ # This replaces `grep -q $'\x00'`, which was a no-op guard: bash strips NUL from
42
+ # `$'\x00'`, so the pattern was the EMPTY string and `grep -q ""` matches any
43
+ # non-empty input. Every staged file was therefore judged binary and `continue`d,
44
+ # silently disabling Check 1 (trailing whitespace), Check 2 (missing EOF newline)
45
+ # and -- worst -- Check 3 (merge conflict markers, a BLOCK-level check). Checks 4
46
+ # and 5 lacked the guard, which is why the gate looked half-working.
47
+ #
48
+ # Ask git instead of guessing: `--numstat` reports a binary entry as `-\t-\t<path>`
49
+ # because git already made that determination. A byte-level NUL test cannot be
50
+ # written with a shell pattern at all (variables and command substitution both drop
51
+ # NUL), and `grep -I` only suppresses output on a binary input -- it does not make
52
+ # `grep -q` fail, which is why that shortcut still reported text.
53
+ is_binary_staged_file() {
54
+ local _file="$1"
55
+ case "$(git diff --cached --numstat -- "$_file" 2>/dev/null)" in
56
+ -[[:space:]]-*) return 0 ;; # binary: git could not count lines
57
+ *) return 1 ;; # text (or empty -- nothing for the line checks)
58
+ esac
59
+ }
60
+
39
61
  # --- Check 1: Trailing whitespace ---
40
62
  TRAILING_WS_FILES=""
41
63
  if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
42
64
  while IFS= read -r file; do
43
65
  [ -z "$file" ] && continue
44
- # Skip binary files by checking if file contains null bytes
45
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
66
+ # Skip binary files : they have no lines to check
67
+ if is_binary_staged_file "$file"; then
46
68
  continue
47
69
  fi
48
70
  # Check staged content for trailing whitespace (spaces/tabs at end of lines)
@@ -69,7 +91,7 @@ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
69
91
  while IFS= read -r file; do
70
92
  [ -z "$file" ] && continue
71
93
  # Skip binary files
72
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
94
+ if is_binary_staged_file "$file"; then
73
95
  continue
74
96
  fi
75
97
  # Check if file content ends with newline
@@ -108,7 +130,7 @@ else
108
130
  while IFS= read -r file; do
109
131
  [ -z "$file" ] && continue
110
132
  # Skip binary files
111
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
133
+ if is_binary_staged_file "$file"; then
112
134
  continue
113
135
  fi
114
136
  # Check for conflict markers in staged content
@@ -527,6 +527,18 @@ gate13_count_lint_findings() {
527
527
  [ -n "$src" ] || src=$(cat)
528
528
  printf '%s\n' "$src" | awk '
529
529
  BEGIN { n = 0 }
530
+ # (#673) Maven 3.9.7+ reactor summary appends the packaging type per module:
531
+ # [INFO] whaleaudit-core [jar]
532
+ # `[pom]`/`[jar]` match the open-category rule below, so a 3-module reactor
533
+ # reported 4 javac findings on code with ZERO warnings -- and got worse with
534
+ # every module added. The marker set is closed (packaging types are an enum in
535
+ # Maven itself), so excluding it does not reopen the #659 fail-open hole that
536
+ # the open-category rule was introduced to close.
537
+ #
538
+ # Scoped to `[INFO]` lines: a javac diagnostic is never prefixed with the
539
+ # Maven [INFO] banner, so this cannot mask a real finding even when a category
540
+ # name collides with a packaging type.
541
+ /^\[INFO\].*\[(pom|jar|war|ear|rar|ejb|maven-plugin|par|test-jar|java-source|javadoc|zip|tar\.gz|tar\.bz2)\][[:space:]]*$/ { next }
530
542
  # Any bracketed lowercase category: covers the whole javac -Xlint enum and any
531
543
  # later addition to it. Restricted to lowercase/digit/hyphen so a Maven
532
544
  # `[WARNING]` with no category, or a `[INFO]` banner, is not counted.
package/hooks/pre-commit CHANGED
@@ -319,6 +319,32 @@ if ! declare -F run_tsx >/dev/null 2>&1; then
319
319
  }
320
320
  fi
321
321
 
322
+ # Run a repo-local Node CLI (eslint, vitest, biome, ...) without npx when the
323
+ # binary is already in this repo's node_modules.
324
+ #
325
+ # `npx <tool>` costs ~1.4-1.5 s more than the tool itself on this machine
326
+ # (measured: eslint --version 1654 ms via npx vs 348 ms direct; vitest 1799 ms vs
327
+ # 250 ms). That is npm resolving the package before it ever runs: on a cold
328
+ # cache it may reach the network, which a quality gate must never depend on.
329
+ # pre-commit invokes these tools a handful of times per commit, so the wrapper
330
+ # was paying several seconds to start processes that were already installed.
331
+ #
332
+ # `run_node_bin <tool> <node-entry> [args...]`. Falls back to npx when the local
333
+ # entry is absent (a globally-installed toolchain is still a valid setup).
334
+ if ! declare -F run_node_bin >/dev/null 2>&1; then
335
+ run_node_bin() {
336
+ local _tool="$1" _entry="$2"
337
+ shift 2
338
+ local _root="${PROJECT_ROOT:-$(pwd)}"
339
+ local _local="$_root/node_modules/$_entry"
340
+ if [ -f "$_local" ] && command -v node >/dev/null 2>&1; then
341
+ node "$_local" "$@"
342
+ else
343
+ npx "$_tool" "$@"
344
+ fi
345
+ }
346
+ fi
347
+
322
348
  # Gate 5's vitest exit-code judgement (is_runner_infrastructure_error,
323
349
  # is_partial_threshold_only_exit, handle_test_failure) is sourced from
324
350
  # lib/test-failure.sh below, next to the other hook libraries.
@@ -1677,9 +1703,9 @@ else
1677
1703
  # each linted file (found in the subdirectory automatically); flat
1678
1704
  # configs are discovered from the CWD, so point at them explicitly.
1679
1705
  if [ "$TS_PROJECT_DIR" != "." ] && [ -f "$TS_PROJECT_DIR/eslint.config.js" ]; then
1680
- ESLINT_OUTPUT=$(npx eslint --config "$TS_PROJECT_DIR/eslint.config.js" $ESLINT_FILES -f json --no-warn-ignored 2>&1)
1706
+ ESLINT_OUTPUT=$(run_node_bin eslint eslint/bin/eslint.js --config "$TS_PROJECT_DIR/eslint.config.js" $ESLINT_FILES -f json --no-warn-ignored 2>&1)
1681
1707
  else
1682
- ESLINT_OUTPUT=$(npx eslint $ESLINT_FILES -f json --no-warn-ignored 2>&1)
1708
+ ESLINT_OUTPUT=$(run_node_bin eslint eslint/bin/eslint.js $ESLINT_FILES -f json --no-warn-ignored 2>&1)
1683
1709
  fi
1684
1710
  ESLINT_EXIT=$?
1685
1711
  if [ "$ESLINT_EXIT" -ne 0 ]; then
@@ -3008,7 +3034,7 @@ else
3008
3034
  if source "$ADAPTER_FILE" 2>/dev/null; then
3009
3035
  # TypeScript: vitest with smart test selection
3010
3036
  if [ "$CURRENT_LANG" = "typescript" ] && { [ -f "package.json" ] || [ -f "$PROJECT_ROOT/package.json" ]; }; then
3011
- if run_without_git_context npx vitest --version >/dev/null 2>&1; then
3037
+ if run_without_git_context run_node_bin vitest vitest/vitest.mjs --version >/dev/null 2>&1; then
3012
3038
  # Count changed test files to decide strategy. `grep -c '.'` prints its
3013
3039
  # own zero AND exits 1 on an empty list, so `|| echo "0"` used to yield
3014
3040
  # $'0\n0' -- the same two-value bug that false-blocked Gate 8 (#493).
@@ -3055,7 +3081,7 @@ else
3055
3081
  if [ "$CHANGED_TEST_FILE_COUNT" -gt 20 ] 2>/dev/null; then
3056
3082
  # Too many test files changed → full run
3057
3083
  echo "Too many test files changed ($CHANGED_TEST_FILE_COUNT), running full test suite..."
3058
- TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage 2>&1)
3084
+ TESTS_OUTPUT=$(run_without_git_context run_node_bin vitest vitest/vitest.mjs run --coverage 2>&1)
3059
3085
  TESTS_EXIT_CODE=$?
3060
3086
  echo "$TESTS_OUTPUT" | head -60
3061
3087
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
@@ -3069,7 +3095,7 @@ else
3069
3095
  SMART_TARGETS=$(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')
3070
3096
  echo "Running ${CHANGED_TEST_FILE_COUNT} changed test file(s) with coverage..."
3071
3097
  echo " Target(s): $(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')"
3072
- TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage $SMART_TARGETS 2>&1)
3098
+ TESTS_OUTPUT=$(run_without_git_context run_node_bin vitest vitest/vitest.mjs run --coverage $SMART_TARGETS 2>&1)
3073
3099
  TESTS_EXIT_CODE=$?
3074
3100
  echo "$TESTS_OUTPUT" | head -60
3075
3101
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
@@ -3114,7 +3140,7 @@ else
3114
3140
  if [ -n "$RELATED_TESTS" ]; then
3115
3141
  echo " Found related test(s): $(echo "$RELATED_TESTS" | tr '\n' ' ')"
3116
3142
  PARTIAL_TEST_RUN=true # Mark as partial run for coverage check
3117
- TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage $RELATED_TESTS 2>&1)
3143
+ TESTS_OUTPUT=$(run_without_git_context run_node_bin vitest vitest/vitest.mjs run --coverage $RELATED_TESTS 2>&1)
3118
3144
  TESTS_EXIT_CODE=$?
3119
3145
  echo "$TESTS_OUTPUT" | head -60
3120
3146
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
@@ -3125,7 +3151,7 @@ else
3125
3151
  else
3126
3152
  # No related tests found → full run is safest
3127
3153
  echo " No related test files found, running full test suite..."
3128
- TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage 2>&1)
3154
+ TESTS_OUTPUT=$(run_without_git_context run_node_bin vitest vitest/vitest.mjs run --coverage 2>&1)
3129
3155
  TESTS_EXIT_CODE=$?
3130
3156
  echo "$TESTS_OUTPUT" | head -60
3131
3157
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
@@ -3982,6 +4008,8 @@ else
3982
4008
  cd "$PROJECT_ROOT" || exit 1
3983
4009
  if [ "$ARCHLINT_KIND" = "npx" ]; then
3984
4010
  npx @archlinter/cli "$@"
4011
+ elif [ "${ARCHLINT_NODE:-0}" = "1" ]; then
4012
+ node "$ARCHLINT_BIN" "$@"
3985
4013
  else
3986
4014
  "$ARCHLINT_BIN" "$@"
3987
4015
  fi
@@ -4002,8 +4030,17 @@ else
4002
4030
  for CURRENT_LANG in $PROJECT_LANGS; do
4003
4031
  case "$CURRENT_LANG" in
4004
4032
  "typescript")
4005
- # Try local npx install first (devDependencies), fall back to global
4006
- if [ -x "$PROJECT_ROOT/node_modules/.bin/archlint" ]; then
4033
+ # Prefer node on the package's own entry over the .bin shim: the shim's
4034
+ # exec bit and extension vary by platform and package manager (npm writes
4035
+ # archlint, archlint.cmd and archlint.ps1 on Windows), so `-x .bin/...`
4036
+ # is true here while the no-extension shim is what Git Bash would run.
4037
+ # `node <entry>` sidesteps all of that and also avoids npx's ~1.6 s
4038
+ # package resolution (measured: 1909 ms via npx vs 319 ms direct).
4039
+ if [ -f "$PROJECT_ROOT/node_modules/@archlinter/cli/bin/archlint.js" ] && command -v node >/dev/null 2>&1; then
4040
+ ARCHLINT_KIND="binary"
4041
+ ARCHLINT_BIN="$PROJECT_ROOT/node_modules/@archlinter/cli/bin/archlint.js"
4042
+ ARCHLINT_NODE=1
4043
+ elif [ -x "$PROJECT_ROOT/node_modules/.bin/archlint" ]; then
4007
4044
  ARCHLINT_KIND="binary"
4008
4045
  ARCHLINT_BIN="$PROJECT_ROOT/node_modules/.bin/archlint"
4009
4046
  elif command -v archlint >/dev/null 2>&1; then
@@ -4764,7 +4801,12 @@ if [ -n "$MIRROR_STAGED" ] && printf '%s\n' "$MIRROR_STAGED" | grep -qE '^(githo
4764
4801
  MIRROR_FAIL=0
4765
4802
  while IFS= read -r _mf; do
4766
4803
  [ -n "$_mf" ] || continue
4804
+ # `__tests__/` has no mirror BY DESIGN: sync-package-content.js lists it in
4805
+ # SKIP_DIRS so test suites never ship to consumers. Demanding a mirror for
4806
+ # them makes every new BATS file un-committable -- the two rules contradicted
4807
+ # each other, and the only way past was to edit a generated file by hand.
4767
4808
  case "$_mf" in
4809
+ */__tests__/*) continue ;;
4768
4810
  githooks/*) _mm="src/npm-package/hooks/${_mf#githooks/}" ;;
4769
4811
  plugins/*/src/*) _mm=$(printf '%s' "$_mf" | sed 's|^plugins/\([^/]*\)/src/|src/npm-package/plugins/\1/src/|') ;;
4770
4812
  *) continue ;;
@@ -4880,7 +4922,22 @@ generate_quality_report() {
4880
4922
  local TIMESTAMP
4881
4923
  local BRANCH
4882
4924
  local PASSED_COUNT=0
4883
- local TOTAL_GATES=12
4925
+ # #680: the gate list is the single source of truth. TOTAL_GATES, the scoring
4926
+ # loop, the console table and the JSON `overall` block all derive from it.
4927
+ #
4928
+ # Gate 13 was added in v0.35.x with only its execution and JSON write -- these
4929
+ # three sites kept saying "12". A machine with no C/C++ compiler therefore saw
4930
+ # `10.0/10 effective`, `gatesWarned: 0` and a table that stopped at Gate 12,
4931
+ # while the JSON `gates` object held 13 keys. "Compiler strictness was never
4932
+ # checked" and "compiler reported zero warnings" rendered identically, which is
4933
+ # the same silent-success pattern as #561/#570/#585.
4934
+ #
4935
+ # Adding a gate now means editing ALL_GATE_NUMBERS + ONE table row. Nothing
4936
+ # else carries a count.
4937
+ local ALL_GATE_NUMBERS="1 2 3 4 5 6 7 8 9 10 11 12 13"
4938
+ local TOTAL_GATES=0
4939
+ local _g
4940
+ for _g in $ALL_GATE_NUMBERS; do TOTAL_GATES=$((TOTAL_GATES + 1)); done
4884
4941
  local SKIP_COUNT=0
4885
4942
  local WARN_COUNT=0
4886
4943
  local NONPASS_COUNT=0
@@ -4908,7 +4965,7 @@ generate_quality_report() {
4908
4965
  local DEGRADED_JSON=""
4909
4966
  local FAIL_COUNT=0
4910
4967
  local FAILED_JSON=""
4911
- for gate in 1 2 3 4 5 6 7 8 9 10 11 12; do
4968
+ for gate in $ALL_GATE_NUMBERS; do
4912
4969
  local status_var="GATE_${gate}_STATUS"
4913
4970
  # Default mirrors the JSON report rendering below EXACTLY (gates 1-11
4914
4971
  # PASS, gate 12 WARN): a gate that never assigned a status is "not
@@ -4918,6 +4975,10 @@ generate_quality_report() {
4918
4975
  # Default direction is "did not run", never "passed": an unassigned status
4919
4976
  # is a gate that never reached a verdict, and scoring it PASS both claims a
4920
4977
  # success that did not happen and hides the gap in the effective rate.
4978
+ #
4979
+ # Gate 13 defaults to SKIP like the rest: it is only WARN when the toolchain
4980
+ # is missing and the gate explicitly says so, and Gate 12 is the one gate
4981
+ # whose "no verdict" is still a warning rather than "not applicable".
4921
4982
  if [ "$gate" -eq 12 ]; then gate_default="WARN"; else gate_default="SKIP"; fi
4922
4983
  gate_status="${!status_var:-$gate_default}"
4923
4984
  case "$gate_status" in
@@ -5264,6 +5325,9 @@ ENDJSON
5264
5325
  printf " %-45s %s\n" "Gate 10: SAST Security" "${GATE_10_STATUS:-SKIP}"
5265
5326
  printf " %-45s %s\n" "Gate 11: Sprint Flow" "${GATE_11_STATUS:-SKIP}"
5266
5327
  printf " %-45s %s\n" "Gate 12: File Hygiene" "${GATE_12_STATUS:-WARN}"
5328
+ # #680: Gate 13 ran and could block, but had no row here -- the table is what a
5329
+ # human reads, so a gate missing from it reads as a gate that does not exist.
5330
+ printf " %-45s %s\n" "Gate 13: Compiler Strictness" "${GATE_13_STATUS:-SKIP}"
5267
5331
  echo ""
5268
5332
  # #564: both views in one line. The effective score answers "of the gates that
5269
5333
  # could run, how many passed"; the all-gates score counts a skip against the
package/hooks/pre-push CHANGED
@@ -426,7 +426,16 @@ SPRINT_GATE_SCRIPT=""
426
426
  # ~/.config/xp-gate/hooks -- a correctly installed machine was told the file was
427
427
  # missing here too. Nothing is sourced for this: pre-push resolves its libraries
428
428
  # further down (ADAPTER_COMMON), and this gate must decide before that.
429
- for _sprint_gate_dir in "$PROJECT_ROOT/githooks" "$(dirname "$0")" "$HOME/.config/xp-gate/hooks"; do
429
+ #
430
+ # `$HOME/.config/xp-gate/adapters` is the tier #678 found missing: `xp-gate
431
+ # setup-global` runs copyAdapters(), which ships every `gate-*.sh` AND
432
+ # `sprint-gate.sh` into the ADAPTERS directory -- never into hooks/. So a globally
433
+ # installed machine had sprint-gate.sh present under adapters/ while this loop
434
+ # searched only hooks/, and Gate MS skipped on every push without complaint. Both
435
+ # tiers are searched now: which directory `init` chose is an install detail this
436
+ # gate should not depend on.
437
+ for _sprint_gate_dir in "$PROJECT_ROOT/githooks" "$(dirname "$0")" \
438
+ "$HOME/.config/xp-gate/hooks" "$HOME/.config/xp-gate/adapters"; do
430
439
  if [ -n "$_sprint_gate_dir" ] && [ -f "$_sprint_gate_dir/sprint-gate.sh" ]; then
431
440
  SPRINT_GATE_SCRIPT="$_sprint_gate_dir/sprint-gate.sh"
432
441
  break
@@ -440,7 +449,7 @@ if [ -n "$SPRINT_GATE_SCRIPT" ]; then
440
449
  exit 1
441
450
  fi
442
451
  else
443
- echo "⏭️ SKIPPED - Gate MS: Sprint Flow (sprint-gate.sh not found; searched: $PROJECT_ROOT/githooks, $(dirname "$0"), $HOME/.config/xp-gate/hooks)"
452
+ echo "⏭️ SKIPPED - Gate MS: Sprint Flow (sprint-gate.sh not found; searched: $PROJECT_ROOT/githooks, $(dirname "$0"), $HOME/.config/xp-gate/hooks, $HOME/.config/xp-gate/adapters)"
444
453
  GATE_S_STATUS="SKIP"
445
454
  fi
446
455
  echo ""
@@ -337,3 +337,117 @@ describe('doctor delphi (#414 REQ-3/REQ-6)', () => {
337
337
  }
338
338
  });
339
339
  });
340
+
341
+ // ── #684: the documented global-config fallback ──────────────────────────────
342
+ // SKILL.md tells users to keep ONE machine-level config at
343
+ // `~/.config/xp-gate/.delphi-config.json` and carry no repo-local copy. The
344
+ // runner resolves that fallback, so a review works; the doctor did not, and
345
+ // reported FAIL on a layout its own documentation recommends. Worse, the FAIL
346
+ // detail told the user to copy the example into the project — which manufactures
347
+ // exactly the drifted duplicate that #541 added a shadow warning for.
348
+ //
349
+ // The distinction this batch pins: "no config anywhere" is still a FAIL
350
+ // (that is a genuinely broken install); "no project config but a valid global
351
+ // one" is not.
352
+ describe('doctor delphi #684 global config fallback', () => {
353
+ let tmpDirs;
354
+ let tmpHome;
355
+ let originalHome;
356
+
357
+ beforeEach(() => {
358
+ tmpDirs = makeTempDirs(['doctor-684-home']);
359
+ tmpHome = tmpDirs['doctor-684-home'];
360
+ originalHome = process.env.HOME;
361
+ process.env.HOME = tmpHome;
362
+ fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
363
+ resetLibModules(vi, LIB_MODULES);
364
+ muteConsole(vi);
365
+ });
366
+
367
+ afterEach(() => {
368
+ if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
369
+ removeTempDirs(tmpDirs);
370
+ vi.restoreAllMocks();
371
+ });
372
+
373
+ // Local copy: the suite above keeps its own writeConfig inside its describe
374
+ // scope, so this block cannot reach it.
375
+ function writeConfig(configPath) {
376
+ fs.writeFileSync(configPath, JSON.stringify({
377
+ active_profile: 'default',
378
+ profiles: {
379
+ default: {
380
+ providers: {
381
+ gw: { base_url: 'https://gw.example/v1', api_key: 'k' },
382
+ },
383
+ experts: {
384
+ architecture: { provider: 'gw', model: 'model-a' },
385
+ technical: { provider: 'gw', model: 'model-b' },
386
+ feasibility: { provider: 'gw', model: 'model-c' },
387
+ },
388
+ },
389
+ },
390
+ }));
391
+ return configPath;
392
+ }
393
+
394
+ function writeGlobalConfig() {
395
+ const dir = path.join(tmpHome, '.config', 'xp-gate');
396
+ fs.mkdirSync(dir, { recursive: true });
397
+ return writeConfig(path.join(dir, '.delphi-config.json'));
398
+ }
399
+
400
+ function captureLogs() {
401
+ const logs = [];
402
+ const spy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
403
+ return { logs, restore: () => spy.mockRestore() };
404
+ }
405
+
406
+ it('AC-684-01: a valid global config with no project copy PASSes instead of failing', async () => {
407
+ const globalPath = writeGlobalConfig();
408
+ const project = path.join(tmpHome, 'a-project-with-no-config');
409
+ fs.mkdirSync(project, { recursive: true });
410
+ const { logs, restore } = captureLogs();
411
+ const { doctorDelphi } = require('../doctor');
412
+ await doctorDelphi({ projectDir: project, globalConfigPath: globalPath, npmLatest: null });
413
+ restore();
414
+ const cfgLine = logs.find((l) => l.includes('Delphi config'));
415
+ expect(cfgLine).toBeDefined();
416
+ expect(cfgLine).not.toMatch(/FAIL/);
417
+ expect(cfgLine).toMatch(/global/i);
418
+ });
419
+
420
+ it('AC-684-02: no config in either scope still FAILs (fallback must not mask a broken install)', async () => {
421
+ const project = path.join(tmpHome, 'bare-project');
422
+ fs.mkdirSync(project, { recursive: true });
423
+ const { logs, restore } = captureLogs();
424
+ const { doctorDelphi } = require('../doctor');
425
+ const code = await doctorDelphi({
426
+ projectDir: project,
427
+ globalConfigPath: path.join(tmpHome, 'no-such-global.json'),
428
+ npmLatest: null,
429
+ });
430
+ restore();
431
+ expect(code).toBe(1);
432
+ // The rendered line carries a ✗ marker, not the literal word FAIL.
433
+ const cfgLine = logs.find((l) => l.includes('Delphi config (.delphi-config.json)'));
434
+ expect(cfgLine).toBeDefined();
435
+ expect(cfgLine).toContain('✗');
436
+ expect(cfgLine).toMatch(/not found/);
437
+ });
438
+
439
+ it('AC-684-03: a project config still wins over the global one', async () => {
440
+ const globalPath = writeGlobalConfig();
441
+ const project = path.join(tmpHome, 'project-with-own-config');
442
+ fs.mkdirSync(project, { recursive: true });
443
+ const localPath = writeConfig(path.join(project, '.delphi-config.json'));
444
+ const { logs, restore } = captureLogs();
445
+ const { doctorDelphi } = require('../doctor');
446
+ await doctorDelphi({ projectDir: project, globalConfigPath: globalPath, npmLatest: null });
447
+ restore();
448
+ const cfgLine = logs.find((l) => l.includes('Delphi config'));
449
+ expect(cfgLine).toBeDefined();
450
+ expect(cfgLine).toContain(localPath);
451
+ expect(cfgLine).not.toMatch(/global/i);
452
+ });
453
+ });
@@ -294,3 +294,77 @@ describe('doctor-tui installed skills enumerate the filesystem (#635)', () => {
294
294
  expect(row.status).toBe('PASS');
295
295
  });
296
296
  });
297
+
298
+ // ── #681: equal version is not proof of equal content ────────────────────────
299
+ // The freshness check short-circuits on `compareSemver(...) === 0` and answers
300
+ // "Up to date" without ever reading the bodies. A skill that gained a whole
301
+ // chapter without a version bump therefore reported healthy while the installed
302
+ // copy was ~20k characters behind — and `update-skill` agreed with it.
303
+ //
304
+ // The version number is a claim the publisher makes; the content is the fact.
305
+ // When the claim says "same" the fact still has to be checked.
306
+ describe('doctor-tui skill freshness #681 equal version, different content', () => {
307
+ let tmpHome;
308
+ let originalHome;
309
+ const bundledSkillsDir = path.join(__dirname, '..', '..', 'skills');
310
+
311
+ beforeEach(() => {
312
+ originalHome = process.env.HOME;
313
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-fresh681-'));
314
+ process.env.HOME = tmpHome;
315
+ vi.resetModules();
316
+ delete require.cache[require.resolve('../doctor-tui')];
317
+ delete require.cache[require.resolve('../shared-paths')];
318
+ });
319
+
320
+ afterEach(() => {
321
+ process.env.HOME = originalHome;
322
+ fs.rmSync(tmpHome, { recursive: true, force: true });
323
+ vi.restoreAllMocks();
324
+ });
325
+
326
+ function installSkill(name, content) {
327
+ const dir = path.join(tmpHome, '.config', 'opencode', 'skills', name);
328
+ fs.mkdirSync(dir, { recursive: true });
329
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), content);
330
+ }
331
+
332
+ function runFreshness(name) {
333
+ const { diagnoseInstalledSkills } = require('../doctor-tui');
334
+ const checks = [];
335
+ const issues = diagnoseInstalledSkills({ installedSkills: { [name]: {} } }, checks);
336
+ return { issues, check: checks.find((c) => c.name === `Skill: ${name}`) };
337
+ }
338
+
339
+ it('AC-681-01: same version but a missing chapter is WARN, not "Up to date"', () => {
340
+ const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
341
+ // Keep the frontmatter (version included), drop a large slice of the body.
342
+ const idx = bundled.indexOf('\n---', 4);
343
+ const head = bundled.slice(0, idx + 4);
344
+ const body = bundled.slice(idx + 4);
345
+ const truncated = head + body.slice(0, Math.floor(body.length / 2));
346
+ installSkill('delphi-review', truncated);
347
+
348
+ const { issues, check } = runFreshness('delphi-review');
349
+ expect(check).toBeDefined();
350
+ expect(check.detail).not.toMatch(/Up to date/);
351
+ expect(check.status).toBe('WARN');
352
+ expect(issues).toBe(1);
353
+ });
354
+
355
+ it('AC-681-02: same version and same content is still "Up to date" (no false alarm)', () => {
356
+ const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
357
+ installSkill('delphi-review', bundled);
358
+ const { issues, check } = runFreshness('delphi-review');
359
+ expect(check.detail).toMatch(/Up to date/);
360
+ expect(issues).toBe(0);
361
+ });
362
+
363
+ it('AC-681-03: line endings alone still do not count as a difference', () => {
364
+ const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
365
+ installSkill('delphi-review', bundled.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
366
+ const { issues, check } = runFreshness('delphi-review');
367
+ expect(check.detail).toMatch(/Up to date/);
368
+ expect(issues).toBe(0);
369
+ });
370
+ });
@@ -13,7 +13,16 @@ const util = require('util');
13
13
  // tests run in parallel across worker threads.
14
14
  const DESCRIBE_ID = String(Math.random().toString(36).slice(2, 8));
15
15
 
16
+ // Budget for every case in this file. `doctor --fix` walks the whole install
17
+ // surface (hooks, adapters, config, version cache) and the default 5 s budget is
18
+ // a measurement of an idle machine, not of one running a full suite: under
19
+ // concurrent load the same passing case times out, which reads as a doctor
20
+ // defect. 30 s keeps real hangs bounded while leaving the CPU contention room.
21
+ const DOCTOR_TIMEOUT_MS = 30000;
22
+
16
23
  describe('doctor', () => {
24
+ // Applies to every `it` below (the per-test arg is only needed when overriding).
25
+ vi.setConfig({ testTimeout: DOCTOR_TIMEOUT_MS });
17
26
  let tmpHome;
18
27
  let tmpProject;
19
28
  let originalHome;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @test REQ-686
3
+ * @intent 验证 CLI 的门禁编号与钩子一致——钩子把 9 号门叫 Build integrity、
4
+ * 10 号门叫 SAST,而 CLI 注册表把两者对调了;用户按报错提示单跑对应门
5
+ * 会跑到另一道门上去(#677)
6
+ * @covers AC-686-01, AC-686-02, AC-686-03
7
+ *
8
+ * 为什么必须直接断言注册表:这个错误的表现是「跑起来了、报告绿了」。
9
+ * `xp-gate check --gates 9` 在编号错位时不会报错,它跑的是 semgrep 并如实报告
10
+ * semgrep 的结果——只有拿它和钩子里的门名对照才能看出跑错了门。
11
+ */
12
+ 'use strict';
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { GATE_REGISTRY, resolveAlias } = require('../gate-runner');
17
+
18
+ const REPO_ROOT = path.join(__dirname, '../../../..');
19
+
20
+ describe('gate numbering matches the hook (#677)', () => {
21
+ it('AC-686-01: gate 9 is Build Integrity and gate 10 is SAST Security', () => {
22
+ expect(GATE_REGISTRY['9'].name).toBe('Build Integrity');
23
+ expect(GATE_REGISTRY['10'].name).toBe('SAST Security');
24
+ });
25
+
26
+ it('AC-686-02: the hook agrees — its own output names 9 as Build integrity', () => {
27
+ // Reads the hook rather than restating the constant: if the hook ever
28
+ // renumbers again, this fails and points at which side moved.
29
+ const hook = fs.readFileSync(path.join(REPO_ROOT, 'githooks/pre-commit'), 'utf8');
30
+ expect(hook).toMatch(/→ Gate 9: Build integrity/);
31
+ expect(hook).toMatch(/GATE 10: Semgrep SAST/);
32
+ });
33
+
34
+ it('AC-686-03: aliases resolve to the gate the user meant', () => {
35
+ expect(resolveAlias('sast')).toBe('10');
36
+ expect(resolveAlias('semgrep')).toBe('10');
37
+ expect(resolveAlias('build')).toBe('9');
38
+ });
39
+
40
+ it('AC-686-03: gates 12 and 13 are registered instead of "Unknown gate(s)"', () => {
41
+ // Both block commits in the hook. Answering "Unknown gate" for a gate that just
42
+ // blocked the user is the same category of wrong as running the wrong one.
43
+ expect(GATE_REGISTRY['12'].name).toBe('File Hygiene');
44
+ expect(GATE_REGISTRY['13'].name).toBe('Compiler Strictness');
45
+ // They are registered as hook-only, with a stated reason -- not as runnable
46
+ // stubs that would answer PASS without doing the work.
47
+ expect(GATE_REGISTRY['12'].preCommitOnly).toBe(true);
48
+ expect(GATE_REGISTRY['13'].preCommitOnly).toBe(true);
49
+ expect(GATE_REGISTRY['12'].reason).toBeTruthy();
50
+ expect(GATE_REGISTRY['13'].reason).toBeTruthy();
51
+ });
52
+ });