@boyingliu01/xp-gate 0.13.2 → 0.14.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 (50) hide show
  1. package/bin/xp-gate.js +8 -0
  2. package/gate-9.sh +13 -13
  3. package/hooks/gate-9.sh +13 -13
  4. package/lib/__tests__/bootstrap.test.js +78 -0
  5. package/lib/__tests__/detect-deps.test.js +88 -0
  6. package/lib/__tests__/doctor.test.js +28 -0
  7. package/lib/__tests__/install-cmd.test.js +41 -0
  8. package/lib/__tests__/uninstall.test.js +53 -0
  9. package/lib/__tests__/update-hooks.test.js +4 -2
  10. package/lib/detect-deps.js +2 -0
  11. package/lib/doctor.js +13 -0
  12. package/lib/gate-tools.js +87 -0
  13. package/lib/init.js +1 -36
  14. package/lib/install-cmd.js +57 -0
  15. package/lib/shared-utils.js +35 -1
  16. package/lib/uninstall.js +54 -9
  17. package/mock-policy/AGENTS.md +2 -2
  18. package/mutation/AGENTS.md +2 -2
  19. package/package.json +1 -1
  20. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  21. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  22. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  23. package/plugins/claude-code/skills/sprint-flow/SKILL.md +14 -9
  24. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +33 -2
  25. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +63 -0
  26. package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +58 -3
  27. package/plugins/claude-code/skills/sprint-flow/references/phase-6-close.md +60 -1
  28. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  29. package/plugins/opencode/package.json +1 -1
  30. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  31. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  32. package/plugins/opencode/skills/sprint-flow/SKILL.md +14 -9
  33. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +33 -2
  34. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +63 -0
  35. package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +58 -3
  36. package/plugins/opencode/skills/sprint-flow/references/phase-6-close.md +60 -1
  37. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  38. package/plugins/qoder/plugin.json +1 -1
  39. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  40. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  41. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  42. package/principles/AGENTS.md +2 -2
  43. package/skills/delphi-review/AGENTS.md +2 -2
  44. package/skills/sprint-flow/AGENTS.md +2 -2
  45. package/skills/sprint-flow/SKILL.md +14 -9
  46. package/skills/sprint-flow/references/phase-2-design.md +33 -2
  47. package/skills/sprint-flow/references/phase-3-build.md +63 -0
  48. package/skills/sprint-flow/references/phase-5-ship.md +58 -3
  49. package/skills/sprint-flow/references/phase-6-close.md +60 -1
  50. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -157,6 +157,14 @@ const COMMANDS = {
157
157
  }
158
158
  },
159
159
  usage: 'xp-gate update-hooks [--global] [--force] [--dry-run] [--no-backup] [--scope hooks|adapters|all]'
160
+ },
161
+ 'install': {
162
+ description: 'One-step install (init + bootstrap + doctor)',
163
+ run: subargs => {
164
+ const { install } = require('../lib/install-cmd');
165
+ install(subargs).then(code => process.exit(code));
166
+ },
167
+ usage: 'xp-gate install [--global]'
160
168
  }
161
169
  };
162
170
 
package/gate-9.sh CHANGED
@@ -1,14 +1,14 @@
1
1
  # ============================================================================
2
- # GATE 10: Semgrep SAST Security Scan
2
+ # GATE 9: Semgrep SAST Security Scan
3
3
  # Detects security vulnerabilities (SQL injection, XSS, etc.) in staged files
4
4
  # Tool: semgrep -- https://semgrep.dev
5
5
  # ============================================================================
6
6
 
7
7
  2>&1 echo ""
8
- 2>&1 echo "→ Gate 10: Semgrep SAST Security Scan..."
9
- GATE_10_START=$(gate_start_ms)
8
+ 2>&1 echo "→ Gate 9: Semgrep SAST Security Scan..."
9
+ GATE_9_START=$(gate_start_ms)
10
10
 
11
- GATE_10_STATUS=""
11
+ GATE_9_STATUS=""
12
12
 
13
13
  # Semgrep availability check
14
14
  SEMGREP_CMD=""
@@ -22,15 +22,15 @@ if [ -z "$SEMGREP_CMD" ]; then
22
22
  echo " ⚠️ WARN - semgrep not installed — SAST scanning unavailable"
23
23
  echo " Install: brew install semgrep (macOS) | pip install semgrep (Linux) | pip install semgrep (Windows)"
24
24
  echo " Pre-cache rules: semgrep --config=p/security-audit"
25
- echo " Gate 10: SAST Security (WARN, semgrep not installed)"
26
- GATE_10_STATUS="WARN"
25
+ echo " Gate 9: SAST Security (WARN, semgrep not installed)"
26
+ GATE_9_STATUS="WARN"
27
27
  else
28
28
  # Get staged files filtered to Semgrep-supported languages
29
29
  SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
30
30
 
31
31
  if [ -z "$SEMGREP_FILES" ]; then
32
32
  echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
33
- GATE_10_STATUS="SKIP"
33
+ GATE_9_STATUS="SKIP"
34
34
  else
35
35
  # Run semgrep with JSON output
36
36
  # --config=p/security-audit: explicit security ruleset
@@ -41,7 +41,7 @@ else
41
41
 
42
42
  if [ "$SEMGREP_EXIT" -eq 0 ]; then
43
43
  echo " ✅ PASSED - No security vulnerabilities found."
44
- GATE_10_STATUS="PASS"
44
+ GATE_9_STATUS="PASS"
45
45
  elif [ "$SEMGREP_EXIT" -eq 1 ]; then
46
46
  # Findings detected - parse JSON to categorize
47
47
  CRITICAL_HIGH=$(echo "$SEMGREP_OUTPUT" | python3 -c "
@@ -99,7 +99,7 @@ except:
99
99
  if [ "$CRITICAL_HIGH" -gt 0 ]; then
100
100
  echo ""
101
101
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
102
- echo " GATE 10: Semgrep Security Gate"
102
+ echo " GATE 9: Semgrep Security Gate"
103
103
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
104
104
  echo " CRITICAL/HIGH: ${CRITICAL_HIGH} ❌ BLOCKED"
105
105
  echo " MEDIUM/LOW: ${MEDIUM_LOW} ⚠️ warning"
@@ -108,7 +108,7 @@ except:
108
108
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
109
109
  echo "$FINDING_DETAILS"
110
110
  echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
111
- GATE_10_STATUS="FAIL"
111
+ GATE_9_STATUS="FAIL"
112
112
  exit 1
113
113
  else
114
114
  echo ""
@@ -117,14 +117,14 @@ except:
117
117
  echo " ⚠️ ${MEDIUM_LOW} medium/low findings (warnings only)"
118
118
  echo "$FINDING_DETAILS"
119
119
  fi
120
- GATE_10_STATUS="PASS"
120
+ GATE_9_STATUS="PASS"
121
121
  fi
122
122
  else
123
123
  # semgrep runtime error (timeout, config error, etc.)
124
124
  echo " ⚠️ semgrep exited with code ${SEMGREP_EXIT} — skipping gate"
125
125
  echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
126
- GATE_10_STATUS="SKIP"
126
+ GATE_9_STATUS="SKIP"
127
127
  fi
128
128
  fi
129
129
  fi
130
- record_gate_audit "gate-10" "sast-security" "$GATE_10_STATUS" "0" "$GATE_10_START"
130
+ record_gate_audit "gate-9" "sast-security" "$GATE_9_STATUS" "0" "$GATE_9_START"
package/hooks/gate-9.sh CHANGED
@@ -1,14 +1,14 @@
1
1
  # ============================================================================
2
- # GATE 10: Semgrep SAST Security Scan
2
+ # GATE 9: Semgrep SAST Security Scan
3
3
  # Detects security vulnerabilities (SQL injection, XSS, etc.) in staged files
4
4
  # Tool: semgrep -- https://semgrep.dev
5
5
  # ============================================================================
6
6
 
7
7
  2>&1 echo ""
8
- 2>&1 echo "→ Gate 10: Semgrep SAST Security Scan..."
9
- GATE_10_START=$(gate_start_ms)
8
+ 2>&1 echo "→ Gate 9: Semgrep SAST Security Scan..."
9
+ GATE_9_START=$(gate_start_ms)
10
10
 
11
- GATE_10_STATUS=""
11
+ GATE_9_STATUS=""
12
12
 
13
13
  # Semgrep availability check
14
14
  SEMGREP_CMD=""
@@ -22,15 +22,15 @@ if [ -z "$SEMGREP_CMD" ]; then
22
22
  echo " ⚠️ WARN - semgrep not installed — SAST scanning unavailable"
23
23
  echo " Install: brew install semgrep (macOS) | pip install semgrep (Linux) | pip install semgrep (Windows)"
24
24
  echo " Pre-cache rules: semgrep --config=p/security-audit"
25
- echo " Gate 10: SAST Security (WARN, semgrep not installed)"
26
- GATE_10_STATUS="WARN"
25
+ echo " Gate 9: SAST Security (WARN, semgrep not installed)"
26
+ GATE_9_STATUS="WARN"
27
27
  else
28
28
  # Get staged files filtered to Semgrep-supported languages
29
29
  SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
30
30
 
31
31
  if [ -z "$SEMGREP_FILES" ]; then
32
32
  echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
33
- GATE_10_STATUS="SKIP"
33
+ GATE_9_STATUS="SKIP"
34
34
  else
35
35
  # Run semgrep with JSON output
36
36
  # --config=p/security-audit: explicit security ruleset
@@ -41,7 +41,7 @@ else
41
41
 
42
42
  if [ "$SEMGREP_EXIT" -eq 0 ]; then
43
43
  echo " ✅ PASSED - No security vulnerabilities found."
44
- GATE_10_STATUS="PASS"
44
+ GATE_9_STATUS="PASS"
45
45
  elif [ "$SEMGREP_EXIT" -eq 1 ]; then
46
46
  # Findings detected - parse JSON to categorize
47
47
  CRITICAL_HIGH=$(echo "$SEMGREP_OUTPUT" | python3 -c "
@@ -99,7 +99,7 @@ except:
99
99
  if [ "$CRITICAL_HIGH" -gt 0 ]; then
100
100
  echo ""
101
101
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
102
- echo " GATE 10: Semgrep Security Gate"
102
+ echo " GATE 9: Semgrep Security Gate"
103
103
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
104
104
  echo " CRITICAL/HIGH: ${CRITICAL_HIGH} ❌ BLOCKED"
105
105
  echo " MEDIUM/LOW: ${MEDIUM_LOW} ⚠️ warning"
@@ -108,7 +108,7 @@ except:
108
108
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
109
109
  echo "$FINDING_DETAILS"
110
110
  echo " Run 'semgrep scan --config=p/security-audit' to review all findings."
111
- GATE_10_STATUS="FAIL"
111
+ GATE_9_STATUS="FAIL"
112
112
  exit 1
113
113
  else
114
114
  echo ""
@@ -117,14 +117,14 @@ except:
117
117
  echo " ⚠️ ${MEDIUM_LOW} medium/low findings (warnings only)"
118
118
  echo "$FINDING_DETAILS"
119
119
  fi
120
- GATE_10_STATUS="PASS"
120
+ GATE_9_STATUS="PASS"
121
121
  fi
122
122
  else
123
123
  # semgrep runtime error (timeout, config error, etc.)
124
124
  echo " ⚠️ semgrep exited with code ${SEMGREP_EXIT} — skipping gate"
125
125
  echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
126
- GATE_10_STATUS="SKIP"
126
+ GATE_9_STATUS="SKIP"
127
127
  fi
128
128
  fi
129
129
  fi
130
- record_gate_audit "gate-10" "sast-security" "$GATE_10_STATUS" "0" "$GATE_10_START"
130
+ record_gate_audit "gate-9" "sast-security" "$GATE_9_STATUS" "0" "$GATE_9_START"
@@ -0,0 +1,78 @@
1
+ const { bootstrap } = require('../bootstrap.js');
2
+
3
+ describe('bootstrap', () => {
4
+ let consoleLogSpy;
5
+
6
+ beforeEach(() => {
7
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
8
+ });
9
+
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ it('returns 0 when --dry-run given with no missing tools', () => {
15
+ vi.mock('../detect-deps.js', () => ({
16
+ GATE_CLI_TOOLS: [],
17
+ checkCliTool: () => ({ available: true }),
18
+ getToolInstallCmd: () => 'echo done',
19
+ }));
20
+
21
+ const { bootstrap } = require('../bootstrap.js');
22
+ const code = bootstrap(['--dry-run']);
23
+ expect(code).toBe(0);
24
+ vi.restoreAllMocks();
25
+ });
26
+
27
+ it('returns 0 when all CLI tools are available', () => {
28
+ vi.mock('../detect-deps.js', () => ({
29
+ GATE_CLI_TOOLS: [{ tool: 'testtool', gates: ['Gate 1'], install: { linux: 'echo ok' } }],
30
+ checkCliTool: () => ({ available: true, version: '1.0' }),
31
+ getToolInstallCmd: () => 'echo ok',
32
+ }));
33
+
34
+ const { bootstrap } = require('../bootstrap.js');
35
+ const code = bootstrap([]);
36
+ expect(code).toBe(0);
37
+ vi.restoreAllMocks();
38
+ });
39
+
40
+ it('accepts --lang ts parameter', () => {
41
+ vi.mock('../detect-deps.js', () => ({
42
+ GATE_CLI_TOOLS: [],
43
+ checkCliTool: () => ({ available: true }),
44
+ getToolInstallCmd: () => 'echo done',
45
+ }));
46
+
47
+ const { bootstrap } = require('../bootstrap.js');
48
+ const code = bootstrap(['--lang', 'ts']);
49
+ expect(code).toBe(0);
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ it('accepts --lang ts,py parameter', () => {
54
+ vi.mock('../detect-deps.js', () => ({
55
+ GATE_CLI_TOOLS: [],
56
+ checkCliTool: () => ({ available: true }),
57
+ getToolInstallCmd: () => 'echo done',
58
+ }));
59
+
60
+ const { bootstrap } = require('../bootstrap.js');
61
+ const code = bootstrap(['--lang', 'ts,py']);
62
+ expect(code).toBe(0);
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ it('recognizes --verbose flag without error', () => {
67
+ vi.mock('../detect-deps.js', () => ({
68
+ GATE_CLI_TOOLS: [],
69
+ checkCliTool: () => ({ available: true }),
70
+ getToolInstallCmd: () => 'echo done',
71
+ }));
72
+
73
+ const { bootstrap } = require('../bootstrap.js');
74
+ const code = bootstrap(['--verbose']);
75
+ expect(code).toBe(0);
76
+ vi.restoreAllMocks();
77
+ });
78
+ });
@@ -413,6 +413,94 @@ describe('detect-deps', () => {
413
413
 
414
414
  // ── checkCliTool tests (Issue #299 — Windows cross-platform) ──
415
415
 
416
+ describe('GATE_TOOLS classification', () => {
417
+ it('exports GATE_TOOLS with required categories', () => {
418
+ const { GATE_TOOLS } = require('../detect-deps');
419
+ expect(GATE_TOOLS.PLATFORM).toEqual(expect.arrayContaining(['jscpd', 'lizard', 'semgrep', 'gitleaks', 'npx']));
420
+ expect(GATE_TOOLS.IAC).toEqual(expect.arrayContaining(['checkov', 'hadolint', 'kube-score', 'tflint']));
421
+ expect(GATE_TOOLS.LANG_MAP.ts).toBe('typescript');
422
+ expect(GATE_TOOLS.LANG_MAP.py).toBe('python');
423
+ });
424
+
425
+ it('has LINT entries for all 12 supported languages', () => {
426
+ const { GATE_TOOLS } = require('../detect-deps');
427
+ expect(Object.keys(GATE_TOOLS.LINT).sort()).toEqual([
428
+ 'cpp', 'dart', 'flutter', 'go', 'java', 'kotlin',
429
+ 'objectivec', 'powershell', 'python', 'shell', 'swift', 'typescript',
430
+ ]);
431
+ });
432
+
433
+ it('has TEST entries for major languages', () => {
434
+ const { GATE_TOOLS } = require('../detect-deps');
435
+ expect(GATE_TOOLS.TEST.typescript).toContain('vitest');
436
+ expect(GATE_TOOLS.TEST.python).toContain('pytest');
437
+ expect(GATE_TOOLS.TEST.go).toContain('go');
438
+ });
439
+
440
+ it('has MUTATION entries for languages with mutation support', () => {
441
+ const { GATE_TOOLS } = require('../detect-deps');
442
+ expect(GATE_TOOLS.MUTATION.typescript).toContain('stryker');
443
+ expect(GATE_TOOLS.MUTATION.python).toContain('mutmut');
444
+ expect(GATE_TOOLS.MUTATION.go).toContain('gomutants');
445
+ expect(GATE_TOOLS.MUTATION.java).toContain('pitest');
446
+ });
447
+
448
+ it('has SPECIAL with jq, tsx, node descriptors', () => {
449
+ const { GATE_TOOLS } = require('../detect-deps');
450
+ expect(GATE_TOOLS.SPECIAL.jq.gate).toBe('MW');
451
+ expect(GATE_TOOLS.SPECIAL.tsx.gate).toContain('M');
452
+ expect(GATE_TOOLS.SPECIAL.node.gate).toContain('0');
453
+ });
454
+
455
+ it('LANG_MAP values all exist as LINT keys', () => {
456
+ const { GATE_TOOLS } = require('../detect-deps');
457
+ const lintKeys = Object.keys(GATE_TOOLS.LINT);
458
+ for (const full of Object.values(GATE_TOOLS.LANG_MAP)) {
459
+ expect(lintKeys).toContain(full);
460
+ }
461
+ });
462
+ });
463
+
464
+ describe('detectProjectLang', () => {
465
+ it('returns empty array for project with no known markers', () => {
466
+ const { detectProjectLang } = require('../detect-deps');
467
+ expect(detectProjectLang(tmpHome)).toEqual([]);
468
+ });
469
+
470
+ it('detects typescript from tsconfig.json', () => {
471
+ fs.writeFileSync(path.join(tmpHome, 'tsconfig.json'), '{}');
472
+ const { detectProjectLang } = require('../detect-deps');
473
+ expect(detectProjectLang(tmpHome)).toContain('typescript');
474
+ });
475
+
476
+ it('detects python from pyproject.toml', () => {
477
+ fs.writeFileSync(path.join(tmpHome, 'pyproject.toml'), '[project]\n');
478
+ const { detectProjectLang } = require('../detect-deps');
479
+ expect(detectProjectLang(tmpHome)).toContain('python');
480
+ });
481
+
482
+ it('detects go from go.mod', () => {
483
+ fs.writeFileSync(path.join(tmpHome, 'go.mod'), 'module example.com/m\n');
484
+ const { detectProjectLang } = require('../detect-deps');
485
+ expect(detectProjectLang(tmpHome)).toContain('go');
486
+ });
487
+
488
+ it('detects java from pom.xml', () => {
489
+ fs.writeFileSync(path.join(tmpHome, 'pom.xml'), '<project></project>');
490
+ const { detectProjectLang } = require('../detect-deps');
491
+ expect(detectProjectLang(tmpHome)).toContain('java');
492
+ });
493
+
494
+ it('detects multiple languages in monorepo', () => {
495
+ fs.writeFileSync(path.join(tmpHome, 'package.json'), JSON.stringify({ devDependencies: { typescript: '^5.0.0' } }));
496
+ fs.writeFileSync(path.join(tmpHome, 'go.mod'), 'module example.com/m\n');
497
+ const { detectProjectLang } = require('../detect-deps');
498
+ const langs = detectProjectLang(tmpHome);
499
+ expect(langs).toContain('typescript');
500
+ expect(langs).toContain('go');
501
+ });
502
+ });
503
+
416
504
  describe('checkCliTool', () => {
417
505
  const { execSync: realExecSync } = require('child_process');
418
506
 
@@ -931,4 +931,32 @@ describe('doctor', () => {
931
931
  const currentContent = fs.readFileSync(path.join(projectAdaptersDir(), 'gate-3.sh'), 'utf8');
932
932
  expect(currentContent).toBe(originalContent);
933
933
  });
934
+
935
+ it('--format json returns valid JSON with tools field', async () => {
936
+ setupLocalInstall();
937
+ seedVersionCache();
938
+ mockExecSuccess();
939
+ const { doctor } = require('../doctor');
940
+ const { formatDoctorJson } = require('../doctor');
941
+
942
+ const checks = [
943
+ { name: 'CLI tool: jscpd (Gate 2)', status: 'PASS', detail: 'v0.3.5' },
944
+ { name: 'CLI tool: lizard (Gate 3)', status: 'WARN', detail: 'Not found' },
945
+ ];
946
+ const json = formatDoctorJson(checks, 1);
947
+ expect(json.ok).toBe(false);
948
+ expect(json.issues).toBe(1);
949
+ expect(json.checks).toHaveLength(2);
950
+ expect(json.missing_tools).toBeDefined();
951
+ });
952
+
953
+ it('--format json returns ok:true when no issues', async () => {
954
+ const { formatDoctorJson } = require('../doctor');
955
+ const checks = [
956
+ { name: 'CLI tool: jscpd (Gate 2)', status: 'PASS', detail: 'v0.3.5' },
957
+ ];
958
+ const json = formatDoctorJson(checks, 0);
959
+ expect(json.ok).toBe(true);
960
+ expect(json.issues).toBe(0);
961
+ });
934
962
  });
@@ -0,0 +1,41 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ describe('install-cmd', () => {
6
+ let tmpDir;
7
+ let consoleLogSpy;
8
+
9
+ beforeEach(() => {
10
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-install-'));
11
+ consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
12
+ vi.resetModules();
13
+ });
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(tmpDir, { recursive: true, force: true });
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ it('exports install function', () => {
21
+ const { install } = require('../install-cmd');
22
+ expect(typeof install).toBe('function');
23
+ });
24
+
25
+ it('install accepts --global flag', async () => {
26
+ const { install } = require('../install-cmd');
27
+ const code = await install(['--global'], tmpDir);
28
+ expect(typeof code).toBe('number');
29
+ });
30
+
31
+ it('install runs without --global (local mode) in git repo', async () => {
32
+ // Simulate git repo
33
+ const gitDir = path.join(tmpDir, '.git');
34
+ fs.mkdirSync(gitDir, { recursive: true });
35
+ fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true });
36
+
37
+ const { install } = require('../install-cmd');
38
+ const code = await install([], tmpDir);
39
+ expect(typeof code).toBe('number');
40
+ });
41
+ });
@@ -623,4 +623,57 @@ describe('uninstall', () => {
623
623
  // After successful uninstall, backup should be cleaned up
624
624
  expect(fs.existsSync(backupDir())).toBe(false);
625
625
  });
626
+
627
+ it('--purge in global mode removes ~/.xp-gate/ and ~/.config/xp-gate/ config dir', async () => {
628
+ setupGlobalInstall();
629
+ const expectedHooksPath = globalHooksDir();
630
+
631
+ const xpGateDir = path.join(tmpHome, '.xp-gate');
632
+ fs.mkdirSync(path.join(xpGateDir, 'reports', 'pre-commit'), { recursive: true });
633
+ fs.writeFileSync(path.join(xpGateDir, 'reports', 'pre-commit', 'test.json'), '{}');
634
+
635
+ mockExecGlobalHooksPath(expectedHooksPath);
636
+ const { uninstall } = require('../uninstall');
637
+
638
+ const result = await uninstall(['--purge']);
639
+
640
+ expect(result).toBe(0);
641
+ expect(fs.existsSync(xpGateDir)).toBe(false);
642
+ expect(fs.existsSync(path.join(tmpHome, '.config', 'xp-gate'))).toBe(false);
643
+ });
644
+
645
+ it('--purge in local mode removes project and global .xp-gate/ dirs', async () => {
646
+ setupLocalInstall();
647
+
648
+ const projectXpGate = path.join(tmpProject, '.xp-gate');
649
+ fs.mkdirSync(path.join(projectXpGate, 'quality-status'), { recursive: true });
650
+ fs.writeFileSync(path.join(projectXpGate, 'quality-status', 'main.json'), '{}');
651
+
652
+ const globalXpGate = path.join(tmpHome, '.xp-gate');
653
+ fs.mkdirSync(path.join(globalXpGate, 'reports', 'pre-commit'), { recursive: true });
654
+ fs.writeFileSync(path.join(globalXpGate, 'reports', 'pre-commit', 'test.json'), '{}');
655
+
656
+ mockExecSuccess();
657
+ const { uninstall } = require('../uninstall');
658
+
659
+ const result = await uninstall(['--purge']);
660
+
661
+ expect(result).toBe(0);
662
+ expect(fs.existsSync(projectXpGate)).toBe(false);
663
+ expect(fs.existsSync(globalXpGate)).toBe(false);
664
+ expect(fs.existsSync(path.join(tmpHome, '.config', 'xp-gate'))).toBe(false);
665
+ });
666
+
667
+ it('--purge --dry-run prints purge plan without deleting', async () => {
668
+ setupGlobalInstall();
669
+ mockExecGlobalHooksPath(globalHooksDir());
670
+ const { uninstall } = require('../uninstall');
671
+
672
+ const result = await uninstall(['--purge', '--dry-run']);
673
+
674
+ expect(result).toBe(0);
675
+ expect(fs.existsSync(globalHooksDir())).toBe(true);
676
+ expect(fs.existsSync(globalAdaptersDir())).toBe(true);
677
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Purge'));
678
+ });
626
679
  });
@@ -347,10 +347,12 @@ describe('updateHooks', () => {
347
347
 
348
348
  describe('getProjectHooksDir', () => {
349
349
  it('returns .git/hooks path under cwd', () => {
350
+ const gitDir = path.join(tmpProject, '.git');
351
+ fs.mkdirSync(gitDir, { recursive: true });
352
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
350
353
  const mod = getModule();
351
- const cwd = process.cwd();
352
354
  const result = mod.getProjectHooksDir();
353
- expect(result).toBe(path.join(cwd, '.git', 'hooks'));
355
+ expect(result).toBe(path.join(tmpProject, '.git', 'hooks'));
354
356
  });
355
357
 
356
358
  it('throws when .git/ does not exist', () => {
@@ -518,4 +518,6 @@ module.exports = {
518
518
  GATE_CLI_TOOLS,
519
519
  checkCliTool,
520
520
  getToolInstallCmd,
521
+ GATE_TOOLS: require('./gate-tools.js').GATE_TOOLS,
522
+ detectProjectLang: require('./gate-tools.js').detectProjectLang,
521
523
  };
package/lib/doctor.js CHANGED
@@ -804,6 +804,18 @@ async function doctor(args) {
804
804
  return issues > 0 ? 1 : 0;
805
805
  }
806
806
 
807
+ function formatDoctorJson(checks, issues) {
808
+ const missing_tools = checks
809
+ .filter(c => c.status === 'WARN' || c.status === 'FAIL')
810
+ .map(c => ({ name: c.name, detail: c.detail }));
811
+ return {
812
+ ok: issues === 0,
813
+ issues,
814
+ checks: checks.map(c => ({ name: c.name, status: c.status, detail: c.detail })),
815
+ missing_tools,
816
+ };
817
+ }
818
+
807
819
  module.exports = {
808
820
  doctor, isXpGateFile, SIGNATURES,
809
821
  fixVersionMismatch,
@@ -815,4 +827,5 @@ module.exports = {
815
827
  fixTuiRegistration,
816
828
  ensureTuiRegistration,
817
829
  readTuiJson,
830
+ formatDoctorJson,
818
831
  };
@@ -0,0 +1,87 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const GATE_TOOLS = {
5
+ PLATFORM: ['jscpd', 'lizard', 'semgrep', 'gitleaks', 'npx'],
6
+ IAC: ['checkov', 'hadolint', 'kube-score', 'tflint'],
7
+
8
+ LANG_MAP: {
9
+ ts: 'typescript', py: 'python', go: 'go', java: 'java',
10
+ kt: 'kotlin', cpp: 'cpp', swift: 'swift', dart: 'dart',
11
+ flutter: 'flutter', sh: 'shell', ps: 'powershell', objc: 'objectivec',
12
+ },
13
+
14
+ LINT: {
15
+ typescript: ['biome', 'eslint', 'tsc'],
16
+ python: ['ruff', 'flake8', 'mypy', 'black', 'isort'],
17
+ go: ['golangci-lint', 'go'],
18
+ java: ['checkstyle', 'pmd'],
19
+ kotlin: ['ktlint', 'detekt'],
20
+ cpp: ['clang-tidy', 'cppcheck', 'cmake'],
21
+ swift: ['swiftlint', 'swift'],
22
+ dart: ['dart'],
23
+ flutter: ['flutter'],
24
+ shell: ['shellcheck'],
25
+ powershell: ['pwsh'],
26
+ objectivec: ['oclint'],
27
+ },
28
+
29
+ TEST: {
30
+ typescript: ['vitest', 'jest'],
31
+ python: ['pytest'],
32
+ go: ['go'],
33
+ java: ['mvn', 'gradle'],
34
+ kotlin: ['mvn', 'gradle'],
35
+ swift: ['swift'],
36
+ cpp: ['ctest', 'gcovr'],
37
+ dart: ['dart'],
38
+ flutter: ['flutter'],
39
+ },
40
+
41
+ MUTATION: {
42
+ typescript: ['stryker'],
43
+ python: ['mutmut'],
44
+ go: ['gomutants'],
45
+ java: ['pitest'],
46
+ },
47
+
48
+ SPECIAL: {
49
+ jq: { gate: 'MW', desc: 'code-walkthrough JSON parser (mandatory)' },
50
+ tsx: { gate: 'M/M3/4/6', desc: 'TypeScript executor (via npx)' },
51
+ node: { gate: '0/4/6/M/M3', desc: 'All TypeScript gate runtime' },
52
+ },
53
+ };
54
+
55
+ const LANG_MARKERS = [
56
+ { file: 'tsconfig.json', lang: 'typescript' },
57
+ { file: 'package.json', check: (p) => {
58
+ try {
59
+ const json = JSON.parse(fs.readFileSync(p, 'utf8'));
60
+ return !!(json.devDependencies && json.devDependencies.typescript)
61
+ || !!(json.dependencies && json.dependencies.typescript);
62
+ } catch { return false; }
63
+ }, lang: 'typescript' },
64
+ { file: 'go.mod', lang: 'go' },
65
+ { file: 'pyproject.toml', lang: 'python' },
66
+ { file: 'manage.py', lang: 'python' },
67
+ { file: 'requirements.txt', lang: 'python' },
68
+ { file: 'pom.xml', lang: 'java' },
69
+ { file: 'build.gradle', lang: 'java' },
70
+ { file: 'build.gradle.kts', lang: 'kotlin' },
71
+ { file: 'pubspec.yaml', lang: 'flutter' },
72
+ { file: 'CMakeLists.txt', lang: 'cpp' },
73
+ { file: 'Package.swift', lang: 'swift' },
74
+ ];
75
+
76
+ function detectProjectLang(projectRoot) {
77
+ const found = new Set();
78
+ const exists = (f) => fs.existsSync(path.join(projectRoot, f));
79
+ for (const marker of LANG_MARKERS) {
80
+ if (!exists(marker.file)) continue;
81
+ if (marker.check && !marker.check(path.join(projectRoot, marker.file))) continue;
82
+ found.add(marker.lang);
83
+ }
84
+ return [...found];
85
+ }
86
+
87
+ module.exports = { GATE_TOOLS, detectProjectLang };
package/lib/init.js CHANGED
@@ -2,6 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const crypto = require('crypto');
4
4
  const { checkDeps, checkBash, autoInstallDeps, detectPlatform, GATE_CLI_TOOLS, checkCliTool } = require('./detect-deps.js');
5
+ const { copyHooks, copyAdapters } = require('./shared-utils.js');
5
6
  const {
6
7
  HOME_DIR,
7
8
  CONFIG_DIR,
@@ -11,42 +12,6 @@ const {
11
12
  GLOBAL_ADAPTERS_DIR,
12
13
  } = require('./shared-paths.js');
13
14
 
14
- function copyHooks(srcDir, destDir) {
15
- ['pre-commit', 'pre-push'].forEach(hook => {
16
- const src = path.join(srcDir, 'hooks', hook);
17
- const dest = path.join(destDir, hook);
18
- if (fs.existsSync(src)) {
19
- fs.copyFileSync(src, dest);
20
- fs.chmodSync(dest, 0o755);
21
- }
22
- });
23
- }
24
-
25
- function copyAdapters(srcDir, destDir) {
26
- const adapterSrc = path.join(srcDir, 'adapter-common.sh');
27
- if (fs.existsSync(adapterSrc)) {
28
- fs.copyFileSync(adapterSrc, path.join(destDir, 'adapter-common.sh'));
29
- }
30
- const adaptersDir = path.join(srcDir, 'adapters');
31
- if (fs.existsSync(adaptersDir)) {
32
- fs.readdirSync(adaptersDir).forEach(f => {
33
- if (f.endsWith('.sh')) {
34
- fs.copyFileSync(path.join(adaptersDir, f), path.join(destDir, f));
35
- }
36
- });
37
- }
38
- // Copy gate scripts (gate-*.sh) from githooks/ source-of-truth to destDir.
39
- // These are sourced by pre-commit via GATE_DIR which resolves to the adapter dir.
40
- const githooksDir = path.resolve(srcDir, '..', '..', '..', 'githooks');
41
- if (fs.existsSync(githooksDir)) {
42
- fs.readdirSync(githooksDir).forEach(f => {
43
- if (f.startsWith('gate-') && f.endsWith('.sh')) {
44
- fs.copyFileSync(path.join(githooksDir, f), path.join(destDir, f));
45
- }
46
- });
47
- }
48
- }
49
-
50
15
  function copyRecursive(src, dest) {
51
16
  const stat = fs.statSync(src);
52
17
  if (stat.isDirectory()) {