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.
Files changed (66) hide show
  1. package/README.md +64 -0
  2. package/bin/fchek.js +107 -0
  3. package/lib/api.js +110 -0
  4. package/lib/audit.js +211 -0
  5. package/lib/bench.js +248 -0
  6. package/lib/config.js +191 -0
  7. package/lib/context.js +356 -0
  8. package/lib/convention.js +526 -0
  9. package/lib/coverage.js +604 -0
  10. package/lib/db.js +135 -0
  11. package/lib/deps-check.js +264 -0
  12. package/lib/deps.js +374 -0
  13. package/lib/docker.js +84 -0
  14. package/lib/doctor.js +149 -0
  15. package/lib/dom.js +226 -0
  16. package/lib/fuzz.js +470 -0
  17. package/lib/git.js +290 -0
  18. package/lib/goto.js +544 -0
  19. package/lib/launch.js +182 -0
  20. package/lib/lint.js +624 -0
  21. package/lib/new_features.test.js +181 -0
  22. package/lib/output.js +46 -0
  23. package/lib/port.js +173 -0
  24. package/lib/process.js +228 -0
  25. package/lib/profile.js +453 -0
  26. package/lib/python.js +41 -0
  27. package/lib/race.js +186 -0
  28. package/lib/registry.js +179 -0
  29. package/lib/repl.js +135 -0
  30. package/lib/run.js +403 -0
  31. package/lib/screenshot.js +152 -0
  32. package/lib/secrets.js +257 -0
  33. package/lib/state.js +219 -0
  34. package/lib/test.js +471 -0
  35. package/lib/vuln.js +253 -0
  36. package/lib/watch.js +240 -0
  37. package/lib/winlog.js +123 -0
  38. package/package.json +27 -0
  39. package/skills/ACTIVATE.md +274 -0
  40. package/skills/README.md +163 -0
  41. package/skills/agent.md +444 -0
  42. package/skills/api.md +47 -0
  43. package/skills/bench.md +117 -0
  44. package/skills/context.md +116 -0
  45. package/skills/convention.md +143 -0
  46. package/skills/coverage.md +99 -0
  47. package/skills/csharp.md +97 -0
  48. package/skills/db.md +66 -0
  49. package/skills/deps-check.md +135 -0
  50. package/skills/deps.md +143 -0
  51. package/skills/docker.md +61 -0
  52. package/skills/dom.md +56 -0
  53. package/skills/fuzz.md +167 -0
  54. package/skills/goto.md +111 -0
  55. package/skills/lint.md +123 -0
  56. package/skills/port.md +57 -0
  57. package/skills/profile.md +91 -0
  58. package/skills/race.md +117 -0
  59. package/skills/repl.md +81 -0
  60. package/skills/rules.md +318 -0
  61. package/skills/run.md +135 -0
  62. package/skills/secrets.md +170 -0
  63. package/skills/security.md +360 -0
  64. package/skills/state.md +261 -0
  65. package/skills/vuln.md +57 -0
  66. package/skills/windows.md +320 -0
@@ -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 };