@boyingliu01/xp-gate 0.32.3 → 0.33.1

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 (48) hide show
  1. package/bin/xp-gate.js +2 -2
  2. package/gates/common-process.ts +178 -0
  3. package/gates/common.ts +208 -0
  4. package/gates/gate-3.ts +144 -0
  5. package/gates/gate-7.ts +126 -0
  6. package/gates/gate-8.ts +92 -0
  7. package/gates/gate-9.ts +269 -0
  8. package/gates/pbt-detect.ts +163 -0
  9. package/gates/python-health.ts +266 -0
  10. package/gates/test-layers.ts +312 -0
  11. package/lib/__tests__/check-changed-brief.test.js +63 -0
  12. package/lib/__tests__/check-normalize-exit.test.js +24 -0
  13. package/lib/__tests__/shared-utils.test.js +21 -0
  14. package/lib/__tests__/uninstall.test.js +18 -0
  15. package/lib/check.js +140 -5
  16. package/lib/gate-runner.js +6 -1
  17. package/lib/mattpocock-license-verdict.json +1 -1
  18. package/lib/shared-utils.js +6 -1
  19. package/lib/uninstall.js +21 -1
  20. package/mock-policy/AGENTS.md +2 -2
  21. package/mutation/AGENTS.md +2 -2
  22. package/package.json +2 -1
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  25. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  26. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +26 -0
  27. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  28. package/plugins/dsh/package.json +1 -1
  29. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  30. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  31. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +26 -0
  32. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  33. package/plugins/opencode/package.json +1 -1
  34. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  35. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  36. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +26 -0
  37. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  38. package/plugins/qoder/plugin.json +1 -1
  39. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  40. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  41. package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +26 -0
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  43. package/principles/AGENTS.md +2 -2
  44. package/scripts/sync-package-content.js +6 -1
  45. package/skills/delphi-review/AGENTS.md +2 -2
  46. package/skills/sprint-flow/AGENTS.md +2 -2
  47. package/skills/sprint-flow/references/phase-3-build.md +26 -0
  48. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Python Environment Health Check
3
+ * Provides comprehensive Python environment diagnostics.
4
+ * Layer 2 of #356: full diagnostics via CLI/doctor (not pre-commit hot path).
5
+ *
6
+ * Reuses common.ts infrastructure (isToolAvailable, runTool).
7
+ */
8
+ import {
9
+ isToolAvailable, runTool,
10
+ } from './common';
11
+
12
+ // ── Types ──
13
+
14
+ export interface PythonHealthResult {
15
+ healthy: boolean;
16
+ python: { available: boolean; version: string; exe: string };
17
+ pip: { available: boolean; version: string };
18
+ environment: { type: 'system' | 'venv' | 'conda' | 'pyenv' | 'uv'; path: string | null };
19
+ tools: Array<{ name: string; available: boolean; version: string; required: boolean }>;
20
+ issues: string[];
21
+ }
22
+
23
+ // ── Python Tool Registry (sync with language-tools.js LANGUAGE_REGISTRY.python) ──
24
+
25
+ const PYTHON_REQUIRED_TOOLS = ['mypy', 'pytest'];
26
+ const PYTHON_OPTIONAL_TOOLS = ['ruff', 'flake8', 'black'];
27
+
28
+ // ── Python Detection ──
29
+
30
+ /**
31
+ * Detect Python executable, filtering Windows Store stubs.
32
+ * Order: python3 → python → py (Windows)
33
+ */
34
+ export function detectPython(): { available: boolean; exe: string; version: string } {
35
+ const candidates = process.platform === 'win32'
36
+ ? ['python3', 'python', 'py']
37
+ : ['python3', 'python'];
38
+
39
+ for (const exe of candidates) {
40
+ // Check if executable exists
41
+ const whichCheck = isToolAvailable(exe);
42
+ if (!whichCheck.available) continue;
43
+
44
+ const exePath = whichCheck.path;
45
+
46
+ // Filter Windows Store stubs (heuristic: path contains WindowsApps)
47
+ if (exePath.toLowerCase().includes('windowsapps')) continue;
48
+
49
+ // Verify it's a real Python (not a stub that opens Store)
50
+ const verResult = runTool(exePath, ['--version'], { timeoutMs: 3000 });
51
+ if (verResult.exitCode !== 0 || verResult.timedOut) continue;
52
+
53
+ const output = (verResult.stdout + verResult.stderr).trim();
54
+ const versionMatch = output.match(/^Python\s+(\d+\.\d+\.\d+)/);
55
+ if (!versionMatch) continue;
56
+
57
+ return { available: true, exe: exePath, version: versionMatch[1] };
58
+ }
59
+
60
+ return { available: false, exe: '', version: '' };
61
+ }
62
+
63
+ /**
64
+ * Check if Python version meets minimum requirement.
65
+ */
66
+ export function checkPythonVersion(version: string, minVersion = '3.8.0'): boolean {
67
+ const parts = version.split('.').map(Number);
68
+ const minParts = minVersion.split('.').map(Number);
69
+ for (let i = 0; i < 3; i++) {
70
+ const v = parts[i] || 0;
71
+ const m = minParts[i] || 0;
72
+ if (v > m) return true;
73
+ if (v < m) return false;
74
+ }
75
+ return true; // equal
76
+ }
77
+
78
+ // ── pip Detection ──
79
+
80
+ /**
81
+ * Check pip availability via <python-exe> -m pip (ensures consistency).
82
+ */
83
+ export function detectPip(pythonExe: string): { available: boolean; version: string } {
84
+ if (!pythonExe) return { available: false, version: '' };
85
+
86
+ const result = runTool(pythonExe, ['-m', 'pip', '--version'], { timeoutMs: 5000 });
87
+ if (result.exitCode !== 0) return { available: false, version: '' };
88
+
89
+ const output = result.stdout.trim();
90
+ const versionMatch = output.match(/pip\s+(\d+\.\d+(?:\.\d+)?)/);
91
+ return {
92
+ available: true,
93
+ version: versionMatch ? versionMatch[1] : 'unknown',
94
+ };
95
+ }
96
+
97
+ // ── Environment Detection ──
98
+
99
+ /**
100
+ * Detect Python environment type.
101
+ */
102
+ export function detectEnvironment(cwd?: string): { type: PythonHealthResult['environment']['type']; path: string | null } {
103
+ const env = process.env;
104
+
105
+ // conda
106
+ if (env.CONDA_PREFIX) return { type: 'conda', path: env.CONDA_PREFIX };
107
+ if (env.CONDA_DEFAULT_ENV) return { type: 'conda', path: env.CONDA_DEFAULT_ENV };
108
+
109
+ // venv / virtualenv
110
+ if (env.VIRTUAL_ENV) return { type: 'venv', path: env.VIRTUAL_ENV };
111
+
112
+ // uv
113
+ const dir = cwd || process.cwd();
114
+ try {
115
+ const fs = require('fs');
116
+ const path = require('path');
117
+ if (fs.existsSync(path.join(dir, 'uv.lock'))) return { type: 'uv', path: dir };
118
+ if (fs.existsSync(path.join(dir, '.python-version'))) return { type: 'pyenv', path: dir };
119
+ if (fs.existsSync(path.join(dir, '.venv'))) return { type: 'venv', path: path.join(dir, '.venv') };
120
+ } catch { /* ignore */ }
121
+
122
+ return { type: 'system', path: null };
123
+ }
124
+
125
+ // ── Tool Checking ──
126
+
127
+ /**
128
+ * Check availability of Python tools using common.ts infrastructure.
129
+ */
130
+ export function checkTools(pythonExe: string, cwd?: string): PythonHealthResult['tools'] {
131
+ const tools: PythonHealthResult['tools'] = [];
132
+
133
+ for (const name of PYTHON_REQUIRED_TOOLS) {
134
+ const tool = checkPythonTool(name, pythonExe, cwd);
135
+ tools.push({ ...tool, required: true });
136
+ }
137
+
138
+ for (const name of PYTHON_OPTIONAL_TOOLS) {
139
+ const tool = checkPythonTool(name, pythonExe, cwd);
140
+ tools.push({ ...tool, required: false });
141
+ }
142
+
143
+ return tools;
144
+ }
145
+
146
+ function checkPythonTool(
147
+ name: string,
148
+ pythonExe: string,
149
+ cwd?: string
150
+ ): { name: string; available: boolean; version: string } {
151
+ // Try via <python-exe> -m <tool> --version first (ensures correct env)
152
+ if (pythonExe) {
153
+ const result = runTool(pythonExe, ['-m', name, '--version'], { timeoutMs: 5000, cwd });
154
+ if (result.exitCode === 0) {
155
+ const output = (result.stdout + result.stderr).trim();
156
+ const versionMatch = output.match(/(\d+\.\d+(?:\.\d+)?)/);
157
+ return { name, available: true, version: versionMatch ? versionMatch[1] : 'unknown' };
158
+ }
159
+ }
160
+
161
+ // Fallback: direct tool detection via common.ts
162
+ const tool = isToolAvailable(name, cwd);
163
+ if (tool.available) {
164
+ const verResult = runTool(tool.path === `npx ${name}` ? 'npx' : tool.path,
165
+ tool.path === `npx ${name}` ? ['-y', name, '--version'] : ['--version'],
166
+ { timeoutMs: 5000, cwd });
167
+ const output = (verResult.stdout + verResult.stderr).trim();
168
+ const versionMatch = output.match(/(\d+\.\d+(?:\.\d+)?)/);
169
+ return { name, available: true, version: versionMatch ? versionMatch[1] : 'unknown' };
170
+ }
171
+
172
+ return { name, available: false, version: '' };
173
+ }
174
+
175
+ // ── Main Health Check ──
176
+
177
+ /**
178
+ * Run comprehensive Python environment health check.
179
+ */
180
+ export function checkPythonHealth(cwd?: string): PythonHealthResult {
181
+ const issues: string[] = [];
182
+
183
+ // 1. Detect Python
184
+ const python = detectPython();
185
+ if (!python.available) {
186
+ issues.push('Python 3 not found. Install from https://python.org/downloads');
187
+ return {
188
+ healthy: false,
189
+ python,
190
+ pip: { available: false, version: '' },
191
+ environment: { type: 'system', path: null },
192
+ tools: [],
193
+ issues,
194
+ };
195
+ }
196
+
197
+ // 2. Check version
198
+ if (!checkPythonVersion(python.version)) {
199
+ issues.push(`Python ${python.version} < 3.8.0 minimum. Upgrade recommended.`);
200
+ }
201
+
202
+ // 3. Detect pip
203
+ const pip = detectPip(python.exe);
204
+ if (!pip.available) {
205
+ issues.push(`pip not available. Install: ${python.exe} -m ensurepip`);
206
+ }
207
+
208
+ // 4. Detect environment
209
+ const environment = detectEnvironment(cwd);
210
+ if (environment.type === 'system') {
211
+ issues.push('No virtual environment detected. Consider using venv/conda/uv.');
212
+ }
213
+
214
+ // 5. Check tools
215
+ const tools = checkTools(python.exe, cwd);
216
+ const missingRequired = tools.filter(t => t.required && !t.available);
217
+ if (missingRequired.length > 0) {
218
+ const names = missingRequired.map(t => t.name).join(', ');
219
+ issues.push(`Required tools missing: ${names}. Install: ${python.exe} -m pip install ${names}`);
220
+ }
221
+
222
+ const healthy = issues.filter(i => i.includes('not found') || i.includes('minimum')).length === 0
223
+ && missingRequired.length === 0;
224
+
225
+ return { healthy, python, pip, environment, tools, issues };
226
+ }
227
+
228
+ // ── CLI Entry Point ──
229
+
230
+ if (process.argv.includes('--run')) {
231
+ const cwdIdx = process.argv.indexOf('--cwd');
232
+ const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd();
233
+ const result = checkPythonHealth(cwd);
234
+
235
+ console.log('');
236
+ console.log('━━━ Python Environment Health Check ━━━');
237
+ console.log('');
238
+ console.log(` Python: ${result.python.available ? `✅ ${result.python.exe} (${result.python.version})` : '❌ Not found'}`);
239
+ console.log(` pip: ${result.pip.available ? `✅ ${result.pip.version}` : '❌ Not available'}`);
240
+ console.log(` Env: ${result.environment.type}${result.environment.path ? ` (${result.environment.path})` : ''}`);
241
+ console.log('');
242
+
243
+ if (result.tools.length > 0) {
244
+ console.log(' Tools:');
245
+ for (const t of result.tools) {
246
+ const icon = t.available ? '✅' : (t.required ? '❌' : '⚠️');
247
+ const req = t.required ? '(required)' : '(optional)';
248
+ console.log(` ${icon} ${t.name} ${t.available ? t.version : 'not installed'} ${req}`);
249
+ }
250
+ console.log('');
251
+ }
252
+
253
+ if (result.issues.length > 0) {
254
+ console.log(' Issues:');
255
+ result.issues.forEach(i => console.log(` ⚠️ ${i}`));
256
+ console.log('');
257
+ }
258
+
259
+ if (result.healthy) {
260
+ console.log(' ✅ Python environment is healthy.');
261
+ } else {
262
+ console.log(' ❌ Python environment has issues.');
263
+ }
264
+
265
+ process.exit(result.healthy ? 0 : 1);
266
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Test Layer Analytics — reports test distribution across unit/integration/e2e layers.
3
+ * Implements #359: layered test protection network (analytics/report mode).
4
+ *
5
+ * Does NOT block commits/pushes. Provides visibility into test coverage distribution.
6
+ */
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+
10
+ // ── Types ──
11
+
12
+ export interface TestLayerReport {
13
+ summary: {
14
+ totalSources: number;
15
+ totalTests: number;
16
+ unitTests: number;
17
+ integrationTests: number;
18
+ e2eTests: number;
19
+ };
20
+ layer1: {
21
+ pairedSources: number;
22
+ unpairedSources: string[];
23
+ pairRate: number;
24
+ };
25
+ layer2: {
26
+ integrationFiles: number;
27
+ };
28
+ layer3: {
29
+ e2eFiles: number;
30
+ };
31
+ verdict: 'INFO';
32
+ messages: string[];
33
+ }
34
+
35
+ // ── File Classification ──
36
+
37
+ /** Files excluded from source counting (not implementation files). */
38
+ const EXCLUDED_PATTERNS = [
39
+ /\.d\.ts$/,
40
+ /\.test\.ts$/,
41
+ /\.spec\.ts$/,
42
+ /\.e2e\.test\.ts$/,
43
+ /\.integration\.test\.ts$/,
44
+ /\/__tests__\//,
45
+ /\/__mocks__\//,
46
+ /\/node_modules\//,
47
+ ];
48
+
49
+ /** Source files that don't need tests (pure declarations, configs, types). */
50
+ const EXEMPT_FILENAMES = [
51
+ 'types.ts', 'interfaces.ts', 'constants.ts', 'config.ts',
52
+ 'index.ts', 'main.ts', 'app.ts',
53
+ ];
54
+
55
+ function isExcluded(filePath: string): boolean {
56
+ const normalized = filePath.replace(/\\/g, '/');
57
+ return EXCLUDED_PATTERNS.some(p => p.test(normalized));
58
+ }
59
+
60
+ function isExempt(filename: string): boolean {
61
+ return EXEMPT_FILENAMES.includes(filename);
62
+ }
63
+
64
+ function isTestFile(filePath: string): boolean {
65
+ const normalized = filePath.replace(/\\/g, '/');
66
+ return /\.test\.(ts|js)$/.test(normalized) || /\.spec\.(ts|js)$/.test(normalized);
67
+ }
68
+
69
+ function classifyTestLayer(filePath: string): 'unit' | 'integration' | 'e2e' | 'unknown' {
70
+ const normalized = filePath.replace(/\\/g, '/');
71
+ if (/\.e2e\.test\./.test(normalized) || /\/e2e\//.test(normalized)) return 'e2e';
72
+ if (/\.integration\.test\./.test(normalized) || /\/integration\//.test(normalized)) return 'integration';
73
+ if (isTestFile(filePath)) return 'unit';
74
+ return 'unknown';
75
+ }
76
+
77
+ /**
78
+ * Find the expected test file for a source file.
79
+ * Convention: src/foo/bar.ts → src/__tests__/bar.test.ts or src/foo/bar.test.ts
80
+ */
81
+ function findExpectedTest(sourcePath: string, allFiles: string[]): string | null {
82
+ const basename = path.basename(sourcePath, '.ts');
83
+ const dir = path.dirname(sourcePath);
84
+
85
+ // Check adjacent test file: src/foo/bar.ts → src/foo/bar.test.ts
86
+ const adjacentTest = path.join(dir, `${basename}.test.ts`);
87
+ if (allFiles.includes(adjacentTest)) return adjacentTest;
88
+
89
+ // Check __tests__ directory: src/foo/bar.ts → src/foo/__tests__/bar.test.ts
90
+ const testsDirTest = path.join(dir, '__tests__', `${basename}.test.ts`);
91
+ if (allFiles.includes(testsDirTest)) return testsDirTest;
92
+
93
+ // Check parent __tests__: src/foo/bar.ts → src/__tests__/bar.test.ts
94
+ const parentDir = path.dirname(dir);
95
+ const parentTestsTest = path.join(parentDir, '__tests__', `${basename}.test.ts`);
96
+ if (allFiles.includes(parentTestsTest)) return parentTestsTest;
97
+
98
+ return null;
99
+ }
100
+
101
+ // ── Directory Scanner ──
102
+
103
+ /**
104
+ * Directories that never hold hand-written source, so scanning them inflates
105
+ * the source count and fabricates "unpaired" entries (#636 follow-up).
106
+ *
107
+ * `coverage` matters most: `src/npm-package/coverage/` is a gitignored istanbul
108
+ * output directory (`.gitignore:99`), so it is untracked yet full of `.js`
109
+ * files. Counting `block-navigation.js` / `prettify.js` / `sorter.js` as sources
110
+ * understated the pairing rate and pushed three phantom files into the unpaired
111
+ * list — the metric was measuring a build artifact.
112
+ *
113
+ * Kept as a Set for O(1) lookup; names are matched exactly, not by substring,
114
+ * so a real source directory called e.g. `coverage-rules/` stays scanned.
115
+ */
116
+ const SKIPPED_DIRECTORIES = new Set([
117
+ 'node_modules',
118
+ '.git',
119
+ 'dist',
120
+ 'coverage',
121
+ '__pycache__',
122
+ '.next',
123
+ '.turbo',
124
+ 'build',
125
+ 'out',
126
+ ]);
127
+
128
+ /**
129
+ * Read a directory, tolerating the ways it can be unreadable.
130
+ *
131
+ * An unreadable directory (EACCES, ENOENT from a racing delete, a broken
132
+ * symlink) must not abort the whole scan: this runs inside gate reporting, where
133
+ * a partial answer beats a crash. Returning an empty list skips the subtree
134
+ * instead of throwing.
135
+ */
136
+ function readDirectoryEntries(dir: string): fs.Dirent[] {
137
+ try {
138
+ return fs.readdirSync(dir, { withFileTypes: true });
139
+ } catch {
140
+ return [];
141
+ }
142
+ }
143
+
144
+ function scanDirectory(dir: string, basePath?: string): string[] {
145
+ const results: string[] = [];
146
+ const base = basePath || dir;
147
+
148
+ if (!fs.existsSync(dir)) return results;
149
+
150
+ for (const entry of readDirectoryEntries(dir)) {
151
+ const fullPath = path.join(dir, entry.name);
152
+ const relativePath = path.relative(base, fullPath).replace(/\\/g, '/');
153
+
154
+ if (entry.isDirectory()) {
155
+ if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
156
+ results.push(...scanDirectory(fullPath, base));
157
+ } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) {
158
+ results.push(relativePath);
159
+ }
160
+ }
161
+
162
+ return results;
163
+ }
164
+
165
+ // ── Main Analysis ──
166
+
167
+ /** How many unpaired sources are listed individually before summarising. */
168
+ const UNPAIRED_DISPLAY_LIMIT = 10;
169
+
170
+ /** The per-layer counts one scan produces, before formatting. */
171
+ interface LayerCounts {
172
+ sourceFiles: string[];
173
+ testFiles: string[];
174
+ unitTests: string[];
175
+ integrationTests: string[];
176
+ e2eTests: string[];
177
+ unpairedSources: string[];
178
+ pairedCount: number;
179
+ }
180
+
181
+ /**
182
+ * Append the Layer 1 block: pairing rate plus the unpaired list, truncated once
183
+ * it gets long enough that printing every path would bury the summary (#620).
184
+ */
185
+ function appendPairingLines(messages: string[], counts: LayerCounts, totalNeedingTests: number): void {
186
+ const pairRate = totalNeedingTests > 0
187
+ ? Math.round((counts.pairedCount / totalNeedingTests) * 100)
188
+ : 100;
189
+ messages.push(' Layer 1 (Unit Pairing):');
190
+ messages.push(` Paired: ${counts.pairedCount}/${totalNeedingTests} (${pairRate}%)`);
191
+
192
+ const unpaired = counts.unpairedSources;
193
+ if (unpaired.length === 0 || unpaired.length > UNPAIRED_DISPLAY_LIMIT) {
194
+ if (unpaired.length > 0) {
195
+ messages.push(` Unpaired: ${unpaired.length} files (showing first 10)`);
196
+ unpaired.slice(0, UNPAIRED_DISPLAY_LIMIT).forEach(f => messages.push(` - ${f}`));
197
+ }
198
+ return;
199
+ }
200
+ messages.push(` Unpaired: ${unpaired.join(', ')}`);
201
+ }
202
+
203
+ /** Format the console block for one analysis. */
204
+ function buildLayerMessages(counts: LayerCounts, totalNeedingTests: number): string[] {
205
+ const messages: string[] = [
206
+ '',
207
+ '━━━ Test Layer Analytics ━━━',
208
+ '',
209
+ ` Sources: ${counts.sourceFiles.length} files`,
210
+ ` Tests: ${counts.testFiles.length} total`,
211
+ ` Unit: ${counts.unitTests.length}`,
212
+ ` Integra: ${counts.integrationTests.length}`,
213
+ ` E2E: ${counts.e2eTests.length}`,
214
+ '',
215
+ ];
216
+
217
+ appendPairingLines(messages, counts, totalNeedingTests);
218
+
219
+ // Layers 2 and 3 only earn a section when they have content.
220
+ if (counts.integrationTests.length > 0) {
221
+ messages.push('', ` Layer 2 (Integration): ${counts.integrationTests.length} files`);
222
+ }
223
+ if (counts.e2eTests.length > 0) {
224
+ messages.push('', ` Layer 3 (E2E): ${counts.e2eTests.length} files`);
225
+ }
226
+ messages.push('');
227
+
228
+ return messages;
229
+ }
230
+
231
+ /**
232
+ * Layer 1 pairing: split sources into paired and unpaired against the sibling
233
+ * test that should cover each one. Works on src-relative paths because
234
+ * findExpectedTest resolves a source path against that root.
235
+ */
236
+ function computePairing(
237
+ sourceFiles: string[],
238
+ allFiles: string[],
239
+ ): { unpairedSources: string[]; pairedCount: number } {
240
+ const allSourceRelative = allFiles.map(f => path.join('src', f));
241
+ const unpairedSources: string[] = [];
242
+ let pairedCount = 0;
243
+
244
+ for (const source of sourceFiles) {
245
+ if (isExempt(path.basename(source))) continue;
246
+ if (findExpectedTest(path.join('src', source), allSourceRelative)) {
247
+ pairedCount++;
248
+ } else {
249
+ unpairedSources.push(source);
250
+ }
251
+ }
252
+
253
+ return { unpairedSources, pairedCount };
254
+ }
255
+
256
+ /**
257
+ * Analyze test layer distribution for a project.
258
+ */
259
+ export function analyzeTestLayers(cwd?: string): TestLayerReport {
260
+ const dir = cwd || process.cwd();
261
+ const allFiles = scanDirectory(path.join(dir, 'src'));
262
+
263
+ const sourceFiles = allFiles.filter(f => !isExcluded(f) && !isTestFile(f));
264
+ const testFiles = allFiles.filter(f => isTestFile(f));
265
+ const byLayer = (layer: string): string[] => testFiles.filter(f => classifyTestLayer(f) === layer);
266
+
267
+ const { unpairedSources, pairedCount } = computePairing(sourceFiles, allFiles);
268
+
269
+ const counts: LayerCounts = {
270
+ sourceFiles,
271
+ testFiles,
272
+ unitTests: byLayer('unit'),
273
+ integrationTests: byLayer('integration'),
274
+ e2eTests: byLayer('e2e'),
275
+ unpairedSources,
276
+ pairedCount,
277
+ };
278
+ const totalNeedingTests = sourceFiles.filter(f => !isExempt(path.basename(f))).length;
279
+
280
+ return {
281
+ summary: {
282
+ totalSources: sourceFiles.length,
283
+ totalTests: testFiles.length,
284
+ unitTests: counts.unitTests.length,
285
+ integrationTests: counts.integrationTests.length,
286
+ e2eTests: counts.e2eTests.length,
287
+ },
288
+ layer1: {
289
+ pairedSources: pairedCount,
290
+ unpairedSources,
291
+ pairRate: totalNeedingTests > 0 ? Math.round((pairedCount / totalNeedingTests) * 100) : 100,
292
+ },
293
+ layer2: {
294
+ integrationFiles: counts.integrationTests.length,
295
+ },
296
+ layer3: {
297
+ e2eFiles: counts.e2eTests.length,
298
+ },
299
+ verdict: 'INFO',
300
+ messages: buildLayerMessages(counts, totalNeedingTests),
301
+ };
302
+ }
303
+
304
+ // ── CLI Entry Point ──
305
+
306
+ if (process.argv.includes('--run')) {
307
+ const cwdIdx = process.argv.indexOf('--cwd');
308
+ const cwd = cwdIdx >= 0 ? process.argv[cwdIdx + 1] : process.cwd();
309
+ const report = analyzeTestLayers(cwd);
310
+ report.messages.forEach(m => console.log(m));
311
+ process.exit(0);
312
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @test check-changed-brief
3
+ * @intent Prove `xp-gate check --changed --brief` scopes to the current diff, prints one honest verdict per gate, and never reports a gate as PASS when it did not run
4
+ * @covers AC-DSH-019
5
+ */
6
+ const { fileExtension, printBriefSummary } = require('../check.js');
7
+
8
+ describe('check --changed --brief helpers', () => {
9
+ describe('fileExtension', () => {
10
+ it('returns the lowercased extension without the dot', () => {
11
+ expect(fileExtension('src/a/B.TS')).toBe('ts');
12
+ expect(fileExtension('x/y/z.mjs')).toBe('mjs');
13
+ });
14
+
15
+ it('returns empty string for a path without an extension', () => {
16
+ expect(fileExtension('Makefile')).toBe('');
17
+ expect(fileExtension('docs/README')).toBe('');
18
+ });
19
+ });
20
+
21
+ describe('printBriefSummary', () => {
22
+ function capture(fn) {
23
+ const lines = [];
24
+ const spy = vi.spyOn(console, 'log').mockImplementation((...a) => lines.push(a.join(' ')));
25
+ const ret = fn();
26
+ spy.mockRestore();
27
+ return { out: lines.join('\n'), ret };
28
+ }
29
+
30
+ it('reports one PASS line per passing gate and returns 0 failures', () => {
31
+ const { out, ret } = capture(() =>
32
+ printBriefSummary(['a.ts'], ['a.ts'], [{ id: '3', code: 0 }, { id: '4', code: 0 }], 12),
33
+ );
34
+ expect(out).toMatch(/Gate 3 Complexity\s+PASS/);
35
+ expect(out).toMatch(/Gate 4 Principles\s+PASS/);
36
+ expect(out).toMatch(/✅ clear/);
37
+ expect(ret).toBe(0);
38
+ });
39
+
40
+ it('reports BLOCK and counts failures when a gate fails', () => {
41
+ const { out, ret } = capture(() =>
42
+ printBriefSummary(['a.ts'], ['a.ts'], [{ id: '3', code: 0 }, { id: '4', code: 1 }], 12),
43
+ );
44
+ expect(out).toMatch(/Gate 4 Principles\s+BLOCK/);
45
+ expect(out).toMatch(/❌ 1 blocking/);
46
+ expect(ret).toBe(1);
47
+ });
48
+
49
+ it('never prints PASS for a gate that did not run (NOT_RUN -> SKIP)', () => {
50
+ const { out, ret } = capture(() =>
51
+ printBriefSummary(['a.ts'], ['a.ts'], [{ id: '3', code: 'NOT_RUN' }], 5),
52
+ );
53
+ expect(out).toMatch(/Gate 3 Complexity\s+SKIP/);
54
+ expect(out).not.toMatch(/Gate 3 Complexity\s+PASS/);
55
+ expect(ret).toBe(0);
56
+ });
57
+
58
+ it('states docs/config-only diffs apply no blocking source gate', () => {
59
+ const { out } = capture(() => printBriefSummary(['README.md'], [], [], 3));
60
+ expect(out).toMatch(/No source files changed/);
61
+ });
62
+ });
63
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @test check-normalize-exit
3
+ * @intent Prove the CLI never hands a non-numeric verdict to process.exit (the #657 crash) and that ONLY the GATE_NOT_RUN sentinel is coerced to 0
4
+ * @covers AC-DSH-019
5
+ */
6
+ const { normalizeGateExit } = require('../check.js');
7
+
8
+ describe('normalizeGateExit (#657)', () => {
9
+ it('coerces the GATE_NOT_RUN string sentinel to 0 (honest skip, not a crash)', () => {
10
+ expect(normalizeGateExit('NOT_RUN')).toBe(0);
11
+ });
12
+
13
+ it('passes real numeric verdicts through unchanged', () => {
14
+ expect(normalizeGateExit(0)).toBe(0);
15
+ expect(normalizeGateExit(1)).toBe(1);
16
+ expect(normalizeGateExit(2)).toBe(2);
17
+ });
18
+
19
+ it('never returns a non-number for any input', () => {
20
+ for (const v of ['NOT_RUN', 0, 1, undefined, null]) {
21
+ expect(typeof normalizeGateExit(v)).toBe('number');
22
+ }
23
+ });
24
+ });
@@ -109,4 +109,25 @@ describe('shared-utils', () => {
109
109
  });
110
110
  });
111
111
 
112
+ describe('copyHooks ships hook-module-resolution.sh (#655/#631)', () => {
113
+ it('@covers gate-script deployment: copyHooks installs hook-module-resolution.sh so pre-commit does not FATAL', () => {
114
+ // pre-commit loads hook-module-resolution.sh before any tier decision and
115
+ // fails closed. It was absent from copyHooks, so every fresh `init` /
116
+ // `setup-global` produced hooks that FATAL'd on every commit.
117
+ const pkgRoot = path.join(tmpRoot, 'node_modules', '@boyingliu01', 'xp-gate');
118
+ const hooksSrc = path.join(pkgRoot, 'hooks');
119
+ fs.mkdirSync(hooksSrc, { recursive: true });
120
+ for (const h of ['pre-commit', 'pre-push', 'post-commit', 'post-merge', 'hook-module-resolution.sh']) {
121
+ fs.writeFileSync(path.join(hooksSrc, h), `# ${h}`);
122
+ }
123
+ const destDir = path.join(tmpRoot, 'dest-hooks');
124
+ fs.mkdirSync(destDir, { recursive: true });
125
+
126
+ const { copyHooks } = require('../shared-utils');
127
+ copyHooks(pkgRoot, destDir);
128
+
129
+ expect(fs.existsSync(path.join(destDir, 'hook-module-resolution.sh'))).toBe(true);
130
+ });
131
+ });
132
+
112
133
  });