@boyingliu01/xp-gate 0.14.8 → 0.14.10

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 +23 -2
  2. package/lib/__tests__/install-skill.test.js +2 -1
  3. package/lib/__tests__/sprint-discovery.test.ts +6 -4
  4. package/lib/__tests__/sprint-state-manager.test.js +279 -0
  5. package/lib/__tests__/sprint-status.test.js +10 -13
  6. package/lib/__tests__/sprint-status.test.ts +4 -3
  7. package/lib/__tests__/update-hooks.test.js +12 -0
  8. package/lib/__tests__/upgrade-skill-sync.test.js +65 -0
  9. package/lib/check.js +115 -20
  10. package/lib/doctor.js +63 -0
  11. package/lib/gate-runner.js +209 -0
  12. package/lib/install-skill.js +14 -1
  13. package/lib/next-sprint.js +3 -4
  14. package/lib/shared-utils.js +1 -1
  15. package/lib/sprint-discovery.js +4 -4
  16. package/lib/sprint-state-manager.js +122 -0
  17. package/lib/sprint-state-migrator.js +153 -0
  18. package/lib/sprint-status.js +7 -5
  19. package/lib/update-hooks.js +1 -1
  20. package/lib/upgrade.js +8 -0
  21. package/mock-policy/AGENTS.md +4 -4
  22. package/mutation/AGENTS.md +4 -4
  23. package/mutation/__tests__/init-baseline.test.ts +347 -0
  24. package/mutation/__tests__/mutmut-runner.test.ts +561 -0
  25. package/mutation/__tests__/runners-index.test.ts +134 -0
  26. package/mutation/__tests__/stryker-runner.test.ts +455 -0
  27. package/mutation/__tests__/update-baseline.test.ts +393 -0
  28. package/package.json +1 -1
  29. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  30. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  31. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  32. package/plugins/claude-code/skills/sprint-flow/SKILL.md +1 -1
  33. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +34 -8
  34. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  38. package/plugins/opencode/skills/sprint-flow/SKILL.md +1 -1
  39. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +34 -8
  40. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  41. package/plugins/qoder/plugin.json +1 -1
  42. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  43. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  44. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  45. package/principles/AGENTS.md +4 -4
  46. package/skills/delphi-review/AGENTS.md +4 -4
  47. package/skills/sprint-flow/AGENTS.md +4 -4
  48. package/skills/sprint-flow/SKILL.md +1 -1
  49. package/skills/sprint-flow/references/phase-2-design.md +34 -8
  50. package/skills/test-specification-alignment/AGENTS.md +4 -4
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Sprint State Migrator — legacy phase migration + rollback helpers.
3
+ *
4
+ * Extracted from SprintStateManager to satisfy god-class rule (≤15 methods).
5
+ *
6
+ * @module sprint-state-migrator
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ /**
15
+ * Legacy phase number → v1 phase mapping
16
+ */
17
+ const LEGACY_PHASE_MAP = {
18
+ '-1': { phase: 1, phase_name: 'PREP' },
19
+ '-0.5': { phase: 1, phase_name: 'PREP' },
20
+ '0': { phase: 2, phase_name: 'DESIGN' },
21
+ '1': { phase: 2, phase_name: 'DESIGN' },
22
+ '2': { phase: 3, phase_name: 'BUILD' },
23
+ '3': { phase: 4, phase_name: 'VERIFY' },
24
+ '4': { phase: 4, phase_name: 'VERIFY' },
25
+ '5': { phase: 5, phase_name: 'SHIP' },
26
+ '6': { phase: 5, phase_name: 'SHIP' },
27
+ '7': { phase: 6, phase_name: 'CLOSE' },
28
+ '8': { phase: 6, phase_name: 'CLOSE' },
29
+ };
30
+
31
+ const PHASE_NAMES = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
32
+
33
+ /**
34
+ * Migrate legacy sprint-state.json to v1 schema.
35
+ *
36
+ * @param {Object} state - Raw state from file
37
+ * @param {string} stateDir - Path to .sprint-state directory
38
+ * @returns {Object} Migrated state with _schema_version: 1
39
+ */
40
+ function migrateState(state, stateDir) {
41
+ const warnings = [];
42
+
43
+ state._schema_version = 1;
44
+
45
+ if (!state.task_description) {
46
+ state.task_description = '-';
47
+ warnings.push('task_description was missing, set to "-"');
48
+ }
49
+
50
+ if (!state.id) {
51
+ state.id = `sprint-${Date.now()}`;
52
+ warnings.push('id was missing, generated from timestamp');
53
+ }
54
+
55
+ if (!state.status) { state.status = 'in_progress'; }
56
+ if (!state.started_at) { state.started_at = new Date().toISOString(); }
57
+ if (!state.phase_history) { state.phase_history = []; }
58
+ if (!state.isolation) { state.isolation = { worktree_path: null, branch: null }; }
59
+
60
+ state.phase_history = state.phase_history.map(entry => {
61
+ const phaseNum = String(entry.phase);
62
+ const mapped = LEGACY_PHASE_MAP[phaseNum];
63
+ if (mapped) {
64
+ warnings.push(`Migrated legacy phase ${entry.phase} → ${mapped.phase} (${mapped.phase_name})`);
65
+ return { ...entry, phase: mapped.phase, phase_name: mapped.phase_name };
66
+ }
67
+ if (!entry.phase_name && entry.phase >= 1 && entry.phase <= 6) {
68
+ entry.phase_name = PHASE_NAMES[entry.phase - 1];
69
+ }
70
+ return entry;
71
+ });
72
+
73
+ const currentPhaseNum = String(state.phase);
74
+ const currentMapped = LEGACY_PHASE_MAP[currentPhaseNum];
75
+ if (currentMapped) {
76
+ state.phase = currentMapped.phase;
77
+ warnings.push(`Migrated current phase ${currentPhaseNum} → ${currentMapped.phase}`);
78
+ }
79
+
80
+ state.phase_history = state.phase_history.map(entry => {
81
+ if (entry.timestamp && !entry.started_at) {
82
+ entry.started_at = entry.timestamp;
83
+ }
84
+ return entry;
85
+ });
86
+
87
+ if (warnings.length > 0) {
88
+ logMigrationWarnings(warnings, stateDir);
89
+ }
90
+
91
+ return state;
92
+ }
93
+
94
+ /**
95
+ * Create initial sprint state for new sprints.
96
+ *
97
+ * @param {string} projectRoot - Project root directory
98
+ * @returns {Object} Initial state
99
+ */
100
+ function createInitialState(projectRoot) {
101
+ return {
102
+ _schema_version: 1,
103
+ id: `sprint-${Date.now()}`,
104
+ task_description: '-',
105
+ phase: 1,
106
+ status: 'in_progress',
107
+ started_at: new Date().toISOString(),
108
+ phase_history: [],
109
+ isolation: { worktree_path: projectRoot, branch: null },
110
+ outputs: {},
111
+ metrics: {},
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Log migration warnings to file.
117
+ *
118
+ * @param {string[]} warnings - Array of warning messages
119
+ * @param {string} stateDir - Path to .sprint-state directory
120
+ */
121
+ function logMigrationWarnings(warnings, stateDir) {
122
+ if (!fs.existsSync(stateDir)) {
123
+ fs.mkdirSync(stateDir, { recursive: true });
124
+ }
125
+
126
+ const warningsFile = path.join(stateDir, 'migration-warnings.json');
127
+ const existing = fs.existsSync(warningsFile)
128
+ ? JSON.parse(fs.readFileSync(warningsFile, 'utf8'))
129
+ : [];
130
+
131
+ existing.push({ timestamp: new Date().toISOString(), warnings });
132
+ fs.writeFileSync(warningsFile, JSON.stringify(existing, null, 2), 'utf8');
133
+ }
134
+
135
+ /**
136
+ * Rollback to backup state.
137
+ *
138
+ * @param {string} stateFile - Path to sprint-state.json
139
+ * @param {string} backupFile - Path to sprint-state.json.backup
140
+ * @returns {boolean} True if rollback succeeded
141
+ */
142
+ function rollbackState(stateFile, backupFile) {
143
+ if (!fs.existsSync(backupFile)) { return false; }
144
+ fs.copyFileSync(backupFile, stateFile);
145
+ return true;
146
+ }
147
+
148
+ module.exports = {
149
+ LEGACY_PHASE_MAP,
150
+ migrateState,
151
+ createInitialState,
152
+ rollbackState,
153
+ };
@@ -10,6 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { discoverActiveSprints } = require('./sprint-discovery');
13
+ const { SprintStateManager } = require('./sprint-state-manager');
13
14
 
14
15
  // Phase constants (inlined; was shared-phase-constants.js, removed in v0.13.0 slimming)
15
16
  const PHASE_NAMES = {
@@ -47,15 +48,14 @@ function isStale(state) {
47
48
 
48
49
  /**
49
50
  * Read and parse sprint-state.json from a project directory.
51
+ * Uses SprintStateManager for schema validation + auto-migration.
50
52
  * @param {string} dir - Project root directory
51
53
  * @returns {object|null} Parsed sprint state, or null if not found or malformed
52
54
  */
53
55
  function readSprintState(dir) {
54
56
  try {
55
- const stateFile = path.join(dir, '.sprint-state', 'sprint-state.json');
56
- if (!fs.existsSync(stateFile)) return null;
57
- const raw = fs.readFileSync(stateFile, 'utf8');
58
- return JSON.parse(raw);
57
+ const manager = new SprintStateManager(dir);
58
+ return manager.read();
59
59
  } catch {
60
60
  return null;
61
61
  }
@@ -70,6 +70,7 @@ function statusIcon(status) {
70
70
  switch (status) {
71
71
  case 'completed': return '✅';
72
72
  case 'in_progress': return '🔄';
73
+ case 'skipped': return '⏭️';
73
74
  default: return '⏳';
74
75
  }
75
76
  }
@@ -145,7 +146,8 @@ function formatPhaseLine(key, history, maxNameLen) {
145
146
  const icon = statusIcon(status);
146
147
  const dur = formatDuration(history?.duration_seconds);
147
148
  const statusLabel = status === 'completed' ? 'Completed' :
148
- status === 'in_progress' ? 'In Progress' : 'Pending';
149
+ status === 'in_progress' ? 'In Progress' :
150
+ status === 'skipped' ? 'Skipped' : 'Pending';
149
151
  return ` Phase ${key.padStart(4)} ${name.padEnd(maxNameLen + 1)} ${icon} ${dur.padEnd(5)} ${statusLabel}`;
150
152
  }
151
153
 
@@ -190,7 +190,7 @@ function copyAdapters(srcDir, destDir, dryRun, noBackup) {
190
190
  function copyGateScripts(srcDir, destDir, dryRun, noBackup) {
191
191
  if (!fs.existsSync(srcDir)) return;
192
192
  fs.readdirSync(srcDir).forEach(f => {
193
- if (f.startsWith('gate-') && f.endsWith('.sh')) {
193
+ if ((f.startsWith('gate-') || f === 'sprint-gate.sh') && f.endsWith('.sh')) {
194
194
  const src = path.join(srcDir, f);
195
195
  const dest = path.join(destDir, f);
196
196
  atomicCopyFile(src, dest, dryRun, noBackup, f);
package/lib/upgrade.js CHANGED
@@ -95,6 +95,14 @@ async function handleApplyMode(result, pkgName) {
95
95
  // Also check for and update the local OpenCode plugin if installed.
96
96
  await upgradeOpenCodePlugin();
97
97
 
98
+ // Update all installed skills to match the newly upgraded package (#332)
99
+ try {
100
+ const { updateSkill } = require('./update-skill.js');
101
+ await updateSkill(null, { all: true, verbose: false });
102
+ } catch {
103
+ // Non-blocking: skill update failure should never break the main upgrade flow.
104
+ }
105
+
98
106
  return 0;
99
107
  } catch (err) {
100
108
  const msg = err.message || '';
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-07-13
4
- **Commit:** d83bb96
5
- **Branch:** main
6
- **Version:** 0.14.8.0
3
+ **Generated:** 2026-07-16
4
+ **Commit:** 6193b64
5
+ **Branch:** sprint/2026-07-14-01
6
+ **Version:** 0.14.10.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-07-13
4
- **Commit:** d83bb96
5
- **Branch:** main
6
- **Version:** 0.14.8.0
3
+ **Generated:** 2026-07-16
4
+ **Commit:** 6193b64
5
+ **Branch:** sprint/2026-07-14-01
6
+ **Version:** 0.14.10.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.
@@ -0,0 +1,347 @@
1
+ /**
2
+ * @test init-baseline.ts - Mutation Baseline Initialization
3
+ * @intent Verify argument parsing, score calculation, baseline construction,
4
+ * and source file filtering logic used by the init-baseline CLI script.
5
+ * @covers REQ-MUT-002 AC-001, AC-002, AC-003, AC-004
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+
10
+ // ─── Inline copies of pure functions from init-baseline.ts ───────────────────
11
+ // The module runs main() on import, so we cannot import it directly.
12
+ // These are faithful copies of the pure logic for unit testing.
13
+ // See gate-m.test.ts for the same pattern used in this project.
14
+
15
+ interface MutantStatus {
16
+ id: string;
17
+ mutatorName: string;
18
+ replacement: string;
19
+ location: {
20
+ start: { line: number; column: number };
21
+ end: { line: number; column: number };
22
+ };
23
+ status: string;
24
+ }
25
+
26
+ interface StrykerFileReport {
27
+ source: string;
28
+ mutants: MutantStatus[];
29
+ }
30
+
31
+ interface StrykerReport {
32
+ schemaVersion: string;
33
+ thresholds: {
34
+ high: number;
35
+ low: number;
36
+ break: number | null;
37
+ };
38
+ files: Record<string, StrykerFileReport>;
39
+ }
40
+
41
+ interface MutationScoreEntry {
42
+ score: number;
43
+ mutants: number;
44
+ killed: number;
45
+ survived: number;
46
+ }
47
+
48
+ interface MutationBaseline {
49
+ version: string;
50
+ generatedAt: string;
51
+ source: 'local' | 'ci';
52
+ scores: Record<string, MutationScoreEntry>;
53
+ }
54
+
55
+ function parseArgs(args: string[]): { filesPattern: string; source: 'local' | 'ci' } {
56
+ let filesPattern = 'src/**/*.ts';
57
+ let source: 'local' | 'ci' = 'local';
58
+
59
+ for (let i = 0; i < args.length; i++) {
60
+ if (args[i] === '--files' && args[i + 1]) {
61
+ filesPattern = args[i + 1];
62
+ i++;
63
+ }
64
+ if (args[i] === '--source' && args[i + 1]) {
65
+ source = args[i + 1] as 'local' | 'ci';
66
+ i++;
67
+ }
68
+ }
69
+
70
+ return { filesPattern, source };
71
+ }
72
+
73
+ function calculateScore(entry: StrykerFileReport): MutationScoreEntry {
74
+ const total = entry.mutants.length;
75
+ const killed = entry.mutants.filter(m => m.status === 'Killed').length;
76
+ const survived = entry.mutants.filter(m => m.status === 'Survived').length;
77
+
78
+ return {
79
+ score: total > 0 ? parseFloat(((killed / total) * 100).toFixed(1)) : 100,
80
+ mutants: total,
81
+ killed,
82
+ survived
83
+ };
84
+ }
85
+
86
+ function buildBaseline(
87
+ report: StrykerReport,
88
+ sourceFiles: string[],
89
+ source: 'local' | 'ci'
90
+ ): MutationBaseline {
91
+ const scores: Record<string, MutationScoreEntry> = {};
92
+
93
+ for (const [filePath, fileReport] of Object.entries(report.files)) {
94
+ const relativePath = filePath.replace(/^\//, '');
95
+ if (sourceFiles.includes(relativePath) || sourceFiles.some(sf => relativePath.endsWith(sf))) {
96
+ scores[relativePath] = calculateScore(fileReport);
97
+ }
98
+ }
99
+
100
+ return {
101
+ version: '1.0',
102
+ generatedAt: new Date().toISOString(),
103
+ source,
104
+ scores
105
+ };
106
+ }
107
+
108
+ function isSourceFile(file: string): boolean {
109
+ return (
110
+ file.endsWith('.ts') &&
111
+ !file.endsWith('.d.ts') &&
112
+ !file.endsWith('.test.ts') &&
113
+ !file.includes('/__tests__/') &&
114
+ !file.includes('/adapters/')
115
+ );
116
+ }
117
+
118
+ // ─── Helper factories ────────────────────────────────────────────────────────
119
+
120
+ function makeMutant(status: string, id = '0'): MutantStatus {
121
+ return {
122
+ id,
123
+ mutatorName: 'BlockMutator',
124
+ replacement: '{}',
125
+ location: {
126
+ start: { line: 1, column: 0 },
127
+ end: { line: 1, column: 10 }
128
+ },
129
+ status
130
+ };
131
+ }
132
+
133
+ function makeFileReport(mutants: MutantStatus[]): StrykerFileReport {
134
+ return { source: 'const x = 1;', mutants };
135
+ }
136
+
137
+ // ─── Tests ───────────────────────────────────────────────────────────────────
138
+
139
+ describe('parseArgs', () => {
140
+ it('returns defaults when no arguments provided', () => {
141
+ const result = parseArgs([]);
142
+ expect(result.filesPattern).toBe('src/**/*.ts');
143
+ expect(result.source).toBe('local');
144
+ });
145
+
146
+ it('parses --files flag', () => {
147
+ const result = parseArgs(['--files', 'lib/**/*.ts']);
148
+ expect(result.filesPattern).toBe('lib/**/*.ts');
149
+ expect(result.source).toBe('local');
150
+ });
151
+
152
+ it('parses --source flag', () => {
153
+ const result = parseArgs(['--source', 'ci']);
154
+ expect(result.filesPattern).toBe('src/**/*.ts');
155
+ expect(result.source).toBe('ci');
156
+ });
157
+
158
+ it('parses both --files and --source flags', () => {
159
+ const result = parseArgs(['--files', 'app/**/*.ts', '--source', 'ci']);
160
+ expect(result.filesPattern).toBe('app/**/*.ts');
161
+ expect(result.source).toBe('ci');
162
+ });
163
+
164
+ it('ignores --files without a following value', () => {
165
+ const result = parseArgs(['--files']);
166
+ expect(result.filesPattern).toBe('src/**/*.ts');
167
+ });
168
+
169
+ it('ignores unknown flags', () => {
170
+ const result = parseArgs(['--verbose', '--dry-run']);
171
+ expect(result.filesPattern).toBe('src/**/*.ts');
172
+ expect(result.source).toBe('local');
173
+ });
174
+ });
175
+
176
+ describe('calculateScore', () => {
177
+ it('returns 100 score when no mutants exist', () => {
178
+ const entry = makeFileReport([]);
179
+ const result = calculateScore(entry);
180
+ expect(result.score).toBe(100);
181
+ expect(result.mutants).toBe(0);
182
+ expect(result.killed).toBe(0);
183
+ expect(result.survived).toBe(0);
184
+ });
185
+
186
+ it('calculates score from killed and total mutants', () => {
187
+ const entry = makeFileReport([
188
+ makeMutant('Killed', '0'),
189
+ makeMutant('Killed', '1'),
190
+ makeMutant('Survived', '2'),
191
+ makeMutant('Survived', '3')
192
+ ]);
193
+ const result = calculateScore(entry);
194
+ expect(result.mutants).toBe(4);
195
+ expect(result.killed).toBe(2);
196
+ expect(result.survived).toBe(2);
197
+ expect(result.score).toBe(50);
198
+ });
199
+
200
+ it('returns 100 score when all mutants are killed', () => {
201
+ const entry = makeFileReport([
202
+ makeMutant('Killed', '0'),
203
+ makeMutant('Killed', '1'),
204
+ makeMutant('Killed', '2')
205
+ ]);
206
+ const result = calculateScore(entry);
207
+ expect(result.score).toBe(100);
208
+ expect(result.killed).toBe(3);
209
+ expect(result.survived).toBe(0);
210
+ });
211
+
212
+ it('returns 0 score when all mutants survived', () => {
213
+ const entry = makeFileReport([
214
+ makeMutant('Survived', '0'),
215
+ makeMutant('Survived', '1')
216
+ ]);
217
+ const result = calculateScore(entry);
218
+ expect(result.score).toBe(0);
219
+ expect(result.killed).toBe(0);
220
+ expect(result.survived).toBe(2);
221
+ });
222
+
223
+ it('ignores non-Killed/Survived statuses in killed/survived counts', () => {
224
+ const entry = makeFileReport([
225
+ makeMutant('Killed', '0'),
226
+ makeMutant('NoCoverage', '1'),
227
+ makeMutant('RuntimeError', '2'),
228
+ makeMutant('Survived', '3')
229
+ ]);
230
+ const result = calculateScore(entry);
231
+ expect(result.mutants).toBe(4);
232
+ expect(result.killed).toBe(1);
233
+ expect(result.survived).toBe(1);
234
+ expect(result.score).toBe(25);
235
+ });
236
+
237
+ it('rounds score to one decimal place', () => {
238
+ const entry = makeFileReport([
239
+ makeMutant('Killed', '0'),
240
+ makeMutant('Killed', '1'),
241
+ makeMutant('Survived', '2')
242
+ ]);
243
+ const result = calculateScore(entry);
244
+ // 2/3 * 100 = 66.666... → 66.7
245
+ expect(result.score).toBe(66.7);
246
+ });
247
+ });
248
+
249
+ describe('buildBaseline', () => {
250
+ const emptyReport: StrykerReport = {
251
+ schemaVersion: '1.0',
252
+ thresholds: { high: 80, low: 60, break: null },
253
+ files: {}
254
+ };
255
+
256
+ it('returns empty scores when report has no files', () => {
257
+ const baseline = buildBaseline(emptyReport, [], 'local');
258
+ expect(baseline.version).toBe('1.0');
259
+ expect(baseline.source).toBe('local');
260
+ expect(baseline.scores).toEqual({});
261
+ expect(baseline.generatedAt).toBeDefined();
262
+ });
263
+
264
+ it('includes only source files in baseline scores', () => {
265
+ const report: StrykerReport = {
266
+ schemaVersion: '1.0',
267
+ thresholds: { high: 80, low: 60, break: null },
268
+ files: {
269
+ 'src/auth.ts': makeFileReport([makeMutant('Killed', '0')]),
270
+ 'src/utils.ts': makeFileReport([makeMutant('Survived', '0')]),
271
+ 'dist/bundle.js': makeFileReport([makeMutant('Killed', '0')])
272
+ }
273
+ };
274
+ const sourceFiles = ['src/auth.ts', 'src/utils.ts'];
275
+ const baseline = buildBaseline(report, sourceFiles, 'ci');
276
+ expect(Object.keys(baseline.scores)).toHaveLength(2);
277
+ expect(baseline.scores['src/auth.ts']).toBeDefined();
278
+ expect(baseline.scores['src/utils.ts']).toBeDefined();
279
+ expect(baseline.scores['dist/bundle.js']).toBeUndefined();
280
+ expect(baseline.source).toBe('ci');
281
+ });
282
+
283
+ it('strips leading slash from file paths', () => {
284
+ const report: StrykerReport = {
285
+ schemaVersion: '1.0',
286
+ thresholds: { high: 80, low: 60, break: null },
287
+ files: {
288
+ '/src/auth.ts': makeFileReport([makeMutant('Killed', '0')])
289
+ }
290
+ };
291
+ const sourceFiles = ['src/auth.ts'];
292
+ const baseline = buildBaseline(report, sourceFiles, 'local');
293
+ expect(baseline.scores['src/auth.ts']).toBeDefined();
294
+ });
295
+
296
+ it('matches files by suffix when exact match fails', () => {
297
+ const report: StrykerReport = {
298
+ schemaVersion: '1.0',
299
+ thresholds: { high: 80, low: 60, break: null },
300
+ files: {
301
+ 'packages/core/src/auth.ts': makeFileReport([makeMutant('Killed', '0')])
302
+ }
303
+ };
304
+ const sourceFiles = ['src/auth.ts'];
305
+ const baseline = buildBaseline(report, sourceFiles, 'local');
306
+ expect(baseline.scores['packages/core/src/auth.ts']).toBeDefined();
307
+ });
308
+
309
+ it('sets generatedAt to a valid ISO date string', () => {
310
+ const baseline = buildBaseline(emptyReport, [], 'local');
311
+ const parsed = new Date(baseline.generatedAt);
312
+ expect(parsed.toISOString()).toBe(baseline.generatedAt);
313
+ });
314
+ });
315
+
316
+ describe('isSourceFile (getSourceFiles filter predicate)', () => {
317
+ it('accepts regular .ts files', () => {
318
+ expect(isSourceFile('src/auth.ts')).toBe(true);
319
+ expect(isSourceFile('src/utils/helper.ts')).toBe(true);
320
+ });
321
+
322
+ it('rejects .d.ts declaration files', () => {
323
+ expect(isSourceFile('src/types.d.ts')).toBe(false);
324
+ expect(isSourceFile('src/global.d.ts')).toBe(false);
325
+ });
326
+
327
+ it('rejects .test.ts files', () => {
328
+ expect(isSourceFile('src/auth.test.ts')).toBe(false);
329
+ expect(isSourceFile('src/utils/helper.test.ts')).toBe(false);
330
+ });
331
+
332
+ it('rejects files in __tests__ directories', () => {
333
+ expect(isSourceFile('src/__tests__/auth.test.ts')).toBe(false);
334
+ expect(isSourceFile('src/__tests__/helpers.ts')).toBe(false);
335
+ });
336
+
337
+ it('rejects files in adapters directories', () => {
338
+ expect(isSourceFile('githooks/adapters/typescript.sh')).toBe(false);
339
+ expect(isSourceFile('src/adapters/go.ts')).toBe(false);
340
+ });
341
+
342
+ it('rejects non-.ts files', () => {
343
+ expect(isSourceFile('src/index.js')).toBe(false);
344
+ expect(isSourceFile('src/style.css')).toBe(false);
345
+ expect(isSourceFile('README.md')).toBe(false);
346
+ });
347
+ });