@boyingliu01/xp-gate 0.27.0 → 0.27.2
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.
- package/adapter-common.sh +0 -0
- package/adapters/cpp.sh +0 -0
- package/adapters/dart.sh +0 -0
- package/adapters/flutter.sh +0 -0
- package/adapters/go.sh +0 -0
- package/adapters/iac.sh +0 -0
- package/adapters/java.sh +0 -0
- package/adapters/kotlin.sh +0 -0
- package/adapters/objectivec.sh +0 -0
- package/adapters/plugins/book299-20132-python/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-20132-python/scripts/install-maven.sh +0 -0
- package/adapters/plugins/book299-4081-c/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-4081-c/scripts/install-maven.sh +0 -0
- package/adapters/plugins/book299-4083-javascriptes5/scripts/install-gradle.sh +0 -0
- package/adapters/plugins/book299-4083-javascriptes5/scripts/install-maven.sh +0 -0
- package/adapters/powershell.sh +0 -0
- package/adapters/python.sh +0 -0
- package/adapters/shell.sh +0 -0
- package/adapters/swift.sh +0 -0
- package/adapters/typescript.sh +0 -0
- package/bin/xp-gate.js +0 -0
- package/gate-10.sh +0 -0
- package/gate-3.sh +0 -0
- package/gate-4.sh +0 -0
- package/gate-7.sh +0 -0
- package/gate-8.sh +0 -0
- package/gate-9.sh +0 -0
- package/hooks/adapter-common.sh +0 -0
- package/hooks/gate-10.sh +0 -0
- package/hooks/gate-3.sh +0 -0
- package/hooks/gate-4.sh +0 -0
- package/hooks/gate-7.sh +0 -0
- package/hooks/gate-8.sh +0 -0
- package/hooks/gate-9.sh +0 -0
- package/hooks/post-merge +0 -0
- package/hooks/pre-commit +0 -0
- package/hooks/pre-push +0 -0
- package/hooks/sprint-gate.sh +0 -0
- package/lib/__tests__/doctor-delphi.test.js +244 -244
- package/lib/__tests__/doctor-exec-retry.test.js +152 -0
- package/lib/__tests__/sprint-audit.test.js +207 -207
- package/lib/__tests__/windows-bash-probe.test.js +57 -7
- package/lib/detect-deps.js +69 -13
- package/lib/doctor.js +45 -11
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/sprint-audit.js +235 -235
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/bin/delphi-review-guard.sh +0 -0
- package/plugins/claude-code/bin/xp-gate-check +0 -0
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/dsh/lib/command.d.ts +32 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +81 -0
- package/plugins/dsh/lib/delphi-config.d.ts +75 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +155 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +92 -0
- package/plugins/dsh/lib/delphi.d.ts +88 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +161 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/gate-runner.d.ts +18 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +46 -0
- package/plugins/dsh/lib/index.d.ts +5 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +162 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
- package/plugins/qoder/bin/sprint-flow-guard.sh +0 -0
- package/plugins/qoder/bin/xp-gate-check +0 -0
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
- package/principles/AGENTS.md +2 -2
- package/skills/delphi-review/AGENTS.md +2 -2
- package/skills/sprint-flow/AGENTS.md +2 -2
- package/skills/test-specification-alignment/AGENTS.md +2 -2
- package/sprint-gate.sh +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-DOCTOR retry-on-empty-stream guard
|
|
3
|
+
* @intent On win32, cmd.exe (exec's shell) can deliver exit 0 with an EMPTY
|
|
4
|
+
* stdout+stderr when many spawns are in flight — doctor read that as
|
|
5
|
+
* "Not found" and red-flagged Node/Git/hooksPath on a healthy machine.
|
|
6
|
+
* execWithTimeout must retry an empty-but-successful probe before
|
|
7
|
+
* reporting it, and must NOT retry real failures or real output.
|
|
8
|
+
* @covers AC-DOCTOR-RETRY-01
|
|
9
|
+
*/
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { describe, it, expect, afterEach } = await import('vitest');
|
|
12
|
+
|
|
13
|
+
const cpPath = require.resolve('child_process');
|
|
14
|
+
|
|
15
|
+
function stubExec(behaviors) {
|
|
16
|
+
// doctor builds its async exec at load time via util.promisify(exec), which
|
|
17
|
+
// binds Node's INTERNAL custom promisify implementation — replacing the plain
|
|
18
|
+
// `exec` property afterwards is invisible to it (measured: stub fired 0-1
|
|
19
|
+
// times while the real cmd.exe child ran). The only patch point that holds
|
|
20
|
+
// across a fresh require is util.promisify itself.
|
|
21
|
+
const util = require('util');
|
|
22
|
+
const originalPromisify = util.promisify;
|
|
23
|
+
const calls = [];
|
|
24
|
+
util.promisify = function (fn) {
|
|
25
|
+
if (fn && fn.name === 'exec') {
|
|
26
|
+
stubWasConsumed = true;
|
|
27
|
+
return (cmd, opts = {}) => {
|
|
28
|
+
const attempt = calls.length;
|
|
29
|
+
calls.push(cmd);
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
behaviors[attempt]((err, stdout, stderr) => {
|
|
32
|
+
if (err) reject(err);
|
|
33
|
+
else resolve({ stdout, stderr });
|
|
34
|
+
}, attempt);
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return originalPromisify.call(util, fn);
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
calls,
|
|
42
|
+
restore: () => { util.promisify = originalPromisify; },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Node couples exec to promisify via a custom symbol; if a future runtime drops
|
|
47
|
+
// that coupling the name-based stub above silently stops intercepting and the
|
|
48
|
+
// real cmd.exe child runs. Each test flips this to true from inside its own
|
|
49
|
+
// stubbed load (never inherited from an earlier test), so afterEach can fail
|
|
50
|
+
// loudly instead of passing vacuously.
|
|
51
|
+
let stubWasConsumed = false;
|
|
52
|
+
|
|
53
|
+
describe('execWithTimeout empty-stream retry (#525 follow-up)', () => {
|
|
54
|
+
let stub = null;
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
if (stub) { stub.restore(); stub = null; }
|
|
57
|
+
// Fail loudly if the fn.name === 'exec' stub never intercepted this test's
|
|
58
|
+
// doctor load — that would mean Node dropped the exec/promisify custom-
|
|
59
|
+
// symbol coupling and the assertions ran against real cmd.exe children.
|
|
60
|
+
expect(stubWasConsumed, `stub did not intercept the doctor load in this test`).toBe(true);
|
|
61
|
+
stubWasConsumed = false;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
function freshDoctor() {
|
|
65
|
+
const doctorPath = require.resolve('../doctor.js');
|
|
66
|
+
delete require.cache[doctorPath];
|
|
67
|
+
return require(doctorPath).execWithTimeout;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
it('AC-DOCTOR-RETRY-01: retries an exit-0 empty-stdout probe and keeps its own timeout', async () => {
|
|
71
|
+
stub = stubExec([
|
|
72
|
+
(cb) => cb(null, '', ''), // the lost-stream failure mode
|
|
73
|
+
(cb) => cb(null, 'v24.19.0\n', ''), // the same probe, stream intact
|
|
74
|
+
]);
|
|
75
|
+
const execWithTimeout = freshDoctor();
|
|
76
|
+
const result = await execWithTimeout('node --version', { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
77
|
+
expect(stub.calls.length).toBe(2);
|
|
78
|
+
expect(result).toEqual({ stdout: 'v24.19.0', stderr: '' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('AC-DOCTOR-RETRY-01: returns null after exhausting attempts on a persistently empty probe', async () => {
|
|
82
|
+
stub = stubExec([
|
|
83
|
+
(cb) => cb(null, '', ''),
|
|
84
|
+
(cb) => cb(null, '', ''),
|
|
85
|
+
(cb) => cb(null, '', ''),
|
|
86
|
+
(cb) => cb(null, 'unexpected fourth attempt', ''),
|
|
87
|
+
]);
|
|
88
|
+
const execWithTimeout = freshDoctor();
|
|
89
|
+
const result = await execWithTimeout('node --version', {});
|
|
90
|
+
// win32: 3 attempts then null; other platforms: single attempt, still null.
|
|
91
|
+
expect(result).toBeNull();
|
|
92
|
+
expect(stub.calls.length).toBe(process.platform === 'win32' ? 3 : 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('AC-DOCTOR-RETRY-01: does not retry when the probe produced output', async () => {
|
|
96
|
+
stub = stubExec([(cb) => cb(null, 'git version 2.54.0\n', '')]);
|
|
97
|
+
const execWithTimeout = freshDoctor();
|
|
98
|
+
const result = await execWithTimeout('git --version', {});
|
|
99
|
+
expect(stub.calls.length).toBe(1);
|
|
100
|
+
expect(result.stdout).toContain('git version');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('AC-DOCTOR-RETRY-01: does not retry a failing command', async () => {
|
|
104
|
+
stub = stubExec([(cb) => cb(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), '', '')]);
|
|
105
|
+
const execWithTimeout = freshDoctor();
|
|
106
|
+
const result = await execWithTimeout('definitely-not-a-tool --version', {});
|
|
107
|
+
expect(stub.calls.length).toBe(1);
|
|
108
|
+
expect(result).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('AC-DOCTOR-RETRY-01: attempts:1 opts a side-effecting call out of the retry', async () => {
|
|
112
|
+
stub = stubExec([
|
|
113
|
+
(cb) => cb(null, '', ''),
|
|
114
|
+
(cb) => cb(null, 'unexpected second attempt', ''),
|
|
115
|
+
]);
|
|
116
|
+
const execWithTimeout = freshDoctor();
|
|
117
|
+
const result = await execWithTimeout('some-mutating-command', { attempts: 1 });
|
|
118
|
+
expect(stub.calls.length).toBe(1);
|
|
119
|
+
expect(result).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('AC-DOCTOR-RETRY-01: the attempts option is consumed, not forwarded to exec', async () => {
|
|
123
|
+
let forwarded = null;
|
|
124
|
+
stub = stubExec([(cb) => { cb(null, 'ok\n', ''); }]);
|
|
125
|
+
// Capture what doctor hands to the promisified exec by re-stubbing with an
|
|
126
|
+
// options recorder: stubExec's behaviors receive only the callback, so
|
|
127
|
+
// assert forwarding through a second fresh module with a capturing wrapper.
|
|
128
|
+
const util = require('util');
|
|
129
|
+
const originalPromisify = util.promisify;
|
|
130
|
+
const execWithTimeout = (() => {
|
|
131
|
+
util.promisify = function (fn) {
|
|
132
|
+
if (fn && fn.name === 'exec') {
|
|
133
|
+
stubWasConsumed = true;
|
|
134
|
+
return (cmd, opts = {}) => {
|
|
135
|
+
forwarded = opts;
|
|
136
|
+
return Promise.resolve({ stdout: 'ok\n', stderr: '' });
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return originalPromisify.call(util, fn);
|
|
140
|
+
};
|
|
141
|
+
const doctorPath = require.resolve('../doctor.js');
|
|
142
|
+
delete require.cache[doctorPath];
|
|
143
|
+
const f = require(doctorPath).execWithTimeout;
|
|
144
|
+
util.promisify = originalPromisify;
|
|
145
|
+
return f;
|
|
146
|
+
})();
|
|
147
|
+
const result = await execWithTimeout('git --version', { attempts: 2, timeout: 999 });
|
|
148
|
+
expect(result.stdout).toBe('ok');
|
|
149
|
+
expect(forwarded.attempts).toBeUndefined();
|
|
150
|
+
expect(forwarded.timeout).toBe(999);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -1,207 +1,207 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for sprint-audit CLI command (Layer 2)
|
|
3
|
-
* @test REQ-338 Sprint audit — final completeness check
|
|
4
|
-
* @intent Verify sprint-audit correctly checks phase coverage, artifacts, consistency
|
|
5
|
-
* @covers AC-AUDIT-01 (SKIP when no sprint-state.json)
|
|
6
|
-
* @covers AC-AUDIT-02 (PASS when 6/6 completed)
|
|
7
|
-
* @covers AC-AUDIT-03 (PASS_WITH_WARNINGS when 4-5/6 completed)
|
|
8
|
-
* @covers AC-AUDIT-04 (FAIL when <4/6 completed)
|
|
9
|
-
* @covers AC-AUDIT-05 (FAIL on state inconsistency)
|
|
10
|
-
* @covers AC-AUDIT-06 (--json flag outputs JSON)
|
|
11
|
-
* @covers AC-AUDIT-07 (report persisted to audit-report.json)
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
-
import fs from 'node:fs';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
import os from 'node:os';
|
|
18
|
-
import { handleSprintAudit } from '../sprint-audit.js';
|
|
19
|
-
|
|
20
|
-
describe('sprint-audit', () => {
|
|
21
|
-
let tmpDir;
|
|
22
|
-
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-audit-test-'));
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
afterEach(() => {
|
|
28
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
function writeState(state) {
|
|
32
|
-
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
33
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
34
|
-
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), JSON.stringify(state));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function makeState(overrides = {}) {
|
|
38
|
-
return {
|
|
39
|
-
_schema_version: 1,
|
|
40
|
-
id: 'sprint-test-audit',
|
|
41
|
-
task_description: 'Test audit feature',
|
|
42
|
-
phase: 6,
|
|
43
|
-
status: 'completed',
|
|
44
|
-
started_at: '2026-07-22T10:00:00Z',
|
|
45
|
-
phase_history: [],
|
|
46
|
-
isolation: { branch: 'sprint/test-audit', worktree_path: null },
|
|
47
|
-
outputs: {},
|
|
48
|
-
...overrides,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function makePhaseHistory(completedPhases) {
|
|
53
|
-
const phaseNames = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
|
|
54
|
-
return completedPhases.map((p, i) => ({
|
|
55
|
-
phase: p,
|
|
56
|
-
phase_name: phaseNames[p - 1],
|
|
57
|
-
status: 'completed',
|
|
58
|
-
started_at: `2026-07-22T10:${String(i).padStart(2, '0')}:00Z`,
|
|
59
|
-
completed_at: `2026-07-22T10:${String(i).padStart(2, '0')}:30Z`,
|
|
60
|
-
duration_seconds: 30,
|
|
61
|
-
}));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
describe('handleSprintAudit()', () => {
|
|
65
|
-
it('returns 0 and shows help with --help', async () => {
|
|
66
|
-
const code = await handleSprintAudit(['--help']);
|
|
67
|
-
expect(code).toBe(0);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('returns SKIP (exit 0) when no sprint-state.json exists', async () => {
|
|
71
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
72
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
73
|
-
expect(code).toBe(0);
|
|
74
|
-
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No sprint-state.json'));
|
|
75
|
-
logSpy.mockRestore();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('returns PASS when 6/6 phases completed', async () => {
|
|
79
|
-
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
80
|
-
|
|
81
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
82
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
83
|
-
expect(code).toBe(0);
|
|
84
|
-
// Check output contains PASS
|
|
85
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
86
|
-
expect(output).toContain('PASS');
|
|
87
|
-
expect(output).toContain('6/6');
|
|
88
|
-
logSpy.mockRestore();
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('returns PASS_WITH_WARNINGS when 5/6 completed', async () => {
|
|
92
|
-
writeState(makeState({ phase: 5, phase_history: makePhaseHistory([1, 2, 3, 4, 5]) }));
|
|
93
|
-
|
|
94
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
95
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
96
|
-
expect(code).toBe(0);
|
|
97
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
98
|
-
expect(output).toContain('PASS_WITH_WARNINGS');
|
|
99
|
-
logSpy.mockRestore();
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('returns PASS_WITH_WARNINGS when 4/6 completed', async () => {
|
|
103
|
-
writeState(makeState({ phase: 4, phase_history: makePhaseHistory([1, 2, 3, 4]) }));
|
|
104
|
-
|
|
105
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
106
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
107
|
-
expect(code).toBe(0);
|
|
108
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
109
|
-
expect(output).toContain('PASS_WITH_WARNINGS');
|
|
110
|
-
logSpy.mockRestore();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it('returns FAIL when <4/6 completed', async () => {
|
|
114
|
-
writeState(makeState({ phase: 3, phase_history: makePhaseHistory([1, 2, 3]) }));
|
|
115
|
-
|
|
116
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
117
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
118
|
-
expect(code).toBe(1);
|
|
119
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
120
|
-
expect(output).toContain('FAIL');
|
|
121
|
-
logSpy.mockRestore();
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('returns FAIL on state inconsistency (phase vs phase_history)', async () => {
|
|
125
|
-
const history = makePhaseHistory([1, 2, 3, 4, 5, 6]);
|
|
126
|
-
writeState(makeState({ phase: 3, phase_history: history })); // phase=3 but last entry is 6
|
|
127
|
-
|
|
128
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
129
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
130
|
-
expect(code).toBe(1);
|
|
131
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
132
|
-
expect(output).toContain('State inconsistency');
|
|
133
|
-
logSpy.mockRestore();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('outputs JSON when --json flag is set', async () => {
|
|
137
|
-
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
138
|
-
|
|
139
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
140
|
-
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
141
|
-
expect(code).toBe(0);
|
|
142
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
143
|
-
const result = JSON.parse(output);
|
|
144
|
-
expect(result.verdict).toBe('PASS');
|
|
145
|
-
expect(result.coverage.completed).toBe(6);
|
|
146
|
-
logSpy.mockRestore();
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('counts skipped phases as completed in coverage', async () => {
|
|
150
|
-
const history = makePhaseHistory([1, 2, 3, 4, 5]);
|
|
151
|
-
history.push({
|
|
152
|
-
phase: 6, phase_name: 'CLOSE', status: 'skipped',
|
|
153
|
-
started_at: '2026-07-22T10:05:00Z', completed_at: '2026-07-22T10:05:00Z', duration_seconds: 0,
|
|
154
|
-
});
|
|
155
|
-
writeState(makeState({ phase: 6, phase_history: history }));
|
|
156
|
-
|
|
157
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
158
|
-
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
159
|
-
expect(code).toBe(0);
|
|
160
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
161
|
-
const result = JSON.parse(output);
|
|
162
|
-
expect(result.coverage.completed).toBe(6);
|
|
163
|
-
expect(result.verdict).toBe('PASS');
|
|
164
|
-
logSpy.mockRestore();
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('warns about missing summary files for completed phases', async () => {
|
|
168
|
-
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
169
|
-
// No phase-outputs directory → all summaries missing
|
|
170
|
-
|
|
171
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
172
|
-
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
173
|
-
expect(code).toBe(0);
|
|
174
|
-
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
175
|
-
const result = JSON.parse(output);
|
|
176
|
-
expect(result.warnings.length).toBeGreaterThan(0);
|
|
177
|
-
expect(result.warnings.some(w => w.includes('summary missing'))).toBe(true);
|
|
178
|
-
logSpy.mockRestore();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('persists audit report to audit-report.json', async () => {
|
|
182
|
-
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
183
|
-
|
|
184
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
185
|
-
await handleSprintAudit(['--dir', tmpDir]);
|
|
186
|
-
logSpy.mockRestore();
|
|
187
|
-
|
|
188
|
-
const reportPath = path.join(tmpDir, '.sprint-state', 'audit-report.json');
|
|
189
|
-
expect(fs.existsSync(reportPath)).toBe(true);
|
|
190
|
-
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
191
|
-
expect(report.verdict).toBe('PASS');
|
|
192
|
-
expect(report.sprint_id).toBe('sprint-test-audit');
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('handles corrupted sprint-state.json gracefully', async () => {
|
|
196
|
-
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
197
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
198
|
-
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), 'not-valid-json');
|
|
199
|
-
|
|
200
|
-
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
201
|
-
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
202
|
-
expect(code).toBe(1);
|
|
203
|
-
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse'));
|
|
204
|
-
errSpy.mockRestore();
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Tests for sprint-audit CLI command (Layer 2)
|
|
3
|
+
* @test REQ-338 Sprint audit — final completeness check
|
|
4
|
+
* @intent Verify sprint-audit correctly checks phase coverage, artifacts, consistency
|
|
5
|
+
* @covers AC-AUDIT-01 (SKIP when no sprint-state.json)
|
|
6
|
+
* @covers AC-AUDIT-02 (PASS when 6/6 completed)
|
|
7
|
+
* @covers AC-AUDIT-03 (PASS_WITH_WARNINGS when 4-5/6 completed)
|
|
8
|
+
* @covers AC-AUDIT-04 (FAIL when <4/6 completed)
|
|
9
|
+
* @covers AC-AUDIT-05 (FAIL on state inconsistency)
|
|
10
|
+
* @covers AC-AUDIT-06 (--json flag outputs JSON)
|
|
11
|
+
* @covers AC-AUDIT-07 (report persisted to audit-report.json)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { handleSprintAudit } from '../sprint-audit.js';
|
|
19
|
+
|
|
20
|
+
describe('sprint-audit', () => {
|
|
21
|
+
let tmpDir;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-audit-test-'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function writeState(state) {
|
|
32
|
+
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
33
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), JSON.stringify(state));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeState(overrides = {}) {
|
|
38
|
+
return {
|
|
39
|
+
_schema_version: 1,
|
|
40
|
+
id: 'sprint-test-audit',
|
|
41
|
+
task_description: 'Test audit feature',
|
|
42
|
+
phase: 6,
|
|
43
|
+
status: 'completed',
|
|
44
|
+
started_at: '2026-07-22T10:00:00Z',
|
|
45
|
+
phase_history: [],
|
|
46
|
+
isolation: { branch: 'sprint/test-audit', worktree_path: null },
|
|
47
|
+
outputs: {},
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makePhaseHistory(completedPhases) {
|
|
53
|
+
const phaseNames = ['PREP', 'DESIGN', 'BUILD', 'VERIFY', 'SHIP', 'CLOSE'];
|
|
54
|
+
return completedPhases.map((p, i) => ({
|
|
55
|
+
phase: p,
|
|
56
|
+
phase_name: phaseNames[p - 1],
|
|
57
|
+
status: 'completed',
|
|
58
|
+
started_at: `2026-07-22T10:${String(i).padStart(2, '0')}:00Z`,
|
|
59
|
+
completed_at: `2026-07-22T10:${String(i).padStart(2, '0')}:30Z`,
|
|
60
|
+
duration_seconds: 30,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('handleSprintAudit()', () => {
|
|
65
|
+
it('returns 0 and shows help with --help', async () => {
|
|
66
|
+
const code = await handleSprintAudit(['--help']);
|
|
67
|
+
expect(code).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns SKIP (exit 0) when no sprint-state.json exists', async () => {
|
|
71
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
72
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
73
|
+
expect(code).toBe(0);
|
|
74
|
+
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('No sprint-state.json'));
|
|
75
|
+
logSpy.mockRestore();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns PASS when 6/6 phases completed', async () => {
|
|
79
|
+
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
80
|
+
|
|
81
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
82
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
83
|
+
expect(code).toBe(0);
|
|
84
|
+
// Check output contains PASS
|
|
85
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
86
|
+
expect(output).toContain('PASS');
|
|
87
|
+
expect(output).toContain('6/6');
|
|
88
|
+
logSpy.mockRestore();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns PASS_WITH_WARNINGS when 5/6 completed', async () => {
|
|
92
|
+
writeState(makeState({ phase: 5, phase_history: makePhaseHistory([1, 2, 3, 4, 5]) }));
|
|
93
|
+
|
|
94
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
95
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
96
|
+
expect(code).toBe(0);
|
|
97
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
98
|
+
expect(output).toContain('PASS_WITH_WARNINGS');
|
|
99
|
+
logSpy.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('returns PASS_WITH_WARNINGS when 4/6 completed', async () => {
|
|
103
|
+
writeState(makeState({ phase: 4, phase_history: makePhaseHistory([1, 2, 3, 4]) }));
|
|
104
|
+
|
|
105
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
106
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
107
|
+
expect(code).toBe(0);
|
|
108
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
109
|
+
expect(output).toContain('PASS_WITH_WARNINGS');
|
|
110
|
+
logSpy.mockRestore();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('returns FAIL when <4/6 completed', async () => {
|
|
114
|
+
writeState(makeState({ phase: 3, phase_history: makePhaseHistory([1, 2, 3]) }));
|
|
115
|
+
|
|
116
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
117
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
118
|
+
expect(code).toBe(1);
|
|
119
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
120
|
+
expect(output).toContain('FAIL');
|
|
121
|
+
logSpy.mockRestore();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('returns FAIL on state inconsistency (phase vs phase_history)', async () => {
|
|
125
|
+
const history = makePhaseHistory([1, 2, 3, 4, 5, 6]);
|
|
126
|
+
writeState(makeState({ phase: 3, phase_history: history })); // phase=3 but last entry is 6
|
|
127
|
+
|
|
128
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
129
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
130
|
+
expect(code).toBe(1);
|
|
131
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
132
|
+
expect(output).toContain('State inconsistency');
|
|
133
|
+
logSpy.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('outputs JSON when --json flag is set', async () => {
|
|
137
|
+
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
138
|
+
|
|
139
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
140
|
+
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
141
|
+
expect(code).toBe(0);
|
|
142
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
143
|
+
const result = JSON.parse(output);
|
|
144
|
+
expect(result.verdict).toBe('PASS');
|
|
145
|
+
expect(result.coverage.completed).toBe(6);
|
|
146
|
+
logSpy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('counts skipped phases as completed in coverage', async () => {
|
|
150
|
+
const history = makePhaseHistory([1, 2, 3, 4, 5]);
|
|
151
|
+
history.push({
|
|
152
|
+
phase: 6, phase_name: 'CLOSE', status: 'skipped',
|
|
153
|
+
started_at: '2026-07-22T10:05:00Z', completed_at: '2026-07-22T10:05:00Z', duration_seconds: 0,
|
|
154
|
+
});
|
|
155
|
+
writeState(makeState({ phase: 6, phase_history: history }));
|
|
156
|
+
|
|
157
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
158
|
+
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
159
|
+
expect(code).toBe(0);
|
|
160
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
161
|
+
const result = JSON.parse(output);
|
|
162
|
+
expect(result.coverage.completed).toBe(6);
|
|
163
|
+
expect(result.verdict).toBe('PASS');
|
|
164
|
+
logSpy.mockRestore();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('warns about missing summary files for completed phases', async () => {
|
|
168
|
+
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
169
|
+
// No phase-outputs directory → all summaries missing
|
|
170
|
+
|
|
171
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
172
|
+
const code = await handleSprintAudit(['--dir', tmpDir, '--json']);
|
|
173
|
+
expect(code).toBe(0);
|
|
174
|
+
const output = logSpy.mock.calls.map(c => c[0]).join('\n');
|
|
175
|
+
const result = JSON.parse(output);
|
|
176
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
177
|
+
expect(result.warnings.some(w => w.includes('summary missing'))).toBe(true);
|
|
178
|
+
logSpy.mockRestore();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('persists audit report to audit-report.json', async () => {
|
|
182
|
+
writeState(makeState({ phase_history: makePhaseHistory([1, 2, 3, 4, 5, 6]) }));
|
|
183
|
+
|
|
184
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
185
|
+
await handleSprintAudit(['--dir', tmpDir]);
|
|
186
|
+
logSpy.mockRestore();
|
|
187
|
+
|
|
188
|
+
const reportPath = path.join(tmpDir, '.sprint-state', 'audit-report.json');
|
|
189
|
+
expect(fs.existsSync(reportPath)).toBe(true);
|
|
190
|
+
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
191
|
+
expect(report.verdict).toBe('PASS');
|
|
192
|
+
expect(report.sprint_id).toBe('sprint-test-audit');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('handles corrupted sprint-state.json gracefully', async () => {
|
|
196
|
+
const stateDir = path.join(tmpDir, '.sprint-state');
|
|
197
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
198
|
+
fs.writeFileSync(path.join(stateDir, 'sprint-state.json'), 'not-valid-json');
|
|
199
|
+
|
|
200
|
+
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
201
|
+
const code = await handleSprintAudit(['--dir', tmpDir]);
|
|
202
|
+
expect(code).toBe(1);
|
|
203
|
+
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to parse'));
|
|
204
|
+
errSpy.mockRestore();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
* leads with it. Behavior is exercised through an injected fake spawner,
|
|
7
7
|
* so no real interpreter configuration is assumed on the host (#469
|
|
8
8
|
* follow-up, Delphi walkthrough R1 MN-06).
|
|
9
|
-
* @covers AC-WINBASH-01
|
|
9
|
+
* @covers AC-WINBASH-01, AC-WINBASH-02
|
|
10
10
|
*/
|
|
11
11
|
const { describe, it, expect } = await import('vitest');
|
|
12
|
-
const { resolveWindowsBashCandidates } = require('../detect-deps.js');
|
|
12
|
+
const { resolveWindowsBashCandidates, checkBashVersion } = require('../detect-deps.js');
|
|
13
13
|
|
|
14
14
|
// The candidate loop reads `where bash` from the host; tests inject both the
|
|
15
15
|
// discovery runner and the probe spawner, so results are deterministic on any
|
|
@@ -39,10 +39,12 @@ function fakeSpawn(behaviors) {
|
|
|
39
39
|
|
|
40
40
|
describe('resolveWindowsBashCandidates (win32 authoritative bash)', () => {
|
|
41
41
|
it('AC-WINBASH-01: rejects a WSL stub whose $1 is empty and uname is Linux', () => {
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
42
|
+
// The resolver must find nothing rather than settle for the stub — and on
|
|
43
|
+
// current Windows the System32 stub is skipped by name without even being
|
|
44
|
+
// spawned (cold-launch stall); this world uses a non-System32 stub to keep
|
|
45
|
+
// testing the output-based rejection path itself.
|
|
46
|
+
const spawn = fakeSpawn({ 'C:\\wsl-stub\\bash.exe': '[ ]\nLinux\n' });
|
|
47
|
+
expect(resolveWindowsBashCandidates(spawn, fakeWhere('C:\\wsl-stub\\bash.exe\r\n'))).toBeNull();
|
|
46
48
|
});
|
|
47
49
|
|
|
48
50
|
it('AC-WINBASH-01: accepts the first candidate with argv round-trip + MINGW uname', () => {
|
|
@@ -52,7 +54,24 @@ describe('resolveWindowsBashCandidates (win32 authoritative bash)', () => {
|
|
|
52
54
|
});
|
|
53
55
|
const result = resolveWindowsBashCandidates(spawn, fakeWhere('C:\\a\\bash.exe\r\nC:\\b\\bash.exe\r\n'));
|
|
54
56
|
expect(result).toBe('C:\\b\\bash.exe');
|
|
55
|
-
|
|
57
|
+
// Known Git paths are probed BEFORE PATH discoveries (cold WSL launches
|
|
58
|
+
// stall the event loop for seconds), so a/b keep their relative order but
|
|
59
|
+
// trail the hardcoded fallbacks.
|
|
60
|
+
expect(spawn.probed.slice(-2)).toEqual(['C:\\a\\bash.exe', 'C:\\b\\bash.exe']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('AC-WINBASH-02: probes known Git paths before PATH discoveries', () => {
|
|
64
|
+
// Regression for the doctor fan-out starvation: System32\bash.exe leads
|
|
65
|
+
// `where bash` on WSL machines and its cold launch blocks ~3.4s. It must
|
|
66
|
+
// never be spawned at all — neither from where-output nor as any fallback.
|
|
67
|
+
const spawn = fakeSpawn({
|
|
68
|
+
'C:\\Program Files\\Git\\bin\\bash.exe': '[XPGATE_ARGV_PROBE]\nMSYS_NT-10.0\n',
|
|
69
|
+
});
|
|
70
|
+
const { result } = (() => ({
|
|
71
|
+
result: resolveWindowsBashCandidates(spawn, fakeWhere('C:\\Windows\\System32\\bash.exe\r\n')),
|
|
72
|
+
}))();
|
|
73
|
+
expect(result).toBe('C:\\Program Files\\Git\\bin\\bash.exe');
|
|
74
|
+
expect(spawn.probed).not.toContain('C:\\Windows\\System32\\bash.exe');
|
|
56
75
|
});
|
|
57
76
|
|
|
58
77
|
it('AC-WINBASH-01: a Linux uname with intact argv still fails the platform test', () => {
|
|
@@ -70,4 +89,35 @@ describe('resolveWindowsBashCandidates (win32 authoritative bash)', () => {
|
|
|
70
89
|
const result = resolveWindowsBashCandidates(spawn, fakeWhere(new Error('where unavailable')));
|
|
71
90
|
expect(result).toBe('C:\\msys64\\usr\\bin\\bash.exe');
|
|
72
91
|
});
|
|
92
|
+
|
|
93
|
+
it('AC-WINBASH-02: WSL stubs are skipped by name regardless of separators or case', () => {
|
|
94
|
+
// The filter normalizes slashes and ignores case/drive: forward-slash and
|
|
95
|
+
// lowercase variants of the same stub must still produce NO spawn call.
|
|
96
|
+
// The AppX alias lives under <Windows>\SystemApps\Microsoft.WSL_* — a
|
|
97
|
+
// user's own install merely NAMED ...\WindowsApps\... is NOT the stub and
|
|
98
|
+
// must still be probed (output-based rejection covers it).
|
|
99
|
+
const spawn = fakeSpawn({});
|
|
100
|
+
resolveWindowsBashCandidates(spawn, fakeWhere(
|
|
101
|
+
'c:/windows/sysnative/bash.exe\r\nC:\\WINDOWS\\System32\\bash.exe\r\nC:\\Windows\\SysWOW64\\bash.exe\r\nC:\\Users\\me\\AppData\\Local\\Microsoft\\WindowsApps\\bash.exe\r\n'
|
|
102
|
+
));
|
|
103
|
+
expect(spawn.probed.filter(p => /system32|sysnative|syswow64|appdata\\local\\microsoft\\windowsapps/i.test(p.replace(/\//g, '\\')))).toEqual([]);
|
|
104
|
+
const userOwned = fakeSpawn({});
|
|
105
|
+
resolveWindowsBashCandidates(userOwned, fakeWhere('D:\\Tools\\WindowsApps\\bash.exe\r\n'));
|
|
106
|
+
expect(userOwned.probed).toContain('D:\\Tools\\WindowsApps\\bash.exe');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('AC-WINBASH-01: checkBashVersion returns the bash VERSION tuple, not the uname line', () => {
|
|
110
|
+
// Contract regression (walkthrough R5 MJ-01/FC-02): doctor prints "GNU
|
|
111
|
+
// bash, version <field>" — the field must be the version number.
|
|
112
|
+
const spawn = (file, args) => ({
|
|
113
|
+
stdout: 'MINGW64_NT-10.0-26300\nGNU bash, version 5.3.9(1)-release (x86_64-pc-cygwin)\n',
|
|
114
|
+
});
|
|
115
|
+
expect(checkBashVersion(spawn, 'C:\\bash.exe')).toBe('5.3.9(1)-release');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('AC-WINBASH-01: checkBashVersion degrades to unknown, never a crash, on odd output', () => {
|
|
119
|
+
expect(checkBashVersion(() => ({ stdout: '' }), 'C:\\bash.exe')).toBe('unknown');
|
|
120
|
+
expect(checkBashVersion(() => ({ stdout: 'MINGW64_NT-10.0\n' }), 'C:\\bash.exe')).toBe('unknown');
|
|
121
|
+
expect(checkBashVersion(() => { throw new Error('spawn failed'); }, 'C:\\bash.exe')).toBe('unknown');
|
|
122
|
+
});
|
|
73
123
|
});
|