@boyingliu01/xp-gate 0.33.0 → 0.33.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/gate-3.sh +48 -7
- package/gate-8.sh +32 -3
- package/gate-9.sh +14 -2
- package/gates/common-process.ts +178 -0
- package/gates/common.ts +208 -0
- package/gates/gate-3.ts +144 -0
- package/gates/gate-7.ts +147 -0
- package/gates/gate-8.ts +92 -0
- package/gates/gate-9.ts +269 -0
- package/gates/pbt-detect.ts +163 -0
- package/gates/python-health.ts +266 -0
- package/gates/test-layers.ts +312 -0
- package/hooks/gate-3.sh +48 -7
- package/hooks/gate-8.sh +32 -3
- package/hooks/gate-9.sh +14 -2
- package/hooks/pre-commit +51 -2
- package/lib/__tests__/check-normalize-exit.test.js +24 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/__tests__/uninstall.test.js +18 -0
- package/lib/check.js +27 -4
- package/lib/gate-runner.js +6 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-utils.js +6 -1
- package/lib/uninstall.js +21 -1
- package/mock-policy/AGENTS.md +2 -2
- package/mutation/AGENTS.md +2 -2
- package/package.json +2 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- 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/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/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/scripts/sync-package-content.js +6 -1
- 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
|
@@ -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
|
+
}
|
package/hooks/gate-3.sh
CHANGED
|
@@ -172,8 +172,19 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
|
|
|
172
172
|
const entries = baseline.functions || baseline.entries || baseline;
|
|
173
173
|
if (Array.isArray(entries)) {
|
|
174
174
|
for (const e of entries) {
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
// #632: accept the key the baseline WRITER actually emits.
|
|
176
|
+
// tools/Update-ComplexityBaseline.ps1 (the tool this gate
|
|
177
|
+
// points at in its own fix-it message) writes "function",
|
|
178
|
+
// while this reader only knew "name" -- so every entry was
|
|
179
|
+
// dropped, "known" stayed empty, and every function above
|
|
180
|
+
// the threshold became a brand-new function. The ratchet
|
|
181
|
+
// could never pass. Both spellings are read: this is a
|
|
182
|
+
// reader, and the writer vocabulary is not ours to
|
|
183
|
+
// redefine from here.
|
|
184
|
+
if (!e || !e.file) continue;
|
|
185
|
+
const fnName = e.name || e.function;
|
|
186
|
+
if (!fnName) continue;
|
|
187
|
+
known.set(norm(e.file) + "::" + fnName, Number(e.ccn) || 0);
|
|
177
188
|
}
|
|
178
189
|
}
|
|
179
190
|
// The measurement emits ABSOLUTE paths (pwsh resolved them), while
|
|
@@ -529,13 +540,43 @@ else
|
|
|
529
540
|
# this, a crashing or mis-invoked lizard yields 0 warnings and the gate
|
|
530
541
|
# reports PASSED on zero analysed functions -- the same silent-success
|
|
531
542
|
# failure this issue is about.
|
|
543
|
+
#
|
|
544
|
+
# #666: "no rows" has TWO causes and they must not share a verdict. A
|
|
545
|
+
# module of pure constants legitimately has zero functions, and lizard
|
|
546
|
+
# emits no row for it at all (measured: `lizard pure_data.py --csv` prints
|
|
547
|
+
# nothing and exits 0). That was reported as `lizard_output_unparsable`
|
|
548
|
+
# /WARN, which is the message for a BROKEN lizard -- so a healthy run on a
|
|
549
|
+
# data-only diff was indistinguishable from a tooling fault. The two are
|
|
550
|
+
# separated by a POSITIVE probe: ask lizard to describe a fixture known to
|
|
551
|
+
# contain a function. If it produces rows, lizard works and the input
|
|
552
|
+
# simply had no functions (SKIP, `no_functions_in_input`); if it does not,
|
|
553
|
+
# the tool really is broken (WARN, `lizard_output_unparsable`).
|
|
532
554
|
if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
|
|
533
555
|
END { exit(found ? 0 : 1) }'; then
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
556
|
+
_lizard_probe_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}")
|
|
557
|
+
_lizard_probe_file="$_lizard_probe_dir/xp-gate-lizard-probe.py"
|
|
558
|
+
printf 'def _xp_gate_probe(x):\n if x:\n return 1\n return 0\n' > "$_lizard_probe_file" 2>/dev/null
|
|
559
|
+
_lizard_probe_rows=0
|
|
560
|
+
if [ -f "$_lizard_probe_file" ]; then
|
|
561
|
+
_lizard_probe_rows=$(printf '%s\n' "$_lizard_probe_file" \
|
|
562
|
+
| lizard_batches --csv 2>/dev/null \
|
|
563
|
+
| awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { c++ } END { print c + 0 }')
|
|
564
|
+
fi
|
|
565
|
+
rm -rf "$_lizard_probe_dir" 2>/dev/null || true
|
|
566
|
+
if [ "${_lizard_probe_rows:-0}" -gt 0 ]; then
|
|
567
|
+
echo "⏭️ SKIPPED - Complexity: the changed source files define no functions (pure data module)."
|
|
568
|
+
echo " Verified: lizard parsed a probe fixture normally, so this is an empty result set, not a broken tool."
|
|
569
|
+
GATE_3_STATUS="SKIP"
|
|
570
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
571
|
+
# shellcheck disable=SC2034
|
|
572
|
+
GATE_3_REASON="no_functions_in_input"
|
|
573
|
+
else
|
|
574
|
+
echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
|
|
575
|
+
GATE_3_STATUS="WARN"
|
|
576
|
+
# Consumed by the sourcing parent (pre-commit report JSON), not here.
|
|
577
|
+
# shellcheck disable=SC2034
|
|
578
|
+
GATE_3_REASON="lizard_output_unparsable"
|
|
579
|
+
fi
|
|
539
580
|
elif [ "$CC_WARNINGS" -gt 0 ]; then
|
|
540
581
|
echo "$CC_OUTPUT"
|
|
541
582
|
echo ""
|