@vibecodeqa/cli 0.9.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -0
  3. package/dist/check-meta.d.ts +15 -0
  4. package/dist/check-meta.js +166 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.js +140 -0
  7. package/dist/detect.d.ts +8 -0
  8. package/dist/detect.js +67 -0
  9. package/dist/fs-utils.d.ts +23 -0
  10. package/dist/fs-utils.js +77 -0
  11. package/dist/report/html.d.ts +12 -0
  12. package/dist/report/html.js +400 -0
  13. package/dist/runners/architecture.d.ts +28 -0
  14. package/dist/runners/architecture.js +272 -0
  15. package/dist/runners/complexity.d.ts +3 -0
  16. package/dist/runners/complexity.js +152 -0
  17. package/dist/runners/confusion.d.ts +16 -0
  18. package/dist/runners/confusion.js +198 -0
  19. package/dist/runners/context.d.ts +15 -0
  20. package/dist/runners/context.js +200 -0
  21. package/dist/runners/coverage.d.ts +3 -0
  22. package/dist/runners/coverage.js +65 -0
  23. package/dist/runners/dependencies.d.ts +3 -0
  24. package/dist/runners/dependencies.js +106 -0
  25. package/dist/runners/docs.d.ts +3 -0
  26. package/dist/runners/docs.js +97 -0
  27. package/dist/runners/duplication.d.ts +3 -0
  28. package/dist/runners/duplication.js +100 -0
  29. package/dist/runners/exec.d.ts +6 -0
  30. package/dist/runners/exec.js +25 -0
  31. package/dist/runners/lint.d.ts +3 -0
  32. package/dist/runners/lint.js +78 -0
  33. package/dist/runners/secrets.d.ts +3 -0
  34. package/dist/runners/secrets.js +108 -0
  35. package/dist/runners/security.d.ts +3 -0
  36. package/dist/runners/security.js +121 -0
  37. package/dist/runners/standards.d.ts +3 -0
  38. package/dist/runners/standards.js +153 -0
  39. package/dist/runners/structure.d.ts +3 -0
  40. package/dist/runners/structure.js +110 -0
  41. package/dist/runners/testing.d.ts +12 -0
  42. package/dist/runners/testing.js +401 -0
  43. package/dist/runners/tests.d.ts +3 -0
  44. package/dist/runners/tests.js +54 -0
  45. package/dist/runners/type-safety.d.ts +3 -0
  46. package/dist/runners/type-safety.js +74 -0
  47. package/dist/runners/types-check.d.ts +3 -0
  48. package/dist/runners/types-check.js +44 -0
  49. package/dist/score.d.ts +6 -0
  50. package/dist/score.js +19 -0
  51. package/dist/trend.d.ts +19 -0
  52. package/dist/trend.js +63 -0
  53. package/dist/types.d.ts +40 -0
  54. package/dist/types.js +12 -0
  55. package/package.json +53 -0
@@ -0,0 +1,401 @@
1
+ /** Comprehensive testing assessment — pyramid layers, quality, coverage.
2
+ *
3
+ * Dimensions assessed:
4
+ * 1. Pyramid presence — which layers exist? (unit, integration, e2e, component)
5
+ * 2. Test execution — pass/fail from the runner
6
+ * 3. Coverage — statement, branch, function, line
7
+ * 4. File pairing — does each source file have a test file?
8
+ * 5. Test quality — naming, assertions, mocking patterns, snapshot smell
9
+ * 6. Pyramid balance — right ratio of fast-to-slow tests
10
+ */
11
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
12
+ import { basename, extname, join } from "node:path";
13
+ import { gradeFromScore } from "../types.js";
14
+ import { run } from "./exec.js";
15
+ // ── Classification rules ──
16
+ function classifyTestFile(relPath, content) {
17
+ const lower = relPath.toLowerCase();
18
+ // E2E — explicit directory or file naming
19
+ if (lower.includes("/e2e/") || lower.includes(".e2e.") || lower.includes("/playwright/") || lower.includes(".pw."))
20
+ return "e2e";
21
+ if (/\b(page|browser|navigate|goto|click|fill|expect\(page)\b/.test(content) && /playwright|puppeteer|cypress/.test(content))
22
+ return "e2e";
23
+ // Integration — explicit naming or real API/DB calls
24
+ if (lower.includes("integration") || lower.includes(".int."))
25
+ return "integration";
26
+ if (/\bfetch\(["']https?:/.test(content) && !/mock|stub|spy|vi\.fn|jest\.fn/.test(content))
27
+ return "integration";
28
+ if (/\bglobalSetup\b/.test(content))
29
+ return "integration";
30
+ // Component — React component tests (render, screen, fireEvent)
31
+ if (/\b(render|screen|fireEvent|userEvent|@testing-library)\b/.test(content))
32
+ return "component";
33
+ if (lower.includes(".component."))
34
+ return "component";
35
+ // Default: unit
36
+ return "unit";
37
+ }
38
+ function countPatterns(content) {
39
+ const assertions = (content.match(/\bexpect\s*\(/g) || []).length + (content.match(/\bassert[\.(]/g) || []).length;
40
+ const mocks = (content.match(/\b(vi\.fn|jest\.fn|vi\.mock|jest\.mock|vi\.spyOn|jest\.spyOn|sinon\.(stub|spy|mock)|\.mockResolvedValue|\.mockReturnValue|\.mockImplementation)\b/g) || []).length;
41
+ const snapshots = (content.match(/\btoMatchSnapshot|toMatchInlineSnapshot\b/g) || []).length;
42
+ const describes = (content.match(/\bdescribe\s*\(/g) || []).length;
43
+ const its = (content.match(/\b(it|test)\s*\(/g) || []).length;
44
+ return { assertions, mocks, snapshots, describes, its };
45
+ }
46
+ // ── File discovery ──
47
+ function findTestFiles(cwd) {
48
+ const files = [];
49
+ const dirs = ["src", "web/src", "test", "tests", "__tests__", "e2e", "playwright"];
50
+ for (const dir of dirs) {
51
+ const full = join(cwd, dir);
52
+ if (existsSync(full))
53
+ walkTests(full, cwd, files);
54
+ }
55
+ // Also check root for standalone test configs (e.g. playwright.config.ts)
56
+ return files;
57
+ }
58
+ function walkTests(dir, cwd, out) {
59
+ for (const entry of readdirSync(dir)) {
60
+ if (entry === "node_modules" || entry === "dist" || entry === ".git")
61
+ continue;
62
+ const full = join(dir, entry);
63
+ if (statSync(full).isDirectory()) {
64
+ walkTests(full, cwd, out);
65
+ continue;
66
+ }
67
+ const ext = extname(entry);
68
+ if (![".ts", ".tsx", ".js", ".jsx"].includes(ext))
69
+ continue;
70
+ if (!entry.includes(".test.") && !entry.includes(".spec.") && !entry.includes(".e2e.") && !entry.includes(".int.") && !dir.includes("__tests__") && !dir.includes("/e2e") && !dir.includes("/test"))
71
+ continue;
72
+ const content = readFileSync(full, "utf-8");
73
+ const relPath = full.replace(cwd + "/", "");
74
+ const layer = classifyTestFile(relPath, content);
75
+ const patterns = countPatterns(content);
76
+ out.push({
77
+ path: relPath,
78
+ layer,
79
+ lines: content.split("\n").length,
80
+ ...patterns,
81
+ });
82
+ }
83
+ }
84
+ function findSourceFiles(cwd) {
85
+ const files = [];
86
+ const dirs = ["src", "web/src"];
87
+ for (const dir of dirs) {
88
+ try {
89
+ walkSource(join(cwd, dir), cwd, files);
90
+ }
91
+ catch { /* dir doesn't exist */ }
92
+ }
93
+ return files;
94
+ }
95
+ function walkSource(dir, cwd, out) {
96
+ for (const entry of readdirSync(dir)) {
97
+ if (entry === "node_modules" || entry === "dist")
98
+ continue;
99
+ const full = join(dir, entry);
100
+ if (statSync(full).isDirectory()) {
101
+ walkSource(full, cwd, out);
102
+ }
103
+ else {
104
+ const ext = extname(entry);
105
+ if ([".ts", ".tsx", ".js", ".jsx"].includes(ext) && !entry.includes(".test.") && !entry.includes(".spec.")) {
106
+ out.push(full.replace(cwd + "/", ""));
107
+ }
108
+ }
109
+ }
110
+ }
111
+ // ── Pairing analysis ──
112
+ function computePairing(srcFiles, testFiles) {
113
+ const testBases = new Set(testFiles.map((t) => {
114
+ const b = basename(t.path);
115
+ return b.replace(/\.(test|spec|e2e|int)\.(ts|tsx|js|jsx)$/, "");
116
+ }));
117
+ const unpaired = [];
118
+ let paired = 0;
119
+ for (const src of srcFiles) {
120
+ const b = basename(src).replace(/\.(ts|tsx|js|jsx)$/, "");
121
+ // Skip files that conventionally don't need tests
122
+ if (["index", "main", "types", "constants", "config"].includes(b))
123
+ continue;
124
+ if (testBases.has(b)) {
125
+ paired++;
126
+ }
127
+ else {
128
+ unpaired.push(src);
129
+ }
130
+ }
131
+ return { paired, unpaired };
132
+ }
133
+ // ── E2E tool detection ──
134
+ function detectE2E(cwd) {
135
+ const allDeps = readDeps(cwd);
136
+ if (allDeps["@playwright/test"] || allDeps.playwright) {
137
+ const hasConfig = existsSync(join(cwd, "playwright.config.ts")) || existsSync(join(cwd, "playwright.config.js"));
138
+ return { tool: "playwright", configured: hasConfig };
139
+ }
140
+ if (allDeps.cypress) {
141
+ const hasConfig = existsSync(join(cwd, "cypress.config.ts")) || existsSync(join(cwd, "cypress.config.js")) || existsSync(join(cwd, "cypress.json"));
142
+ return { tool: "cypress", configured: hasConfig };
143
+ }
144
+ return { tool: "none", configured: false };
145
+ }
146
+ function readDeps(cwd) {
147
+ try {
148
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
149
+ return { ...pkg.dependencies, ...pkg.devDependencies };
150
+ }
151
+ catch {
152
+ return {};
153
+ }
154
+ }
155
+ // ── Coverage collection ──
156
+ function collectCoverage(cwd, stack) {
157
+ if (stack.testRunner === "none")
158
+ return null;
159
+ const cmd = stack.testRunner === "vitest"
160
+ ? "npx vitest run --coverage 2>/dev/null || true"
161
+ : "npx jest --coverage --coverageReporters=json-summary 2>/dev/null || true";
162
+ run(cmd, cwd, 120_000);
163
+ const searchPaths = ["coverage/coverage-summary.json", "test-results/coverage/coverage-summary.json"];
164
+ for (const p of searchPaths) {
165
+ const full = join(cwd, p);
166
+ if (existsSync(full)) {
167
+ try {
168
+ const summary = JSON.parse(readFileSync(full, "utf-8"));
169
+ if (summary?.total) {
170
+ return {
171
+ statements: summary.total.statements?.pct || 0,
172
+ lines: summary.total.lines?.pct || 0,
173
+ branches: summary.total.branches?.pct || 0,
174
+ functions: summary.total.functions?.pct || 0,
175
+ };
176
+ }
177
+ }
178
+ catch { /* parse failed */ }
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+ // ── Test execution ──
184
+ function executeTests(cwd, stack) {
185
+ if (stack.testRunner === "none")
186
+ return null;
187
+ const cmd = stack.testRunner === "vitest"
188
+ ? "npx vitest run --reporter=json 2>/dev/null || true"
189
+ : "npx jest --json 2>/dev/null || true";
190
+ const { stdout } = run(cmd, cwd, 120_000);
191
+ try {
192
+ const jsonStart = stdout.indexOf("{");
193
+ if (jsonStart >= 0) {
194
+ const data = JSON.parse(stdout.slice(jsonStart));
195
+ return {
196
+ passed: data.numPassedTests || 0,
197
+ failed: data.numFailedTests || 0,
198
+ total: data.numTotalTests || 0,
199
+ };
200
+ }
201
+ }
202
+ catch { /* parse failed */ }
203
+ return null;
204
+ }
205
+ function analyzeQuality(testFiles) {
206
+ let totalAssertions = 0;
207
+ let totalMocks = 0;
208
+ let totalSnapshots = 0;
209
+ let totalIts = 0;
210
+ let testsWithNoAssertions = 0;
211
+ for (const f of testFiles) {
212
+ totalAssertions += f.assertions;
213
+ totalMocks += f.mocks;
214
+ totalSnapshots += f.snapshots;
215
+ totalIts += f.its;
216
+ if (f.assertions === 0 && f.its > 0)
217
+ testsWithNoAssertions += f.its;
218
+ }
219
+ return {
220
+ avgAssertionsPerTest: totalIts > 0 ? Math.round((totalAssertions / totalIts) * 10) / 10 : 0,
221
+ testsWithNoAssertions,
222
+ mockRatio: totalAssertions > 0 ? Math.round((totalMocks / totalAssertions) * 100) / 100 : 0,
223
+ snapshotRatio: totalAssertions > 0 ? Math.round((totalSnapshots / totalAssertions) * 100) / 100 : 0,
224
+ emptyDescribes: 0, // TODO: detect empty describe blocks
225
+ wellNamedTests: totalIts, // simplified for v0.2
226
+ totalTests: totalIts,
227
+ };
228
+ }
229
+ // ── Main runner ──
230
+ export function runTesting(cwd, stack, skipExec) {
231
+ const start = Date.now();
232
+ const issues = [];
233
+ // 1. Discover test files and classify by layer
234
+ const testFiles = findTestFiles(cwd);
235
+ const srcFiles = findSourceFiles(cwd);
236
+ if (testFiles.length === 0) {
237
+ return {
238
+ name: "testing",
239
+ score: 0,
240
+ grade: "F",
241
+ details: {
242
+ reason: "No test files found",
243
+ srcFiles: srcFiles.length,
244
+ testFiles: 0,
245
+ pyramid: { unit: 0, integration: 0, component: 0, e2e: 0 },
246
+ },
247
+ issues: [{ severity: "error", message: `${srcFiles.length} source files with zero tests`, rule: "no-tests" }],
248
+ duration: Date.now() - start,
249
+ };
250
+ }
251
+ // 2. Pyramid layer analysis
252
+ const layers = { unit: [], integration: [], component: [], e2e: [] };
253
+ for (const f of testFiles)
254
+ layers[f.layer].push(f);
255
+ const pyramid = {
256
+ unit: { present: layers.unit.length > 0, files: layers.unit.length, assertions: layers.unit.reduce((s, f) => s + f.assertions, 0), score: 0 },
257
+ integration: { present: layers.integration.length > 0, files: layers.integration.length, assertions: layers.integration.reduce((s, f) => s + f.assertions, 0), score: 0 },
258
+ component: { present: layers.component.length > 0, files: layers.component.length, assertions: layers.component.reduce((s, f) => s + f.assertions, 0), score: 0 },
259
+ e2e: { present: layers.e2e.length > 0, files: layers.e2e.length, assertions: layers.e2e.reduce((s, f) => s + f.assertions, 0), score: 0 },
260
+ };
261
+ // 3. E2E tool detection
262
+ const e2eTool = detectE2E(cwd);
263
+ const needsE2E = stack.framework !== "none"; // frontends should have E2E
264
+ const needsComponent = stack.framework === "react" || stack.framework === "vue" || stack.framework === "svelte";
265
+ // 4. Penalize missing layers based on what the stack needs
266
+ if (!pyramid.unit.present) {
267
+ issues.push({ severity: "error", message: "No unit tests found — every project needs unit tests", rule: "no-unit-tests" });
268
+ }
269
+ if (!pyramid.integration.present && srcFiles.length > 5) {
270
+ issues.push({ severity: "warning", message: "No integration tests — consider testing service boundaries", rule: "no-integration-tests" });
271
+ }
272
+ if (needsComponent && !pyramid.component.present) {
273
+ issues.push({ severity: "warning", message: `${stack.framework} project with no component tests — test your UI components`, rule: "no-component-tests" });
274
+ }
275
+ if (needsE2E && !pyramid.e2e.present) {
276
+ if (e2eTool.tool === "none") {
277
+ issues.push({ severity: "warning", message: "No E2E test framework (Playwright/Cypress) — critical user flows untested", rule: "no-e2e-framework" });
278
+ }
279
+ else if (!e2eTool.configured) {
280
+ issues.push({ severity: "info", message: `${e2eTool.tool} installed but not configured`, rule: "e2e-not-configured" });
281
+ }
282
+ else {
283
+ issues.push({ severity: "info", message: `${e2eTool.tool} configured but no E2E test files found`, rule: "e2e-no-tests" });
284
+ }
285
+ }
286
+ // 5. File pairing analysis
287
+ const pairing = computePairing(srcFiles, testFiles);
288
+ const pairingPct = srcFiles.length > 0 ? Math.round(((pairing.paired) / Math.max(1, srcFiles.length - countUntestable(srcFiles))) * 100) : 100;
289
+ if (pairingPct < 30) {
290
+ issues.push({ severity: "warning", message: `Only ${pairingPct}% of source files have matching test files`, rule: "low-test-pairing" });
291
+ }
292
+ // Report up to 5 unpaired files
293
+ for (const f of pairing.unpaired.slice(0, 5)) {
294
+ issues.push({ severity: "info", message: `No test file for ${f}`, file: f, rule: "untested-file" });
295
+ }
296
+ if (pairing.unpaired.length > 5) {
297
+ issues.push({ severity: "info", message: `...and ${pairing.unpaired.length - 5} more untested files`, rule: "untested-file" });
298
+ }
299
+ // 6. Test quality analysis
300
+ const quality = analyzeQuality(testFiles);
301
+ if (quality.avgAssertionsPerTest < 1) {
302
+ issues.push({ severity: "warning", message: `Low assertion density: ${quality.avgAssertionsPerTest} assertions/test (aim for 2+)`, rule: "low-assertions" });
303
+ }
304
+ if (quality.mockRatio > 0.5) {
305
+ issues.push({ severity: "warning", message: `High mock ratio: ${quality.mockRatio} mocks per assertion — tests may not reflect real behavior`, rule: "over-mocked" });
306
+ }
307
+ if (quality.snapshotRatio > 0.3) {
308
+ issues.push({ severity: "warning", message: `${Math.round(quality.snapshotRatio * 100)}% of assertions are snapshots — brittle, prefer explicit assertions`, rule: "snapshot-heavy" });
309
+ }
310
+ // 7. Execute tests + collect coverage (unless --skip-tests)
311
+ let execution = null;
312
+ let coverage = null;
313
+ if (!skipExec) {
314
+ // Run with coverage to get both results in one pass
315
+ coverage = collectCoverage(cwd, stack);
316
+ execution = executeTests(cwd, stack);
317
+ if (execution) {
318
+ if (execution.failed > 0) {
319
+ issues.push({ severity: "error", message: `${execution.failed} of ${execution.total} tests failing`, rule: "failing-tests" });
320
+ }
321
+ }
322
+ if (coverage) {
323
+ if (coverage.branches < 50) {
324
+ issues.push({ severity: "warning", message: `Branch coverage ${coverage.branches}% — below 50% threshold`, rule: "low-branch-coverage" });
325
+ }
326
+ if (coverage.statements < 60) {
327
+ issues.push({ severity: "warning", message: `Statement coverage ${coverage.statements}% — below 60% threshold`, rule: "low-statement-coverage" });
328
+ }
329
+ }
330
+ }
331
+ // 8. Compute composite score
332
+ let score = 0;
333
+ // Pyramid presence (30 points)
334
+ const layerCount = [pyramid.unit.present, pyramid.integration.present, pyramid.component.present && needsComponent, pyramid.e2e.present && needsE2E].filter(Boolean).length;
335
+ const expectedLayers = 1 + (srcFiles.length > 5 ? 1 : 0) + (needsComponent ? 1 : 0) + (needsE2E ? 1 : 0);
336
+ const pyramidScore = expectedLayers > 0 ? Math.round((layerCount / expectedLayers) * 30) : 30;
337
+ score += pyramidScore;
338
+ // Execution (20 points)
339
+ if (execution) {
340
+ const passRate = execution.total > 0 ? execution.passed / execution.total : 0;
341
+ score += Math.round(passRate * 20);
342
+ }
343
+ else if (!skipExec) {
344
+ // Couldn't run tests
345
+ }
346
+ else {
347
+ score += 10; // partial credit when skipped
348
+ }
349
+ // Coverage (20 points)
350
+ if (coverage) {
351
+ const avgCov = (coverage.statements + coverage.branches + coverage.lines + coverage.functions) / 4;
352
+ score += Math.round((avgCov / 100) * 20);
353
+ }
354
+ // Pairing (15 points)
355
+ score += Math.round((pairingPct / 100) * 15);
356
+ // Quality (15 points)
357
+ let qualityScore = 15;
358
+ if (quality.avgAssertionsPerTest < 1)
359
+ qualityScore -= 5;
360
+ if (quality.mockRatio > 0.5)
361
+ qualityScore -= 3;
362
+ if (quality.snapshotRatio > 0.3)
363
+ qualityScore -= 3;
364
+ if (quality.testsWithNoAssertions > 0)
365
+ qualityScore -= 2;
366
+ score += Math.max(0, qualityScore);
367
+ score = Math.max(0, Math.min(100, score));
368
+ return {
369
+ name: "testing",
370
+ score,
371
+ grade: gradeFromScore(score),
372
+ details: {
373
+ pyramid: {
374
+ unit: pyramid.unit.files,
375
+ integration: pyramid.integration.files,
376
+ component: pyramid.component.files,
377
+ e2e: pyramid.e2e.files,
378
+ },
379
+ layersPresent: layerCount + "/" + expectedLayers,
380
+ e2eTool: e2eTool.tool,
381
+ testFiles: testFiles.length,
382
+ srcFiles: srcFiles.length,
383
+ pairing: pairingPct + "%",
384
+ quality: {
385
+ assertionsPerTest: quality.avgAssertionsPerTest,
386
+ mockRatio: quality.mockRatio,
387
+ snapshotRatio: quality.snapshotRatio,
388
+ },
389
+ ...(execution ? { passed: execution.passed, failed: execution.failed, total: execution.total } : {}),
390
+ ...(coverage ? { coverage: { stmts: coverage.statements, branches: coverage.branches, lines: coverage.lines, fns: coverage.functions } } : {}),
391
+ },
392
+ issues,
393
+ duration: Date.now() - start,
394
+ };
395
+ }
396
+ function countUntestable(srcFiles) {
397
+ return srcFiles.filter((f) => {
398
+ const b = basename(f).replace(/\.(ts|tsx|js|jsx)$/, "");
399
+ return ["index", "main", "types", "constants", "config"].includes(b);
400
+ }).length;
401
+ }
@@ -0,0 +1,3 @@
1
+ /** Test runner — auto-detects vitest or jest. */
2
+ import type { CheckResult, StackInfo } from "../types.js";
3
+ export declare function runTests(cwd: string, stack: StackInfo): CheckResult;
@@ -0,0 +1,54 @@
1
+ /** Test runner — auto-detects vitest or jest. */
2
+ import { gradeFromScore } from "../types.js";
3
+ import { run } from "./exec.js";
4
+ export function runTests(cwd, stack) {
5
+ const start = Date.now();
6
+ if (stack.testRunner === "none") {
7
+ return {
8
+ name: "tests",
9
+ score: 0,
10
+ grade: "F",
11
+ details: { skipped: true, reason: "no test runner" },
12
+ issues: [],
13
+ duration: Date.now() - start,
14
+ };
15
+ }
16
+ const cmd = stack.testRunner === "vitest"
17
+ ? "npx vitest run --reporter=json 2>/dev/null || true"
18
+ : "npx jest --json 2>/dev/null || true";
19
+ const { stdout } = run(cmd, cwd, 120_000);
20
+ // Extract JSON from output (vitest may print other stuff before the JSON)
21
+ let data = null;
22
+ try {
23
+ // Find the JSON object in stdout
24
+ const jsonStart = stdout.indexOf("{");
25
+ if (jsonStart >= 0) {
26
+ data = JSON.parse(stdout.slice(jsonStart));
27
+ }
28
+ }
29
+ catch {
30
+ /* parse failed */
31
+ }
32
+ if (!data) {
33
+ return {
34
+ name: "tests",
35
+ score: 0,
36
+ grade: "F",
37
+ details: { error: "could not parse test output" },
38
+ issues: [],
39
+ duration: Date.now() - start,
40
+ };
41
+ }
42
+ const passed = data.numPassedTests || 0;
43
+ const failed = data.numFailedTests || 0;
44
+ const total = data.numTotalTests || 0;
45
+ const score = total === 0 ? 0 : Math.round((passed / total) * 100);
46
+ return {
47
+ name: "tests",
48
+ score,
49
+ grade: gradeFromScore(score),
50
+ details: { passed, failed, total, runner: stack.testRunner },
51
+ issues: [],
52
+ duration: Date.now() - start,
53
+ };
54
+ }
@@ -0,0 +1,3 @@
1
+ /** Type safety check — count unsafe patterns: `as any`, explicit `any`, non-null assertions. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runTypeSafety(cwd: string): CheckResult;
@@ -0,0 +1,74 @@
1
+ /** Type safety check — count unsafe patterns: `as any`, explicit `any`, non-null assertions. */
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { extname, join } from "node:path";
4
+ import { gradeFromScore } from "../types.js";
5
+ const PATTERNS = [
6
+ { name: "as any", pattern: /\bas any\b/g, severity: "warning", weight: 2 },
7
+ { name: ": any", pattern: /:\s*any\b/g, severity: "warning", weight: 1 },
8
+ { name: "non-null assertion (!.)", pattern: /\w+!\./g, severity: "info", weight: 0.5 },
9
+ { name: "@ts-ignore", pattern: /@ts-ignore/g, severity: "error", weight: 5 },
10
+ { name: "@ts-expect-error", pattern: /@ts-expect-error/g, severity: "warning", weight: 2 },
11
+ { name: "@ts-nocheck", pattern: /@ts-nocheck/g, severity: "error", weight: 10 },
12
+ ];
13
+ export function runTypeSafety(cwd) {
14
+ const start = Date.now();
15
+ const issues = [];
16
+ const counts = {};
17
+ let totalPenalty = 0;
18
+ const files = [];
19
+ const dirs = ["src", "web/src"];
20
+ for (const dir of dirs) {
21
+ try {
22
+ collectFiles(join(cwd, dir), files);
23
+ }
24
+ catch { /* dir doesn't exist */ }
25
+ }
26
+ if (files.length === 0) {
27
+ return { name: "type-safety", score: 100, grade: "A", details: { skipped: true, reason: "no source files" }, issues: [], duration: Date.now() - start };
28
+ }
29
+ for (const file of files) {
30
+ const content = readFileSync(file, "utf-8");
31
+ const relPath = file.replace(cwd + "/", "");
32
+ const lines = content.split("\n");
33
+ for (let i = 0; i < lines.length; i++) {
34
+ const line = lines[i];
35
+ if (line.trim().startsWith("//"))
36
+ continue;
37
+ for (const p of PATTERNS) {
38
+ const matches = line.match(p.pattern);
39
+ if (matches) {
40
+ counts[p.name] = (counts[p.name] || 0) + matches.length;
41
+ totalPenalty += p.weight * matches.length;
42
+ for (const _m of matches) {
43
+ issues.push({ severity: p.severity, message: p.name, file: relPath, line: i + 1, rule: "unsafe-type" });
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ const score = Math.max(0, Math.min(100, Math.round(100 - totalPenalty)));
50
+ return {
51
+ name: "type-safety",
52
+ score,
53
+ grade: gradeFromScore(score),
54
+ details: { ...counts, filesScanned: files.length, totalUnsafe: issues.length },
55
+ issues,
56
+ duration: Date.now() - start,
57
+ };
58
+ }
59
+ function collectFiles(dir, out) {
60
+ for (const entry of readdirSync(dir)) {
61
+ if (entry === "node_modules" || entry === "dist")
62
+ continue;
63
+ const full = join(dir, entry);
64
+ if (statSync(full).isDirectory()) {
65
+ collectFiles(full, out);
66
+ }
67
+ else {
68
+ const ext = extname(entry);
69
+ if ((ext === ".ts" || ext === ".tsx") && !entry.includes(".test.") && !entry.includes(".spec.")) {
70
+ out.push(full);
71
+ }
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,3 @@
1
+ /** TypeScript type checking runner. */
2
+ import type { CheckResult } from "../types.js";
3
+ export declare function runTypeCheck(cwd: string): CheckResult;
@@ -0,0 +1,44 @@
1
+ /** TypeScript type checking runner. */
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { gradeFromScore } from "../types.js";
5
+ import { run } from "./exec.js";
6
+ export function runTypeCheck(cwd) {
7
+ const start = Date.now();
8
+ const issues = [];
9
+ if (!existsSync(join(cwd, "tsconfig.json")) &&
10
+ !existsSync(join(cwd, "tsconfig.app.json"))) {
11
+ return {
12
+ name: "types",
13
+ score: 0,
14
+ grade: "F",
15
+ details: { skipped: true, reason: "no tsconfig.json" },
16
+ issues: [],
17
+ duration: Date.now() - start,
18
+ };
19
+ }
20
+ const { stdout } = run("npx tsc --noEmit 2>&1 || true", cwd, 30_000);
21
+ const lines = stdout.split("\n");
22
+ for (const line of lines) {
23
+ const match = line.match(/^(.+)\((\d+),\d+\): error (TS\d+): (.+)/);
24
+ if (match) {
25
+ issues.push({
26
+ severity: "error",
27
+ file: match[1],
28
+ line: parseInt(match[2]),
29
+ rule: match[3],
30
+ message: match[4],
31
+ });
32
+ }
33
+ }
34
+ const errorCount = issues.length;
35
+ const score = errorCount === 0 ? 100 : Math.max(0, 100 - errorCount * 5);
36
+ return {
37
+ name: "types",
38
+ score,
39
+ grade: gradeFromScore(score),
40
+ details: { errors: errorCount, ok: errorCount === 0 },
41
+ issues,
42
+ duration: Date.now() - start,
43
+ };
44
+ }
@@ -0,0 +1,6 @@
1
+ /** Compute weighted composite score from individual check results.
2
+ * Weights are sourced from check-meta.ts — single source of truth. */
3
+ import type { CheckResult } from "./types.js";
4
+ export declare function computeScore(checks: CheckResult[]): number;
5
+ /** Total weight across all checks (should be 100). */
6
+ export declare function totalWeight(): number;
package/dist/score.js ADDED
@@ -0,0 +1,19 @@
1
+ /** Compute weighted composite score from individual check results.
2
+ * Weights are sourced from check-meta.ts — single source of truth. */
3
+ import { CHECK_META, getCheckMeta } from "./check-meta.js";
4
+ export function computeScore(checks) {
5
+ let totalWeight = 0;
6
+ let weightedSum = 0;
7
+ for (const check of checks) {
8
+ if (check.details.skipped)
9
+ continue;
10
+ const meta = getCheckMeta(check.name);
11
+ totalWeight += meta.weight;
12
+ weightedSum += check.score * meta.weight;
13
+ }
14
+ return totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0;
15
+ }
16
+ /** Total weight across all checks (should be 100). */
17
+ export function totalWeight() {
18
+ return Object.values(CHECK_META).reduce((s, m) => s + m.weight, 0);
19
+ }
@@ -0,0 +1,19 @@
1
+ /** Trend comparison — compares current report to previous run. */
2
+ import type { VibeReport } from "./types.js";
3
+ export interface TrendDelta {
4
+ scoreDelta: number;
5
+ checkDeltas: {
6
+ name: string;
7
+ prev: number;
8
+ curr: number;
9
+ delta: number;
10
+ }[];
11
+ newIssues: number;
12
+ fixedIssues: number;
13
+ prevTimestamp: string;
14
+ }
15
+ export declare function computeTrend(report: VibeReport, outputDir: string): TrendDelta | null;
16
+ /** Render trend delta as terminal-friendly string. */
17
+ export declare function formatTrend(trend: TrendDelta): string;
18
+ /** Render trend HTML for the report. */
19
+ export declare function trendHTML(trend: TrendDelta): string;