@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.
Files changed (48) hide show
  1. package/gate-3.sh +48 -7
  2. package/gate-8.sh +32 -3
  3. package/gate-9.sh +14 -2
  4. package/gates/common-process.ts +178 -0
  5. package/gates/common.ts +208 -0
  6. package/gates/gate-3.ts +144 -0
  7. package/gates/gate-7.ts +147 -0
  8. package/gates/gate-8.ts +92 -0
  9. package/gates/gate-9.ts +269 -0
  10. package/gates/pbt-detect.ts +163 -0
  11. package/gates/python-health.ts +266 -0
  12. package/gates/test-layers.ts +312 -0
  13. package/hooks/gate-3.sh +48 -7
  14. package/hooks/gate-8.sh +32 -3
  15. package/hooks/gate-9.sh +14 -2
  16. package/hooks/pre-commit +51 -2
  17. package/lib/__tests__/check-normalize-exit.test.js +24 -0
  18. package/lib/__tests__/shared-utils.test.js +21 -0
  19. package/lib/__tests__/uninstall.test.js +18 -0
  20. package/lib/check.js +27 -4
  21. package/lib/gate-runner.js +6 -1
  22. package/lib/mattpocock-license-verdict.json +1 -1
  23. package/lib/shared-utils.js +6 -1
  24. package/lib/uninstall.js +21 -1
  25. package/mock-policy/AGENTS.md +2 -2
  26. package/mutation/AGENTS.md +2 -2
  27. package/package.json +2 -1
  28. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  29. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  30. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  31. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  32. package/plugins/dsh/package.json +1 -1
  33. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  34. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  35. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/plugins/opencode/package.json +1 -1
  37. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  38. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  39. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  40. package/plugins/qoder/plugin.json +1 -1
  41. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  42. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  43. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  44. package/principles/AGENTS.md +2 -2
  45. package/scripts/sync-package-content.js +6 -1
  46. package/skills/delphi-review/AGENTS.md +2 -2
  47. package/skills/sprint-flow/AGENTS.md +2 -2
  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
+ }
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
- if (!e || !e.file || !e.name) continue;
176
- known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
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
- echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
535
- GATE_3_STATUS="WARN"
536
- # Consumed by the sourcing parent (pre-commit report JSON), not here.
537
- # shellcheck disable=SC2034
538
- GATE_3_REASON="lizard_output_unparsable"
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 ""