@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
package/lib/detect-deps.js
CHANGED
|
@@ -296,10 +296,14 @@ function probeWindowsBash() {
|
|
|
296
296
|
function resolveWindowsBashCandidates(spawnProbe, whereExecSync) {
|
|
297
297
|
const _execSync = whereExecSync || execSync;
|
|
298
298
|
const candidates = [];
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
299
|
+
// Well-known interpreters FIRST. Two reasons, both measured on win32:
|
|
300
|
+
// 1. A cold WSL launch (System32\bash.exe leads `where bash` when WSL is
|
|
301
|
+
// installed) blocks its spawnSync for ~3.4s. checkBash runs inside
|
|
302
|
+
// doctor's parallel fan-out, and that synchronous stall starves the
|
|
303
|
+
// event loop past EXEC_TIMEOUT_MS — node/git then falsely report
|
|
304
|
+
// "Not found" while spawning them standalone takes ~300ms.
|
|
305
|
+
// 2. The known Git paths ARE the answer we are looking for; probing them
|
|
306
|
+
// first also skips pointless launches of stubs we would reject anyway.
|
|
303
307
|
candidates.push(
|
|
304
308
|
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
305
309
|
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
|
@@ -308,6 +312,33 @@ function resolveWindowsBashCandidates(spawnProbe, whereExecSync) {
|
|
|
308
312
|
'C:\\msys64\\usr\\bin\\bash.exe',
|
|
309
313
|
'C:\\cygwin64\\bin\\bash.exe'
|
|
310
314
|
);
|
|
315
|
+
try {
|
|
316
|
+
const found = _execSync('where bash', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
317
|
+
for (const line of found.split(/\r?\n/)) {
|
|
318
|
+
const p = line.trim();
|
|
319
|
+
// Skip the WSL launcher outright: uname would reject it, and a cold
|
|
320
|
+
// launch costs seconds (see above). WindowsApps\bash.exe is the same
|
|
321
|
+
// WSL stub family (it just errors differently when WSL is absent).
|
|
322
|
+
if (!p) continue;
|
|
323
|
+
// Match on normalized separators: forward slashes, mixed case, or a
|
|
324
|
+
// non-C drive would otherwise slip a WSL stub past the filter and back
|
|
325
|
+
// into the multi-second cold-launch stall.
|
|
326
|
+
const norm = p.replace(/\//g, '\\');
|
|
327
|
+
// The WSL launcher appears as System32/Sysnative/SysWOW64\bash.exe, the
|
|
328
|
+
// AppX alias <Windows>\SystemApps\Microsoft.WSL_*\bash.exe, and the
|
|
329
|
+
// per-user AppX execution alias ...\AppData\Local\Microsoft\WindowsApps\
|
|
330
|
+
// bash.exe. A user's own install merely NAMED ...\WindowsApps\... (not
|
|
331
|
+
// under Microsoft\) is NOT the stub and stays in the list.
|
|
332
|
+
if (/\\windowsapps\\bash\.exe$/i.test(norm)
|
|
333
|
+
&& (/\\systemapps\\/i.test(norm) || /\\appdata\\local\\microsoft\\windowsapps\\/i.test(norm))) continue;
|
|
334
|
+
// The WSL stub lives in the matching-arch System32 view: 64-bit
|
|
335
|
+
// C:\Windows\System32\bash.exe AND the 32-bit emulation redirect target
|
|
336
|
+
// C:\Windows\SysWOW64\bash.exe (where reports either depending on the
|
|
337
|
+
// bitness of the caller).
|
|
338
|
+
if (/\\windows\\system32\\bash\.exe$/i.test(norm) || /\\windows\\sysnative\\bash\.exe$/i.test(norm) || /\\windows\\syswow64\\bash\.exe$/i.test(norm)) continue;
|
|
339
|
+
if (!candidates.includes(p)) candidates.push(p);
|
|
340
|
+
}
|
|
341
|
+
} catch { /* where unavailable */ }
|
|
311
342
|
for (const candidate of candidates) {
|
|
312
343
|
try {
|
|
313
344
|
const r = spawnProbe(candidate, ['-c', 'echo "[$1]"; uname -s', 'sh', 'XPGATE_ARGV_PROBE'], {
|
|
@@ -327,22 +358,46 @@ function resolveWindowsBashCandidates(spawnProbe, whereExecSync) {
|
|
|
327
358
|
/**
|
|
328
359
|
* Check if bash is available on the system.
|
|
329
360
|
* XP-Gate hooks are bash scripts — Windows users need Git Bash installed.
|
|
330
|
-
* @returns {{ok: boolean, path?: string, message?: string}}
|
|
361
|
+
* @returns {{ok: boolean, path?: string, version?: string, message?: string}}
|
|
331
362
|
*/
|
|
363
|
+
// Split out for behavior tests: checkBashVersion takes the resolved bash path
|
|
364
|
+
// and a spawner so the version-field contract (a bash VERSION tuple, not the
|
|
365
|
+
// uname string) is assertable without a real interpreter on the host.
|
|
366
|
+
function checkBashVersion(spawnProbe, winBash) {
|
|
367
|
+
let version = 'unknown';
|
|
368
|
+
try {
|
|
369
|
+
// --version through the SAME probe-style argv as the candidate loop:
|
|
370
|
+
// Git Bash's login startup (`bash --version`) costs ~1.7s, and checkBash
|
|
371
|
+
// sits on hot paths (doctor fan-out, every gate-runner launch) where a
|
|
372
|
+
// synchronous stall starves concurrent execs past their timeouts. The
|
|
373
|
+
// -c form is non-login (~300ms). uname proves the MSYS family AND bash
|
|
374
|
+
// --version inside it still yields the real bash version number — the
|
|
375
|
+
// `version` field must stay a bash version, not the uname string. No
|
|
376
|
+
// sed in the command: the parse lives in JS below, so a stripped MSYS
|
|
377
|
+
// install without sed cannot silently degrade to "unknown".
|
|
378
|
+
// NOTE the cp. prefix: this module binds only execSync at file scope,
|
|
379
|
+
// and checkBash sits outside probeWindowsBash's local spawnSync binding
|
|
380
|
+
// — a bare spawnSync here is a ReferenceError swallowed by the catch,
|
|
381
|
+
// which used to pin the detail line at "unknown" forever.
|
|
382
|
+
const result = spawnProbe(winBash, ['-c', 'uname -s; bash --version', 'sh'], { encoding: 'utf8', timeout: 5000 });
|
|
383
|
+
const out = String((result && result.stdout) || '').trim();
|
|
384
|
+
// Keep the field in the same shape checkEnv prints for every other tool
|
|
385
|
+
// ("GNU bash, version X.Y.Z(...)"): doctor prefixes "GNU bash, version ",
|
|
386
|
+
// so hand back the bare version tuple, not the whole line.
|
|
387
|
+
const versionLine = out.split('\n').find(line => /version/.test(line));
|
|
388
|
+
const match = versionLine && versionLine.match(/version\s+(\S+)/);
|
|
389
|
+
if (match) version = match[1];
|
|
390
|
+
} catch { /* keep unknown */ }
|
|
391
|
+
return version;
|
|
392
|
+
}
|
|
393
|
+
|
|
332
394
|
function checkBash() {
|
|
333
395
|
// On Windows resolve explicitly: PATH may lead with the WSL System32 stub,
|
|
334
396
|
// which cannot execute these hooks (wrong filesystem view, dropped $1).
|
|
335
397
|
if (process.platform === 'win32') {
|
|
336
398
|
const winBash = probeWindowsBash();
|
|
337
399
|
if (winBash) {
|
|
338
|
-
|
|
339
|
-
try {
|
|
340
|
-
// spawnSync with discrete argv — never interpolate a discovered path
|
|
341
|
-
// into a shell string (a PATH dir name may carry cmd metacharacters).
|
|
342
|
-
const result = spawnSync(winBash, ['--version'], { encoding: 'utf8', timeout: 5000 });
|
|
343
|
-
const versionMatch = String(result.stdout || '').match(/version\s+([^\s]+)/);
|
|
344
|
-
if (versionMatch) version = versionMatch[1];
|
|
345
|
-
} catch { /* keep unknown */ }
|
|
400
|
+
const version = checkBashVersion((...args) => require('child_process').spawnSync(...args), winBash);
|
|
346
401
|
return { ok: true, path: winBash, version };
|
|
347
402
|
}
|
|
348
403
|
return {
|
|
@@ -567,6 +622,7 @@ function compareVersions(a, b) {
|
|
|
567
622
|
module.exports = {
|
|
568
623
|
checkDeps,
|
|
569
624
|
checkBash,
|
|
625
|
+
checkBashVersion,
|
|
570
626
|
resolveWindowsBashCandidates,
|
|
571
627
|
autoInstallDeps,
|
|
572
628
|
detectPlatform,
|
package/lib/doctor.js
CHANGED
|
@@ -104,18 +104,52 @@ function noteSandboxAttribution(err) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
async function execWithTimeout(cmd, opts = {}) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
107
|
+
// Win32 false-negative guard. Under cmd.exe (exec's shell), a spawned probe
|
|
108
|
+
// can exit 0 while its stdout never reaches us — measured on this machine:
|
|
109
|
+
// `node --version` returns exit 0 with an EMPTY stream when many spawns are
|
|
110
|
+
// in flight at once (doctor's parallel fan-out), while the same command
|
|
111
|
+
// standalone prints fine in ~300ms. The old code read that empty stream as
|
|
112
|
+
// "Not found" and red-flagged Node/Git/hooksPath. A version/config probe is
|
|
113
|
+
// idempotent, so quiet retries distinguish the lost stream from a tool that
|
|
114
|
+
// genuinely fails. Contract: every caller here runs an IDEMPOTENT probe
|
|
115
|
+
// (`--version`, `config --get`, `rev-parse`) — a future side-effecting call
|
|
116
|
+
// site must pass { attempts: 1 } to opt out of the retry below. The option
|
|
117
|
+
// is consumed here and never forwarded to exec.
|
|
118
|
+
// Scope note: only the exit-0-with-empty-stream shape is retried. A probe
|
|
119
|
+
// that exits non-zero is a REAL failure verdict (cmd.exe reports a missing
|
|
120
|
+
// tool as exit 1 + "'x' is not recognized"), and re-running it would only
|
|
121
|
+
// burn time, so that path still returns null immediately.
|
|
122
|
+
const { attempts, ...execOpts } = opts;
|
|
123
|
+
const maxAttempts = process.platform === 'win32' ? (attempts || 3) : 1;
|
|
124
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
127
|
+
...execOpts,
|
|
128
|
+
timeout: execOpts.timeout || EXEC_TIMEOUT_MS,
|
|
129
|
+
});
|
|
130
|
+
if (!stdout.trim() && !stderr.trim()) {
|
|
131
|
+
// Exit-0-but-empty: nothing usable came back. Retry while attempts
|
|
132
|
+
// remain; report null once they are exhausted, so every caller's
|
|
133
|
+
// existing null check ("probe produced nothing") applies — an empty
|
|
134
|
+
// non-null result would read as a present tool with no version.
|
|
135
|
+
if (attempt + 1 < maxAttempts) {
|
|
136
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// #525: a bare null here reads as "tool missing" and has already cost a
|
|
144
|
+
// misdiagnosis round. Name the sandbox once when it is the sandbox.
|
|
145
|
+
noteSandboxAttribution(err);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
118
148
|
}
|
|
149
|
+
// Exhausted all attempts with exit-0-but-empty streams: report null so every
|
|
150
|
+
// caller's existing null check ("probe produced nothing usable") applies —
|
|
151
|
+
// an empty non-null result would read as a present tool with no version.
|
|
152
|
+
return null;
|
|
119
153
|
}
|
|
120
154
|
|
|
121
155
|
/**
|
package/lib/sprint-audit.js
CHANGED
|
@@ -1,235 +1,235 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* xp-gate sprint-audit — Layer 2: Final sprint completeness audit.
|
|
3
|
-
*
|
|
4
|
-
* Checks phase_history coverage, time records, artifact records,
|
|
5
|
-
* and state consistency at Phase 6 CLOSE.
|
|
6
|
-
*
|
|
7
|
-
* Usage:
|
|
8
|
-
* xp-gate sprint-audit [--dir <path>] [--json]
|
|
9
|
-
*
|
|
10
|
-
* Verdict rules:
|
|
11
|
-
* 6/6 completed + 0 errors → PASS (exit 0)
|
|
12
|
-
* >= 4/6 completed + 0 errors → PASS_WITH_WARNINGS (exit 0)
|
|
13
|
-
* < 4/6 completed or any errors → FAIL (exit 1)
|
|
14
|
-
* No sprint-state.json → SKIP (exit 0)
|
|
15
|
-
*
|
|
16
|
-
* @module sprint-audit
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
'use strict';
|
|
20
|
-
|
|
21
|
-
const fs = require('fs');
|
|
22
|
-
const path = require('path');
|
|
23
|
-
const { PHASE_NAMES } = require('./phase-transition');
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Run the sprint audit.
|
|
27
|
-
* @param {string[]} args - CLI arguments
|
|
28
|
-
* @returns {Promise<number>} Exit code
|
|
29
|
-
*/
|
|
30
|
-
async function handleSprintAudit(args = []) {
|
|
31
|
-
if (args.includes('--help')) {
|
|
32
|
-
console.log('Usage: xp-gate sprint-audit [options]');
|
|
33
|
-
console.log('');
|
|
34
|
-
console.log('Options:');
|
|
35
|
-
console.log(' --dir <path> Project directory (default: cwd)');
|
|
36
|
-
console.log(' --json Output as JSON instead of human-readable');
|
|
37
|
-
console.log('');
|
|
38
|
-
console.log('Runs final sprint completeness audit at Phase 6 CLOSE.');
|
|
39
|
-
console.log('Checks phase coverage, time records, artifact records,');
|
|
40
|
-
console.log('and state consistency.');
|
|
41
|
-
return 0;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const dirIdx = args.indexOf('--dir');
|
|
45
|
-
const projectDir = dirIdx >= 0 ? args[dirIdx + 1] : process.cwd();
|
|
46
|
-
const jsonFlag = args.includes('--json');
|
|
47
|
-
|
|
48
|
-
const stateFile = path.join(projectDir, '.sprint-state', 'sprint-state.json');
|
|
49
|
-
|
|
50
|
-
// No sprint-state.json → SKIP
|
|
51
|
-
if (!fs.existsSync(stateFile)) {
|
|
52
|
-
if (jsonFlag) {
|
|
53
|
-
console.log(JSON.stringify({ verdict: 'SKIP', reason: 'No sprint-state.json found' }));
|
|
54
|
-
} else {
|
|
55
|
-
console.log('⏭️ No sprint-state.json found — skipping audit.');
|
|
56
|
-
}
|
|
57
|
-
return 0;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let state;
|
|
61
|
-
try {
|
|
62
|
-
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
63
|
-
} catch (err) {
|
|
64
|
-
const result = {
|
|
65
|
-
verdict: 'FAIL',
|
|
66
|
-
errors: [`Failed to parse sprint-state.json: ${err.message}`],
|
|
67
|
-
};
|
|
68
|
-
if (jsonFlag) {
|
|
69
|
-
console.log(JSON.stringify(result, null, 2));
|
|
70
|
-
} else {
|
|
71
|
-
console.error(`❌ ${result.errors[0]}`);
|
|
72
|
-
}
|
|
73
|
-
return 1;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── Run checks ──
|
|
77
|
-
const warnings = [];
|
|
78
|
-
const errors = [];
|
|
79
|
-
const phaseHistory = Array.isArray(state.phase_history) ? state.phase_history : [];
|
|
80
|
-
|
|
81
|
-
// Build lookup
|
|
82
|
-
const historyLookup = {};
|
|
83
|
-
for (const entry of phaseHistory) {
|
|
84
|
-
historyLookup[entry.phase] = entry;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Check 1: Phase coverage
|
|
88
|
-
const completedPhases = [];
|
|
89
|
-
const missingPhases = [];
|
|
90
|
-
const incompletePhases = [];
|
|
91
|
-
|
|
92
|
-
for (let i = 1; i <= 6; i++) {
|
|
93
|
-
const entry = historyLookup[i];
|
|
94
|
-
if (!entry) {
|
|
95
|
-
missingPhases.push(i);
|
|
96
|
-
} else if (entry.status === 'completed' || entry.status === 'skipped') {
|
|
97
|
-
completedPhases.push(i);
|
|
98
|
-
} else {
|
|
99
|
-
incompletePhases.push({ phase: i, name: PHASE_NAMES[i] || `Phase ${i}`, status: entry.status });
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Check 2: Time records for completed phases
|
|
104
|
-
for (const phaseNum of completedPhases) {
|
|
105
|
-
const entry = historyLookup[phaseNum];
|
|
106
|
-
if (entry.status === 'completed' && (entry.duration_seconds == null || entry.duration_seconds === 0)) {
|
|
107
|
-
warnings.push(`Phase ${phaseNum} (${PHASE_NAMES[phaseNum]}) completed but has no duration recorded`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Check 3: Artifact records (phase-{N}-summary.md)
|
|
112
|
-
const outputsDir = path.join(projectDir, '.sprint-state', 'phase-outputs');
|
|
113
|
-
for (const phaseNum of completedPhases) {
|
|
114
|
-
if (historyLookup[phaseNum].status === 'completed') {
|
|
115
|
-
const summaryFile = path.join(outputsDir, `phase-${phaseNum}-summary.md`);
|
|
116
|
-
if (!fs.existsSync(summaryFile)) {
|
|
117
|
-
warnings.push(`Phase ${phaseNum} summary missing: .sprint-state/phase-outputs/phase-${phaseNum}-summary.md`);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Check 4: State consistency — phase field vs phase_history last entry
|
|
123
|
-
if (phaseHistory.length > 0) {
|
|
124
|
-
const lastEntry = phaseHistory[phaseHistory.length - 1];
|
|
125
|
-
if (state.phase !== undefined && state.phase !== lastEntry.phase) {
|
|
126
|
-
errors.push(
|
|
127
|
-
`State inconsistency: sprint-state.phase=${state.phase} but phase_history last entry is phase ${lastEntry.phase}`
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ── Determine verdict ──
|
|
133
|
-
const completedCount = completedPhases.length;
|
|
134
|
-
let verdict;
|
|
135
|
-
if (errors.length > 0 || completedCount < 4) {
|
|
136
|
-
verdict = 'FAIL';
|
|
137
|
-
} else if (completedCount >= 6 && errors.length === 0) {
|
|
138
|
-
verdict = 'PASS';
|
|
139
|
-
} else {
|
|
140
|
-
verdict = 'PASS_WITH_WARNINGS';
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ── Build result ──
|
|
144
|
-
const result = {
|
|
145
|
-
sprint_id: state.id || 'unknown',
|
|
146
|
-
branch: state.isolation?.branch || 'unknown',
|
|
147
|
-
coverage: {
|
|
148
|
-
completed: completedCount,
|
|
149
|
-
total: 6,
|
|
150
|
-
pct: Math.round((completedCount / 6) * 100),
|
|
151
|
-
},
|
|
152
|
-
missing_phases: missingPhases.map(p => ({ phase: p, name: PHASE_NAMES[p] || `Phase ${p}` })),
|
|
153
|
-
incomplete_phases: incompletePhases,
|
|
154
|
-
warnings,
|
|
155
|
-
errors,
|
|
156
|
-
verdict,
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
// ── Persist report (latest overwrites — expected behavior) ──
|
|
160
|
-
const reportPath = path.join(projectDir, '.sprint-state', 'audit-report.json');
|
|
161
|
-
try {
|
|
162
|
-
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), 'utf8');
|
|
163
|
-
} catch (err) {
|
|
164
|
-
// Report write failure is WARNING, not BLOCK
|
|
165
|
-
warnings.push(`Failed to write audit report: ${err.message}`);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ── Output ──
|
|
169
|
-
if (jsonFlag) {
|
|
170
|
-
console.log(JSON.stringify(result, null, 2));
|
|
171
|
-
} else {
|
|
172
|
-
printHumanReadable(result, state);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Exit code: FAIL → 1, others → 0
|
|
176
|
-
return verdict === 'FAIL' ? 1 : 0;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Print human-readable audit report.
|
|
181
|
-
* @param {object} result - Audit result object
|
|
182
|
-
* @param {object} state - Sprint state object
|
|
183
|
-
*/
|
|
184
|
-
function printHumanReadable(result, state) {
|
|
185
|
-
const lines = [];
|
|
186
|
-
lines.push('');
|
|
187
|
-
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
188
|
-
lines.push(` Sprint Audit Report — ${result.sprint_id}`);
|
|
189
|
-
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
190
|
-
lines.push('');
|
|
191
|
-
lines.push(` Branch: ${result.branch}`);
|
|
192
|
-
lines.push(` Phase Coverage: ${result.coverage.completed}/${result.coverage.total} (${result.coverage.pct}%)`);
|
|
193
|
-
lines.push('');
|
|
194
|
-
lines.push(' Phase History:');
|
|
195
|
-
|
|
196
|
-
for (let i = 1; i <= 6; i++) {
|
|
197
|
-
const entry = result.incomplete_phases.find(p => p.phase === i);
|
|
198
|
-
const missing = result.missing_phases.find(p => p.phase === i);
|
|
199
|
-
const name = (PHASE_NAMES[i] || `Phase ${i}`).padEnd(10);
|
|
200
|
-
|
|
201
|
-
if (missing) {
|
|
202
|
-
lines.push(` ❌ Phase ${i}/6 ${name} — not recorded`);
|
|
203
|
-
} else if (entry) {
|
|
204
|
-
lines.push(` ⚠️ Phase ${i}/6 ${name} ${entry.status} ← not yet completed`);
|
|
205
|
-
} else {
|
|
206
|
-
lines.push(` ✅ Phase ${i}/6 ${name} completed`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (result.warnings.length > 0) {
|
|
211
|
-
lines.push('');
|
|
212
|
-
lines.push(' Warnings:');
|
|
213
|
-
for (const w of result.warnings) {
|
|
214
|
-
lines.push(` ⚠️ ${w}`);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (result.errors.length > 0) {
|
|
219
|
-
lines.push('');
|
|
220
|
-
lines.push(' Errors:');
|
|
221
|
-
for (const e of result.errors) {
|
|
222
|
-
lines.push(` ❌ ${e}`);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
lines.push('');
|
|
227
|
-
const issueCount = result.warnings.length + result.errors.length;
|
|
228
|
-
lines.push(` Overall: ${result.warnings.length} warning(s), ${result.errors.length} error(s)`);
|
|
229
|
-
lines.push(` Verdict: ${result.verdict}`);
|
|
230
|
-
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
231
|
-
|
|
232
|
-
console.log(lines.join('\n'));
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
module.exports = { handleSprintAudit };
|
|
1
|
+
/**
|
|
2
|
+
* xp-gate sprint-audit — Layer 2: Final sprint completeness audit.
|
|
3
|
+
*
|
|
4
|
+
* Checks phase_history coverage, time records, artifact records,
|
|
5
|
+
* and state consistency at Phase 6 CLOSE.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* xp-gate sprint-audit [--dir <path>] [--json]
|
|
9
|
+
*
|
|
10
|
+
* Verdict rules:
|
|
11
|
+
* 6/6 completed + 0 errors → PASS (exit 0)
|
|
12
|
+
* >= 4/6 completed + 0 errors → PASS_WITH_WARNINGS (exit 0)
|
|
13
|
+
* < 4/6 completed or any errors → FAIL (exit 1)
|
|
14
|
+
* No sprint-state.json → SKIP (exit 0)
|
|
15
|
+
*
|
|
16
|
+
* @module sprint-audit
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { PHASE_NAMES } = require('./phase-transition');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run the sprint audit.
|
|
27
|
+
* @param {string[]} args - CLI arguments
|
|
28
|
+
* @returns {Promise<number>} Exit code
|
|
29
|
+
*/
|
|
30
|
+
async function handleSprintAudit(args = []) {
|
|
31
|
+
if (args.includes('--help')) {
|
|
32
|
+
console.log('Usage: xp-gate sprint-audit [options]');
|
|
33
|
+
console.log('');
|
|
34
|
+
console.log('Options:');
|
|
35
|
+
console.log(' --dir <path> Project directory (default: cwd)');
|
|
36
|
+
console.log(' --json Output as JSON instead of human-readable');
|
|
37
|
+
console.log('');
|
|
38
|
+
console.log('Runs final sprint completeness audit at Phase 6 CLOSE.');
|
|
39
|
+
console.log('Checks phase coverage, time records, artifact records,');
|
|
40
|
+
console.log('and state consistency.');
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const dirIdx = args.indexOf('--dir');
|
|
45
|
+
const projectDir = dirIdx >= 0 ? args[dirIdx + 1] : process.cwd();
|
|
46
|
+
const jsonFlag = args.includes('--json');
|
|
47
|
+
|
|
48
|
+
const stateFile = path.join(projectDir, '.sprint-state', 'sprint-state.json');
|
|
49
|
+
|
|
50
|
+
// No sprint-state.json → SKIP
|
|
51
|
+
if (!fs.existsSync(stateFile)) {
|
|
52
|
+
if (jsonFlag) {
|
|
53
|
+
console.log(JSON.stringify({ verdict: 'SKIP', reason: 'No sprint-state.json found' }));
|
|
54
|
+
} else {
|
|
55
|
+
console.log('⏭️ No sprint-state.json found — skipping audit.');
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let state;
|
|
61
|
+
try {
|
|
62
|
+
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const result = {
|
|
65
|
+
verdict: 'FAIL',
|
|
66
|
+
errors: [`Failed to parse sprint-state.json: ${err.message}`],
|
|
67
|
+
};
|
|
68
|
+
if (jsonFlag) {
|
|
69
|
+
console.log(JSON.stringify(result, null, 2));
|
|
70
|
+
} else {
|
|
71
|
+
console.error(`❌ ${result.errors[0]}`);
|
|
72
|
+
}
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Run checks ──
|
|
77
|
+
const warnings = [];
|
|
78
|
+
const errors = [];
|
|
79
|
+
const phaseHistory = Array.isArray(state.phase_history) ? state.phase_history : [];
|
|
80
|
+
|
|
81
|
+
// Build lookup
|
|
82
|
+
const historyLookup = {};
|
|
83
|
+
for (const entry of phaseHistory) {
|
|
84
|
+
historyLookup[entry.phase] = entry;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check 1: Phase coverage
|
|
88
|
+
const completedPhases = [];
|
|
89
|
+
const missingPhases = [];
|
|
90
|
+
const incompletePhases = [];
|
|
91
|
+
|
|
92
|
+
for (let i = 1; i <= 6; i++) {
|
|
93
|
+
const entry = historyLookup[i];
|
|
94
|
+
if (!entry) {
|
|
95
|
+
missingPhases.push(i);
|
|
96
|
+
} else if (entry.status === 'completed' || entry.status === 'skipped') {
|
|
97
|
+
completedPhases.push(i);
|
|
98
|
+
} else {
|
|
99
|
+
incompletePhases.push({ phase: i, name: PHASE_NAMES[i] || `Phase ${i}`, status: entry.status });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check 2: Time records for completed phases
|
|
104
|
+
for (const phaseNum of completedPhases) {
|
|
105
|
+
const entry = historyLookup[phaseNum];
|
|
106
|
+
if (entry.status === 'completed' && (entry.duration_seconds == null || entry.duration_seconds === 0)) {
|
|
107
|
+
warnings.push(`Phase ${phaseNum} (${PHASE_NAMES[phaseNum]}) completed but has no duration recorded`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check 3: Artifact records (phase-{N}-summary.md)
|
|
112
|
+
const outputsDir = path.join(projectDir, '.sprint-state', 'phase-outputs');
|
|
113
|
+
for (const phaseNum of completedPhases) {
|
|
114
|
+
if (historyLookup[phaseNum].status === 'completed') {
|
|
115
|
+
const summaryFile = path.join(outputsDir, `phase-${phaseNum}-summary.md`);
|
|
116
|
+
if (!fs.existsSync(summaryFile)) {
|
|
117
|
+
warnings.push(`Phase ${phaseNum} summary missing: .sprint-state/phase-outputs/phase-${phaseNum}-summary.md`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Check 4: State consistency — phase field vs phase_history last entry
|
|
123
|
+
if (phaseHistory.length > 0) {
|
|
124
|
+
const lastEntry = phaseHistory[phaseHistory.length - 1];
|
|
125
|
+
if (state.phase !== undefined && state.phase !== lastEntry.phase) {
|
|
126
|
+
errors.push(
|
|
127
|
+
`State inconsistency: sprint-state.phase=${state.phase} but phase_history last entry is phase ${lastEntry.phase}`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Determine verdict ──
|
|
133
|
+
const completedCount = completedPhases.length;
|
|
134
|
+
let verdict;
|
|
135
|
+
if (errors.length > 0 || completedCount < 4) {
|
|
136
|
+
verdict = 'FAIL';
|
|
137
|
+
} else if (completedCount >= 6 && errors.length === 0) {
|
|
138
|
+
verdict = 'PASS';
|
|
139
|
+
} else {
|
|
140
|
+
verdict = 'PASS_WITH_WARNINGS';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Build result ──
|
|
144
|
+
const result = {
|
|
145
|
+
sprint_id: state.id || 'unknown',
|
|
146
|
+
branch: state.isolation?.branch || 'unknown',
|
|
147
|
+
coverage: {
|
|
148
|
+
completed: completedCount,
|
|
149
|
+
total: 6,
|
|
150
|
+
pct: Math.round((completedCount / 6) * 100),
|
|
151
|
+
},
|
|
152
|
+
missing_phases: missingPhases.map(p => ({ phase: p, name: PHASE_NAMES[p] || `Phase ${p}` })),
|
|
153
|
+
incomplete_phases: incompletePhases,
|
|
154
|
+
warnings,
|
|
155
|
+
errors,
|
|
156
|
+
verdict,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ── Persist report (latest overwrites — expected behavior) ──
|
|
160
|
+
const reportPath = path.join(projectDir, '.sprint-state', 'audit-report.json');
|
|
161
|
+
try {
|
|
162
|
+
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), 'utf8');
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Report write failure is WARNING, not BLOCK
|
|
165
|
+
warnings.push(`Failed to write audit report: ${err.message}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Output ──
|
|
169
|
+
if (jsonFlag) {
|
|
170
|
+
console.log(JSON.stringify(result, null, 2));
|
|
171
|
+
} else {
|
|
172
|
+
printHumanReadable(result, state);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Exit code: FAIL → 1, others → 0
|
|
176
|
+
return verdict === 'FAIL' ? 1 : 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Print human-readable audit report.
|
|
181
|
+
* @param {object} result - Audit result object
|
|
182
|
+
* @param {object} state - Sprint state object
|
|
183
|
+
*/
|
|
184
|
+
function printHumanReadable(result, state) {
|
|
185
|
+
const lines = [];
|
|
186
|
+
lines.push('');
|
|
187
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
188
|
+
lines.push(` Sprint Audit Report — ${result.sprint_id}`);
|
|
189
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
190
|
+
lines.push('');
|
|
191
|
+
lines.push(` Branch: ${result.branch}`);
|
|
192
|
+
lines.push(` Phase Coverage: ${result.coverage.completed}/${result.coverage.total} (${result.coverage.pct}%)`);
|
|
193
|
+
lines.push('');
|
|
194
|
+
lines.push(' Phase History:');
|
|
195
|
+
|
|
196
|
+
for (let i = 1; i <= 6; i++) {
|
|
197
|
+
const entry = result.incomplete_phases.find(p => p.phase === i);
|
|
198
|
+
const missing = result.missing_phases.find(p => p.phase === i);
|
|
199
|
+
const name = (PHASE_NAMES[i] || `Phase ${i}`).padEnd(10);
|
|
200
|
+
|
|
201
|
+
if (missing) {
|
|
202
|
+
lines.push(` ❌ Phase ${i}/6 ${name} — not recorded`);
|
|
203
|
+
} else if (entry) {
|
|
204
|
+
lines.push(` ⚠️ Phase ${i}/6 ${name} ${entry.status} ← not yet completed`);
|
|
205
|
+
} else {
|
|
206
|
+
lines.push(` ✅ Phase ${i}/6 ${name} completed`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (result.warnings.length > 0) {
|
|
211
|
+
lines.push('');
|
|
212
|
+
lines.push(' Warnings:');
|
|
213
|
+
for (const w of result.warnings) {
|
|
214
|
+
lines.push(` ⚠️ ${w}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (result.errors.length > 0) {
|
|
219
|
+
lines.push('');
|
|
220
|
+
lines.push(' Errors:');
|
|
221
|
+
for (const e of result.errors) {
|
|
222
|
+
lines.push(` ❌ ${e}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
lines.push('');
|
|
227
|
+
const issueCount = result.warnings.length + result.errors.length;
|
|
228
|
+
lines.push(` Overall: ${result.warnings.length} warning(s), ${result.errors.length} error(s)`);
|
|
229
|
+
lines.push(` Verdict: ${result.verdict}`);
|
|
230
|
+
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
231
|
+
|
|
232
|
+
console.log(lines.join('\n'));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = { handleSprintAudit };
|