project-scan 1.0.0 → 1.0.3

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.
@@ -228,20 +228,40 @@ function printTestResults(results) {
228
228
  const ms = r.duration ? `${c.dim} (${(r.duration/1000).toFixed(2)}s)${c.reset}` : '';
229
229
  log(`\n ${icon}${c.reset} ${c.bold}${r.suite}${c.reset}${ms}`);
230
230
 
231
- if (r.passed !== undefined) { log(` ${c.green}✔ ${r.passed} passed${c.reset}`); totalPassed += r.passed; }
232
- if (r.failed > 0) { log(` ${c.red}✖ ${r.failed} failed${c.reset}`); totalFailed += r.failed; }
233
- if (r.skipped > 0) { log(` ${c.dim}○ ${r.skipped} skipped${c.reset}`); totalSkipped += r.skipped; }
234
- if (r.suites) { log(` ${c.dim}${r.suites}${c.reset}`); }
235
- if (r.status === 'skipped' && r.output) { warn(r.output); }
236
- if (r.status === 'error' && r.error) { fail(r.error.slice(0, 200)); }
237
-
238
- // Print first failed test details
239
- if (r.status === 'failed' && r.output) {
240
- const lines = r.output.split('\n').filter(l => l.includes('✖') || l.includes('FAIL') || l.includes('Error') || l.includes('expect'));
241
- if (lines.length > 0) {
242
- log(` ${c.dim}${c.red}${lines.slice(0, 5).join('\n ')}${c.reset}`);
231
+ if (r.suites) { log(` ${c.dim}${r.suites}${c.reset}`); }
232
+ if (r.status === 'skipped' && r.output) { warn(r.output); }
233
+ if (r.status === 'error' && r.error) { fail(r.error.slice(0, 200)); }
234
+
235
+ // ── Per-test-case LeetCode style list ────────────────────────────────────
236
+ if (r.testCases && r.testCases.length > 0) {
237
+ // Group by file
238
+ const byFile = {};
239
+ for (const tc of r.testCases) {
240
+ (byFile[tc.file] = byFile[tc.file] || []).push(tc);
243
241
  }
242
+ for (const [file, cases] of Object.entries(byFile)) {
243
+ log(`\n ${c.dim}📄 ${file}${c.reset}`);
244
+ for (const tc of cases) {
245
+ const tcIcon = tc.status === 'passed' ? `${c.green} ✔` : tc.status === 'failed' ? `${c.red} ✖` : `${c.dim} ○`;
246
+ const tcMs = tc.duration > 0 ? `${c.dim} ${tc.duration}ms${c.reset}` : '';
247
+ log(` ${tcIcon}${c.reset} ${tc.name}${tcMs}`);
248
+ if (tc.error) {
249
+ // First meaningful error line
250
+ const errLine = tc.error.split('\n').find(l => l.trim().startsWith('expect') || l.trim().startsWith('Error') || l.includes('Expected') || l.includes('Received'));
251
+ if (errLine) log(` ${c.red}${c.dim}${errLine.trim()}${c.reset}`);
252
+ }
253
+ }
254
+ }
255
+ } else {
256
+ // Fallback summary for non-Jest runners
257
+ if (r.passed > 0) log(` ${c.green}✔ ${r.passed} passed${c.reset}`);
258
+ if (r.failed > 0) log(` ${c.red}✖ ${r.failed} failed${c.reset}`);
259
+ if (r.skipped > 0) log(` ${c.dim}○ ${r.skipped} skipped${c.reset}`);
244
260
  }
261
+
262
+ totalPassed += r.passed || 0;
263
+ totalFailed += r.failed || 0;
264
+ totalSkipped += r.skipped || 0;
245
265
  }
246
266
 
247
267
  log(`\n ${c.bold}Total: ${c.green}${totalPassed} passed${c.reset} · ${c.red}${totalFailed} failed${c.reset} · ${c.dim}${totalSkipped} skipped${c.reset}`);
@@ -24,28 +24,66 @@ function generateHTML(scanResult, testResults) {
24
24
  const card = (content, extra = '') =>
25
25
  `<div style="background:#f7f8fa;border:1px solid #e5e7eb;border-radius:8px;padding:16px 18px;margin-bottom:10px${extra}">${content}</div>`;
26
26
 
27
- // Test suite rows
27
+ // Test suite rows — LeetCode style per-test breakdown
28
28
  const testRows = testResults.map(r => {
29
29
  const icon = r.status === 'passed' ? '✔' : r.status === 'failed' ? '✖' : r.status === 'error' ? '⚠' : '○';
30
30
  const color = r.status === 'passed' ? '#22c55e' : r.status === 'failed' ? '#ef4444' : r.status === 'error' ? '#f59e0b' : '#94a3b8';
31
31
  const ms = r.duration ? `${(r.duration / 1000).toFixed(2)}s` : '—';
32
- const output = r.output ? `<pre style="margin:8px 0 0;padding:10px;background:#1e1e2e;color:#cdd6f4;border-radius:5px;font-size:11px;overflow-x:auto;white-space:pre-wrap;max-height:220px;overflow-y:auto">${esc(r.output.slice(-2500))}</pre>` : '';
33
- const errOut = r.error ? `<pre style="margin:8px 0 0;padding:10px;background:#2a0a0a;color:#fca5a5;border-radius:5px;font-size:11px;overflow-x:auto;white-space:pre-wrap">${esc(r.error)}</pre>` : '';
34
32
  const suitesMeta = r.suites ? `<span style="margin-left:8px;color:#57606a;font-size:12px">(${r.suites})</span>` : '';
33
+ const errOut = r.error ? `<pre style="margin:8px 0 0;padding:10px;background:#2a0a0a;color:#fca5a5;border-radius:5px;font-size:11px;white-space:pre-wrap">${esc(r.error)}</pre>` : '';
34
+
35
+ // Per-test-case LeetCode grid
36
+ let caseGrid = '';
37
+ if (r.testCases && r.testCases.length > 0) {
38
+ // Group by file
39
+ const byFile = {};
40
+ for (const tc of r.testCases) {
41
+ (byFile[tc.file] = byFile[tc.file] || []).push(tc);
42
+ }
43
+ const fileBlocks = Object.entries(byFile).map(([file, cases]) => {
44
+ const rows = cases.map(tc => {
45
+ const tcColor = tc.status === 'passed' ? '#22c55e' : tc.status === 'failed' ? '#ef4444' : '#94a3b8';
46
+ const tcBg = tc.status === 'passed' ? '#f0fdf4' : tc.status === 'failed' ? '#fef2f2' : '#f8fafc';
47
+ const tcBorder = tc.status === 'passed' ? '#bbf7d0' : tc.status === 'failed' ? '#fecaca' : '#e2e8f0';
48
+ const tcIcon = tc.status === 'passed' ? '✔' : tc.status === 'failed' ? '✖' : '○';
49
+ const tcMs = tc.duration > 0 ? `<span style="color:#94a3b8;font-size:11px;margin-left:auto;padding-left:8px;white-space:nowrap">${tc.duration}ms</span>` : '';
50
+ const errBlock = tc.error ? `<pre style="margin:6px 0 0;padding:8px 10px;background:#1e1e2e;color:#fca5a5;border-radius:4px;font-size:10.5px;white-space:pre-wrap;overflow-x:auto">${esc(tc.error)}</pre>` : '';
51
+ return `
52
+ <div style="display:flex;flex-direction:column;border:1px solid ${tcBorder};border-radius:6px;padding:8px 12px;background:${tcBg}">
53
+ <div style="display:flex;align-items:center;gap:8px">
54
+ <span style="color:${tcColor};font-size:14px;font-weight:700;flex-shrink:0">${tcIcon}</span>
55
+ <span style="font-size:12.5px;color:#1f2328;flex:1;line-height:1.4">${esc(tc.name)}</span>
56
+ ${tcMs}
57
+ </div>
58
+ ${errBlock}
59
+ </div>`;
60
+ }).join('');
61
+ return `
62
+ <div style="margin-bottom:12px">
63
+ <div style="font-size:11px;color:#57606a;font-family:monospace;margin-bottom:6px;padding:3px 8px;background:#f0f3f6;border-radius:4px;display:inline-block">${esc(file)}</div>
64
+ <div style="display:flex;flex-direction:column;gap:5px">${rows}</div>
65
+ </div>`;
66
+ }).join('');
67
+ caseGrid = `<div style="margin-top:12px">${fileBlocks}</div>`;
68
+ } else if (r.output && r.status !== 'skipped') {
69
+ // Fallback: raw output for non-Jest runners
70
+ caseGrid = `<pre style="margin:8px 0 0;padding:10px;background:#1e1e2e;color:#cdd6f4;border-radius:5px;font-size:11px;overflow-x:auto;white-space:pre-wrap;max-height:200px;overflow-y:auto">${esc(r.output.slice(-2000))}</pre>`;
71
+ }
72
+
35
73
  return `
36
- <div style="border:1px solid #e5e7eb;border-radius:7px;padding:14px 16px;margin-bottom:8px;background:#fff">
74
+ <div style="border:1px solid #e5e7eb;border-radius:8px;padding:14px 16px;margin-bottom:12px;background:#fff">
37
75
  <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:4px">
38
- <span style="font-weight:600;color:#1f2328;font-size:14px">
76
+ <span style="font-weight:700;color:#1f2328;font-size:14px">
39
77
  <span style="color:${color};margin-right:6px;font-size:16px">${icon}</span>${esc(r.suite)}${suitesMeta}
40
78
  </span>
41
79
  <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
42
- ${r.passed !== undefined ? `<span style="color:#22c55e;font-size:12px;font-weight:700">${r.passed} passed</span>` : ''}
43
- ${r.failed !== undefined && r.failed > 0 ? `<span style="color:#ef4444;font-size:12px;font-weight:700">${r.failed} failed</span>` : ''}
44
- ${r.skipped !== undefined && r.skipped > 0 ? `<span style="color:#94a3b8;font-size:12px;font-weight:700">${r.skipped} skipped</span>` : ''}
80
+ ${r.passed > 0 ? `<span style="color:#22c55e;font-size:12px;font-weight:700">${r.passed} passed</span>` : ''}
81
+ ${r.failed > 0 ? `<span style="color:#ef4444;font-size:12px;font-weight:700">${r.failed} failed</span>` : ''}
82
+ ${r.skipped > 0 ? `<span style="color:#94a3b8;font-size:12px;font-weight:700">${r.skipped} skipped</span>` : ''}
45
83
  <span style="color:#57606a;font-size:12px">${ms}</span>
46
84
  </div>
47
85
  </div>
48
- ${output}${errOut}
86
+ ${caseGrid}${errOut}
49
87
  </div>`;
50
88
  }).join('');
51
89
 
package/lib/md-report.js CHANGED
@@ -217,9 +217,30 @@ function generateTestsReport(scanResult, testResults) {
217
217
  const suiteDetails = testResults.map(r => {
218
218
  const icon = r.status === 'passed' ? '✅' : r.status === 'failed' ? '❌' : r.status === 'error' ? '⚠️' : '⏭️';
219
219
  const ms = r.duration ? `${(r.duration/1000).toFixed(2)}s` : '—';
220
- const output = r.output ? `\n\n<details>\n<summary>Output log</summary>\n\n\`\`\`\n${r.output.slice(-2000)}\n\`\`\`\n</details>` : '';
221
- const error = r.error ? `\n\n> **Error:** ${r.error}` : '';
222
- return `### ${icon} ${r.suite}\n\n| Metric | Value |\n|--------|-------|\n| Status | \`${r.status}\` |\n| Passed | ${r.passed||0} |\n| Failed | ${r.failed||0} |\n| Skipped | ${r.skipped||0} |\n| Duration | ${ms} |\n${r.suites ? `| Suites | ${r.suites} |\n` : ''}${output}${error}`;
220
+ const error = r.error ? `\n\n> **Error:** ${r.error}` : '';
221
+
222
+ // Per-test-case rows (LeetCode style)
223
+ let caseTable = '';
224
+ if (r.testCases && r.testCases.length > 0) {
225
+ const byFile = {};
226
+ for (const tc of r.testCases) {
227
+ (byFile[tc.file] = byFile[tc.file] || []).push(tc);
228
+ }
229
+ const blocks = Object.entries(byFile).map(([file, cases]) => {
230
+ const rows = cases.map(tc => {
231
+ const tcIcon = tc.status === 'passed' ? '✅' : tc.status === 'failed' ? '❌' : '⏭️';
232
+ const tcMs = tc.duration > 0 ? `${tc.duration}ms` : '—';
233
+ const errLine = tc.error
234
+ ? `\n > \`\`\`\n > ${tc.error.split('\n').slice(0,4).join('\n > ')}\n > \`\`\``
235
+ : '';
236
+ return `| ${tcIcon} | ${tc.name} | ${tcMs} |${errLine}`;
237
+ }).join('\n');
238
+ return `**\`${file}\`**\n\n| | Test Case | Duration |\n|--|-----------|----------|\n${rows}`;
239
+ });
240
+ caseTable = '\n\n' + blocks.join('\n\n');
241
+ }
242
+
243
+ return `### ${icon} ${r.suite}\n\n| Metric | Value |\n|--------|-------|\n| Status | \`${r.status}\` |\n| Passed | ${r.passed||0} |\n| Failed | ${r.failed||0} |\n| Skipped | ${r.skipped||0} |\n| Duration | ${ms} |\n${r.suites ? `| Suites | ${r.suites} |\n` : ''}${caseTable}${error}`;
223
244
  }).join('\n\n---\n\n');
224
245
 
225
246
  const testFilesList = (scanResult.structure.testFiles || []).slice(0, 30).map(f => `- \`${f}\``).join('\n') || '- _None found_';
@@ -9,6 +9,8 @@ const { execSync, spawnSync } = require('child_process');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
 
12
+ const IS_WIN = process.platform === 'win32';
13
+
12
14
  // ─── Per-language runner configs ─────────────────────────────────────────────
13
15
 
14
16
  /**
@@ -100,9 +102,9 @@ const RUNNERS = [
100
102
  hasSourceExt(root, '.py');
101
103
  },
102
104
  run(root) {
103
- const cmd = commandExists('pytest') ? 'pytest -v --tb=short 2>&1'
104
- : commandExists('python3') ? 'python3 -m pytest -v --tb=short 2>&1'
105
- : commandExists('python') ? 'python -m pytest -v --tb=short 2>&1'
105
+ const cmd = commandExists('pytest') ? 'pytest -v --tb=short'
106
+ : commandExists('python3') ? 'python3 -m pytest -v --tb=short'
107
+ : commandExists('python') ? 'python -m pytest -v --tb=short'
106
108
  : null;
107
109
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'pytest', parseOutput: parsePytestOutput })];
108
110
  }
@@ -115,7 +117,7 @@ const RUNNERS = [
115
117
  run(root) {
116
118
  return [runCommand({
117
119
  dir: root, label: '(root)',
118
- cmd: commandExists('go') ? 'go test ./... -v 2>&1' : null,
120
+ cmd: commandExists('go') ? 'go test ./... -v' : null,
119
121
  suite: 'Go test',
120
122
  parseOutput: parseGoTestOutput,
121
123
  })];
@@ -129,7 +131,7 @@ const RUNNERS = [
129
131
  run(root) {
130
132
  return [runCommand({
131
133
  dir: root, label: '(root)',
132
- cmd: commandExists('cargo') ? 'cargo test 2>&1' : null,
134
+ cmd: commandExists('cargo') ? 'cargo test' : null,
133
135
  suite: 'Cargo test',
134
136
  parseOutput: parseCargoOutput,
135
137
  })];
@@ -141,7 +143,7 @@ const RUNNERS = [
141
143
  name: 'Maven',
142
144
  detect(root) { return fs.existsSync(path.join(root, 'pom.xml')); },
143
145
  run(root) {
144
- const cmd = commandExists('mvn') ? 'mvn test -B 2>&1' : null;
146
+ const cmd = commandExists('mvn') ? 'mvn test -B' : null;
145
147
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'Maven', parseOutput: parseMavenOutput })];
146
148
  }
147
149
  },
@@ -154,8 +156,13 @@ const RUNNERS = [
154
156
  fs.existsSync(path.join(root, 'build.gradle.kts'));
155
157
  },
156
158
  run(root) {
157
- const gradleCmd = fs.existsSync(path.join(root, 'gradlew')) ? './gradlew' : (commandExists('gradle') ? 'gradle' : null);
158
- return [runCommand({ dir: root, label: '(root)', cmd: gradleCmd ? `${gradleCmd} test 2>&1` : null, suite: 'Gradle', parseOutput: parseGradleOutput })];
159
+ // Windows uses gradlew.bat; Unix uses ./gradlew
160
+ const wrapperWin = path.join(root, 'gradlew.bat');
161
+ const wrapperUnix = path.join(root, 'gradlew');
162
+ const gradleCmd = fs.existsSync(IS_WIN ? wrapperWin : wrapperUnix)
163
+ ? (IS_WIN ? 'gradlew.bat' : './gradlew')
164
+ : (commandExists('gradle') ? 'gradle' : null);
165
+ return [runCommand({ dir: root, label: '(root)', cmd: gradleCmd ? `${gradleCmd} test` : null, suite: 'Gradle', parseOutput: parseGradleOutput })];
159
166
  }
160
167
  },
161
168
 
@@ -163,14 +170,16 @@ const RUNNERS = [
163
170
  {
164
171
  name: 'PHPUnit',
165
172
  detect(root) {
166
- return fs.existsSync(path.join(root, 'phpunit.xml')) ||
167
- fs.existsSync(path.join(root, 'phpunit.xml.dist')) ||
168
- fs.existsSync(path.join(root, 'vendor/bin/phpunit')) ||
173
+ return fs.existsSync(path.join(root, 'phpunit.xml')) ||
174
+ fs.existsSync(path.join(root, 'phpunit.xml.dist')) ||
175
+ fs.existsSync(path.join(root, 'vendor', 'bin', 'phpunit')) ||
169
176
  hasSourceExt(root, '.php');
170
177
  },
171
178
  run(root) {
172
- const cmd = fs.existsSync(path.join(root, 'vendor/bin/phpunit')) ? './vendor/bin/phpunit --testdox 2>&1'
173
- : commandExists('phpunit') ? 'phpunit --testdox 2>&1' : null;
179
+ const vendorBin = path.join(root, 'vendor', 'bin', 'phpunit');
180
+ const cmd = fs.existsSync(vendorBin)
181
+ ? `"${vendorBin}" --testdox`
182
+ : commandExists('phpunit') ? 'phpunit --testdox' : null;
174
183
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'PHPUnit', parseOutput: parsePHPUnitOutput })];
175
184
  }
176
185
  },
@@ -184,7 +193,7 @@ const RUNNERS = [
184
193
  fs.existsSync(path.join(root, 'spec'));
185
194
  },
186
195
  run(root) {
187
- const cmd = commandExists('rspec') ? 'rspec --format progress 2>&1' : (commandExists('bundle') ? 'bundle exec rspec --format progress 2>&1' : null);
196
+ const cmd = commandExists('rspec') ? 'rspec --format progress' : (commandExists('bundle') ? 'bundle exec rspec --format progress' : null);
188
197
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'RSpec', parseOutput: parseRSpecOutput })];
189
198
  }
190
199
  },
@@ -194,7 +203,7 @@ const RUNNERS = [
194
203
  name: 'Flutter/Dart test',
195
204
  detect(root) { return fs.existsSync(path.join(root, 'pubspec.yaml')); },
196
205
  run(root) {
197
- const cmd = commandExists('flutter') ? 'flutter test 2>&1' : (commandExists('dart') ? 'dart test 2>&1' : null);
206
+ const cmd = commandExists('flutter') ? 'flutter test' : (commandExists('dart') ? 'dart test' : null);
198
207
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'Flutter/Dart', parseOutput: parseDartOutput })];
199
208
  }
200
209
  },
@@ -208,7 +217,7 @@ const RUNNERS = [
208
217
  } catch { return false; }
209
218
  },
210
219
  run(root) {
211
- const cmd = commandExists('dotnet') ? 'dotnet test --logger "console;verbosity=normal" 2>&1' : null;
220
+ const cmd = commandExists('dotnet') ? 'dotnet test --logger "console;verbosity=normal"' : null;
212
221
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'dotnet test', parseOutput: parseDotnetOutput })];
213
222
  }
214
223
  },
@@ -252,17 +261,18 @@ function runAllTests(rootDir) {
252
261
 
253
262
  function runJest(dir, label) {
254
263
  const start = Date.now();
255
- if (!fs.existsSync(path.join(dir, 'node_modules', '.bin', 'jest')) &&
264
+ if (!fs.existsSync(path.join(dir, 'node_modules', '.bin', 'jest')) &&
265
+ !fs.existsSync(path.join(dir, 'node_modules', '.bin', 'jest.cmd')) &&
256
266
  !fs.existsSync(path.join(dir, 'node_modules', 'jest'))) {
257
- // Try npm test fallback
258
267
  const pkg = tryReadJSON(path.join(dir, 'package.json'));
259
268
  if (!pkg?.scripts?.test) {
260
269
  return { suite: `Jest (${label})`, status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'Jest not installed. Run npm install first.', duration: 0 };
261
270
  }
262
271
  }
263
272
 
273
+ // shell:true is required on Windows so npx.cmd is resolved correctly
264
274
  const result = spawnSync('npx', ['jest', '--no-coverage', '--json', '--forceExit'], {
265
- cwd: dir, encoding: 'utf8', timeout: 120000,
275
+ cwd: dir, encoding: 'utf8', timeout: 120000, shell: true,
266
276
  env: { ...process.env, FORCE_COLOR: '0' }
267
277
  });
268
278
 
@@ -280,17 +290,29 @@ function runJest(dir, label) {
280
290
  const skipped = jResult.numPendingTests || 0;
281
291
  const status = failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped');
282
292
 
283
- // Build readable test breakdown
293
+ // Build structured per-test case list (LeetCode style)
294
+ // Jest uses 'assertionResults' per suite (not 'testResults')
295
+ const testCases = [];
284
296
  const lines = [];
285
297
  for (const suite of (jResult.testResults || [])) {
286
- const relPath = path.relative(dir, suite.testFilePath);
298
+ const relPath = path.relative(dir, suite.testFilePath || suite.name || '');
287
299
  lines.push(`\n 📄 ${relPath}`);
288
- for (const t of (suite.testResults || [])) {
300
+ const assertions = suite.assertionResults || suite.testResults || [];
301
+ for (const t of assertions) {
289
302
  const icon = t.status === 'passed' ? ' ✔' : t.status === 'failed' ? ' ✖' : ' ○';
290
- lines.push(`${icon} ${t.fullName}`);
303
+ lines.push(`${icon} ${t.fullName || t.title}`);
291
304
  if (t.status === 'failed' && t.failureMessages?.length) {
292
305
  lines.push(` ${t.failureMessages[0].split('\n').slice(0, 3).join('\n ')}`);
293
306
  }
307
+ testCases.push({
308
+ file: relPath,
309
+ name: t.fullName || t.title || '(unnamed)',
310
+ status: t.status, // 'passed' | 'failed' | 'pending'
311
+ duration: t.duration || 0,
312
+ error: t.status === 'failed' && t.failureMessages?.length
313
+ ? t.failureMessages[0].split('\n').slice(0, 6).join('\n')
314
+ : null,
315
+ });
294
316
  }
295
317
  }
296
318
 
@@ -301,6 +323,7 @@ function runJest(dir, label) {
301
323
  duration,
302
324
  output: lines.join('\n'),
303
325
  suites: jResult.numPassedTestSuites + '/' + jResult.numTotalTestSuites + ' suites passed',
326
+ testCases,
304
327
  };
305
328
  } catch { /* fall through to text parse */ }
306
329
  }
@@ -316,7 +339,11 @@ function runCommand({ dir, label, cmd, suite, parseOutput }) {
316
339
  }
317
340
  const start = Date.now();
318
341
  try {
319
- const output = execSync(cmd, { cwd: dir, encoding: 'utf8', timeout: 120000, env: { ...process.env, FORCE_COLOR: '0' }, stdio: 'pipe' });
342
+ // shell:true is required on Windows; harmless on Unix
343
+ const output = execSync(cmd, {
344
+ cwd: dir, encoding: 'utf8', timeout: 120000, shell: true,
345
+ env: { ...process.env, FORCE_COLOR: '0' }, stdio: 'pipe'
346
+ });
320
347
  const duration = Date.now() - start;
321
348
  return { suite: `${suite} (${label})`, duration, ...parseOutput(output, true) };
322
349
  } catch (err) {
@@ -419,12 +446,28 @@ function parseDotnetOutput(output, exitOk) {
419
446
  return { status: exitOk ? 'passed' : 'failed', passed: p ? parseInt(p[1]) : 0, failed: f ? parseInt(f[1]) : 0, skipped: 0, output: output.slice(-3000) };
420
447
  }
421
448
 
422
- // ─── Utility helpers ──────────────────────────────────────────────────────────
449
+ // ─── Utility helpers ─────────────────────────────────────────────────────────
423
450
 
424
451
  function tryReadJSON(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
425
452
  function allDeps(pkg) { return [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; }
426
- function commandExists(cmd) { try { execSync(`which ${cmd} 2>/dev/null || command -v ${cmd} 2>/dev/null`, { encoding: 'utf8' }).trim(); return true; } catch { return false; } }
427
- function hasBin(dir, bin) { return fs.existsSync(path.join(dir, 'node_modules', '.bin', bin)); }
453
+
454
+ /** Works on Windows (where and Get-Command) and Unix (which / command -v). */
455
+ function commandExists(cmd) {
456
+ try {
457
+ if (IS_WIN) {
458
+ execSync(`where ${cmd}`, { encoding: 'utf8', stdio: 'pipe' });
459
+ } else {
460
+ execSync(`command -v ${cmd}`, { encoding: 'utf8', stdio: 'pipe', shell: '/bin/sh' });
461
+ }
462
+ return true;
463
+ } catch { return false; }
464
+ }
465
+
466
+ function hasBin(dir, bin) {
467
+ // On Windows the bin is a .cmd wrapper
468
+ return fs.existsSync(path.join(dir, 'node_modules', '.bin', bin)) ||
469
+ fs.existsSync(path.join(dir, 'node_modules', '.bin', bin + '.cmd'));
470
+ }
428
471
  function hasSourceExt(root, ext) {
429
472
  try { return fs.readdirSync(root).some(f => f.endsWith(ext)); } catch { return false; }
430
473
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-scan",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
4
4
  "description": "Universal project summarizer & test runner — scans any project for languages, frameworks, databases, cloud services, CI/CD pipelines and runs tests. Works with JavaScript, TypeScript, Python, Go, Rust, Java, Kotlin, PHP, Ruby, Dart, C#, and more.",
5
5
  "main": "lib/scanner.js",
6
6
  "bin": {