@boyingliu01/xp-gate 0.9.3 → 0.9.5

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 (38) hide show
  1. package/lib/__tests__/baseline.test.js +89 -0
  2. package/lib/__tests__/doctor-helpers.test.js +139 -0
  3. package/lib/__tests__/upgrade-exec.test.js +42 -33
  4. package/lib/baseline.js +75 -48
  5. package/lib/doctor.js +199 -96
  6. package/lib/install-skill.js +81 -62
  7. package/lib/shared-phase-constants.d.ts +17 -0
  8. package/lib/shared-phase-constants.js +77 -0
  9. package/lib/shared-utils.js +14 -3
  10. package/lib/sprint-status.js +75 -83
  11. package/lib/upgrade.js +128 -85
  12. package/mock-policy/AGENTS.md +3 -3
  13. package/mutation/AGENTS.md +3 -3
  14. package/package.json +1 -1
  15. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  17. package/plugins/claude-code/skills/delphi-review/SKILL.md +34 -0
  18. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  19. package/plugins/claude-code/skills/sprint-flow/SKILL.md +53 -1
  20. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  21. package/plugins/opencode/__tests__/xp-gate-update.test.ts +123 -4
  22. package/plugins/opencode/index.ts +27 -3
  23. package/plugins/opencode/package.json +1 -1
  24. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  25. package/plugins/opencode/skills/delphi-review/SKILL.md +34 -0
  26. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  27. package/plugins/opencode/skills/sprint-flow/SKILL.md +53 -1
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/plugins/opencode/tui-plugin.ts +49 -76
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/principles/AGENTS.md +3 -3
  34. package/skills/delphi-review/AGENTS.md +3 -3
  35. package/skills/delphi-review/SKILL.md +34 -0
  36. package/skills/sprint-flow/AGENTS.md +3 -3
  37. package/skills/sprint-flow/SKILL.md +53 -1
  38. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -66,6 +66,95 @@ describe('baseline.js - CLI handler', () => {
66
66
  });
67
67
  });
68
68
 
69
+ // === TECH-005/006/007: Extracted helper unit tests (CCN reduction) ===
70
+
71
+ describe('countBaselineByTool', () => {
72
+ it('counts files by tool type from baseline data', () => {
73
+ const doc = require('../baseline.js');
74
+ const data = {
75
+ 'a.ts': { eslint: { warnings: 3, errors: 0 }, totalWarnings: 3, lastAnalyzed: '2026-01-01' },
76
+ 'b.py': { ruff: { warnings: 2, errors: 0 }, totalWarnings: 2, lastAnalyzed: '2026-01-01' },
77
+ 'c.go': { golangci: { warnings: 1, errors: 1 }, totalWarnings: 1, lastAnalyzed: '2026-01-01' },
78
+ 'd.sh': { shellcheck: { warnings: 4, errors: 0 }, totalWarnings: 4, lastAnalyzed: '2026-01-01' },
79
+ };
80
+ const result = doc.countBaselineByTool(data);
81
+ expect(result.eslintCount).toBe(1);
82
+ expect(result.ruffCount).toBe(1);
83
+ expect(result.golangciCount).toBe(1);
84
+ expect(result.shellcheckCount).toBe(1);
85
+ expect(result.totalFiles).toBe(4);
86
+ expect(result.totalWarnings).toBe(10);
87
+ });
88
+
89
+ it('returns zeroes for empty baseline', () => {
90
+ const doc = require('../baseline.js');
91
+ const result = doc.countBaselineByTool({});
92
+ expect(result.eslintCount).toBe(0);
93
+ expect(result.ruffCount).toBe(0);
94
+ expect(result.totalFiles).toBe(0);
95
+ expect(result.totalWarnings).toBe(0);
96
+ });
97
+
98
+ it('handles mixed entries with some missing tool fields', () => {
99
+ const doc = require('../baseline.js');
100
+ const data = {
101
+ 'a.ts': { eslint: { warnings: 3, errors: 0 }, totalWarnings: 3, lastAnalyzed: '2026-01-01' },
102
+ 'b.ts': { totalWarnings: 0, lastAnalyzed: '2026-01-01' },
103
+ };
104
+ const result = doc.countBaselineByTool(data);
105
+ expect(result.eslintCount).toBe(1);
106
+ expect(result.totalFiles).toBe(2);
107
+ expect(result.totalWarnings).toBe(3);
108
+ });
109
+ });
110
+
111
+ describe('compareBaselines', () => {
112
+ it('detects added, removed, increased, and decreased files', () => {
113
+ const doc = require('../baseline.js');
114
+ const oldB = {
115
+ 'stable.ts': { totalWarnings: 3 },
116
+ 'increased.ts': { totalWarnings: 2 },
117
+ 'decreased.ts': { totalWarnings: 5 },
118
+ 'removed.ts': { totalWarnings: 1 },
119
+ };
120
+ const newB = {
121
+ 'stable.ts': { totalWarnings: 3 },
122
+ 'increased.ts': { totalWarnings: 5 },
123
+ 'decreased.ts': { totalWarnings: 1 },
124
+ 'added.ts': { totalWarnings: 4 },
125
+ };
126
+ const result = doc.compareBaselines(oldB, newB);
127
+ expect(result.totalWarningsDelta).toBe(2);
128
+ expect(result.increased.length).toBe(1);
129
+ expect(result.increased[0]).toContain('increased.ts');
130
+ expect(result.decreased.length).toBe(1);
131
+ expect(result.decreased[0]).toContain('decreased.ts');
132
+ expect(result.added.length).toBe(1);
133
+ expect(result.added[0]).toContain('added.ts');
134
+ expect(result.removed.length).toBe(1);
135
+ expect(result.removed[0]).toContain('removed.ts');
136
+ });
137
+
138
+ it('returns empty arrays when baselines are identical', () => {
139
+ const doc = require('../baseline.js');
140
+ const baseline = { 'file.ts': { totalWarnings: 3 } };
141
+ const result = doc.compareBaselines(baseline, baseline);
142
+ expect(result.totalWarningsDelta).toBe(0);
143
+ expect(result.increased).toEqual([]);
144
+ expect(result.decreased).toEqual([]);
145
+ expect(result.added).toEqual([]);
146
+ expect(result.removed).toEqual([]);
147
+ });
148
+
149
+ it('handles empty old or new baseline', () => {
150
+ const doc = require('../baseline.js');
151
+ const baseline = { 'file.ts': { totalWarnings: 3 } };
152
+ const result = doc.compareBaselines({}, baseline);
153
+ expect(result.added.length).toBe(1);
154
+ expect(result.totalWarningsDelta).toBe(3);
155
+ });
156
+ });
157
+
69
158
  describe('showBaseline - no baseline file', () => {
70
159
  it('shows message when no baseline exists', () => {
71
160
  const logs = [];
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @test TECH-001 Extracted doctor.js fix helpers (CCN reduction)
3
+ * @intent Unit-test each extracted helper in isolation
4
+ * @covers TECH-001
5
+ */
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ describe('fixVersionMismatch', () => {
11
+ let tmpHome;
12
+ let originalHome;
13
+
14
+ beforeEach(() => {
15
+ originalHome = process.env.HOME;
16
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dh-'));
17
+ process.env.HOME = tmpHome;
18
+ vi.resetModules();
19
+ delete require.cache[require.resolve('../doctor')];
20
+ });
21
+
22
+ afterEach(() => {
23
+ process.env.HOME = originalHome;
24
+ if (tmpHome && fs.existsSync(tmpHome)) {
25
+ fs.rmSync(tmpHome, { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ function ensureConfigDir() {
30
+ const cfgDir = path.join(tmpHome, '.config', 'xp-gate');
31
+ fs.mkdirSync(cfgDir, { recursive: true });
32
+ }
33
+
34
+ it('updates config version when package version differs', () => {
35
+ ensureConfigDir();
36
+ delete require.cache[require.resolve('../doctor')];
37
+ const doc = require('../doctor');
38
+ const config = { version: '0.3.1.1', mode: 'local' };
39
+ const result = doc.fixVersionMismatch(config, '0.9.3.0');
40
+ expect(result).toBe(true);
41
+ expect(config.version).toBe('0.9.3.0');
42
+ });
43
+
44
+ it('skips when package version is null', () => {
45
+ delete require.cache[require.resolve('../doctor')];
46
+ const doc = require('../doctor');
47
+ const config = { version: '0.3.1.1', mode: 'local' };
48
+ const result = doc.fixVersionMismatch(config, null);
49
+ expect(result).toBe(false);
50
+ expect(config.version).toBe('0.3.1.1');
51
+ });
52
+
53
+ it('skips when versions already match', () => {
54
+ delete require.cache[require.resolve('../doctor')];
55
+ const doc = require('../doctor');
56
+ const config = { version: '0.9.3.0', mode: 'local' };
57
+ const result = doc.fixVersionMismatch(config, '0.9.3.0');
58
+ expect(result).toBe(false);
59
+ expect(config.version).toBe('0.9.3.0');
60
+ });
61
+ });
62
+
63
+ describe('fixMissingHooks', () => {
64
+ let tmpHome;
65
+ let tmpProject;
66
+ let originalHome;
67
+
68
+ beforeEach(() => {
69
+ originalHome = process.env.HOME;
70
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dh-'));
71
+ tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dh-proj-'));
72
+ process.env.HOME = tmpHome;
73
+ vi.resetModules();
74
+ delete require.cache[require.resolve('../doctor')];
75
+ });
76
+
77
+ afterEach(() => {
78
+ process.env.HOME = originalHome;
79
+ if (tmpHome && fs.existsSync(tmpHome)) {
80
+ fs.rmSync(tmpHome, { recursive: true, force: true });
81
+ }
82
+ if (tmpProject && fs.existsSync(tmpProject)) {
83
+ fs.rmSync(tmpProject, { recursive: true, force: true });
84
+ }
85
+ });
86
+
87
+ it('restores pre-commit and pre-push hooks for local mode', () => {
88
+ delete require.cache[require.resolve('../doctor')];
89
+ const srcDir = path.join(__dirname, '..', '..');
90
+ const hooksDir = path.join(tmpProject, '.git', 'hooks');
91
+ fs.mkdirSync(hooksDir, { recursive: true });
92
+ const preCommit = path.join(hooksDir, 'pre-commit');
93
+ if (fs.existsSync(preCommit)) fs.unlinkSync(preCommit);
94
+ const doc = require('../doctor');
95
+ const result = doc.fixMissingHooks('local', srcDir, hooksDir);
96
+ expect(result).toBe(true);
97
+ expect(fs.existsSync(preCommit)).toBe(true);
98
+ });
99
+ });
100
+
101
+ describe('fixMissingAdapters', () => {
102
+ let tmpHome;
103
+ let tmpProject;
104
+ let originalHome;
105
+
106
+ beforeEach(() => {
107
+ originalHome = process.env.HOME;
108
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dh-'));
109
+ tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-dh-proj-'));
110
+ process.env.HOME = tmpHome;
111
+ vi.resetModules();
112
+ delete require.cache[require.resolve('../doctor')];
113
+ });
114
+
115
+ afterEach(() => {
116
+ process.env.HOME = originalHome;
117
+ if (tmpHome && fs.existsSync(tmpHome)) {
118
+ fs.rmSync(tmpHome, { recursive: true, force: true });
119
+ }
120
+ if (tmpProject && fs.existsSync(tmpProject)) {
121
+ fs.rmSync(tmpProject, { recursive: true, force: true });
122
+ }
123
+ });
124
+
125
+ it('copies adapters when directory is missing', () => {
126
+ delete require.cache[require.resolve('../doctor')];
127
+ const srcDir = path.join(__dirname, '..', '..');
128
+ const adaptersDir = path.join(tmpProject, 'githooks', 'adapters');
129
+ if (fs.existsSync(adaptersDir)) {
130
+ fs.rmSync(path.join(tmpProject, 'githooks'), { recursive: true, force: true });
131
+ }
132
+ const doc = require('../doctor');
133
+ const result = doc.fixMissingAdapters('local', srcDir, adaptersDir);
134
+ expect(result).toBe(true);
135
+ expect(fs.existsSync(adaptersDir)).toBe(true);
136
+ const files = fs.readdirSync(adaptersDir).filter(f => f.endsWith('.sh'));
137
+ expect(files.length).toBeGreaterThan(0);
138
+ });
139
+ });
@@ -12,6 +12,8 @@
12
12
  * standalone test verification.
13
13
  */
14
14
 
15
+ const { EventEmitter } = require('events');
16
+
15
17
  describe('upgrade.js --apply execSync path', () => {
16
18
  function evictCache() {
17
19
  const resolved = require.resolve('../upgrade');
@@ -24,7 +26,19 @@ describe('upgrade.js --apply execSync path', () => {
24
26
  delete require.cache[cvResolved];
25
27
  }
26
28
 
27
- function withMockedEnv(latestVersion, execSyncImpl, fn) {
29
+ function spawnEE(closeCode) {
30
+ const ee = new EventEmitter();
31
+ process.nextTick(() => ee.emit('close', closeCode));
32
+ return ee;
33
+ }
34
+
35
+ function spawnErr(err) {
36
+ const ee = new EventEmitter();
37
+ process.nextTick(() => ee.emit('error', err));
38
+ return ee;
39
+ }
40
+
41
+ function withMockedEnv(latestVersion, spawnImpl, fn) {
28
42
  const fs = require('fs');
29
43
  const os = require('os');
30
44
  const cpPath = require('path').join(os.homedir(), '.xp-gate', 'version-cache.json');
@@ -34,8 +48,8 @@ describe('upgrade.js --apply execSync path', () => {
34
48
  evictCache();
35
49
  const cp = require('child_process');
36
50
  const https = require('https');
37
- const saved = { execSync: cp.execSync, httpsGet: https.get };
38
- cp.execSync = execSyncImpl;
51
+ const saved = { spawn: cp.spawn, httpsGet: https.get };
52
+ cp.spawn = spawnImpl;
39
53
  const body = JSON.stringify({ latest: latestVersion });
40
54
  https.get = (_url, _opts, cb) => {
41
55
  const callback = typeof _opts === 'function' ? _opts : cb;
@@ -55,61 +69,56 @@ describe('upgrade.js --apply execSync path', () => {
55
69
  const m = require('../upgrade');
56
70
  return fn(m);
57
71
  } finally {
58
- cp.execSync = saved.execSync;
72
+ cp.spawn = saved.spawn;
59
73
  https.get = saved.httpsGet;
60
74
  }
61
75
  }
62
76
 
63
- it('returns 0 when execSync succeeds', async () => {
64
- const code = await withMockedEnv('99.99.99', vi.fn().mockReturnValue(Buffer.from('')), async (m) => {
77
+ it('returns 0 when spawn succeeds', async () => {
78
+ const code = await withMockedEnv('99.99.99', vi.fn(() => spawnEE(0)), async (m) => {
65
79
  return m.upgrade(['--apply']);
66
80
  });
67
81
  expect(code).toBe(0);
68
82
  }, 10000);
69
83
 
70
- it('returns 1 when execSync throws EACCES', async () => {
71
- const err = new Error('EACCES');
72
- err.stderr = Buffer.from('EACCES: permission denied');
73
- const code = await withMockedEnv('99.99.99', vi.fn(() => { throw err; }), async (m) => {
84
+ it('returns 1 when spawn errors with EACCES', async () => {
85
+ const err = new Error('EACCES: permission denied');
86
+ const code = await withMockedEnv('99.99.99', vi.fn(() => spawnErr(err)), async (m) => {
74
87
  return m.upgrade(['--apply']);
75
88
  });
76
89
  expect(code).toBe(1);
77
90
  }, 10000);
78
91
 
79
- it('returns 1 when execSync throws ETIMEDOUT', async () => {
92
+ it('returns 1 when spawn errors with ETIMEDOUT', async () => {
80
93
  const err = new Error('ETIMEDOUT');
81
- err.code = 'ETIMEDOUT';
82
- err.stderr = Buffer.from('');
83
- const code = await withMockedEnv('99.99.99', vi.fn(() => { throw err; }), async (m) => {
94
+ const code = await withMockedEnv('99.99.99', vi.fn(() => spawnErr(err)), async (m) => {
84
95
  return m.upgrade(['--apply']);
85
96
  });
86
97
  expect(code).toBe(1);
87
98
  }, 10000);
88
99
 
89
- it('returns 1 when execSync throws generic error', async () => {
90
- const err = new Error('generic failure');
91
- err.stderr = Buffer.from('');
92
- const code = await withMockedEnv('99.99.99', vi.fn(() => { throw err; }), async (m) => {
100
+ it('returns 1 when spawn exits with non-zero code', async () => {
101
+ const code = await withMockedEnv('99.99.99', vi.fn(() => spawnEE(1)), async (m) => {
93
102
  return m.upgrade(['--apply']);
94
103
  });
95
104
  expect(code).toBe(1);
96
105
  }, 10000);
97
106
 
98
- it('calls execSync with correct pkgName and version', async () => {
99
- const mockFn = vi.fn().mockReturnValue(Buffer.from(''));
100
- await withMockedEnv('99.99.99', mockFn, async (m) => {
107
+ it('calls spawn with correct args', async () => {
108
+ const mockSpawn = vi.fn(() => spawnEE(0));
109
+ await withMockedEnv('99.99.99', mockSpawn, async (m) => {
101
110
  await m.upgrade(['--apply']);
102
- expect(mockFn).toHaveBeenCalledWith(
103
- expect.stringMatching(/npm install -g .+@99\.99\.99/),
111
+ expect(mockSpawn).toHaveBeenCalledWith(
112
+ 'npm',
113
+ ['install', '-g', expect.stringMatching(/@99\.99\.99/)],
104
114
  expect.objectContaining({ stdio: 'inherit', timeout: 120000 }),
105
115
  );
106
116
  });
107
117
  }, 10000);
108
118
 
109
119
  // ── default mode (no --apply/--preview) outdated path ──
110
- // Covers upgrade.js L110-112: formatUpgradeMsg + console.log
111
120
  it('default mode: shows upgrade msg when outdated', async () => {
112
- await withMockedEnv('99.99.99', vi.fn().mockReturnValue(Buffer.from('')), async (m) => {
121
+ await withMockedEnv('99.99.99', vi.fn(() => spawnEE(0)), async (m) => {
113
122
  const out = [];
114
123
  const origLog = console.log;
115
124
  console.log = (...a) => { out.push(a.join(' ')); };
@@ -125,7 +134,7 @@ describe('upgrade.js --apply execSync path', () => {
125
134
  }, 10000);
126
135
 
127
136
  // ── withMockedEnv variant: also mocks getLocalVersion() to return null ──
128
- function withMockedEnvNoLocal(latestVersion, execSyncImpl, fn) {
137
+ function withMockedEnvNoLocal(latestVersion, spawnImpl, fn) {
129
138
  const fs = require('fs');
130
139
  const os = require('os');
131
140
  const cpPath = require('path').join(os.homedir(), '.xp-gate', 'version-cache.json');
@@ -135,8 +144,8 @@ describe('upgrade.js --apply execSync path', () => {
135
144
  evictCache();
136
145
  const cp = require('child_process');
137
146
  const https = require('https');
138
- const saved = { execSync: cp.execSync, httpsGet: https.get, fsReadFileSync: fs.readFileSync };
139
- cp.execSync = execSyncImpl;
147
+ const saved = { spawn: cp.spawn, httpsGet: https.get, fsReadFileSync: fs.readFileSync };
148
+ cp.spawn = spawnImpl;
140
149
  // Make getLocalVersion() return null by making fs.readFileSync throw for package.json
141
150
  fs.readFileSync = (filePath, encoding) => {
142
151
  if (typeof filePath === 'string' && filePath.includes('package.json')) {
@@ -165,7 +174,7 @@ describe('upgrade.js --apply execSync path', () => {
165
174
  const m = require('../upgrade');
166
175
  return fn(m);
167
176
  } finally {
168
- cp.execSync = saved.execSync;
177
+ cp.spawn = saved.spawn;
169
178
  https.get = saved.httpsGet;
170
179
  fs.readFileSync = saved.fsReadFileSync;
171
180
  }
@@ -178,7 +187,7 @@ describe('upgrade.js --apply execSync path', () => {
178
187
  const origLog = console.log;
179
188
  console.log = (...a) => { out.push(a.join(' ')); };
180
189
  try {
181
- const code = await withMockedEnvNoLocal('0.8.12', vi.fn().mockReturnValue(Buffer.from('')), async (m) => m.upgrade(['--apply']));
190
+ const code = await withMockedEnvNoLocal('0.8.12', vi.fn(() => spawnEE(0)), async (m) => m.upgrade(['--apply']));
182
191
  expect(code).toBe(0);
183
192
  expect(out.join(' ')).toBe('xp-gate is up to date.');
184
193
  } finally {
@@ -192,7 +201,7 @@ describe('upgrade.js --apply execSync path', () => {
192
201
  const origLog = console.log;
193
202
  console.log = (...a) => { out.push(a.join(' ')); };
194
203
  try {
195
- const code = await withMockedEnvNoLocal('0.8.12', vi.fn().mockReturnValue(Buffer.from('')), async (m) => m.upgrade([]));
204
+ const code = await withMockedEnvNoLocal('0.8.12', vi.fn(() => spawnEE(0)), async (m) => m.upgrade([]));
196
205
  expect(code).toBe(0);
197
206
  expect(out.join(' ')).toBe('xp-gate is up to date.');
198
207
  } finally {
@@ -206,7 +215,7 @@ describe('upgrade.js --apply execSync path', () => {
206
215
  const origLog = console.log;
207
216
  console.log = (...a) => { out.push(a.join(' ')); };
208
217
  try {
209
- const code = await withMockedEnv('99.99.99', vi.fn().mockReturnValue(Buffer.from('')), async (m) => m.upgrade([]));
218
+ const code = await withMockedEnv('99.99.99', vi.fn(() => spawnEE(0)), async (m) => m.upgrade([]));
210
219
  expect(code).toBe(0);
211
220
  expect(out.length + (out.join(' ').length > 0 ? 1 : 0)).toBeGreaterThan(0);
212
221
  } finally {
@@ -220,7 +229,7 @@ describe('upgrade.js --apply execSync path', () => {
220
229
  const origLog = console.log;
221
230
  console.log = (...a) => { out.push(a.join(' ')); };
222
231
  try {
223
- const code = await withMockedEnv('1.0.0', vi.fn().mockReturnValue(Buffer.from('')), async (m) => m.upgrade(['--preview']));
232
+ const code = await withMockedEnv('1.0.0', vi.fn(() => spawnEE(0)), async (m) => m.upgrade(['--preview']));
224
233
  expect(code).toBe(0);
225
234
  expect(out.length).toBe(1);
226
235
  const parsed = JSON.parse(out[0]);
package/lib/baseline.js CHANGED
@@ -220,6 +220,29 @@ async function createBaseline() {
220
220
  return baseline;
221
221
  }
222
222
 
223
+ /**
224
+ * Count baseline entries grouped by tool type.
225
+ * @param {object} data - Parsed baseline object (filePath → entry)
226
+ * @returns {{ totalFiles: number, totalWarnings: number, eslintCount: number, ruffCount: number, golangciCount: number, shellcheckCount: number }}
227
+ */
228
+ function countBaselineByTool(data) {
229
+ const files = Object.keys(data);
230
+ const totalWarnings = files.reduce((s, f) => s + data[f].totalWarnings, 0);
231
+ let eslintCount = 0;
232
+ let ruffCount = 0;
233
+ let golangciCount = 0;
234
+ let shellcheckCount = 0;
235
+
236
+ for (const entry of Object.values(data)) {
237
+ if (entry.eslint) eslintCount++;
238
+ if (entry.ruff) ruffCount++;
239
+ if (entry.golangci) golangciCount++;
240
+ if (entry.shellcheck) shellcheckCount++;
241
+ }
242
+
243
+ return { totalFiles: files.length, totalWarnings, eslintCount, ruffCount, golangciCount, shellcheckCount };
244
+ }
245
+
223
246
  /**
224
247
  * Show current baseline.
225
248
  */
@@ -237,27 +260,16 @@ function showBaseline() {
237
260
  return 0;
238
261
  }
239
262
 
240
- const totalWarnings = files.reduce((s, f) => s + data[f].totalWarnings, 0);
241
- let eslintCount = 0;
242
- let ruffCount = 0;
243
- let golangciCount = 0;
244
- let shellcheckCount = 0;
245
-
246
- for (const entry of Object.values(data)) {
247
- if (entry.eslint) eslintCount++;
248
- if (entry.ruff) ruffCount++;
249
- if (entry.golangci) golangciCount++;
250
- if (entry.shellcheck) shellcheckCount++;
251
- }
263
+ const counts = countBaselineByTool(data);
252
264
 
253
265
  console.log('Lint Baseline:');
254
266
  console.log(` Created: ${data[files[0]]?.lastAnalyzed?.slice(0, 10) || 'N/A'}`);
255
- console.log(` Files tracked: ${files.length}`);
256
- console.log(` Total warnings: ${totalWarnings}`);
257
- if (eslintCount > 0) console.log(` ESLint: ${eslintCount} files`);
258
- if (ruffCount > 0) console.log(` Ruff: ${ruffCount} files`);
259
- if (golangciCount > 0) console.log(` golangci-lint: ${golangciCount} files`);
260
- if (shellcheckCount > 0) console.log(` ShellCheck: ${shellcheckCount} files`);
267
+ console.log(` Files tracked: ${counts.totalFiles}`);
268
+ console.log(` Total warnings: ${counts.totalWarnings}`);
269
+ if (counts.eslintCount > 0) console.log(` ESLint: ${counts.eslintCount} files`);
270
+ if (counts.ruffCount > 0) console.log(` Ruff: ${counts.ruffCount} files`);
271
+ if (counts.golangciCount > 0) console.log(` golangci-lint: ${counts.golangciCount} files`);
272
+ if (counts.shellcheckCount > 0) console.log(` ShellCheck: ${counts.shellcheckCount} files`);
261
273
 
262
274
  return 0;
263
275
  }
@@ -272,21 +284,13 @@ async function resetBaseline() {
272
284
  }
273
285
 
274
286
  /**
275
- * Diff current lint state against stored baseline.
287
+ * Compare two baselines and produce diff data.
288
+ * @param {object} oldBaseline
289
+ * @param {object} currentBaseline
290
+ * @returns {{ totalWarningsDelta: number, increased: string[], decreased: string[], added: string[], removed: string[] }}
276
291
  */
277
- async function diffBaseline() {
278
- if (!fs.existsSync(getBaselineFile())) {
279
- console.log('No baseline found. Run `xp-gate baseline create` first.');
280
- return 1;
281
- }
282
-
283
- const oldBaseline = JSON.parse(fs.readFileSync(getBaselineFile(), 'utf8'));
284
- const currentBaseline = await createBaseline();
285
-
286
- const allFiles = new Set([
287
- ...Object.keys(oldBaseline),
288
- ...Object.keys(currentBaseline),
289
- ]);
292
+ function compareBaselines(oldBaseline, currentBaseline) {
293
+ const allFiles = new Set([...Object.keys(oldBaseline), ...Object.keys(currentBaseline)]);
290
294
 
291
295
  let totalWarningsDelta = 0;
292
296
  const increased = [];
@@ -312,31 +316,54 @@ async function diffBaseline() {
312
316
  }
313
317
  }
314
318
 
319
+ return { totalWarningsDelta, increased, decreased, added, removed };
320
+ }
321
+
322
+ /**
323
+ * Print a diff result report to console.
324
+ */
325
+ function printDiffReport(result) {
315
326
  console.log('Lint Baseline Diff:');
316
- console.log(` Total warnings delta: ${totalWarningsDelta >= 0 ? '+' : ''}${totalWarningsDelta}`);
327
+ console.log(` Total warnings delta: ${result.totalWarningsDelta >= 0 ? '+' : ''}${result.totalWarningsDelta}`);
317
328
 
318
- if (added.length > 0) {
319
- console.log(`\nFiles added (${added.length}):`);
320
- added.forEach(l => console.log(l));
329
+ if (result.added.length > 0) {
330
+ console.log(`\nFiles added (${result.added.length}):`);
331
+ result.added.forEach(l => console.log(l));
321
332
  }
322
- if (removed.length > 0) {
323
- console.log(`\nFiles removed (${removed.length}):`);
324
- removed.forEach(l => console.log(l));
333
+ if (result.removed.length > 0) {
334
+ console.log(`\nFiles removed (${result.removed.length}):`);
335
+ result.removed.forEach(l => console.log(l));
325
336
  }
326
- if (increased.length > 0) {
327
- console.log(`\nWarnings increased (${increased.length}):`);
328
- increased.forEach(l => console.log(l));
337
+ if (result.increased.length > 0) {
338
+ console.log(`\nWarnings increased (${result.increased.length}):`);
339
+ result.increased.forEach(l => console.log(l));
329
340
  }
330
- if (decreased.length > 0) {
331
- console.log(`\nWarnings decreased (${decreased.length}):`);
332
- decreased.forEach(l => console.log(l));
341
+ if (result.decreased.length > 0) {
342
+ console.log(`\nWarnings decreased (${result.decreased.length}):`);
343
+ result.decreased.forEach(l => console.log(l));
333
344
  }
334
345
 
335
- if (added.length === 0 && removed.length === 0 && increased.length === 0 && decreased.length === 0) {
346
+ if (result.added.length === 0 && result.removed.length === 0 && result.increased.length === 0 && result.decreased.length === 0) {
336
347
  console.log(' No change from baseline.');
337
348
  }
349
+ }
350
+
351
+ /**
352
+ * Diff current lint state against stored baseline.
353
+ */
354
+ async function diffBaseline() {
355
+ if (!fs.existsSync(getBaselineFile())) {
356
+ console.log('No baseline found. Run `xp-gate baseline create` first.');
357
+ return 1;
358
+ }
359
+
360
+ const oldBaseline = JSON.parse(fs.readFileSync(getBaselineFile(), 'utf8'));
361
+ const currentBaseline = await createBaseline();
362
+
363
+ const result = compareBaselines(oldBaseline, currentBaseline);
364
+ printDiffReport(result);
338
365
 
339
- return totalWarningsDelta > 0 ? 1 : 0;
366
+ return result.totalWarningsDelta > 0 ? 1 : 0;
340
367
  }
341
368
 
342
369
  /**
@@ -389,4 +416,4 @@ Examples:
389
416
  }
390
417
  }
391
418
 
392
- module.exports = { handleBaseline, createBaseline, showBaseline, resetBaseline, diffBaseline };
419
+ module.exports = { handleBaseline, createBaseline, showBaseline, resetBaseline, diffBaseline, countBaselineByTool, compareBaselines };