@boyingliu01/xp-gate 0.24.0 → 0.26.0

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 (46) hide show
  1. package/adapter-common.sh +51 -2
  2. package/adapters/python.sh +63 -5
  3. package/hooks/adapter-common.sh +51 -2
  4. package/hooks/lib/coverage-total.sh +20 -0
  5. package/hooks/lib/python-incremental-typecheck.sh +104 -0
  6. package/hooks/lib/python-test-selection.sh +132 -0
  7. package/hooks/lib/typecheck.sh +10 -0
  8. package/hooks/pre-commit +157 -33
  9. package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
  10. package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
  11. package/lib/init.js +46 -1
  12. package/lib/mattpocock-license-verdict.json +1 -1
  13. package/lib/phase-transition.js +61 -0
  14. package/lib/sprint-state-manager.js +86 -39
  15. package/lib/sprint-state-migrator.js +60 -22
  16. package/mock-policy/AGENTS.md +3 -3
  17. package/mutation/AGENTS.md +3 -3
  18. package/mutation/__tests__/gremlins-runner.test.ts +167 -0
  19. package/mutation/__tests__/mutmut-runner.test.ts +20 -22
  20. package/mutation/__tests__/runners-index.test.ts +31 -3
  21. package/mutation/gate-m.ts +22 -4
  22. package/mutation/runners/gremlins-runner.ts +238 -0
  23. package/mutation/runners/index.ts +9 -1
  24. package/mutation/runners/mutmut-runner.ts +18 -12
  25. package/mutation/runners/types.ts +20 -0
  26. package/package.json +1 -1
  27. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  28. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  29. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/plugins/dsh/package.json +1 -1
  32. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  33. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  34. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  43. package/principles/AGENTS.md +3 -3
  44. package/skills/delphi-review/AGENTS.md +3 -3
  45. package/skills/sprint-flow/AGENTS.md +3 -3
  46. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -532,12 +532,14 @@ async function handlePhaseTransition(args = []) {
532
532
  console.log(' --render Render ASCII dashboard after transition');
533
533
  console.log(' --dir <path> Project directory (default: cwd)');
534
534
  console.log(' --skip-evidence <reason> Skip evidence validation (requires reason)');
535
+ console.log(' --force Override the phase sequence guard (#519)');
535
536
  console.log('');
536
537
  console.log('Examples:');
537
538
  console.log(' xp-gate phase-transition 1 completed');
538
539
  console.log(' xp-gate phase-transition 3 completed --render');
539
540
  console.log(' xp-gate phase-transition 2 in_progress --outputs \'{"spec":"path.yaml"}\'');
540
541
  console.log(' xp-gate phase-transition 4 completed --skip-evidence "Emergency hotfix"');
542
+ console.log(' xp-gate phase-transition 3 completed --force # skip-order override');
541
543
  return 0;
542
544
  }
543
545
 
@@ -576,6 +578,9 @@ async function handlePhaseTransition(args = []) {
576
578
  }
577
579
  }
578
580
 
581
+ // #519: explicit override for the sequence guard below.
582
+ const forceTransition = args.includes('--force');
583
+
579
584
  // Validate inputs
580
585
  if (phase < 1 || phase > 6) {
581
586
  console.error(`Error: Phase must be 1-6, got ${phase}`);
@@ -592,6 +597,37 @@ async function handlePhaseTransition(args = []) {
592
597
  try {
593
598
  const manager = new SprintStateManager(projectDir);
594
599
 
600
+ // ── #519 Layer 0: Sequence guard ──
601
+ // `completed` on phase n requires phase n-1 to be completed/skipped first.
602
+ // #507 proved that a missed transition is invisible in the state file (the
603
+ // pointer just looks "not yet advanced"), so out-of-order completion —
604
+ // skipping a phase, or completing from a stale pointer — must be refused
605
+ // loudly rather than written silently. `--force` overrides for the rare
606
+ // legitimate case (e.g. retroactively recording a skipped phase).
607
+ if (status === 'completed' && phase >= 2) {
608
+ const seqState = manager.read();
609
+ if (seqState && Array.isArray(seqState.phase_history)) {
610
+ const prevPhase = phase - 1;
611
+ const prevEntry = seqState.phase_history.find(e => e.phase === prevPhase);
612
+ const prevOk = prevEntry && (prevEntry.status === 'completed' || prevEntry.status === 'skipped');
613
+ if (!prevOk && !forceTransition) {
614
+ const summary = seqState.phase_history
615
+ .map(e => ` ${e.phase}:${e.phase_name || PHASE_NAMES[e.phase] || '?'} = ${e.status}`)
616
+ .join('\n');
617
+ console.error(`❌ [sprint-audit] Cannot complete phase ${phase} (${PHASE_NAMES[phase]}):`);
618
+ console.error(` phase ${prevPhase} (${PHASE_NAMES[prevPhase]}) is not completed/skipped.`);
619
+ console.error(' Current phase history:');
620
+ console.error(summary || ' (empty)');
621
+ console.error(` Advance it first: xp-gate phase-transition ${prevPhase} completed`);
622
+ console.error(' Or override explicitly: --force');
623
+ return 1;
624
+ }
625
+ if (!prevOk && forceTransition) {
626
+ console.warn(`⚠️ [sprint-audit] Sequence guard overridden (--force): phase ${prevPhase} is not completed/skipped.`);
627
+ }
628
+ }
629
+ }
630
+
595
631
  // ── Layer 1: Pre-transition gate check ──
596
632
  // Check BEFORE transitionPhase() to validate previous phase completion.
597
633
  // Runs when status === 'in_progress' and phase >= 2.
@@ -640,9 +676,34 @@ async function handlePhaseTransition(args = []) {
640
676
  }
641
677
  }
642
678
 
679
+ // Capture the before-image for the echo below (#519). Read once here so a
680
+ // concurrent writer cannot make before/after disagree about their baseline.
681
+ const beforeSnapshot = (() => {
682
+ try {
683
+ const s = manager.read();
684
+ if (!s) return { phase: undefined, status: undefined, entryStatus: undefined };
685
+ const e = Array.isArray(s.phase_history) ? s.phase_history.find(x => x.phase === phase) : undefined;
686
+ return { phase: s.phase, status: s.status, entryStatus: e ? e.status : undefined };
687
+ } catch {
688
+ return { phase: undefined, status: undefined, entryStatus: undefined };
689
+ }
690
+ })();
691
+
643
692
  const newState = manager.transitionPhase(phase, status, { outputs });
644
693
 
694
+ // #519: before/after echo. The #507 failure mode was "the command either
695
+ // never ran or ran without effect" — both look identical in a terminal that
696
+ // prints only a success line. Printing the observed state on both sides
697
+ // makes those two cases distinguishable in the output itself.
698
+ const afterEntry = Array.isArray(newState.phase_history)
699
+ ? newState.phase_history.find(e => e.phase === phase)
700
+ : undefined;
701
+ const oldTop = `${beforeSnapshot.phase ?? '-'}/${beforeSnapshot.status ?? '-'}`;
702
+ const newTop = `${newState.phase ?? '-'}/${newState.status ?? '-'}`;
703
+ const oldEntry = beforeSnapshot.entryStatus ?? '(none)';
704
+ const newEntryStatus = afterEntry ? afterEntry.status : '(none)';
645
705
  console.log(`✅ Phase ${phase} transitioned to '${status}'`);
706
+ console.log(` before→after phase/status: ${oldTop} → ${newTop} | phase ${phase} entry: ${oldEntry} → ${newEntryStatus}`);
646
707
 
647
708
  // ── Layer 1.5: Auto-trigger Layer 2 reminder on Phase 6 completed ──
648
709
  if (phase === 6 && status === 'completed') {
@@ -12,6 +12,38 @@ const fs = require('fs');
12
12
  const path = require('path');
13
13
  const { migrateState, createInitialState, rollbackState, LEGACY_PHASE_MAP } = require('./sprint-state-migrator');
14
14
 
15
+ /** Canonical phase order; index = phase - 1. */
16
+ const PHASE_NAMES = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
17
+
18
+ /**
19
+ * Apply a status change to the phase_history entry for `phase`, creating it on
20
+ * first sight, and stamp started_at/completed_at/duration_seconds.
21
+ *
22
+ * Module-level and pure (it only mutates the passed-in state), extracted from
23
+ * transitionPhase (#519) so that method stays within the long-function budget.
24
+ */
25
+ function applyHistoryEntry(state, phase, phaseName, status) {
26
+ let entry = state.phase_history.find(e => e.phase === phase);
27
+ if (!entry) {
28
+ entry = { phase, phase_name: phaseName, status };
29
+ state.phase_history.push(entry);
30
+ }
31
+
32
+ entry.status = status;
33
+ if (status === 'in_progress' && !entry.started_at) {
34
+ entry.started_at = new Date().toISOString();
35
+ }
36
+ if (status === 'completed' || status === 'skipped') {
37
+ entry.completed_at = new Date().toISOString();
38
+ if (entry.started_at) {
39
+ const startMs = new Date(entry.started_at).getTime();
40
+ const endMs = new Date(entry.completed_at).getTime();
41
+ entry.duration_seconds = Math.round((endMs - startMs) / 1000);
42
+ }
43
+ }
44
+ return entry;
45
+ }
46
+
15
47
  class SprintStateManager {
16
48
  constructor(projectRoot) {
17
49
  this.projectRoot = projectRoot;
@@ -48,59 +80,57 @@ class SprintStateManager {
48
80
  }
49
81
 
50
82
  write(state) {
51
- // Ensure state directory exists
52
- if (!fs.existsSync(this.stateDir)) {
53
- fs.mkdirSync(this.stateDir, { recursive: true });
83
+ try {
84
+ if (!fs.existsSync(this.stateDir)) {
85
+ fs.mkdirSync(this.stateDir, { recursive: true });
86
+ }
87
+ // Atomic write: write to tmp, then rename
88
+ const tmpFile = this.stateFile + '.tmp';
89
+ fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
90
+ fs.renameSync(tmpFile, this.stateFile);
91
+ } catch (err) {
92
+ throw new Error(`Failed to write sprint state: ${err.message}`);
54
93
  }
55
-
56
- // Atomic write: write to tmp, then rename
57
- const tmpFile = this.stateFile + '.tmp';
58
- fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), 'utf8');
59
- fs.renameSync(tmpFile, this.stateFile);
60
94
  }
61
95
 
62
96
  transitionPhase(phase, status, options = {}) {
63
97
  const state = this.read() || createInitialState(this.projectRoot);
64
98
 
65
- // Update current phase
66
- state.phase = phase;
67
-
68
- // Find or create phase_history entry
69
- const phaseNames = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
70
- const phaseName = phaseNames[phase - 1];
71
-
72
- let entry = state.phase_history.find(e => e.phase === phase);
73
- if (!entry) {
74
- entry = {
75
- phase,
76
- phase_name: phaseName,
77
- status,
78
- };
79
- state.phase_history.push(entry);
80
- }
99
+ // Capture the before-image so the caller can echo a before/after
100
+ // comparison. #519: the same values feed the append-only audit log.
101
+ const beforePhase = state.phase;
102
+ const beforeEntry = Array.isArray(state.phase_history)
103
+ ? state.phase_history.find(e => e.phase === phase)
104
+ : undefined;
105
+ const beforeStatus = beforeEntry ? beforeEntry.status : undefined;
81
106
 
82
- // Update entry
83
- entry.status = status;
84
- if (status === 'in_progress' && !entry.started_at) {
85
- entry.started_at = new Date().toISOString();
86
- }
87
- if (status === 'completed' || status === 'skipped') {
88
- entry.completed_at = new Date().toISOString();
89
- if (entry.started_at) {
90
- const startMs = new Date(entry.started_at).getTime();
91
- const endMs = new Date(entry.completed_at).getTime();
92
- entry.duration_seconds = Math.round((endMs - startMs) / 1000);
93
- }
94
- }
107
+ state.phase = phase;
108
+ const phaseName = PHASE_NAMES[phase - 1];
109
+ applyHistoryEntry(state, phase, phaseName, status);
95
110
 
96
- // Update outputs if provided
97
111
  if (options.outputs) {
98
112
  state.outputs = { ...state.outputs, ...options.outputs };
99
113
  }
100
114
 
101
- // Write state atomically
102
115
  this.write(state);
103
116
 
117
+ // #519: append-only transition audit. The #507 incident proved that a
118
+ // MISSED transition is invisible in the state file — the pointer drift
119
+ // looked identical to a legitimately-not-yet-run phase. A per-transition
120
+ // record — from/to plus statuses — lets post-mortems distinguish "never
121
+ // ran" from "ran and was overwritten". Best-effort: a logging failure must
122
+ // never break the transition, same contract as the onTransition callback.
123
+ this.appendTransitionAudit({
124
+ phase,
125
+ phase_name: phaseName,
126
+ from_phase: beforePhase,
127
+ // from_status === undefined together with a defined to_status is exactly
128
+ // what proves the phase was reached from nothing — i.e. never ran.
129
+ from_status: beforeStatus,
130
+ to_status: status,
131
+ timestamp: new Date().toISOString(),
132
+ });
133
+
104
134
  // Trigger onTransition callback (for auto-render)
105
135
  if (typeof options.onTransition === 'function') {
106
136
  try {
@@ -114,6 +144,23 @@ class SprintStateManager {
114
144
  return state;
115
145
  }
116
146
 
147
+ /**
148
+ * Append one JSON line to .sprint-state/transition-audit.jsonl (#519).
149
+ * Never throws — audit is evidence, not a gate.
150
+ * @param {object} record
151
+ */
152
+ appendTransitionAudit(record) {
153
+ try {
154
+ if (!fs.existsSync(this.stateDir)) {
155
+ fs.mkdirSync(this.stateDir, { recursive: true });
156
+ }
157
+ const auditFile = path.join(this.stateDir, 'transition-audit.jsonl');
158
+ fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', 'utf8');
159
+ } catch (err) {
160
+ console.warn(`[WARN] transition audit write failed: ${err.message}`);
161
+ }
162
+ }
163
+
117
164
  rollback() {
118
165
  return rollbackState(this.stateFile, this.backupFile);
119
166
  }
@@ -57,18 +57,9 @@ function migrateState(state, stateDir) {
57
57
  if (!state.phase_history) { state.phase_history = []; }
58
58
  if (!state.isolation) { state.isolation = { worktree_path: null, branch: null }; }
59
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
- });
60
+ state.phase_history = state.phase_history.map(entry =>
61
+ normalizePhaseEntry(entry, warnings),
62
+ );
72
63
 
73
64
  const currentPhaseNum = String(state.phase);
74
65
  const currentMapped = LEGACY_PHASE_MAP[currentPhaseNum];
@@ -91,6 +82,47 @@ function migrateState(state, stateDir) {
91
82
  return state;
92
83
  }
93
84
 
85
+ /**
86
+ * Migrate and normalize a single phase_history entry.
87
+ *
88
+ * Two passes, in this order:
89
+ * 1. legacy numbering → current numbering via LEGACY_PHASE_MAP;
90
+ * 2. field normalization: phase 1 historically carried `name` while phase 2+
91
+ * carried `phase_name` (transitionPhase always writes `phase_name`).
92
+ * Normalize to `phase_name` as the single canonical field, in BOTH
93
+ * directions so a half-migrated file converges. The legacy `name` is kept
94
+ * (not deleted) so any reader still consulting it keeps working.
95
+ *
96
+ * Ordering matters: the normalization must run for every entry, including those
97
+ * the legacy remap just rewrote — LEGACY_PHASE_MAP covers every phase 1-6, so
98
+ * gating normalization on "not remapped" would skip exactly the legacy files it
99
+ * exists for. (#507 left such a file: phase 1 with `name: 'PREP'`, phase 2+
100
+ * with `phase_name`.)
101
+ *
102
+ * @param {Object} entry - One phase_history entry (possibly legacy-shaped)
103
+ * @param {string[]} warnings - Accumulator for human-readable migration notes
104
+ * @returns {Object} The normalized entry
105
+ */
106
+ function normalizePhaseEntry(entry, warnings) {
107
+ const phaseNum = String(entry.phase);
108
+ const mapped = LEGACY_PHASE_MAP[phaseNum];
109
+ if (mapped) {
110
+ warnings.push(`Migrated legacy phase ${entry.phase} → ${mapped.phase} (${mapped.phase_name})`);
111
+ entry = { ...entry, phase: mapped.phase, phase_name: mapped.phase_name };
112
+ }
113
+
114
+ if (!entry.phase_name && entry.name) {
115
+ entry.phase_name = entry.name;
116
+ }
117
+ if (!entry.phase_name && entry.phase >= 1 && entry.phase <= 6) {
118
+ entry.phase_name = PHASE_NAMES[entry.phase - 1];
119
+ }
120
+ if (!entry.name && entry.phase_name) {
121
+ entry.name = entry.phase_name;
122
+ }
123
+ return entry;
124
+ }
125
+
94
126
  /**
95
127
  * Create initial sprint state for new sprints.
96
128
  *
@@ -120,17 +152,23 @@ function createInitialState(projectRoot) {
120
152
  * @param {string} stateDir - Path to .sprint-state directory
121
153
  */
122
154
  function logMigrationWarnings(warnings, stateDir) {
123
- if (!fs.existsSync(stateDir)) {
124
- fs.mkdirSync(stateDir, { recursive: true });
125
- }
126
-
127
- const warningsFile = path.join(stateDir, 'migration-warnings.json');
128
- const existing = fs.existsSync(warningsFile)
129
- ? JSON.parse(fs.readFileSync(warningsFile, 'utf8'))
130
- : [];
155
+ try {
156
+ if (!fs.existsSync(stateDir)) {
157
+ fs.mkdirSync(stateDir, { recursive: true });
158
+ }
131
159
 
132
- existing.push({ timestamp: new Date().toISOString(), warnings });
133
- fs.writeFileSync(warningsFile, JSON.stringify(existing, null, 2), 'utf8');
160
+ const warningsFile = path.join(stateDir, 'migration-warnings.json');
161
+ const existing = fs.existsSync(warningsFile)
162
+ ? JSON.parse(fs.readFileSync(warningsFile, 'utf8'))
163
+ : [];
164
+
165
+ existing.push({ timestamp: new Date().toISOString(), warnings });
166
+ fs.writeFileSync(warningsFile, JSON.stringify(existing, null, 2), 'utf8');
167
+ } catch (err) {
168
+ // Migration must still return the migrated state; a failure to persist the
169
+ // warning log is reported to stderr and never thrown.
170
+ console.warn(`[WARN] could not write migration warnings: ${err.message}`);
171
+ }
134
172
  }
135
173
 
136
174
  /**
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-14
4
- **Commit:** e466ce16
3
+ **Generated:** 2026-10-16
4
+ **Commit:** 98096abe
5
5
  **Branch:** main
6
- **Version:** 0.24.0.0
6
+ **Version:** 0.26.0.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-10-14
4
- **Commit:** e466ce16
3
+ **Generated:** 2026-10-16
4
+ **Commit:** 98096abe
5
5
  **Branch:** main
6
- **Version:** 0.24.0.0
6
+ **Version:** 0.26.0.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,167 @@
1
+ /**
2
+ * @intent #410: mutmut 3.x depends on fork() and cannot run natively on
3
+ * Windows — Gate M (Python) formed a structural deadlock (installed =>
4
+ * always blocked, uninstalled => always skipped). pytest-gremlins is
5
+ * architecturally cross-platform (mutation switching, no fork), so it
6
+ * becomes the second engine. This runner drives
7
+ * `pytest --gremlins` and parses the console report.
8
+ * @covers GremlinsRunner availability probe + console report parsing
9
+ */
10
+
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
12
+ import { spawn, execSync, type ChildProcess } from 'child_process';
13
+
14
+ vi.mock('child_process', () => ({
15
+ spawn: vi.fn(),
16
+ execSync: vi.fn(),
17
+ }));
18
+
19
+ /** Capture the stdout/close callbacks so the test can drive them by hand. */
20
+ function mockChild(capture: {
21
+ stdout?: (cb: (d: Buffer) => void) => void;
22
+ stderr?: (cb: (d: Buffer) => void) => void;
23
+ close?: (cb: (code: number | null) => void) => void;
24
+ error?: (cb: (e: Error) => void) => void;
25
+ }): ChildProcess {
26
+ return {
27
+ stdout: { on: (_e: string, cb: (d: Buffer) => void) => capture.stdout?.(cb) },
28
+ stderr: { on: (_e: string, cb: (d: Buffer) => void) => capture.stderr?.(cb) },
29
+ on: (event: string, cb: (arg: never) => void) => {
30
+ if (event === 'close') capture.close?.(cb as (code: number | null) => void);
31
+ if (event === 'error') capture.error?.(cb as unknown as (e: Error) => void);
32
+ },
33
+ kill: vi.fn(),
34
+ } as unknown as ChildProcess;
35
+ }
36
+
37
+ describe('GremlinsRunner', () => {
38
+ let GremlinsRunner: typeof import('../runners/gremlins-runner').GremlinsRunner;
39
+
40
+ beforeEach(async () => {
41
+ vi.clearAllMocks();
42
+ vi.mocked(execSync).mockReset();
43
+ vi.mocked(spawn).mockReset();
44
+ vi.mocked(execSync).mockReturnValue('' as never);
45
+ GremlinsRunner = (await import('../runners/gremlins-runner')).GremlinsRunner;
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.useRealTimers();
50
+ vi.restoreAllMocks();
51
+ });
52
+
53
+ it('declares the pytest-gremlins engine name and the py extension', () => {
54
+ const runner = new GremlinsRunner();
55
+ expect(runner.name).toBe('pytest-gremlins');
56
+ expect(runner.extensions).toEqual(['py']);
57
+ });
58
+
59
+ it('isAvailable probes the pytest_gremlins import', async () => {
60
+ const runner = new GremlinsRunner();
61
+ await expect(runner.isAvailable()).resolves.toBe(true);
62
+ const probed = vi.mocked(execSync).mock.calls[0]?.[0] as string;
63
+ expect(probed).toContain('import pytest_gremlins');
64
+ });
65
+
66
+ it('isAvailable returns false when the import probe fails', async () => {
67
+ vi.mocked(execSync).mockImplementation(() => {
68
+ throw new Error('ModuleNotFoundError');
69
+ });
70
+ const runner = new GremlinsRunner();
71
+ await expect(runner.isAvailable()).resolves.toBe(false);
72
+ });
73
+
74
+ it('parses the console report (Zapped/Survived) into a normalized result', async () => {
75
+ let closeCb: ((code: number | null) => void) | undefined;
76
+ let stdoutCb: ((d: Buffer) => void) | undefined;
77
+ vi.mocked(spawn).mockReturnValue(
78
+ mockChild({
79
+ stdout: cb => { stdoutCb = cb; },
80
+ close: cb => { closeCb = cb; },
81
+ }),
82
+ );
83
+
84
+ const runner = new GremlinsRunner();
85
+ const promise = runner.run({ files: ['src/foo.py'], timeoutMs: 5000, cwd: '/tmp' });
86
+ await new Promise(r => setTimeout(r, 0));
87
+
88
+ stdoutCb?.(
89
+ Buffer.from(
90
+ '================== pytest-gremlins mutation report ==================\n' +
91
+ 'Zapped: 142 gremlins (89%)\n' +
92
+ 'Survived: 18 gremlins (11%)\n' +
93
+ '=====================================================================\n',
94
+ ),
95
+ );
96
+ closeCb?.(0);
97
+
98
+ const outcome = await promise;
99
+ expect(outcome.report).not.toBeNull();
100
+ expect(outcome.report?.nrOfKilledMutants).toBe(142);
101
+ expect(outcome.report?.nrOfSurvivedMutants).toBe(18);
102
+ expect(outcome.report?.nrOfMutants).toBe(160);
103
+ expect(Math.round(outcome.report!.mutationScore)).toBe(89);
104
+ expect(outcome.timedOut).toBe(false);
105
+ });
106
+
107
+ it('returns report=null (never throws) when the report lines are absent', async () => {
108
+ let closeCb: ((code: number | null) => void) | undefined;
109
+ vi.mocked(spawn).mockReturnValue(mockChild({ stdout: () => {}, close: cb => { closeCb = cb; } }));
110
+
111
+ const runner = new GremlinsRunner();
112
+ const promise = runner.run({ files: ['src/foo.py'], timeoutMs: 5000, cwd: '/tmp' });
113
+ await new Promise(r => setTimeout(r, 0));
114
+ closeCb?.(1);
115
+
116
+ const outcome = await promise;
117
+ expect(outcome.report).toBeNull();
118
+ });
119
+
120
+ it('returns report=null when the report has zero gremlins', async () => {
121
+ let closeCb: ((code: number | null) => void) | undefined;
122
+ let stdoutCb: ((d: Buffer) => void) | undefined;
123
+ vi.mocked(spawn).mockReturnValue(
124
+ mockChild({ stdout: cb => { stdoutCb = cb; }, close: cb => { closeCb = cb; } }),
125
+ );
126
+
127
+ const runner = new GremlinsRunner();
128
+ const promise = runner.run({ files: ['src/foo.py'], timeoutMs: 5000, cwd: '/tmp' });
129
+ await new Promise(r => setTimeout(r, 0));
130
+ stdoutCb?.(Buffer.from('Zapped: 0 gremlins (0%)\nSurvived: 0 gremlins (0%)\n'));
131
+ closeCb?.(0);
132
+
133
+ const outcome = await promise;
134
+ expect(outcome.report).toBeNull();
135
+ });
136
+
137
+ it('passes --gremlin-targets derived from the changed file directories', async () => {
138
+ let closeCb: ((code: number | null) => void) | undefined;
139
+ vi.mocked(spawn).mockReturnValue(mockChild({ stdout: () => {}, close: cb => { closeCb = cb; } }));
140
+
141
+ const runner = new GremlinsRunner();
142
+ const promise = runner.run({ files: ['src/foo/bar.py', 'src/baz.py'], timeoutMs: 5000, cwd: '/tmp' });
143
+ await new Promise(r => setTimeout(r, 0));
144
+ closeCb?.(0);
145
+ await promise;
146
+
147
+ const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1] as string[];
148
+ const joined = spawnArgs.join(' ');
149
+ expect(joined).toContain('--gremlins');
150
+ expect(joined).toContain('--gremlin-targets=src/foo,src');
151
+ });
152
+
153
+ it('reports timedOut when the child exceeds the deadline', async () => {
154
+ let closeCb: ((code: number | null) => void) | undefined;
155
+ const child = mockChild({ stdout: () => {}, close: cb => { closeCb = cb; } });
156
+ vi.mocked(spawn).mockReturnValue(child);
157
+
158
+ const runner = new GremlinsRunner();
159
+ const promise = runner.run({ files: ['src/foo.py'], timeoutMs: 1, cwd: '/tmp' });
160
+ await new Promise(r => setTimeout(r, 20));
161
+ closeCb?.(null);
162
+
163
+ const outcome = await promise;
164
+ expect(outcome.timedOut).toBe(true);
165
+ expect(child.kill).toHaveBeenCalled();
166
+ });
167
+ });
@@ -119,7 +119,7 @@ describe('MutmutRunner', () => {
119
119
  it('should return true when mutmut is installed', async () => {
120
120
  vi.mocked(execSync).mockReturnValue('mutmut, version 3.0.0\n');
121
121
 
122
- const runner = new MutmutRunner();
122
+ const runner = new MutmutRunner('linux');
123
123
  const result = await runner.isAvailable();
124
124
  expect(result).toBe(true);
125
125
  expect(execSync).toHaveBeenCalledWith('mutmut --version', {
@@ -150,9 +150,11 @@ describe('MutmutRunner', () => {
150
150
  const result = await runner.isAvailable();
151
151
 
152
152
  expect(result).toBe(true);
153
- expect(execSync).toHaveBeenNthCalledWith(2, 'wsl mutmut --version', {
153
+ // #410: win32 route is decided by the WSL probe alone (no native probe).
154
+ expect(execSync).toHaveBeenCalledTimes(1);
155
+ expect(execSync).toHaveBeenCalledWith('wsl mutmut --version', {
154
156
  stdio: 'pipe',
155
- timeout: 5000,
157
+ timeout: 10000,
156
158
  });
157
159
  });
158
160
 
@@ -205,7 +207,7 @@ describe('MutmutRunner', () => {
205
207
  // No existing pyproject.toml
206
208
  vi.mocked(existsSync).mockReturnValue(false);
207
209
 
208
- const runner = new MutmutRunner();
210
+ const runner = new MutmutRunner('linux');
209
211
  const promise = runner.run({
210
212
  files: ['src/main.py'],
211
213
  timeoutMs: 60000,
@@ -235,14 +237,12 @@ describe('MutmutRunner', () => {
235
237
  expect(result.report!.nrOfSurvivedMutants).toBe(29);
236
238
  });
237
239
 
238
- it('should execute native mutmut when native detection succeeds on Windows', async () => {
239
- let closeCb: ((code: number | null) => void) | null = null;
240
+ it('should be unavailable on Windows when WSL is absent even if native mutmut is installed', async () => {
240
241
  const mockChild = {
241
242
  stdout: { on: vi.fn() },
242
243
  stderr: { on: vi.fn() },
243
- on: vi.fn((event: string, cb: (code: number | null) => void) => {
244
- if (event === 'close') closeCb = cb;
245
- }),
244
+ // Unavailable route: `run()` never spawns, so `close` is never subscribed.
245
+ on: vi.fn(),
246
246
  kill: vi.fn(),
247
247
  killed: false,
248
248
  };
@@ -254,22 +254,17 @@ describe('MutmutRunner', () => {
254
254
  vi.mocked(existsSync).mockReturnValue(false);
255
255
 
256
256
  const runner = new MutmutRunner('win32');
257
- expect(await runner.isAvailable()).toBe(true);
258
- const promise = runner.run({
257
+ // #410: native mutmut on Windows proves nothing about `mutmut run`
258
+ // (fork()-based). The WSL route decides availability; no WSL → SKIP.
259
+ expect(await runner.isAvailable()).toBe(false);
260
+ const outcome = await runner.run({
259
261
  files: ['src/main.py'],
260
262
  timeoutMs: 60000,
261
263
  cwd: 'C:\\work\\project',
262
264
  });
263
- await waitForSpawn();
264
- closeCb!(0);
265
- await promise;
266
265
 
267
- expect(execSync).toHaveBeenCalledTimes(1);
268
- expect(spawn).toHaveBeenCalledWith(
269
- 'mutmut',
270
- ['run'],
271
- expect.objectContaining({ cwd: 'C:\\work\\project' }),
272
- );
266
+ expect(outcome).toEqual({ report: null, timedOut: false });
267
+ expect(spawn).not.toHaveBeenCalled();
273
268
  });
274
269
 
275
270
  it('should execute selected WSL route with Linux --cd and Windows host cwd', async () => {
@@ -428,7 +423,7 @@ describe('MutmutRunner', () => {
428
423
  expect(vi.mocked(spawn).mock.calls[0]?.[0]).toBe('wsl');
429
424
  });
430
425
 
431
- it('should follow repeated WSL to native availability transitions', async () => {
426
+ it('should keep the WSL route on Windows even when native mutmut becomes available', async () => {
432
427
  const mockChild = {
433
428
  stdout: { on: vi.fn() },
434
429
  stderr: { on: vi.fn() },
@@ -450,10 +445,13 @@ describe('MutmutRunner', () => {
450
445
  const runner = new MutmutRunner('win32');
451
446
  expect(await runner.isAvailable()).toBe(true);
452
447
  nativeAvailable = true;
448
+ // #410: even when native `mutmut --version` starts succeeding, the win32
449
+ // route is decided by WSL alone — native success proves nothing about
450
+ // `mutmut run` (fork()). The route must NOT flip to native.
453
451
  expect(await runner.isAvailable()).toBe(true);
454
452
  await runner.run({ files: ['src/main.py'], timeoutMs: 60000, cwd: 'C:\\repo' });
455
453
 
456
- expect(vi.mocked(spawn).mock.calls[0]?.[0]).toBe('mutmut');
454
+ expect(vi.mocked(spawn).mock.calls[0]?.[0]).toBe('wsl');
457
455
  });
458
456
 
459
457
  it('should stop spawning after availability transitions to unavailable', async () => {