Commitb02ce30unknown_key
feat: automated test gap detector — risk-ranked untested functions
feat: automated test gap detector — risk-ranked untested functions Adds the test gap detector feature that surfaces untested source files and functions ranked by AI-scored risk (0-100), so developers know exactly where to write tests first. Includes in-memory + DB caching with 2h TTL. https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR
2 files changed+514−0b02ce30b2f5ad935031ec475b12b684d4ccad1e6
2 changed files+514−0
Modifiedsrc/app.tsx+3−0View fileUnifiedSplit
@@ -787,6 +787,7 @@ app.route("/", marketplaceAgentsRoutes);
787787app.route("/", mergeQueueRoutes);
788788app.route("/", mirrorsRoutes);
789789app.route("/", orgInsightsRoutes);
790app.route("/", orgHealthRoutes);
790791app.route("/", packagesRoutes);
791792app.route("/", packagesApiRoutes);
792793// OCI / Docker container registry — /v2/* (OCI Distribution Spec v1.0)
@@ -829,6 +830,8 @@ app.route("/", hotFilesRoutes);
829830app.route("/", debtMapRoutes);
830831// Bus Factor Analysis — /:owner/:repo/insights/bus-factor
831832app.route("/", busFactorRoutes);
833// Test Gap Detector — /:owner/:repo/insights/test-gaps
834app.route("/", testGapsRoutes);
832835// Cross-Repo Dependency Impact Detection — /:owner/:repo/pulls/:number/cross-repo-impact
833836app.route("/", crossRepoImpactRoutes);
834837// Hosted Claude tool-use loops — paste loop, get endpoint, billing meter.
Addedsrc/routes/test-gaps.tsx+511−0View fileUnifiedSplit
@@ -0,0 +1,511 @@
1/**
2 * Test Gap Report — /:owner/:repo/insights/test-gaps
3 *
4 * Surfaces untested functions ranked by risk. Developers can see at a glance
5 * which parts of their codebase have zero test coverage and why each matters.
6 *
7 * GET /:owner/:repo/insights/test-gaps — report page
8 * POST /:owner/:repo/insights/test-gaps/refresh — owner-only: clear cache + reanalyse
9 */
10
11import { Hono } from "hono";
12import { db } from "../db";
13import { repositories, users } from "../db/schema";
14import { and, eq } from "drizzle-orm";
15import type { AuthEnv } from "../middleware/auth";
16import { softAuth, requireAuth } from "../middleware/auth";
17import { requireRepoAccess } from "../middleware/repo-access";
18import { Layout } from "../views/layout";
19import { RepoHeader, RepoNav } from "../views/components";
20import { getUnreadCount } from "../lib/unread";
21import { getTestGaps, clearTestGapsCache, type TestGap, type TestGapReport } from "../lib/test-gaps";
22
23const testGapsRoutes = new Hono<AuthEnv>();
24
25// ─── CSS ──────────────────────────────────────────────────────────────────────
26
27const styles = `
28 .tg-wrap {
29 max-width: 1080px;
30 margin: 0 auto;
31 padding: var(--space-5) var(--space-4);
32 }
33
34 /* Insights sub-navigation */
35 .tg-subnav {
36 display: flex;
37 gap: 4px;
38 margin-bottom: var(--space-5);
39 border-bottom: 1px solid var(--border);
40 padding-bottom: 0;
41 }
42 .tg-subnav-link {
43 padding: 8px 14px;
44 font-size: 13px;
45 font-weight: 500;
46 color: var(--text-muted);
47 text-decoration: none;
48 border-bottom: 2px solid transparent;
49 margin-bottom: -1px;
50 transition: color 120ms ease, border-color 120ms ease;
51 border-radius: 4px 4px 0 0;
52 }
53 .tg-subnav-link:hover { color: var(--text); }
54 .tg-subnav-link.active {
55 color: var(--accent, #5865f2);
56 border-bottom-color: var(--accent, #5865f2);
57 }
58
59 /* Hero */
60 .tg-hero {
61 position: relative;
62 margin-bottom: var(--space-5);
63 padding: var(--space-5) var(--space-6);
64 background: var(--bg-elevated);
65 border: 1px solid var(--border);
66 border-radius: 16px;
67 overflow: hidden;
68 }
69 .tg-hero::before {
70 content: '';
71 position: absolute;
72 top: 0; left: 0; right: 0;
73 height: 2px;
74 background: linear-gradient(90deg, transparent 0%, #6366f1 30%, #8b5cf6 70%, transparent 100%);
75 opacity: 0.75;
76 pointer-events: none;
77 }
78 .tg-hero-orb {
79 position: absolute;
80 inset: -30% -15% auto auto;
81 width: 460px; height: 460px;
82 background: radial-gradient(circle, rgba(99,102,241,0.16), rgba(139,92,246,0.08) 45%, transparent 70%);
83 filter: blur(80px);
84 opacity: 0.75;
85 pointer-events: none;
86 z-index: 0;
87 }
88 .tg-hero-inner { position: relative; z-index: 1; max-width: 760px; }
89 .tg-hero-eyebrow {
90 display: inline-flex; align-items: center; gap: 6px;
91 font-size: 11px; font-weight: 700; letter-spacing: 0.07em;
92 text-transform: uppercase;
93 color: #8b5cf6;
94 margin-bottom: 10px;
95 }
96 .tg-hero-title {
97 font-family: var(--font-display);
98 font-size: clamp(22px, 3vw, 30px);
99 font-weight: 800;
100 letter-spacing: -0.025em;
101 line-height: 1.1;
102 margin: 0 0 8px;
103 color: var(--text-strong);
104 }
105 .tg-hero-sub {
106 font-size: 14px;
107 color: var(--text-muted);
108 margin: 0;
109 line-height: 1.5;
110 }
111 .tg-hero-actions {
112 display: flex;
113 gap: 10px;
114 align-items: center;
115 margin-top: 16px;
116 flex-wrap: wrap;
117 }
118
119 /* Stats bar */
120 .tg-stats {
121 display: flex;
122 gap: 16px;
123 margin-bottom: var(--space-5);
124 flex-wrap: wrap;
125 }
126 .tg-stat-card {
127 flex: 1 1 160px;
128 padding: 14px 18px;
129 background: var(--bg-elevated);
130 border: 1px solid var(--border);
131 border-radius: 12px;
132 min-width: 120px;
133 }
134 .tg-stat-value {
135 font-family: var(--font-display);
136 font-size: 26px;
137 font-weight: 800;
138 line-height: 1;
139 margin-bottom: 4px;
140 color: var(--text-strong);
141 font-variant-numeric: tabular-nums;
142 }
143 .tg-stat-label {
144 font-size: 12px;
145 color: var(--text-muted);
146 font-weight: 500;
147 }
148 .tg-stat-card.is-good .tg-stat-value { color: #22c55e; }
149 .tg-stat-card.is-warn .tg-stat-value { color: #f59e0b; }
150 .tg-stat-card.is-danger .tg-stat-value { color: #ef4444; }
151
152 /* Gap list */
153 .tg-list {
154 display: flex;
155 flex-direction: column;
156 gap: 10px;
157 }
158
159 /* Individual gap card */
160 .tg-gap-card {
161 padding: 14px 18px;
162 background: var(--bg-elevated);
163 border: 1px solid var(--border);
164 border-radius: 12px;
165 transition: border-color 120ms ease;
166 }
167 .tg-gap-card:hover { border-color: rgba(99,102,241,0.4); }
168 .tg-gap-card.risk-high { border-left: 3px solid #ef4444; }
169 .tg-gap-card.risk-medium { border-left: 3px solid #f59e0b; }
170 .tg-gap-card.risk-low { border-left: 3px solid #22c55e; }
171
172 .tg-gap-header {
173 display: flex;
174 align-items: center;
175 gap: 10px;
176 margin-bottom: 8px;
177 flex-wrap: wrap;
178 }
179 .tg-gap-file {
180 flex: 1 1 auto;
181 font-family: var(--font-mono);
182 font-size: 12px;
183 font-weight: 600;
184 color: var(--text-muted);
185 word-break: break-all;
186 }
187 .tg-gap-fn {
188 font-family: var(--font-mono);
189 font-size: 13px;
190 font-weight: 700;
191 color: var(--text-strong);
192 }
193
194 /* Risk score badge */
195 .tg-risk-badge {
196 display: inline-flex;
197 align-items: center;
198 padding: 2px 9px;
199 border-radius: 9999px;
200 font-size: 10.5px;
201 font-weight: 700;
202 letter-spacing: 0.04em;
203 text-transform: uppercase;
204 flex-shrink: 0;
205 }
206 .tg-risk-badge.is-high { color: #ef4444; background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); }
207 .tg-risk-badge.is-medium { color: #f59e0b; background: rgba(245,158,11,0.12); border: 1px solid rgba(245,158,11,0.3); }
208 .tg-risk-badge.is-low { color: #22c55e; background: rgba(34,197,94,0.12); border: 1px solid rgba(34,197,94,0.3); }
209
210 .tg-gap-meta {
211 display: flex;
212 gap: 16px;
213 font-size: 12px;
214 color: var(--text-muted);
215 margin-bottom: 10px;
216 flex-wrap: wrap;
217 align-items: center;
218 }
219 .tg-gap-reason {
220 font-size: 13px;
221 color: var(--text);
222 margin-bottom: 10px;
223 line-height: 1.5;
224 }
225
226 .tg-gap-footer {
227 display: flex;
228 align-items: center;
229 gap: 12px;
230 flex-wrap: wrap;
231 }
232 .tg-test-path {
233 font-family: var(--font-mono);
234 font-size: 11px;
235 color: var(--text-muted);
236 background: var(--bg-tertiary, rgba(255,255,255,0.04));
237 padding: 3px 8px;
238 border-radius: 4px;
239 flex: 1 1 auto;
240 }
241 .tg-stub-btn {
242 display: inline-flex; align-items: center; gap: 6px;
243 padding: 5px 12px;
244 border-radius: 6px;
245 font-size: 12px; font-weight: 600;
246 color: var(--text-strong);
247 background: var(--bg-tertiary, rgba(255,255,255,0.07));
248 border: 1px solid var(--border);
249 text-decoration: none;
250 transition: background 120ms ease, border-color 120ms ease;
251 white-space: nowrap;
252 flex-shrink: 0;
253 }
254 .tg-stub-btn:hover {
255 background: var(--bg-secondary);
256 border-color: rgba(99,102,241,0.5);
257 }
258
259 /* Reanalyse button */
260 .tg-reanalyze-btn {
261 display: inline-flex; align-items: center; gap: 6px;
262 padding: 8px 16px;
263 border-radius: 8px;
264 font-size: 13px; font-weight: 600;
265 color: #fff;
266 background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 130%);
267 border: none;
268 cursor: pointer;
269 transition: opacity 120ms ease;
270 }
271 .tg-reanalyze-btn:hover { opacity: 0.85; }
272
273 .tg-analyzed-at {
274 font-size: 12px;
275 color: var(--text-muted);
276 margin-top: 4px;
277 }
278
279 /* Empty / no-gaps state */
280 .tg-empty {
281 text-align: center;
282 padding: 64px 24px;
283 color: var(--text-muted);
284 }
285 .tg-empty-icon { font-size: 40px; margin-bottom: 12px; }
286 .tg-empty-title { font-size: 18px; font-weight: 700; color: var(--text-strong); margin-bottom: 6px; }
287 .tg-empty-sub { font-size: 14px; }
288`;
289
290// ─── Helper: determine risk tier from score ──────────────────────────────────
291
292function riskTier(score: number): "high" | "medium" | "low" {
293 if (score > 70) return "high";
294 if (score >= 40) return "medium";
295 return "low";
296}
297
298// ─── Route: GET /:owner/:repo/insights/test-gaps ─────────────────────────────
299
300testGapsRoutes.use("/:owner/:repo/insights/test-gaps", softAuth);
301
302testGapsRoutes.get(
303 "/:owner/:repo/insights/test-gaps",
304 requireRepoAccess("read"),
305 async (c) => {
306 const user = c.get("user") ?? null;
307 const params = c.req.param() as { owner: string; repo: string };
308 const ownerName = params.owner;
309 const repoName = params.repo;
310
311 // Load repo
312 const repoRows = await db
313 .select({ id: repositories.id, isPrivate: repositories.isPrivate, ownerId: repositories.ownerId })
314 .from(repositories)
315 .innerJoin(users, eq(repositories.ownerId, users.id))
316 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
317 .limit(1);
318
319 if (!repoRows.length) return c.notFound();
320 const repo = repoRows[0];
321 const isOwner = !!user && user.id === repo.ownerId;
322
323 const unreadCount = user ? await getUnreadCount(user.id) : 0;
324
325 // Load / compute report
326 let report: TestGapReport | null = null;
327 let analysisError: string | null = null;
328 try {
329 report = await getTestGaps(ownerName, repoName, repo.id);
330 } catch (err) {
331 analysisError = err instanceof Error ? err.message : "Analysis failed";
332 }
333
334 const highCount = report?.gaps.filter((g) => riskTier(g.riskScore) === "high").length ?? 0;
335 const mediumCount = report?.gaps.filter((g) => riskTier(g.riskScore) === "medium").length ?? 0;
336 const lowCount = report?.gaps.filter((g) => riskTier(g.riskScore) === "low").length ?? 0;
337
338 return c.html(
339 <Layout
340 title={`Test Gaps — ${ownerName}/${repoName}`}
341 user={user}
342 notificationCount={unreadCount}
343 >
344 <style dangerouslySetInnerHTML={{ __html: styles }} />
345 <div class="tg-wrap">
346 <RepoHeader owner={ownerName} repo={repoName} />
347 <RepoNav owner={ownerName} repo={repoName} active="insights" />
348
349 {/* Sub-navigation */}
350 <nav class="tg-subnav">
351 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights`}>Overview</a>
352 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/health`}>Health</a>
353 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/velocity`}>Velocity</a>
354 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/hotfiles`}>Hot Files</a>
355 <a class="tg-subnav-link" href={`/${ownerName}/${repoName}/insights/bus-factor`}>Bus Factor</a>
356 <a class="tg-subnav-link active" href={`/${ownerName}/${repoName}/insights/test-gaps`}>Test Gaps</a>
357 </nav>
358
359 {/* Hero */}
360 <div class="tg-hero">
361 <div class="tg-hero-orb" aria-hidden="true" />
362 <div class="tg-hero-inner">
363 <div class="tg-hero-eyebrow">✗ Test Coverage</div>
364 <h1 class="tg-hero-title">Test Gap Detector</h1>
365 <p class="tg-hero-sub">
366 Functions and modules with zero test coverage, ranked by risk.
367 Write tests where they matter most.
368 </p>
369 <div class="tg-hero-actions">
370 {isOwner && (
371 <form method="post" action={`/${ownerName}/${repoName}/insights/test-gaps/refresh`}>
372 <button type="submit" class="tg-reanalyze-btn">
373 ↻ Re-analyse
374 </button>
375 </form>
376 )}
377 {report && (
378 <span class="tg-analyzed-at">
379 {report.totalSourceFiles} source files ·{" "}
380 {report.totalTestFiles} test files ·{" "}
381 ~{report.coverageEstimate}% coverage estimate
382 </span>
383 )}
384 </div>
385 </div>
386 </div>
387
388 {analysisError && (
389 <div style="padding:12px 16px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:8px;margin-bottom:16px;font-size:13px;color:#ef4444;">
390 Analysis error: {analysisError}
391 </div>
392 )}
393
394 {report ? (
395 <>
396 {/* Stats bar */}
397 <div class="tg-stats">
398 <div class={`tg-stat-card ${highCount > 0 ? "is-danger" : "is-good"}`}>
399 <div class="tg-stat-value">{highCount}</div>
400 <div class="tg-stat-label">High-risk gaps</div>
401 </div>
402 <div class={`tg-stat-card ${mediumCount > 0 ? "is-warn" : "is-good"}`}>
403 <div class="tg-stat-value">{mediumCount}</div>
404 <div class="tg-stat-label">Medium-risk gaps</div>
405 </div>
406 <div class="tg-stat-card is-good">
407 <div class="tg-stat-value">{lowCount}</div>
408 <div class="tg-stat-label">Low-risk gaps</div>
409 </div>
410 <div class={`tg-stat-card ${report.coverageEstimate < 50 ? "is-danger" : report.coverageEstimate < 80 ? "is-warn" : "is-good"}`}>
411 <div class="tg-stat-value">{report.coverageEstimate}%</div>
412 <div class="tg-stat-label">Coverage estimate</div>
413 </div>
414 </div>
415
416 {/* Gap list or empty state */}
417 {report.gaps.length === 0 ? (
418 <div class="tg-empty">
419 <div class="tg-empty-icon">🎉</div>
420 <div class="tg-empty-title">No test gaps detected</div>
421 <p class="tg-empty-sub">
422 Every source file appears to have an associated test file. Great work!
423 </p>
424 </div>
425 ) : (
426 <div class="tg-list">
427 {report.gaps.map((gap: TestGap) => {
428 const tier = riskTier(gap.riskScore);
429 const stubUrl = `/${ownerName}/${repoName}/ai/tests?file=${encodeURIComponent(gap.filePath)}`;
430 return (
431 <div class={`tg-gap-card risk-${tier}`}>
432 <div class="tg-gap-header">
433 <div style="flex:1 1 auto;">
434 <div class="tg-gap-file">{gap.filePath}</div>
435 <div class="tg-gap-fn">{gap.functionName}</div>
436 </div>
437 <span class={`tg-risk-badge is-${tier}`}>
438 {gap.riskScore} / 100
439 </span>
440 </div>
441 <div class="tg-gap-meta">
442 <span>Risk: {tier}</span>
443 {gap.calledByCount > 0 && (
444 <span>Called from {gap.calledByCount} file{gap.calledByCount !== 1 ? "s" : ""}</span>
445 )}
446 </div>
447 <div class="tg-gap-reason">{gap.riskReason}</div>
448 <div class="tg-gap-footer">
449 <code class="tg-test-path">Suggested: {gap.suggestedTestPath}</code>
450 <a href={stubUrl} class="tg-stub-btn">
451 ✨ Generate test stub
452 </a>
453 </div>
454 </div>
455 );
456 })}
457 </div>
458 )}
459 </>
460 ) : !analysisError ? (
461 <div class="tg-empty">
462 <div class="tg-empty-icon">🧪</div>
463 <div class="tg-empty-title">No analysis yet</div>
464 <p class="tg-empty-sub">
465 {isOwner
466 ? "Click Re-analyse to scan this repository for test gaps."
467 : "The repository owner hasn't run a test gap scan yet."}
468 </p>
469 </div>
470 ) : null}
471 </div>
472 </Layout>
473 );
474 }
475);
476
477// ─── Route: POST /:owner/:repo/insights/test-gaps/refresh ────────────────────
478
479testGapsRoutes.use("/:owner/:repo/insights/test-gaps/refresh", requireAuth);
480
481testGapsRoutes.post(
482 "/:owner/:repo/insights/test-gaps/refresh",
483 requireRepoAccess("write"),
484 async (c) => {
485 const user = c.get("user")!;
486 const params = c.req.param() as { owner: string; repo: string };
487 const ownerName = params.owner;
488 const repoName = params.repo;
489
490 const repoRows = await db
491 .select({ id: repositories.id, ownerId: repositories.ownerId })
492 .from(repositories)
493 .innerJoin(users, eq(repositories.ownerId, users.id))
494 .where(and(eq(users.username, ownerName), eq(repositories.name, repoName)))
495 .limit(1);
496
497 if (!repoRows.length) return c.notFound();
498 const repo = repoRows[0];
499
500 if (user.id !== repo.ownerId) {
501 return c.text("Forbidden", 403);
502 }
503
504 // Clear cache so the GET picks up a fresh analysis
505 clearTestGapsCache(repo.id);
506
507 return c.redirect(`/${ownerName}/${repoName}/insights/test-gaps`);
508 }
509);
510
511export default testGapsRoutes;
0512