@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.
Files changed (95) hide show
  1. package/adapter-common.sh +0 -0
  2. package/adapters/cpp.sh +0 -0
  3. package/adapters/dart.sh +0 -0
  4. package/adapters/flutter.sh +0 -0
  5. package/adapters/go.sh +0 -0
  6. package/adapters/iac.sh +0 -0
  7. package/adapters/java.sh +0 -0
  8. package/adapters/kotlin.sh +0 -0
  9. package/adapters/objectivec.sh +0 -0
  10. package/adapters/plugins/book299-20132-python/scripts/install-gradle.sh +0 -0
  11. package/adapters/plugins/book299-20132-python/scripts/install-maven.sh +0 -0
  12. package/adapters/plugins/book299-4081-c/scripts/install-gradle.sh +0 -0
  13. package/adapters/plugins/book299-4081-c/scripts/install-maven.sh +0 -0
  14. package/adapters/plugins/book299-4083-javascriptes5/scripts/install-gradle.sh +0 -0
  15. package/adapters/plugins/book299-4083-javascriptes5/scripts/install-maven.sh +0 -0
  16. package/adapters/powershell.sh +0 -0
  17. package/adapters/python.sh +0 -0
  18. package/adapters/shell.sh +0 -0
  19. package/adapters/swift.sh +0 -0
  20. package/adapters/typescript.sh +0 -0
  21. package/bin/xp-gate.js +0 -0
  22. package/gate-10.sh +0 -0
  23. package/gate-3.sh +0 -0
  24. package/gate-4.sh +0 -0
  25. package/gate-7.sh +0 -0
  26. package/gate-8.sh +0 -0
  27. package/gate-9.sh +0 -0
  28. package/hooks/adapter-common.sh +0 -0
  29. package/hooks/gate-10.sh +0 -0
  30. package/hooks/gate-3.sh +0 -0
  31. package/hooks/gate-4.sh +0 -0
  32. package/hooks/gate-7.sh +0 -0
  33. package/hooks/gate-8.sh +0 -0
  34. package/hooks/gate-9.sh +0 -0
  35. package/hooks/post-merge +0 -0
  36. package/hooks/pre-commit +0 -0
  37. package/hooks/pre-push +0 -0
  38. package/hooks/sprint-gate.sh +0 -0
  39. package/lib/__tests__/doctor-delphi.test.js +244 -244
  40. package/lib/__tests__/doctor-exec-retry.test.js +152 -0
  41. package/lib/__tests__/sprint-audit.test.js +207 -207
  42. package/lib/__tests__/windows-bash-probe.test.js +57 -7
  43. package/lib/detect-deps.js +69 -13
  44. package/lib/doctor.js +45 -11
  45. package/lib/mattpocock-license-verdict.json +1 -1
  46. package/lib/sprint-audit.js +235 -235
  47. package/mock-policy/AGENTS.md +2 -2
  48. package/mutation/AGENTS.md +2 -2
  49. package/package.json +1 -1
  50. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  51. package/plugins/claude-code/bin/delphi-review-guard.sh +0 -0
  52. package/plugins/claude-code/bin/xp-gate-check +0 -0
  53. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  54. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  55. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  56. package/plugins/dsh/lib/command.d.ts +32 -0
  57. package/plugins/dsh/lib/command.d.ts.map +1 -0
  58. package/plugins/dsh/lib/command.js +81 -0
  59. package/plugins/dsh/lib/delphi-config.d.ts +75 -0
  60. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  61. package/plugins/dsh/lib/delphi-config.js +155 -0
  62. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  63. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  64. package/plugins/dsh/lib/delphi-run.js +92 -0
  65. package/plugins/dsh/lib/delphi.d.ts +88 -0
  66. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  67. package/plugins/dsh/lib/delphi.js +161 -0
  68. package/plugins/dsh/lib/dialect.d.ts +24 -0
  69. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  70. package/plugins/dsh/lib/dialect.js +36 -0
  71. package/plugins/dsh/lib/gate-runner.d.ts +18 -0
  72. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  73. package/plugins/dsh/lib/gate-runner.js +46 -0
  74. package/plugins/dsh/lib/index.d.ts +5 -0
  75. package/plugins/dsh/lib/index.d.ts.map +1 -0
  76. package/plugins/dsh/lib/index.js +162 -0
  77. package/plugins/dsh/package.json +1 -1
  78. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  79. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  80. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  81. package/plugins/opencode/package.json +1 -1
  82. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  83. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  84. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  85. package/plugins/qoder/bin/sprint-flow-guard.sh +0 -0
  86. package/plugins/qoder/bin/xp-gate-check +0 -0
  87. package/plugins/qoder/plugin.json +1 -1
  88. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  89. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  90. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  91. package/principles/AGENTS.md +2 -2
  92. package/skills/delphi-review/AGENTS.md +2 -2
  93. package/skills/sprint-flow/AGENTS.md +2 -2
  94. package/skills/test-specification-alignment/AGENTS.md +2 -2
  95. package/sprint-gate.sh +0 -0
@@ -296,10 +296,14 @@ function probeWindowsBash() {
296
296
  function resolveWindowsBashCandidates(spawnProbe, whereExecSync) {
297
297
  const _execSync = whereExecSync || execSync;
298
298
  const candidates = [];
299
- try {
300
- const found = _execSync('where bash', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
301
- for (const line of found.split(/\r?\n/)) if (line.trim()) candidates.push(line.trim());
302
- } catch { /* where unavailable */ }
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
- let version = 'unknown';
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
- try {
108
- const { stdout, stderr } = await execAsync(cmd, {
109
- ...opts,
110
- timeout: opts.timeout || EXEC_TIMEOUT_MS,
111
- });
112
- return { stdout: stdout.trim(), stderr: stderr.trim() };
113
- } catch (err) {
114
- // #525: a bare null here reads as "tool missing" and has already cost a
115
- // misdiagnosis round. Name the sandbox once when it is the sandbox.
116
- noteSandboxAttribution(err);
117
- return null;
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
  /**
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-16T13:39:44.004Z",
5
+ "checked_at": "2026-10-16T16:21:34.578Z",
6
6
  "source": "network"
7
7
  }
@@ -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 };