@boyingliu01/xp-gate 0.19.3 → 0.20.1

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 (177) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +115 -29
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/powershell.sh +60 -5
  5. package/adapters/python.sh +38 -4
  6. package/adapters/typescript.sh +15 -60
  7. package/bin/xp-gate.js +15 -16
  8. package/build-integrity/__tests__/import-resolver.test.ts +59 -0
  9. package/build-integrity/gate-10.ts +17 -2
  10. package/gate-10.sh +35 -2
  11. package/gate-4.sh +110 -21
  12. package/gate-8.sh +115 -29
  13. package/hooks/gate-10.sh +35 -2
  14. package/hooks/gate-4.sh +110 -21
  15. package/hooks/gate-8.sh +115 -29
  16. package/hooks/lib/jscpd-run.sh +63 -0
  17. package/hooks/lib/sprint-gate-report.sh +58 -0
  18. package/hooks/lib/test-failure.sh +152 -0
  19. package/hooks/lib/typecheck.sh +141 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  21. package/hooks/post-merge +100 -0
  22. package/hooks/pre-commit +514 -184
  23. package/hooks/pre-push +75 -19
  24. package/lib/__tests__/bootstrap.test.js +23 -41
  25. package/lib/__tests__/check-version.test.js +8 -1
  26. package/lib/__tests__/detect-deps.test.js +16 -0
  27. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  28. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  29. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  30. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  31. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  32. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  33. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  34. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  35. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  36. package/lib/__tests__/doctor.test.js +155 -0
  37. package/lib/__tests__/gate-runner.test.js +19 -3
  38. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  39. package/lib/__tests__/init.test.js +44 -17
  40. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  41. package/lib/__tests__/install-skill.test.js +51 -132
  42. package/lib/__tests__/language-tools-detect.test.js +158 -0
  43. package/lib/__tests__/phase-transition.test.js +15 -1
  44. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  45. package/lib/__tests__/shared-paths.test.js +20 -0
  46. package/lib/__tests__/shared-utils.test.js +69 -0
  47. package/lib/__tests__/skill-flags.test.js +48 -0
  48. package/lib/__tests__/ui-detector.test.ts +6 -2
  49. package/lib/__tests__/uninstall.test.js +20 -0
  50. package/lib/__tests__/update-hooks.test.js +12 -0
  51. package/lib/__tests__/update-skill.test.js +7 -4
  52. package/lib/detect-deps.js +3 -20
  53. package/lib/doctor-tui.js +22 -5
  54. package/lib/doctor.js +1178 -31
  55. package/lib/init.js +184 -33
  56. package/lib/install-cmd.js +7 -4
  57. package/lib/install-skill.js +38 -95
  58. package/lib/language-tools.js +83 -4
  59. package/lib/mattpocock-license-verdict.json +1 -1
  60. package/lib/shared-paths.js +5 -2
  61. package/lib/shared-utils.js +47 -3
  62. package/lib/uninstall.js +12 -0
  63. package/lib/update-hooks.js +4 -4
  64. package/lib/update-skill.js +6 -7
  65. package/mock-policy/AGENTS.md +4 -4
  66. package/mutation/AGENTS.md +4 -4
  67. package/mutation/__tests__/gate-m.test.ts +66 -14
  68. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  69. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  70. package/mutation/gate-m.ts +21 -8
  71. package/package.json +2 -2
  72. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  73. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  74. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  76. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  77. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  78. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  79. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  80. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  81. package/plugins/dsh/README.md +169 -4
  82. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  83. package/plugins/dsh/package.json +1 -1
  84. package/plugins/dsh/scripts/prepack.cjs +24 -19
  85. package/plugins/dsh/setup-dsh.ps1 +160 -0
  86. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  87. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  89. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  90. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  91. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  92. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  93. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  94. package/plugins/dsh/src/command.test.ts +66 -2
  95. package/plugins/dsh/src/command.ts +36 -14
  96. package/plugins/dsh/src/contract.test.ts +31 -1
  97. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  98. package/plugins/dsh/src/delphi-config.ts +223 -0
  99. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  100. package/plugins/dsh/src/delphi-run.ts +129 -0
  101. package/plugins/dsh/src/delphi.test.ts +222 -0
  102. package/plugins/dsh/src/delphi.ts +247 -0
  103. package/plugins/dsh/src/dialect.test.ts +59 -0
  104. package/plugins/dsh/src/dialect.ts +40 -0
  105. package/plugins/dsh/src/index.ts +70 -4
  106. package/plugins/dsh/src/skills.test.ts +26 -19
  107. package/plugins/opencode/package.json +1 -1
  108. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  109. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  111. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  112. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  113. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  114. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  115. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  116. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  117. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  118. package/plugins/qoder/agents/delphi-technical.md +1 -1
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  121. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  123. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  124. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  125. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  126. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  127. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  128. package/principles/AGENTS.md +9 -8
  129. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  130. package/principles/__tests__/analyzer.test.ts +29 -6
  131. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  132. package/principles/__tests__/baseline-prune.test.ts +338 -0
  133. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  134. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  135. package/principles/__tests__/boy-scout.test.ts +253 -7
  136. package/principles/__tests__/config-enforcement.test.ts +469 -0
  137. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  138. package/principles/__tests__/direct-execution.test.ts +187 -0
  139. package/principles/__tests__/index.test.ts +16 -3
  140. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  141. package/principles/adapters/typescript.ts +613 -13
  142. package/principles/analyzer.ts +22 -0
  143. package/principles/baseline-prune.ts +217 -0
  144. package/principles/boy-scout.ts +516 -56
  145. package/principles/config.ts +362 -54
  146. package/principles/direct-execution.ts +60 -0
  147. package/principles/index.ts +40 -17
  148. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  149. package/principles/rules/clean-code/code-duplication.ts +8 -8
  150. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  151. package/principles/rules/clean-code/god-class.ts +8 -8
  152. package/principles/rules/clean-code/large-file.ts +15 -11
  153. package/principles/rules/clean-code/long-function.ts +8 -8
  154. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  155. package/principles/rules/clean-code/many-exports.ts +7 -7
  156. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  157. package/principles/rules/clean-code/too-many-params.ts +8 -8
  158. package/principles/rules/clean-code/unused-imports.ts +5 -5
  159. package/principles/rules/solid/dip.ts +18 -8
  160. package/principles/rules/solid/isp.ts +9 -8
  161. package/principles/rules/solid/lsp.ts +6 -6
  162. package/principles/rules/solid/ocp.ts +6 -5
  163. package/principles/rules/solid/srp.ts +7 -8
  164. package/principles/types.ts +15 -0
  165. package/scripts/__tests__/sync-package-content.test.js +28 -0
  166. package/scripts/delphi-external-review.cjs +193 -10
  167. package/scripts/sync-package-content.js +20 -10
  168. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  169. package/skills/delphi-review/AGENTS.md +4 -4
  170. package/skills/delphi-review/SKILL.md +98 -14
  171. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  172. package/skills/delphi-review/references/requirements.md +6 -6
  173. package/skills/sprint-flow/AGENTS.md +4 -4
  174. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  175. package/skills/test-specification-alignment/AGENTS.md +4 -4
  176. package/lib/__tests__/download-skill.test.js +0 -281
  177. package/lib/download-skill.js +0 -96
@@ -16,29 +16,97 @@ if [ "$PROJECT_LANG" = "documentation-only" ]; then
16
16
  else
17
17
  # Get source files to check against principles
18
18
  PRINCIPLES_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx|py|go|java|kt|dart|swift|cpp|c|hpp|h|m|mm)$' || true)
19
+
20
+ # One path per line, into an array. Passing $PRINCIPLES_FILES unquoted relies on
21
+ # word splitting to turn the list into separate argv entries, which also splits
22
+ # any path containing a space -- handing the checker two bogus paths and checking
23
+ # neither. Quoting it would instead collapse the list into one argument. An array
24
+ # is the only form that does both. Same defect class as the --config fix (#457).
25
+ PRINCIPLES_ARGS=()
26
+ while IFS= read -r _principles_file; do
27
+ [ -n "$_principles_file" ] && PRINCIPLES_ARGS+=("$_principles_file")
28
+ done <<< "$PRINCIPLES_FILES"
19
29
 
20
30
  if [ -n "$PRINCIPLES_FILES" ]; then
21
- # Check for principles checker in installed modules first, then project src/
31
+ # Same anchoring as githooks/gate-4.sh: CHANGED_FILES paths are repo-root relative
32
+ # and the checker resolves them against its own working directory, so both the
33
+ # probes and the invocation run from the repo root. Resolving them against the
34
+ # hook's CWD lost the checker in single-language subdir mode (#478).
35
+ PRINCIPLES_BASE="${PROJECT_ROOT:-$(pwd)}"
36
+
22
37
  PRINCIPLES_DIR=""
23
- if [ -d ".xp-gate/modules/principles" ]; then
24
- PRINCIPLES_DIR=".xp-gate/modules/principles"
25
- elif [ -f "src/principles/index.ts" ]; then
26
- PRINCIPLES_DIR="src/principles"
27
- elif [ -d "$HOME/.config/xp-gate/modules/principles" ]; then
28
- PRINCIPLES_DIR="$HOME/.config/xp-gate/modules/principles"
29
- fi
38
+ for _principles_candidate in \
39
+ "$PRINCIPLES_BASE/.xp-gate/modules/principles" \
40
+ "$PRINCIPLES_BASE/src/principles" \
41
+ "$(pwd)/.xp-gate/modules/principles" \
42
+ "$(pwd)/src/principles" \
43
+ "$HOME/.config/xp-gate/modules/principles"; do
44
+ if [ -f "$_principles_candidate/index.ts" ]; then
45
+ PRINCIPLES_DIR="$_principles_candidate"
46
+ break
47
+ fi
48
+ done
30
49
 
31
50
  if [ -n "$PRINCIPLES_DIR" ]; then
32
51
  echo "Checking Clean Code + SOLID principles..."
33
52
 
34
53
  if command -v npx > /dev/null 2>&1; then
35
- # Run principles checker and store results
36
- if npx tsx $PRINCIPLES_DIR/index.ts --files $PRINCIPLES_FILES --format json > /tmp/principles-output.json 2>/dev/null; then
37
- # Check severity levels
38
- ERROR_COUNT=$(grep -c '"severity":"error"' /tmp/principles-output.json 2>/dev/null || true)
54
+ # Run principles checker and store results.
55
+ #
56
+ # Do NOT wrap this in `if ...; then`: the checker's exit codes are
57
+ # deliberately distinct (0 clean, 1 ERROR-severity findings, >=2 tool
58
+ # failure), and an `if` collapses 1 and 2 into "failed". That sent real
59
+ # error-severity findings down the crash branch, which SKIPs and prints
60
+ # PASSED -- releasing exactly the most serious violations.
61
+ # The trailing `|| PRINCIPLES_EXIT=$?` captures the exit status without
62
+ # letting a non-zero return abort the hook under `set -e`. pre-commit itself
63
+ # runs with only `set -o pipefail` (no `set -e`, pinned by
64
+ # scripts/__tests__/gate-5-runner-error.test.ts AC-454-08), but this module is
65
+ # also sourced by consumer hooks that may set it, and a bare call followed by
66
+ # `PRINCIPLES_EXIT=$?` never reaches the assignment under `set -e`. `|| true`
67
+ # would erase the status -- which is exactly the distinction this gate depends
68
+ # on.
69
+ PRINCIPLES_EXIT=0
70
+ # Keep stderr: it carries the checker's configuration warnings -- a rejected
71
+ # threshold or an out-of-vocabulary severity says so here and nowhere else.
72
+ # Sending it to /dev/null made an exit-2 SKIP undiagnosable and hid every
73
+ # .principlesrc typo (Delphi walkthrough MC-03). Captured to a file so a clean
74
+ # run stays quiet, then replayed only when the checker had a problem.
75
+ # `cd || exit 2`: a base we cannot enter means the checker never ran, which is
76
+ # this module's tool-failure code. Falling through would return the exit status
77
+ # of the failed cd (1), and 1 asserts "I examined files and found errors" -- an
78
+ # empty report at 1 reads as a clean pass.
79
+ #
80
+ # The report is a PER-INVOCATION mktemp, not a shared `/tmp/principles-output.json`:
81
+ # two commits racing on one machine (two worktrees, a hook and a CI job) overwrote
82
+ # each other's report before it was counted, so a run with ERROR findings could
83
+ # read a clean report and PASS (#457). PRINCIPLES_JSON is exported so a caller can
84
+ # point a harness at the same file.
85
+ PRINCIPLES_STDERR=$(mktemp)
86
+ PRINCIPLES_JSON=$(mktemp)
87
+ export PRINCIPLES_JSON
88
+ ( cd "$PRINCIPLES_BASE" 2>/dev/null || exit 2
89
+ npx tsx "$PRINCIPLES_DIR/index.ts" --files ${PRINCIPLES_ARGS[@]+"${PRINCIPLES_ARGS[@]}"} --format json ) > "$PRINCIPLES_JSON" 2>"$PRINCIPLES_STDERR" || PRINCIPLES_EXIT=$?
90
+
91
+ if [ -s "$PRINCIPLES_STDERR" ]; then
92
+ sed 's/^/ /' "$PRINCIPLES_STDERR"
93
+ fi
94
+ rm -f "$PRINCIPLES_STDERR"
95
+
96
+ if [ "$PRINCIPLES_EXIT" -ge 2 ]; then
97
+ echo "⚠️ Warning: Principles checker execution failed"
98
+ echo "⏭️ SKIPPED - Principles check (execution issue)"
99
+ GATE_4_STATUS="SKIP"
100
+ rm -f "$PRINCIPLES_JSON"
101
+ else
102
+ # Check severity levels. The reporter emits JSON.stringify(out, null, 2),
103
+ # i.e. `"severity": "warning"` WITH a space; tolerate any whitespace so a
104
+ # future minified format cannot silently zero these counts again (#444).
105
+ ERROR_COUNT=$(grep -cE '"severity"[[:space:]]*:[[:space:]]*"error"' "$PRINCIPLES_JSON" 2>/dev/null || true)
39
106
  ERROR_COUNT=${ERROR_COUNT:-0}
40
- WARNING_COUNT=$(grep -c '"severity":"warning"' /tmp/principles-output.json 2>/dev/null || true)
107
+ WARNING_COUNT=$(grep -cE '"severity"[[:space:]]*:[[:space:]]*"warning"' "$PRINCIPLES_JSON" 2>/dev/null || true)
41
108
  WARNING_COUNT=${WARNING_COUNT:-0}
109
+ rm -f "$PRINCIPLES_JSON"
42
110
 
43
111
  if [ "$ERROR_COUNT" -gt 0 ]; then
44
112
  echo ""
@@ -47,7 +115,8 @@ else
47
115
  echo " - error-handling violations"
48
116
  echo " - SOLID principle violations"
49
117
  echo " - architectural violations"
50
- npx tsx $PRINCIPLES_DIR/index.ts --files $PRINCIPLES_FILES --format console
118
+ ( cd "$PRINCIPLES_BASE" 2>/dev/null || exit 2
119
+ npx tsx "$PRINCIPLES_DIR/index.ts" --files ${PRINCIPLES_ARGS[@]+"${PRINCIPLES_ARGS[@]}"} --format console )
51
120
  GATE_4_STATUS="FAIL"
52
121
  exit 1
53
122
  fi
@@ -57,10 +126,6 @@ else
57
126
  if [ "$WARNING_COUNT" -gt 0 ]; then
58
127
  echo "ℹ️ $WARNING_COUNT warnings found (will be handled by Boy Scout Rule)."
59
128
  fi
60
- else
61
- echo "⚠️ Warning: Principles checker execution failed"
62
- echo "⏭️ SKIPPED - Principles check (execution issue)"
63
- GATE_4_STATUS="SKIP"
64
129
  fi
65
130
  else
66
131
  echo "ℹ️ npx not available - skipping principles check"
@@ -1,11 +1,14 @@
1
1
  # ============================================================================
2
- # GATE 8: Secret Scanning (gitleaks)
3
- # Detects secrets (API keys, passwords, tokens) in staged files
4
- # Tool: gitleaks -- https://github.com/gitleaks/gitleaks
2
+ # GATE 8: Secret Scanning (gitleaks + detect-secrets)
3
+ # Detects secrets (API keys, passwords, tokens) in staged files.
4
+ # Tools:
5
+ # gitleaks -- primary, fail-closed (missing or non-0/1 exit => BLOCK, #499)
6
+ # detect-secrets -- secondary, baseline-diff: only blocks on secrets NOT in
7
+ # .secrets.baseline; silently does nothing when unavailable
5
8
  # ============================================================================
6
9
 
7
10
  2>&1 echo ""
8
- 2>&1 echo "→ Gate 8: Secret scanning (gitleaks)..."
11
+ 2>&1 echo "→ Gate 8: Secret scanning (gitleaks + detect-secrets)..."
9
12
  GATE_8_START=$(gate_start_ms)
10
13
 
11
14
  # Gitleaks availability check
@@ -16,41 +19,124 @@ elif [ -f "$HOME/.local/bin/gitleaks" ]; then
16
19
  GITLEAKS_CMD="$HOME/.local/bin/gitleaks"
17
20
  fi
18
21
 
19
- if [ -n "$GITLEAKS_CMD" ]; then
20
- GITLEAKS_CONFIG=""
21
- if [ -f ".gitleaks.toml" ]; then
22
- GITLEAKS_CONFIG="--config=.gitleaks.toml"
22
+ if [ -z "$GITLEAKS_CMD" ]; then
23
+ # fail-closed (#499): an uninstalled gitleaks used to SKIP the gate, so any
24
+ # contributor without it shipped commits with zero secret protection and no
25
+ # signal. A security gate that cannot run must not report success.
26
+ echo " ❌ BLOCKED - gitleaks not installed (fail-closed, #499)."
27
+ echo " Secret scanning is mandatory before commit. Install gitleaks then retry:"
28
+ echo " macOS: brew install gitleaks"
29
+ echo " Windows: winget install gitleaks"
30
+ echo " Linux: scripts/install-gitleaks.sh (or https://github.com/gitleaks/gitleaks)"
31
+ exit 1
32
+ fi
33
+
34
+ GITLEAKS_CONFIG=""
35
+ if [ -f ".gitleaks.toml" ]; then
36
+ # gitleaks is a native binary and may not understand MSYS paths, so pass an
37
+ # absolute path in the host's own format when we can derive one (#449).
38
+ if command -v cygpath >/dev/null 2>&1; then
39
+ GITLEAKS_CONFIG="--config=$(cygpath -w "$(pwd)/.gitleaks.toml")"
40
+ else
41
+ GITLEAKS_CONFIG="--config=$(pwd)/.gitleaks.toml"
23
42
  fi
43
+ fi
24
44
 
25
- # Run gitleaks on staged changes only (pre-commit mode for speed)
26
- GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --pre-commit --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path=/tmp/gitleaks-report.json 2>&1)
27
- GITLEAKS_EXIT=$?
45
+ # Scan the INDEX, not history. gitleaks 8.x `git --pre-commit` walks committed
46
+ # revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
47
+ # secret was never seen and Gate 8 always passed. `git --staged` scans
48
+ # `git diff --cached`, which is exactly the pre-commit payload (#449).
49
+ GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
50
+ rm -f "$GITLEAKS_REPORT"
51
+ GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
52
+ GITLEAKS_EXIT=$?
28
53
 
29
- if [ "$GITLEAKS_EXIT" -eq 0 ]; then
54
+ if [ "$GITLEAKS_EXIT" -eq 0 ]; then
55
+ # Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
56
+ # meant a gitleaks message change would silently turn a no-evidence run into
57
+ # a PASS -- the same fail-open class this gate just fixed (#449).
58
+ # The report exists and is a JSON array of findings; `[]` == clean scan.
59
+ GITLEAKS_FINDINGS="?"
60
+ if [ -f "$GITLEAKS_REPORT" ]; then
61
+ # A report that is not shaped like a JSON array is no evidence: leave the
62
+ # '?' sentinel so the gate reports SKIP, never a PASS it cannot support
63
+ # (the fail-closed-on-the-claim rule #449 established).
64
+ if grep -q '^\[' "$GITLEAKS_REPORT" 2>/dev/null; then
65
+ # Count occurrences, not matching lines: `grep -c` prints its own zero AND
66
+ # exits 1 on a clean report, so the old `|| echo 0` handed the verdict table
67
+ # $'0\n0' -- no branch matched and a clean scan was BLOCKED as if it leaked
68
+ # (#493). wc -l emits exactly one value, and `|| true` keeps the assignment
69
+ # errexit/pipefail-safe (#489).
70
+ GITLEAKS_FINDINGS=$(grep -o '"RuleID"' "$GITLEAKS_REPORT" 2>/dev/null | wc -l | tr -d '[:space:]') || true
71
+ fi
72
+ fi
73
+
74
+ if [ "$GITLEAKS_FINDINGS" = "0" ]; then
30
75
  echo " ✅ PASSED - No secrets detected."
31
76
  GATE_8_STATUS="PASS"
32
- elif [ "$GITLEAKS_EXIT" -eq 1 ]; then
33
- # Secrets found — output details
77
+ elif [ "$GITLEAKS_FINDINGS" = "?" ]; then
78
+ # Exit 0 but no readable report: we have no evidence the scan happened.
79
+ # Fail closed on the *claim*: report SKIP, never PASS.
80
+ echo " ⚠️ gitleaks exited 0 but produced no readable report"
81
+ echo " ⏭️ SKIPPED - secret scanning produced no evidence (not a pass)"
82
+ GATE_8_STATUS="SKIP"
83
+ else
84
+ echo " ❌ BLOCKED - gitleaks reported $GITLEAKS_FINDINGS finding(s)"
34
85
  echo "$GITLEAKS_OUTPUT"
35
86
  echo ""
36
- echo "❌ BLOCKED - Secrets detected in staged files."
37
- echo ""
38
- echo "Remediation options:"
39
- echo " 1. Remove the secret and use environment variables instead"
40
- echo " 2. Add a false positive to .gitleaks.toml allowlist"
41
- echo " 3. Use git secret or vault for sensitive data"
42
- echo ""
43
- echo "See: https://github.com/gitleaks/gitleaks"
87
+ echo "Remediation: remove the secret, or allowlist a false positive in .gitleaks.toml."
44
88
  exit 1
89
+ fi
90
+ elif [ "$GITLEAKS_EXIT" -eq 1 ]; then
91
+ # Secrets found — output details
92
+ echo "$GITLEAKS_OUTPUT"
93
+ echo ""
94
+ echo " ❌ BLOCKED - Secrets detected in staged files."
95
+ echo ""
96
+ echo "Remediation options:"
97
+ echo " 1. Remove the secret and use environment variables instead"
98
+ echo " 2. Add a false positive to .gitleaks.toml allowlist"
99
+ echo " 3. Use git secret or vault for sensitive data"
100
+ echo ""
101
+ echo "See: https://github.com/gitleaks/gitleaks"
102
+ exit 1
103
+ else
104
+ # fail-closed (#499): a non-0/1 exit (config error, crash) used to SKIP, which
105
+ # let a broken scanner wave every commit through without any scan.
106
+ echo " ❌ BLOCKED - gitleaks exited with code $GITLEAKS_EXIT (fail-closed, #499)."
107
+ echo "$GITLEAKS_OUTPUT"
108
+ echo " Secret scanning could not complete; fix the gitleaks error above and retry."
109
+ exit 1
110
+ fi
111
+
112
+ # ---- detect-secrets(次要防线,基线比对)----
113
+ # detect-secrets-hook comes from the project's Python dev extra; it blocks only
114
+ # on secrets NOT yet registered in .secrets.baseline, so test-fixture dummy keys
115
+ # recorded in the baseline never trip it.
116
+ #
117
+ # The file list is piped NUL-delimited STRAIGHT into xargs -0 — never through a
118
+ # command substitution. `var=$(cmd -z)` strips every NUL byte (bash cannot hold
119
+ # NULs in a variable), which concatenated all filenames into one giant
120
+ # nonexistent path and would have BLOCKED every commit once a project shipped
121
+ # both detect-secrets-hook and .secrets.baseline. The pipeline lives inside an
122
+ # if-condition (errexit-exempt); a non-zero hook exit lands in the else branch.
123
+ if command -v detect-secrets-hook >/dev/null 2>&1 && [ -f ".secrets.baseline" ]; then
124
+ DS_TMP="$(dirname "$(mktemp -u)")/ds-output.$$"
125
+ if git diff --cached --name-only --diff-filter=ACM -z \
126
+ | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
127
+ echo " ✅ detect-secrets PASSED - no new secrets beyond baseline."
45
128
  else
46
- echo " ⚠️ gitleaks exited with code $GITLEAKS_EXIT - skipping gate"
47
- echo " ✅ Secret Scanning (SKIP, gitleaks error)"
48
- GATE_8_STATUS="SKIP"
129
+ cat "$DS_TMP"
130
+ echo ""
131
+ echo " ❌ BLOCKED - detect-secrets found secrets not in .secrets.baseline."
132
+ echo " If these are false positives, regenerate the baseline (cross-platform):"
133
+ echo " detect-secrets scan --update .secrets.baseline && git add .secrets.baseline"
134
+ rm -f "$DS_TMP"
135
+ exit 1
49
136
  fi
137
+ rm -f "$DS_TMP"
50
138
  else
51
- echo " ℹ️ gitleaks not installed — secret scanning unavailable"
52
- echo " Install: brew install gitleaks (macOS) | winget install gitleaks (Windows) | scripts/install-gitleaks.sh (Linux)"
53
- echo " ⏭️ SKIPPED - Secret scanning (gitleaks not installed)"
54
- GATE_8_STATUS="SKIP"
139
+ echo " ⚠️ detect-secrets-hook unavailable (install: pip install -e \".[dev]\") — gitleaks only."
55
140
  fi
141
+
56
142
  record_gate_audit "gate-8" "secret-scanning" "$GATE_8_STATUS" "0" "$GATE_8_START"
package/adapters/iac.sh CHANGED
@@ -124,7 +124,7 @@ run_static_analysis() {
124
124
 
125
125
  # Print captured output then check for failures
126
126
  cat "$checkov_log"
127
- local failed_count=$(grep -c "FAILED for resource" "$checkov_log" 2>/dev/null || echo 0)
127
+ local failed_count=$(grep -c "FAILED for resource" "$checkov_log" 2>/dev/null || true)
128
128
  rm -f "$checkov_log"
129
129
 
130
130
  if [ "$failed_count" -gt 0 ]; then
@@ -190,17 +190,72 @@ run_coverage() {
190
190
  source_files[${#source_files[@]}]="$path"
191
191
  done < "$discovery_file"
192
192
  rm -f "$discovery_file"
193
+
194
+ # Optional project-level exclusions. Some scripts cannot be instrumented or
195
+ # exercised by a unit-test runner at all (e.g. a bootstrap script whose Main
196
+ # ends in `exit`, which terminates the Pester host when invoked in-process).
197
+ # Counting them as 0% coverage permanently caps the achievable percentage and
198
+ # makes the threshold unsatisfiable no matter how good the suite is.
199
+ # Format: one glob per line in .xp-gate-powershell-coverage-ignore
200
+ local ignore_file=".xp-gate-powershell-coverage-ignore"
201
+ if [ -f "$ignore_file" ]; then
202
+ local filtered=()
203
+ local src
204
+ for src in "${source_files[@]}"; do
205
+ local skip=0
206
+ local pat
207
+ # Discovery yields paths like "./setup.ps1"; match the pattern against the
208
+ # raw path AND the "./"-stripped form so a plain "setup.ps1" glob works.
209
+ local src_norm="${src#./}"
210
+ while IFS= read -r pat; do
211
+ # skip blanks and comments
212
+ case "$pat" in ''|'#'*) continue ;; esac
213
+ local pat_norm="${pat#./}"
214
+ # shellcheck disable=SC2254
215
+ case "$src" in $pat|$pat_norm) skip=1; break ;; esac
216
+ # shellcheck disable=SC2254
217
+ case "$src_norm" in $pat_norm) skip=1; break ;; esac
218
+ done < "$ignore_file"
219
+ if [ "$skip" -eq 0 ]; then
220
+ filtered[${#filtered[@]}]="$src"
221
+ else
222
+ echo " ℹ️ Coverage exclusion matched: $src"
223
+ fi
224
+ done
225
+ # Assign back unconditionally: when EVERY source file matched an
226
+ # exclusion, `filtered` is empty and the check below must see 0 to skip
227
+ # coverage. Only assigning on >0 silently ignored a full exclusion.
228
+ # (Empty-array guards keep this bash-3.2 / `set -u` safe.)
229
+ if [ "${#filtered[@]}" -gt 0 ]; then
230
+ source_files=("${filtered[@]}")
231
+ else
232
+ source_files=()
233
+ fi
234
+ fi
235
+
236
+ if [ "${#source_files[@]}" -eq 0 ]; then
237
+ echo "No PowerShell source files left after exclusions, skipping coverage"
238
+ return 0
239
+ fi
240
+
193
241
  test_paths=$(_powershell_path_array "${test_files[@]}")
194
242
  coverage_paths=$(_powershell_path_array "${source_files[@]}")
195
243
  echo "Running Pester with code coverage..."
244
+ # Pester 5 requires the modern configuration object: `-Path ... -CodeCoverage ...`
245
+ # is the deprecated Pester 4 parameter set and fails outright on Pester 5.x
246
+ # ("cannot resolve parameter set"). Using it silently produced no coverage.xml,
247
+ # which the pre-commit hook then reported as an unparseable/zero result.
196
248
  _pester_timeout "$PWSH" "${POWERSHELL_ARGS[@]}" "
197
- \$results = Invoke-Pester -Path $test_paths -CodeCoverage $coverage_paths -PassThru
249
+ \$cfg = New-PesterConfiguration
250
+ \$cfg.Run.Path = $test_paths
251
+ \$cfg.CodeCoverage.Enabled = \$true
252
+ \$cfg.CodeCoverage.Path = $coverage_paths
253
+ \$cfg.CodeCoverage.OutputFormat = 'JaCoCo'
254
+ \$cfg.CodeCoverage.OutputPath = (Join-Path (Get-Location) 'coverage.xml')
255
+ \$cfg.Run.PassThru = \$true
256
+ \$results = Invoke-Pester -Configuration \$cfg
198
257
  \$pct = [math]::Round(\$results.CodeCoverage.CoveragePercent, 1)
199
258
  Write-Host \"Coverage: \$pct%\"
200
- if (\$pct -lt 80) {
201
- Write-Host \"WARNING: Coverage \$pct% is below 80% threshold\"
202
- exit 0
203
- }
204
259
  exit 0
205
260
  " 2>&1
206
261
  local cov_exit=$?
@@ -4,13 +4,33 @@
4
4
  # Tools: mypy, ruff/flake8, pytest, import-linter (architecture)
5
5
 
6
6
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ # SC1091:adapter-common.sh 由调用方按查找顺序提供,路径靠变量拼接,shellcheck 无法静态跟随。
8
+ # `source … || true` 是既有容错口径(仓库存档时该文件可能不在),不要改成硬依赖。
9
+ # shellcheck source=/dev/null
7
10
  source "$SCRIPT_DIR/../adapter-common.sh" 2>/dev/null || true
8
11
 
9
12
  run_static_analysis() {
10
13
  require_tool mypy "mypy" || return 1
11
14
 
15
+ # 口径 = CI type job(.github/workflows/ci.yml:43-45)的两条命令,逐字一致。
16
+ # 为什么不能写 `mypy .`:`.` 会递归扫到两类文件,而它们在仓库里从未被声明为
17
+ # 门禁面,CI 也从不检查:
18
+ # ① 根目录一次性调研/演示脚本(fetch_*.py、demo_*.py、validate_*.py 等)——
19
+ # `[tool.ruff] exclude`(pyproject.toml:217-238)已逐文件把它们列为"历史产物,
20
+ # 从未被 `ruff check src/ tests/` 覆盖",mypy 侧却没有对应排除;
21
+ # ② `tests/` —— CI 只跑 `mypy src/` 与 `mypy scripts/`,测试域的类型问题
22
+ # 没有任何门禁核对过。
23
+ # 两份清单不一致的后果是钩子恒红且与本仓任何真实缺陷无关:实测 `mypy .` 87 条
24
+ # error(本机),而 `mypy src/`(136 files)与 `mypy scripts/`(42 files)各自
25
+ # exit 0。即当时钩子会阻断**任何**提交,包括纯文档改动。
26
+ # 处置是让钩子对齐已声明的 CI 口径,而不是给那 87 处补注解——补注解既不在声明
27
+ # 口径内,又会顺手动几十个无关文件(含 tests/)。
28
+ # 口径一致性由仓库侧 BATS 测试双向锁住:
29
+ # githooks/__tests__/adapter-mypy-pester-scope.test.bats(含"适配器里不得再
30
+ # 出现裸 mypy ."),防止 `mypy .` 被写回来时无人报警。
12
31
  echo "Running Python static analysis (mypy)..."
13
- mypy .
32
+ mypy src/ || return 1
33
+ mypy scripts/
14
34
  return $?
15
35
  }
16
36
 
@@ -45,7 +65,8 @@ run_tests() {
45
65
  require_tool pytest "pytest" || return 1
46
66
 
47
67
  echo "Running Python tests..."
48
- PYTEST_OUTPUT=$(pytest --exitfirst --tb=short 2>&1)
68
+ # Skip e2e tests by default in pre-commit (require live API + network)
69
+ PYTEST_OUTPUT=$(pytest --exitfirst --tb=short -m "not e2e" 2>&1)
49
70
  PYTEST_EXIT=$?
50
71
 
51
72
  # Show the short summary tail — this is where FAILED/assert diagnostics live.
@@ -85,7 +106,10 @@ run_coverage() {
85
106
  require_tool pytest "pytest" || return 1
86
107
 
87
108
  echo "Running Python coverage..."
88
- PYTEST_OUTPUT=$(pytest --exitfirst --tb=short --cov=. --cov-fail-under=80 2>&1)
109
+ # 阈值不在此处硬编码:pytest-cov 自动读取 pyproject.toml 的
110
+ # [tool.coverage.report] fail_under(唯一事实源)。此前这里重复写了
111
+ # --cov-fail-under=79.9,改 pyproject 不会让门禁跟着变,属于假配置。
112
+ PYTEST_OUTPUT=$(pytest --exitfirst --tb=short --cov=src -m "not e2e" 2>&1)
89
113
  PYTEST_EXIT=$?
90
114
  echo "$PYTEST_OUTPUT" | grep -E "(FAILED|PASSED|passed|failed|error|ERROR|TOTAL|assert)" | tail -10
91
115
  echo "$PYTEST_OUTPUT" | tail -5
@@ -152,7 +176,17 @@ run_mutation() {
152
176
  local MUTATION_OUTPUT
153
177
  MUTATION_OUTPUT=$(mktemp)
154
178
 
155
- timeout "${timeout_s}s" mutmut run --paths-to-mutate $file_list > "$MUTATION_OUTPUT" 2>&1
179
+ local MUTMUT_CMD="mutmut"
180
+ if ! command -v mutmut >/dev/null 2>&1; then
181
+ MUTMUT_CMD="python3 -m mutmut"
182
+ fi
183
+
184
+ # SC2086:`$MUTMUT_CMD` 与 `$file_list` 都是**故意按空白分词**的——
185
+ # 前者可能是两个词(`python3 -m mutmut`),后者是空格分隔的多条路径,
186
+ # 加引号会把它们并成单个参数、直接让命令失败。故此处显式声明意图,
187
+ # 而不是改成引号(引号在这里不是修 bug,是造 bug)。
188
+ # shellcheck disable=SC2086
189
+ timeout "${timeout_s}s" $MUTMUT_CMD run --paths-to-mutate $file_list > "$MUTATION_OUTPUT" 2>&1
156
190
  local EXIT_CODE=$?
157
191
 
158
192
  cat "$MUTATION_OUTPUT"
@@ -3,70 +3,25 @@
3
3
  # TypeScript adapter for quality gates
4
4
 
5
5
  run_static_analysis() {
6
- if ! command -v npx >/dev/null 2>&1; then
7
- echo "npx not available, skipping TypeScript static analysis"
8
- return 0
9
- fi
10
-
11
- local HAS_ERRORS=0
12
- local TSC_VERSION
13
-
14
- echo "Running TypeScript static analysis..."
15
- npx tsc --noEmit
16
- TSC_EXIT=$?
17
- if [ $TSC_EXIT -ne 0 ]; then
18
- HAS_ERRORS=1
6
+ # The type-check decision is shared with pre-commit's Gate 1 (#436): a project's
7
+ # own checker (typecheck script, or GATE_TS_TYPECHECK_CMD) is used when declared,
8
+ # otherwise the pre-#436 `npx tsc --noEmit` path. This adapter's contract predates
9
+ # that split, so it keeps reporting only 0 (clean/skipped) or 1 (type errors).
10
+ local lib_dir
11
+ lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd)"
12
+ if [ -n "$lib_dir" ] && [ -f "$lib_dir/typecheck.sh" ]; then
13
+ # shellcheck source=/dev/null
14
+ source "$lib_dir/typecheck.sh"
19
15
  fi
20
16
 
21
- # Also check test files if tsconfig.json exists (see Issue #293).
22
- # Many projects exclude __tests__/ from tsconfig.json, so test file type
23
- # errors silently accumulate. We detect this and run a second tsc pass.
24
- local HAS_TESTS=false
25
- if [ -d "src/__tests__" ] || [ -d "src/tests" ] || [ -d "tests" ] || [ -d "__tests__" ]; then
26
- HAS_TESTS=true
27
- fi
28
-
29
- if [ "$HAS_TESTS" = true ] && [ -f "tsconfig.json" ]; then
30
- # Check if tsconfig.json already includes test files
31
- if npx tsc --noEmit --listFiles 2>/dev/null | grep -qE '__tests__/|\.test\.ts|\.spec\.ts'; then
32
- : # Test files already checked — no additional pass needed
33
- elif [ -f "tsconfig.tests.json" ]; then
34
- echo "Checking test files with tsconfig.tests.json..."
35
- npx tsc --noEmit --project tsconfig.tests.json
36
- TSC_TEST_EXIT=$?
37
- if [ $TSC_TEST_EXIT -ne 0 ]; then
38
- HAS_ERRORS=1
39
- fi
40
- else
41
- # Generate a temp tsconfig that includes test files for the additional pass
42
- local TSC_TEST_EXIT=0
43
- echo "Checking test files for type errors (generating temp tsconfig)..."
44
- npx tsc --noEmit 2>/dev/null
45
- # Create temp tsconfig extending the original but with test files included
46
- local TEMP_TSCONFIG=".tsconfig.withtests.json"
47
- if [ ! -f "$TEMP_TSCONFIG" ]; then
48
- node -e "
49
- const cfg = JSON.parse(require('fs').readFileSync('tsconfig.json','utf8'));
50
- delete cfg.exclude;
51
- cfg.include = cfg.include || ['src/**/*'];
52
- cfg.include.push('src/**/__tests__/**','src/**/*.test.ts','src/**/*.spec.ts');
53
- require('fs').writeFileSync('$TEMP_TSCONFIG', JSON.stringify(cfg, null, 2));
54
- " 2>/dev/null
55
- npx tsc --noEmit --project "$TEMP_TSCONFIG"
56
- TSC_TEST_EXIT=$?
57
- rm -f "$TEMP_TSCONFIG"
58
- fi
59
- if [ $TSC_TEST_EXIT -ne 0 ]; then
60
- echo ""
61
- echo "❌ BLOCKED - TYPE ERRORS in test files"
62
- echo "Your project's tsconfig.json excludes test files from type checking."
63
- echo "Fix the type errors above or add a tsconfig.tests.json to customise test file checking."
64
- HAS_ERRORS=1
65
- fi
66
- fi
17
+ if ! command -v run_typescript_typecheck >/dev/null 2>&1; then
18
+ echo "typecheck.sh not resolvable, skipping TypeScript static analysis"
19
+ return 0
67
20
  fi
68
21
 
69
- return $HAS_ERRORS
22
+ run_typescript_typecheck
23
+ [ $? -eq 1 ] && return 1
24
+ return 0
70
25
  }
71
26
 
72
27
  run_lint() {
package/bin/xp-gate.js CHANGED
@@ -6,6 +6,7 @@ const { updateSkill } = require('../lib/update-skill.js');
6
6
  const { uninstallSkill } = require('../lib/uninstall-skill.js');
7
7
  const { uninstall } = require('../lib/uninstall.js');
8
8
  const { doctor } = require('../lib/doctor.js');
9
+ const { parseSkillFlags, SKILL_FLAGS_BY_COMMAND } = require('../lib/shared-utils.js');
9
10
  const { checkDeps } = require('../lib/detect-deps.js');
10
11
  const { migrate } = require('../lib/migrate.js');
11
12
  const { handleBaseline } = require('../lib/baseline.js');
@@ -80,18 +81,18 @@ const COMMANDS = {
80
81
  usage: 'xp-gate setup-global'
81
82
  },
82
83
  'install-skill': {
83
- description: 'Install a xp-gate skill from GitHub',
84
+ description: 'Install a xp-gate skill from the package bundle',
84
85
  run: subargs => {
85
86
  const name = subargs[0];
86
87
  if (!name) {
87
88
  console.error('Error: Skill name required');
88
- console.error('Usage: xp-gate install-skill <name>[@<version>]');
89
+ console.error('Usage: xp-gate install-skill <name> [--verbose] [--force]');
89
90
  process.exit(1);
90
91
  }
91
- const options = parseOptions(subargs.slice(1));
92
+ const options = parseOptions(subargs.slice(1), 'install-skill');
92
93
  installSkill(name, options).then(code => process.exit(code));
93
94
  },
94
- usage: 'xp-gate install-skill <name>[@<version>] [--offline] [--verbose] [--force]'
95
+ usage: 'xp-gate install-skill <name> [--verbose] [--force]'
95
96
  },
96
97
  'update-skill': {
97
98
  description: 'Update installed skill(s)',
@@ -101,12 +102,12 @@ const COMMANDS = {
101
102
  let name, options;
102
103
  if (allFlagIndex !== -1) {
103
104
  // Remove --all from args before parsing, name is undefined
104
- options = parseOptions([...subargs.slice(0, allFlagIndex), ...subargs.slice(allFlagIndex + 1)]);
105
+ options = parseOptions([...subargs.slice(0, allFlagIndex), ...subargs.slice(allFlagIndex + 1)], 'update-skill');
105
106
  options.all = true;
106
107
  name = undefined; // explicit: no single name when --all is used
107
108
  } else {
108
109
  name = subargs[0];
109
- options = parseOptions(subargs.slice(1));
110
+ options = parseOptions(subargs.slice(1), 'update-skill');
110
111
  }
111
112
  updateSkill(name, options).then(code => process.exit(code));
112
113
  },
@@ -121,7 +122,7 @@ const COMMANDS = {
121
122
  console.error('Usage: xp-gate uninstall-skill <name>');
122
123
  process.exit(1);
123
124
  }
124
- const options = parseOptions(subargs.slice(1));
125
+ const options = parseOptions(subargs.slice(1), 'uninstall-skill');
125
126
  uninstallSkill(name, options).then(code => process.exit(code));
126
127
  },
127
128
  usage: 'xp-gate uninstall-skill <name> [--force]'
@@ -139,7 +140,7 @@ const COMMANDS = {
139
140
  'doctor': {
140
141
  description: 'Diagnose xp-gate installation health',
141
142
  run: subargs => doctor(subargs).then(code => process.exit(code)),
142
- usage: 'xp-gate doctor [--fix]'
143
+ usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force]] | --json | --format json (the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks)'
143
144
  },
144
145
  'ui-review': {
145
146
  description: 'Run UI review for non-sprint developers (generates .ui-gate-result.json)',
@@ -487,14 +488,12 @@ function printStatsTable(stats) {
487
488
  console.log('└──────────────────┴────────┴────────┴────────────┘');
488
489
  }
489
490
 
490
- function parseOptions(args) {
491
- const options = { offline: false, verbose: false, force: false, all: false, check: false };
492
- for (const arg of args) {
493
- if (arg === '--offline') options.offline = true;
494
- if (arg === '--verbose') options.verbose = true;
495
- if (arg === '--force') options.force = true;
496
- if (arg === '--all') options.all = true;
497
- if (arg === '--check') options.check = true;
491
+ function parseOptions(args, command) {
492
+ const { options, unknown } = parseSkillFlags(args, command);
493
+ if (unknown.length > 0) {
494
+ console.error(`Unknown argument(s) for ${command}: ${unknown.join(', ')}`);
495
+ console.error(`Supported flags: ${(SKILL_FLAGS_BY_COMMAND[command] || []).join(', ') || '(none)'}`);
496
+ process.exit(1);
498
497
  }
499
498
  return options;
500
499
  }