fchek 1.0.0
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/README.md +64 -0
- package/bin/fchek.js +107 -0
- package/lib/api.js +110 -0
- package/lib/audit.js +211 -0
- package/lib/bench.js +248 -0
- package/lib/config.js +191 -0
- package/lib/context.js +356 -0
- package/lib/convention.js +526 -0
- package/lib/coverage.js +604 -0
- package/lib/db.js +135 -0
- package/lib/deps-check.js +264 -0
- package/lib/deps.js +374 -0
- package/lib/docker.js +84 -0
- package/lib/doctor.js +149 -0
- package/lib/dom.js +226 -0
- package/lib/fuzz.js +470 -0
- package/lib/git.js +290 -0
- package/lib/goto.js +544 -0
- package/lib/launch.js +182 -0
- package/lib/lint.js +624 -0
- package/lib/new_features.test.js +181 -0
- package/lib/output.js +46 -0
- package/lib/port.js +173 -0
- package/lib/process.js +228 -0
- package/lib/profile.js +453 -0
- package/lib/python.js +41 -0
- package/lib/race.js +186 -0
- package/lib/registry.js +179 -0
- package/lib/repl.js +135 -0
- package/lib/run.js +403 -0
- package/lib/screenshot.js +152 -0
- package/lib/secrets.js +257 -0
- package/lib/state.js +219 -0
- package/lib/test.js +471 -0
- package/lib/vuln.js +253 -0
- package/lib/watch.js +240 -0
- package/lib/winlog.js +123 -0
- package/package.json +27 -0
- package/skills/ACTIVATE.md +274 -0
- package/skills/README.md +163 -0
- package/skills/agent.md +444 -0
- package/skills/api.md +47 -0
- package/skills/bench.md +117 -0
- package/skills/context.md +116 -0
- package/skills/convention.md +143 -0
- package/skills/coverage.md +99 -0
- package/skills/csharp.md +97 -0
- package/skills/db.md +66 -0
- package/skills/deps-check.md +135 -0
- package/skills/deps.md +143 -0
- package/skills/docker.md +61 -0
- package/skills/dom.md +56 -0
- package/skills/fuzz.md +167 -0
- package/skills/goto.md +111 -0
- package/skills/lint.md +123 -0
- package/skills/port.md +57 -0
- package/skills/profile.md +91 -0
- package/skills/race.md +117 -0
- package/skills/repl.md +81 -0
- package/skills/rules.md +318 -0
- package/skills/run.md +135 -0
- package/skills/secrets.md +170 -0
- package/skills/security.md +360 -0
- package/skills/state.md +261 -0
- package/skills/vuln.md +57 -0
- package/skills/windows.md +320 -0
package/lib/coverage.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* coverage.js — code coverage report
|
|
5
|
+
*
|
|
6
|
+
* Python (.py) → coverage.py
|
|
7
|
+
* C/C++ (.c .cpp) → gcov (requires gcc -fprofile-arcs -ftest-coverage)
|
|
8
|
+
* Rust → cargo-tarpaulin (Linux) or cargo llvm-cov (cross-platform)
|
|
9
|
+
* JS/TS → c8 / nyc (via npm test --coverage)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { spawnSync, execSync } = require('child_process');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
const { output, ok, fail } = require('./output');
|
|
17
|
+
const { findPython } = require('./python');
|
|
18
|
+
|
|
19
|
+
const HELP = `
|
|
20
|
+
fchek coverage <file_or_dir> [--html] [--threshold=<0-100>]
|
|
21
|
+
|
|
22
|
+
Show which lines are NOT covered by tests.
|
|
23
|
+
|
|
24
|
+
Supported:
|
|
25
|
+
.py → coverage.py (pip install coverage)
|
|
26
|
+
.c / .cpp → gcov (apt install gcc)
|
|
27
|
+
.rs / Cargo → cargo-tarpaulin or cargo llvm-cov
|
|
28
|
+
.js / .ts → c8 (npm install -g c8)
|
|
29
|
+
.go / go.mod → go test -coverprofile
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--html Also generate HTML report (saved to ./coverage_html/)
|
|
33
|
+
--threshold=80 Fail (status: error) if total coverage < N%
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
fchek coverage test_auth.py
|
|
37
|
+
fchek coverage src/main.c
|
|
38
|
+
fchek coverage . (Rust — runs cargo tarpaulin in cwd)
|
|
39
|
+
fchek coverage tests/ (JS — runs c8 in dir)
|
|
40
|
+
fchek coverage . (Go — runs go test -coverprofile in cwd)
|
|
41
|
+
fchek coverage test.py --threshold=80
|
|
42
|
+
`.trim();
|
|
43
|
+
|
|
44
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
45
|
+
|
|
46
|
+
function commandExists(cmd) {
|
|
47
|
+
try {
|
|
48
|
+
execSync(process.platform === 'win32' ? `where ${cmd}` : `which ${cmd}`, { stdio: 'ignore' });
|
|
49
|
+
return true;
|
|
50
|
+
} catch { return false; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Python ──────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function parseCoverageReport(raw) {
|
|
56
|
+
const lines = raw.split('\n');
|
|
57
|
+
const files = [];
|
|
58
|
+
let total = null;
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
// Format: "src/foo.py 120 8 93% 45-52, 88"
|
|
62
|
+
const m = line.match(/^(\S+\.py)\s+(\d+)\s+(\d+)\s+(\d+)%\s*([\d\s,\-]*)?$/);
|
|
63
|
+
if (m) {
|
|
64
|
+
files.push({
|
|
65
|
+
file: m[1],
|
|
66
|
+
stmts: parseInt(m[2]),
|
|
67
|
+
missed: parseInt(m[3]),
|
|
68
|
+
coverage_pct: parseInt(m[4]),
|
|
69
|
+
missing_lines: (m[5] || '').trim() || null,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const tot = line.match(/^TOTAL\s+\d+\s+\d+\s+(\d+)%/);
|
|
73
|
+
if (tot) total = parseInt(tot[1]);
|
|
74
|
+
}
|
|
75
|
+
return { files, total };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function runPythonCoverage(file, html, threshold, timeoutMs) {
|
|
79
|
+
const py = findPython();
|
|
80
|
+
if (!py) return output(fail('Python not found. Install: https://python.org'));
|
|
81
|
+
|
|
82
|
+
if (!commandExists('coverage')) {
|
|
83
|
+
// Try via python -m coverage
|
|
84
|
+
const check = spawnSync(py, ['-m', 'coverage', '--version'], {
|
|
85
|
+
encoding: 'utf8', timeout: 5000, windowsHide: true,
|
|
86
|
+
});
|
|
87
|
+
if (check.status !== 0) {
|
|
88
|
+
return output(fail('coverage.py not found. Install: pip install coverage'));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const covCmd = commandExists('coverage') ? 'coverage' : null;
|
|
93
|
+
const runArgs = covCmd
|
|
94
|
+
? ['run', '--branch', file]
|
|
95
|
+
: ['-m', 'coverage', 'run', '--branch', file];
|
|
96
|
+
|
|
97
|
+
const runner = covCmd ? 'coverage' : py;
|
|
98
|
+
const runRes = spawnSync(runner, runArgs, { encoding: 'utf8', timeout: timeoutMs });
|
|
99
|
+
if (runRes.error?.code === 'ETIMEDOUT') {
|
|
100
|
+
return output(fail(`Coverage run timed out after ${timeoutMs}ms`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const reportArgs = covCmd
|
|
104
|
+
? ['report', '--show-missing']
|
|
105
|
+
: ['-m', 'coverage', 'report', '--show-missing'];
|
|
106
|
+
const reportRes = spawnSync(runner, reportArgs, { encoding: 'utf8', timeout: 30_000 });
|
|
107
|
+
|
|
108
|
+
const { files, total } = parseCoverageReport(reportRes.stdout || '');
|
|
109
|
+
|
|
110
|
+
if (html) {
|
|
111
|
+
const htmlArgs = covCmd ? ['html', '-d', 'coverage_html'] : ['-m', 'coverage', 'html', '-d', 'coverage_html'];
|
|
112
|
+
spawnSync(runner, htmlArgs, { encoding: 'utf8', timeout: 30_000 });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const uncovered = files.filter(f => f.coverage_pct < 100);
|
|
116
|
+
|
|
117
|
+
if (threshold !== null && total !== null && total < threshold) {
|
|
118
|
+
return output(fail(
|
|
119
|
+
`Coverage ${total}% is below threshold ${threshold}%. Uncovered files: ${uncovered.length}`
|
|
120
|
+
));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
output(ok({
|
|
124
|
+
file,
|
|
125
|
+
lang: 'python',
|
|
126
|
+
tool: 'coverage.py',
|
|
127
|
+
total_pct: total,
|
|
128
|
+
threshold: threshold,
|
|
129
|
+
passed_threshold: threshold !== null ? (total >= threshold) : null,
|
|
130
|
+
files,
|
|
131
|
+
uncovered_files: uncovered,
|
|
132
|
+
html_report: html ? path.resolve('coverage_html/index.html') : null,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── C/C++ via gcov ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
function runGcov(file, timeoutMs) {
|
|
139
|
+
if (!commandExists('gcc') && !commandExists('clang')) {
|
|
140
|
+
return output(fail('gcc not found. Install: apt install build-essential'));
|
|
141
|
+
}
|
|
142
|
+
if (!commandExists('gcov')) {
|
|
143
|
+
return output(fail('gcov not found. Install: apt install gcc'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ext = path.extname(file).toLowerCase();
|
|
147
|
+
const compiler = ext === '.cpp' || ext === '.cc' ? 'g++' : 'gcc';
|
|
148
|
+
const outBin = path.join(os.tmpdir(), `fchek_cov_${path.basename(file, ext)}`);
|
|
149
|
+
|
|
150
|
+
const compile = spawnSync(
|
|
151
|
+
compiler,
|
|
152
|
+
['-fprofile-arcs', '-ftest-coverage', '-g', '-O0', '-o', outBin, file],
|
|
153
|
+
{ encoding: 'utf8' }
|
|
154
|
+
);
|
|
155
|
+
if (compile.status !== 0) {
|
|
156
|
+
return output(fail(`Compilation failed:\n${compile.stderr}`));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const run = spawnSync(outBin, [], { encoding: 'utf8', timeout: timeoutMs });
|
|
160
|
+
if (run.error?.code === 'ETIMEDOUT') {
|
|
161
|
+
try { fs.unlinkSync(outBin); } catch {}
|
|
162
|
+
return output(fail(`Execution timed out after ${timeoutMs}ms`));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const gcovRes = spawnSync('gcov', [file], { encoding: 'utf8', cwd: process.cwd() });
|
|
166
|
+
|
|
167
|
+
// Parse gcov output file
|
|
168
|
+
const gcovFile = path.basename(file) + '.gcov';
|
|
169
|
+
let coveredLines = 0, uncoveredLines = 0;
|
|
170
|
+
const missed = [];
|
|
171
|
+
|
|
172
|
+
if (fs.existsSync(gcovFile)) {
|
|
173
|
+
const gcovLines = fs.readFileSync(gcovFile, 'utf8').split('\n');
|
|
174
|
+
for (const gl of gcovLines) {
|
|
175
|
+
const m = gl.match(/^\s*(\d+|#+):\s*(\d+):/);
|
|
176
|
+
if (!m) continue;
|
|
177
|
+
const count = m[1];
|
|
178
|
+
const lineNo = parseInt(m[2]);
|
|
179
|
+
if (lineNo === 0) continue;
|
|
180
|
+
if (count === '#####' || count.startsWith('#')) {
|
|
181
|
+
uncoveredLines++;
|
|
182
|
+
missed.push(lineNo);
|
|
183
|
+
} else {
|
|
184
|
+
coveredLines++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
try { fs.unlinkSync(gcovFile); } catch {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const total = coveredLines + uncoveredLines;
|
|
191
|
+
const pct = total > 0 ? Math.round((coveredLines / total) * 100) : 0;
|
|
192
|
+
|
|
193
|
+
try { fs.unlinkSync(outBin); } catch {}
|
|
194
|
+
// Cleanup gcda/gcno files
|
|
195
|
+
for (const ext2 of ['.gcda', '.gcno']) {
|
|
196
|
+
try { fs.unlinkSync(path.basename(file, path.extname(file)) + ext2); } catch {}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
output(ok({
|
|
200
|
+
file: path.resolve(file),
|
|
201
|
+
lang: 'c',
|
|
202
|
+
tool: 'gcov',
|
|
203
|
+
total_pct: pct,
|
|
204
|
+
covered_lines: coveredLines,
|
|
205
|
+
missed_lines: uncoveredLines,
|
|
206
|
+
missed_line_numbers: missed.slice(0, 50),
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Rust via cargo-tarpaulin or cargo llvm-cov ───────────────────────────────
|
|
211
|
+
|
|
212
|
+
function runRustCoverage(dir, html, timeoutMs) {
|
|
213
|
+
const cwd = path.resolve(dir === '.' ? process.cwd() : dir);
|
|
214
|
+
|
|
215
|
+
if (!commandExists('cargo')) {
|
|
216
|
+
return output(fail('cargo not found. Install Rust: https://rustup.rs'));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Prefer tarpaulin (Linux only) then llvm-cov (cross-platform)
|
|
220
|
+
const hasTarpaulin = (() => {
|
|
221
|
+
const r = spawnSync('cargo', ['tarpaulin', '--version'], { encoding: 'utf8', timeout: 5000 });
|
|
222
|
+
return r.status === 0;
|
|
223
|
+
})();
|
|
224
|
+
|
|
225
|
+
const hasLlvmCov = (() => {
|
|
226
|
+
const r = spawnSync('cargo', ['llvm-cov', '--version'], { encoding: 'utf8', timeout: 5000 });
|
|
227
|
+
return r.status === 0;
|
|
228
|
+
})();
|
|
229
|
+
|
|
230
|
+
if (!hasTarpaulin && !hasLlvmCov) {
|
|
231
|
+
return output(fail(
|
|
232
|
+
'No Rust coverage tool found.\n' +
|
|
233
|
+
'Install tarpaulin (Linux): cargo install cargo-tarpaulin\n' +
|
|
234
|
+
'Install llvm-cov (all platforms): cargo install cargo-llvm-cov'
|
|
235
|
+
));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let result;
|
|
239
|
+
let tool;
|
|
240
|
+
|
|
241
|
+
if (hasTarpaulin && process.platform === 'linux') {
|
|
242
|
+
tool = 'cargo-tarpaulin';
|
|
243
|
+
const args = html
|
|
244
|
+
? ['tarpaulin', '--out', 'Json', '--output-dir', '/tmp/fchek_tarp', '--out', 'Html']
|
|
245
|
+
: ['tarpaulin', '--out', 'Json', '--output-dir', '/tmp/fchek_tarp'];
|
|
246
|
+
result = spawnSync('cargo', args, { encoding: 'utf8', cwd, timeout: timeoutMs });
|
|
247
|
+
} else {
|
|
248
|
+
tool = 'cargo-llvm-cov';
|
|
249
|
+
const args = html
|
|
250
|
+
? ['llvm-cov', '--json', '--output-path', '/tmp/fchek_llvmcov.json', '--html']
|
|
251
|
+
: ['llvm-cov', '--json', '--output-path', '/tmp/fchek_llvmcov.json'];
|
|
252
|
+
result = spawnSync('cargo', args, { encoding: 'utf8', cwd, timeout: timeoutMs });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (result.error?.code === 'ETIMEDOUT') {
|
|
256
|
+
return output(fail(`Rust coverage timed out after ${timeoutMs}ms`));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Try to parse JSON output
|
|
260
|
+
let totalPct = null;
|
|
261
|
+
try {
|
|
262
|
+
const jsonFile = tool === 'cargo-tarpaulin'
|
|
263
|
+
? '/tmp/fchek_tarp/tarpaulin-report.json'
|
|
264
|
+
: '/tmp/fchek_llvmcov.json';
|
|
265
|
+
if (fs.existsSync(jsonFile)) {
|
|
266
|
+
const data = JSON.parse(fs.readFileSync(jsonFile, 'utf8'));
|
|
267
|
+
// tarpaulin: { "coverage": 85.3 }
|
|
268
|
+
// llvm-cov: { "data": [{ "totals": { "lines": { "percent": 85.3 } } }] }
|
|
269
|
+
totalPct = data.coverage
|
|
270
|
+
?? data?.data?.[0]?.totals?.lines?.percent
|
|
271
|
+
?? null;
|
|
272
|
+
if (totalPct) totalPct = Math.round(totalPct);
|
|
273
|
+
}
|
|
274
|
+
} catch {}
|
|
275
|
+
|
|
276
|
+
output(ok({
|
|
277
|
+
dir: cwd,
|
|
278
|
+
lang: 'rust',
|
|
279
|
+
tool,
|
|
280
|
+
total_pct: totalPct,
|
|
281
|
+
raw_output: (result.stdout || result.stderr || '').slice(0, 3000),
|
|
282
|
+
html_report: html ? path.join(cwd, 'target/llvm-cov/html/index.html') : null,
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── C# via coverlet ─────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function runCSharpCoverage(target, html, threshold, timeoutMs) {
|
|
289
|
+
if (!commandExists('dotnet')) {
|
|
290
|
+
return output(fail('dotnet not found. Install .NET SDK: https://dotnet.microsoft.com/download'));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Find test project (.csproj with test references)
|
|
294
|
+
let projDir = path.resolve(fs.statSync(target).isDirectory() ? target : path.dirname(target));
|
|
295
|
+
for (let i = 0; i < 6; i++) {
|
|
296
|
+
const found = fs.readdirSync(projDir).find(f => f.endsWith('.csproj') || f.endsWith('.sln'));
|
|
297
|
+
if (found) break;
|
|
298
|
+
const parent = path.dirname(projDir);
|
|
299
|
+
if (parent === projDir) { projDir = null; break; }
|
|
300
|
+
projDir = parent;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!projDir) {
|
|
304
|
+
return output(fail('No .csproj or .sln found.'));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const coverageDir = path.join(os.tmpdir(), 'fchek_coverage_' + Date.now());
|
|
308
|
+
fs.mkdirSync(coverageDir, { recursive: true });
|
|
309
|
+
|
|
310
|
+
const testArgs = [
|
|
311
|
+
'test', projDir,
|
|
312
|
+
'--collect', 'XPlat Code Coverage',
|
|
313
|
+
'--results-directory', coverageDir,
|
|
314
|
+
'--no-build',
|
|
315
|
+
'-v', 'quiet',
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const testRes = spawnSync('dotnet', testArgs, {
|
|
319
|
+
encoding: 'utf8', cwd: projDir, timeout: timeoutMs,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (testRes.error?.code === 'ETIMEDOUT') {
|
|
323
|
+
return output(fail(`Coverage timed out after ${timeoutMs}ms`));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Find coverage.cobertura.xml
|
|
327
|
+
let coberturaFile = null;
|
|
328
|
+
function findCobertura(dir) {
|
|
329
|
+
try {
|
|
330
|
+
for (const name of fs.readdirSync(dir)) {
|
|
331
|
+
const full = path.join(dir, name);
|
|
332
|
+
if (fs.statSync(full).isDirectory()) findCobertura(full);
|
|
333
|
+
else if (name === 'coverage.cobertura.xml') coberturaFile = full;
|
|
334
|
+
}
|
|
335
|
+
} catch {}
|
|
336
|
+
}
|
|
337
|
+
findCobertura(coverageDir);
|
|
338
|
+
|
|
339
|
+
let totalPct = null;
|
|
340
|
+
let fileResults = [];
|
|
341
|
+
|
|
342
|
+
if (coberturaFile) {
|
|
343
|
+
const xml = fs.readFileSync(coberturaFile, 'utf8');
|
|
344
|
+
// Parse line-rate from <coverage line-rate="0.85" ...>
|
|
345
|
+
const rateMatch = xml.match(/line-rate="([\d.]+)"/);
|
|
346
|
+
if (rateMatch) totalPct = Math.round(parseFloat(rateMatch[1]) * 100);
|
|
347
|
+
|
|
348
|
+
// Parse per-file coverage
|
|
349
|
+
const classMatches = [...xml.matchAll(/<class name="([^"]+)"[^>]*line-rate="([\d.]+)"/g)];
|
|
350
|
+
for (const m of classMatches) {
|
|
351
|
+
fileResults.push({
|
|
352
|
+
class: m[1],
|
|
353
|
+
coverage_pct: Math.round(parseFloat(m[2]) * 100),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Generate HTML report with reportgenerator if available
|
|
359
|
+
let htmlReport = null;
|
|
360
|
+
if (html && coberturaFile && commandExists('reportgenerator')) {
|
|
361
|
+
const reportDir = path.join(projDir, 'coverage_html');
|
|
362
|
+
spawnSync('reportgenerator', [
|
|
363
|
+
`-reports:${coberturaFile}`,
|
|
364
|
+
`-targetdir:${reportDir}`,
|
|
365
|
+
'-reporttypes:Html',
|
|
366
|
+
], { encoding: 'utf8', timeout: 30000 });
|
|
367
|
+
htmlReport = path.join(reportDir, 'index.html');
|
|
368
|
+
} else if (html && !commandExists('reportgenerator')) {
|
|
369
|
+
htmlReport = null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Cleanup temp
|
|
373
|
+
try { fs.rmSync(coverageDir, { recursive: true }); } catch {}
|
|
374
|
+
|
|
375
|
+
if (threshold !== null && totalPct !== null && totalPct < threshold) {
|
|
376
|
+
return output(fail(`Coverage ${totalPct}% is below threshold ${threshold}%`));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
output(ok({
|
|
380
|
+
target: projDir,
|
|
381
|
+
lang: 'csharp',
|
|
382
|
+
tool: 'dotnet test + coverlet',
|
|
383
|
+
total_pct: totalPct,
|
|
384
|
+
threshold,
|
|
385
|
+
passed_threshold: threshold !== null ? (totalPct >= threshold) : null,
|
|
386
|
+
files: fileResults,
|
|
387
|
+
uncovered_files: fileResults.filter(f => f.coverage_pct < 100),
|
|
388
|
+
html_report: htmlReport,
|
|
389
|
+
html_note: html && !commandExists('reportgenerator')
|
|
390
|
+
? 'Install reportgenerator for HTML: dotnet tool install -g dotnet-reportgenerator-globaltool'
|
|
391
|
+
: null,
|
|
392
|
+
test_output: (testRes.stdout || '').slice(0, 2000),
|
|
393
|
+
}));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ─── JS/TS via c8 ────────────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
function runJsCoverage(dir, html, threshold, timeoutMs) {
|
|
399
|
+
if (!commandExists('c8') && !commandExists('npx')) {
|
|
400
|
+
return output(fail('c8 not found. Install: npm install -g c8'));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const cwd = path.resolve(dir);
|
|
404
|
+
const runner = commandExists('c8') ? 'c8' : 'npx';
|
|
405
|
+
const baseArgs = commandExists('c8') ? [] : ['c8'];
|
|
406
|
+
const thresholdArgs = threshold !== null ? [`--lines=${threshold}`] : [];
|
|
407
|
+
const reporterArgs = html ? ['--reporter=html', '--reporter=text'] : ['--reporter=text'];
|
|
408
|
+
const args = [...baseArgs, ...thresholdArgs, ...reporterArgs, 'npm', 'test'];
|
|
409
|
+
|
|
410
|
+
const res = spawnSync(runner, args, { encoding: 'utf8', cwd, timeout: timeoutMs });
|
|
411
|
+
|
|
412
|
+
if (res.error?.code === 'ETIMEDOUT') {
|
|
413
|
+
return output(fail(`JS coverage timed out after ${timeoutMs}ms`));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Parse text output for total
|
|
417
|
+
const totalMatch = (res.stdout || '').match(/All files\s*\|\s*([\d.]+)/);
|
|
418
|
+
const totalPct = totalMatch ? Math.round(parseFloat(totalMatch[1])) : null;
|
|
419
|
+
|
|
420
|
+
const failed = res.status !== 0 && threshold !== null;
|
|
421
|
+
|
|
422
|
+
if (failed) {
|
|
423
|
+
return output(fail(
|
|
424
|
+
`Coverage ${totalPct ?? '?'}% is below threshold ${threshold}%`
|
|
425
|
+
));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
output(ok({
|
|
429
|
+
dir: cwd,
|
|
430
|
+
lang: 'javascript',
|
|
431
|
+
tool: 'c8',
|
|
432
|
+
total_pct: totalPct,
|
|
433
|
+
threshold,
|
|
434
|
+
raw_output: (res.stdout || '').slice(0, 3000),
|
|
435
|
+
html_report: html ? path.join(cwd, 'coverage/index.html') : null,
|
|
436
|
+
}));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ─── Go via go test -coverprofile ────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
function runGoCoverage(dir, html, threshold, timeoutMs) {
|
|
442
|
+
if (!commandExists('go')) {
|
|
443
|
+
return output(fail('go not found. Install Go: https://go.dev'));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const cwd = path.resolve(fs.statSync(dir).isDirectory() ? dir : path.dirname(dir));
|
|
447
|
+
|
|
448
|
+
// Find go.mod root
|
|
449
|
+
let goRoot = cwd;
|
|
450
|
+
for (let i = 0; i < 6; i++) {
|
|
451
|
+
if (fs.existsSync(path.join(goRoot, 'go.mod'))) break;
|
|
452
|
+
const parent = path.dirname(goRoot);
|
|
453
|
+
if (parent === goRoot) { goRoot = cwd; break; }
|
|
454
|
+
goRoot = parent;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const coverFile = path.join(os.tmpdir(), `fchek_go_cover_${process.pid}.out`);
|
|
458
|
+
|
|
459
|
+
// Run tests with coverage
|
|
460
|
+
const testRes = spawnSync(
|
|
461
|
+
'go', ['test', '-coverprofile=' + coverFile, '-covermode=atomic', './...'],
|
|
462
|
+
{ encoding: 'utf8', cwd: goRoot, timeout: timeoutMs }
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
if (testRes.error?.code === 'ETIMEDOUT') {
|
|
466
|
+
return output(fail(`Go coverage timed out after ${timeoutMs}ms`));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (testRes.status !== 0 && !fs.existsSync(coverFile)) {
|
|
470
|
+
return output(fail(
|
|
471
|
+
`go test failed:\n${(testRes.stderr || testRes.stdout || '').slice(0, 1000)}`
|
|
472
|
+
));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Get total coverage percent from output: "coverage: 83.3% of statements"
|
|
476
|
+
const combined = (testRes.stdout || '') + (testRes.stderr || '');
|
|
477
|
+
const totalMatch = combined.match(/coverage:\s*([\d.]+)%/);
|
|
478
|
+
const totalPct = totalMatch ? Math.round(parseFloat(totalMatch[1])) : null;
|
|
479
|
+
|
|
480
|
+
// Parse per-file coverage using "go tool cover -func"
|
|
481
|
+
let fileResults = [];
|
|
482
|
+
if (fs.existsSync(coverFile)) {
|
|
483
|
+
const funcRes = spawnSync(
|
|
484
|
+
'go', ['tool', 'cover', `-func=${coverFile}`],
|
|
485
|
+
{ encoding: 'utf8', cwd: goRoot, timeout: 15000 }
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
for (const line of (funcRes.stdout || '').split('\n')) {
|
|
489
|
+
// Format: path/to/file.go:funcName 83.3%
|
|
490
|
+
const m = line.match(/^(.+\.go):(\w+)\s+([\d.]+)%$/);
|
|
491
|
+
if (m && m[2] !== 'total') {
|
|
492
|
+
fileResults.push({
|
|
493
|
+
file: m[1],
|
|
494
|
+
function: m[2],
|
|
495
|
+
coverage_pct: Math.round(parseFloat(m[3])),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Generate HTML report
|
|
501
|
+
let htmlReport = null;
|
|
502
|
+
if (html) {
|
|
503
|
+
const htmlOut = path.join(goRoot, 'coverage.html');
|
|
504
|
+
spawnSync('go', ['tool', 'cover', `-html=${coverFile}`, `-o=${htmlOut}`],
|
|
505
|
+
{ encoding: 'utf8', cwd: goRoot, timeout: 15000 });
|
|
506
|
+
if (fs.existsSync(htmlOut)) htmlReport = htmlOut;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
try { fs.unlinkSync(coverFile); } catch {}
|
|
510
|
+
|
|
511
|
+
if (threshold !== null && totalPct !== null && totalPct < threshold) {
|
|
512
|
+
return output(fail(`Coverage ${totalPct}% is below threshold ${threshold}%`));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return output(ok({
|
|
516
|
+
dir: goRoot,
|
|
517
|
+
lang: 'go',
|
|
518
|
+
tool: 'go test -coverprofile',
|
|
519
|
+
total_pct: totalPct,
|
|
520
|
+
threshold,
|
|
521
|
+
passed_threshold: threshold !== null ? (totalPct >= threshold) : null,
|
|
522
|
+
functions: fileResults,
|
|
523
|
+
uncovered_functions: fileResults.filter(f => f.coverage_pct === 0),
|
|
524
|
+
html_report: htmlReport,
|
|
525
|
+
test_output: combined.slice(0, 2000),
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// No cover file — test probably failed but we got partial output
|
|
530
|
+
try { fs.unlinkSync(coverFile); } catch {}
|
|
531
|
+
return output(ok({
|
|
532
|
+
dir: goRoot,
|
|
533
|
+
lang: 'go',
|
|
534
|
+
tool: 'go test -coverprofile',
|
|
535
|
+
total_pct: totalPct,
|
|
536
|
+
threshold,
|
|
537
|
+
passed_threshold: threshold !== null && totalPct !== null ? (totalPct >= threshold) : null,
|
|
538
|
+
functions: [],
|
|
539
|
+
test_output: combined.slice(0, 2000),
|
|
540
|
+
note: 'No coverage file generated — tests may have failed.',
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ─── Entry point ─────────────────────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
async function run(args) {
|
|
547
|
+
if (args.length === 0 || args[0] === '--help') {
|
|
548
|
+
console.log(HELP);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const target = args[0];
|
|
553
|
+
const html = args.includes('--html');
|
|
554
|
+
const thrArg = args.find(a => a.startsWith('--threshold='));
|
|
555
|
+
const threshold = thrArg ? parseInt(thrArg.replace('--threshold=', ''), 10) : null;
|
|
556
|
+
|
|
557
|
+
const ext = path.extname(target).toLowerCase();
|
|
558
|
+
|
|
559
|
+
const isDir = fs.statSync(target).isDirectory();
|
|
560
|
+
|
|
561
|
+
// C# detection
|
|
562
|
+
if (ext === '.cs') {
|
|
563
|
+
return runCSharpCoverage(target, html, threshold, DEFAULT_TIMEOUT);
|
|
564
|
+
}
|
|
565
|
+
if (isDir) {
|
|
566
|
+
// Check for .csproj anywhere in directory
|
|
567
|
+
const hasCsproj = (() => {
|
|
568
|
+
try {
|
|
569
|
+
const entries = fs.readdirSync(target, { recursive: true });
|
|
570
|
+
return entries.some(e => typeof e === 'string' ? e.endsWith('.csproj') : false);
|
|
571
|
+
} catch {
|
|
572
|
+
// recursive not supported in older Node — fallback
|
|
573
|
+
try { return fs.readdirSync(target).some(f => f.endsWith('.csproj') || f.endsWith('.sln')); } catch { return false; }
|
|
574
|
+
}
|
|
575
|
+
})();
|
|
576
|
+
if (hasCsproj) return runCSharpCoverage(target, html, threshold, DEFAULT_TIMEOUT);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Detect by extension or by directory markers
|
|
580
|
+
if (ext === '.py') {
|
|
581
|
+
return runPythonCoverage(target, html, threshold, DEFAULT_TIMEOUT);
|
|
582
|
+
}
|
|
583
|
+
if (['.c', '.cpp', '.cc'].includes(ext)) {
|
|
584
|
+
return runGcov(target, DEFAULT_TIMEOUT);
|
|
585
|
+
}
|
|
586
|
+
if (ext === '.rs' || target === '.' || fs.existsSync(path.join(target, 'Cargo.toml'))) {
|
|
587
|
+
return runRustCoverage(target, html, DEFAULT_TIMEOUT);
|
|
588
|
+
}
|
|
589
|
+
if (['.js', '.ts', '.mjs'].includes(ext) || fs.existsSync(path.join(target, 'package.json'))) {
|
|
590
|
+
return runJsCoverage(target, html, threshold, DEFAULT_TIMEOUT);
|
|
591
|
+
}
|
|
592
|
+
if (ext === '.go' || fs.existsSync(path.join(target, 'go.mod'))) {
|
|
593
|
+
return runGoCoverage(target, html, threshold, DEFAULT_TIMEOUT);
|
|
594
|
+
}
|
|
595
|
+
// Also detect Go dir by presence of .go files
|
|
596
|
+
if (isDir) {
|
|
597
|
+
const hasGo = (() => { try { return fs.readdirSync(target).some(f => f.endsWith('.go')); } catch { return false; } })();
|
|
598
|
+
if (hasGo) return runGoCoverage(target, html, threshold, DEFAULT_TIMEOUT);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
output(fail(`Cannot detect language for: ${target}. Supported: .py .c .cpp .rs (Cargo.toml) .js .ts .go (go.mod)`));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
module.exports = { run };
|