Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

intelligence.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

intelligence.tsBlame897 lines · 2 contributors
2c34075Claude1/**
2 * Repository Intelligence Engine
3 *
4 * The brain of gluecron. Runs on every push and computes:
5 * - Health score (0-100)
6 * - Security signals
7 * - Complexity trends
8 * - Dependency freshness
9 * - Test coverage estimate
10 * - Documentation coverage
11 * - Hot file detection (churn)
12 *
13 * This is what makes gluecron 30 years ahead of GitHub.
14 * GitHub shows you code. Gluecron UNDERSTANDS your code.
15 */
16
17import { getRepoPath, getDefaultBranch } from "../git/repository";
18
19export interface RepoHealthReport {
20 score: number; // 0-100
21 grade: "A+" | "A" | "B" | "C" | "D" | "F";
22 breakdown: {
23 security: { score: number; issues: SecurityIssue[] };
24 testing: { score: number; hasTests: boolean; testFileCount: number; estimatedCoverage: string };
25 complexity: { score: number; avgFileSize: number; largestFiles: FileMetric[]; totalFiles: number };
26 dependencies: { score: number; total: number; outdatedEstimate: number; lockfileExists: boolean };
27 documentation: { score: number; hasReadme: boolean; hasLicense: boolean; hasContributing: boolean; hasChangelog: boolean; docFileCount: number };
28 activity: { score: number; recentCommits: number; uniqueContributors: number; lastPushDaysAgo: number };
29 };
30 insights: string[];
31 generatedAt: string;
32}
33
34export interface SecurityIssue {
35 severity: "critical" | "high" | "medium" | "low" | "info";
36 file: string;
37 line?: number;
38 message: string;
39 rule: string;
40}
41
42interface FileMetric {
43 path: string;
44 lines: number;
45}
46
47interface PushAnalysis {
48 filesChanged: number;
49 linesAdded: number;
50 linesRemoved: number;
51 riskScore: number; // 0-100
52 riskFactors: string[];
53 breakingChangeSignals: string[];
54 securityIssues: SecurityIssue[];
55 hotFiles: string[];
56 summary: string;
57}
58
59async function exec(
60 cmd: string[],
61 cwd: string
62): Promise<{ stdout: string; exitCode: number }> {
63 const proc = Bun.spawn(cmd, {
64 cwd,
65 stdout: "pipe",
66 stderr: "pipe",
67 });
68 const stdout = await new Response(proc.stdout).text();
69 const exitCode = await proc.exited;
70 return { stdout, exitCode };
71}
72
73// ─── HEALTH SCORE ────────────────────────────────────────────
74
75export async function computeHealthScore(
76 owner: string,
77 repo: string
78): Promise<RepoHealthReport> {
79 const repoDir = getRepoPath(owner, repo);
80 const ref = (await getDefaultBranch(owner, repo)) || "main";
81
82 const [
83 security,
84 testing,
85 complexity,
86 dependencies,
87 documentation,
88 activity,
89 ] = await Promise.all([
90 analyzeSecurityScore(repoDir, ref),
91 analyzeTestingScore(repoDir, ref),
92 analyzeComplexityScore(repoDir, ref),
93 analyzeDependencyScore(repoDir, ref),
94 analyzeDocumentationScore(repoDir, ref),
95 analyzeActivityScore(repoDir, ref),
96 ]);
97
98 const weights = {
99 security: 0.25,
100 testing: 0.20,
101 complexity: 0.15,
102 dependencies: 0.15,
103 documentation: 0.10,
104 activity: 0.15,
105 };
106
107 const score = Math.round(
108 security.score * weights.security +
109 testing.score * weights.testing +
110 complexity.score * weights.complexity +
111 dependencies.score * weights.dependencies +
112 documentation.score * weights.documentation +
113 activity.score * weights.activity
114 );
115
116 const grade = score >= 95 ? "A+" : score >= 85 ? "A" : score >= 70 ? "B" : score >= 55 ? "C" : score >= 40 ? "D" : "F";
117
118 const insights = generateInsights({ security, testing, complexity, dependencies, documentation, activity });
119
120 return {
121 score,
122 grade,
123 breakdown: { security, testing, complexity, dependencies, documentation, activity },
124 insights,
125 generatedAt: new Date().toISOString(),
126 };
127}
128
129// ─── SECURITY ANALYSIS ───────────────────────────────────────
130
131const SECURITY_PATTERNS: Array<{
132 pattern: RegExp;
133 severity: SecurityIssue["severity"];
134 message: string;
135 rule: string;
136}> = [
137 // Hardcoded secrets
138 { pattern: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"']{4,}/i, severity: "critical", message: "Possible hardcoded password", rule: "no-hardcoded-secrets" },
139 { pattern: /(?:api[_-]?key|apikey|secret[_-]?key)\s*[:=]\s*["'][^"']{8,}/i, severity: "critical", message: "Possible hardcoded API key", rule: "no-hardcoded-secrets" },
140 { pattern: /(?:AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}/, severity: "critical", message: "Possible AWS access key", rule: "no-aws-keys" },
141 { pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, severity: "critical", message: "Private key in source code", rule: "no-private-keys" },
142 // Injection vulnerabilities
143 { pattern: /eval\s*\(/, severity: "high", message: "Use of eval() — potential code injection", rule: "no-eval" },
144 { pattern: /innerHTML\s*=/, severity: "medium", message: "Direct innerHTML assignment — potential XSS", rule: "no-inner-html" },
145 { pattern: /document\.write\s*\(/, severity: "medium", message: "document.write usage — potential XSS", rule: "no-document-write" },
146 { pattern: /exec\s*\(\s*[`"'].*\$\{/, severity: "high", message: "Shell command with template literal — potential injection", rule: "no-shell-injection" },
147 // SQL injection
148 { pattern: /query\s*\(\s*[`"'].*\$\{/, severity: "high", message: "SQL query with interpolation — potential SQL injection", rule: "no-sql-injection" },
149 { pattern: /\.raw\s*\(\s*[`"'].*\$\{/, severity: "medium", message: "Raw query with interpolation", rule: "no-raw-sql-injection" },
150 // Crypto issues
151 { pattern: /createHash\s*\(\s*["']md5["']\)/, severity: "medium", message: "MD5 hash used — cryptographically weak", rule: "no-weak-crypto" },
152 { pattern: /createHash\s*\(\s*["']sha1["']\)/, severity: "low", message: "SHA1 hash — consider SHA-256+", rule: "weak-hash" },
153 // Misc
154 { pattern: /TODO.*(?:security|hack|fixme|unsafe|vulnerable)/i, severity: "info", message: "Security-related TODO found", rule: "security-todo" },
155 { pattern: /(?:disable|ignore).*(?:eslint|tslint|security)/i, severity: "low", message: "Security linter rule disabled", rule: "no-security-disable" },
156];
157
2415bdfccantynz-alt158// Committed env *templates* are the documented, expected way to ship an
159// example config — flagging them as leaked secrets is a false positive.
160const ENV_FILE_ALLOWLIST = /\.env\.(example|sample|template|dist|test)$/i;
161
162function isTestOrFixtureFile(filePath: string): boolean {
163 return (
164 /(^|\/)(tests?|__tests__|e2e|fixtures?|mocks?|specs?)(\/|$)/i.test(filePath) ||
165 /\.(test|spec)\.[jt]sx?$/i.test(filePath)
166 );
167}
168
169// Rejects matches where the "secret" is actually a variable reference
170// (`${VAR}`, template literals) or a plain identifier/slug (e.g. a
171// SecretStorage lookup KEY like "gluecron.pat") rather than a literal value.
172function looksLikeRealSecret(value: string): boolean {
173 if (!value) return false;
174 if (/[$`{]/.test(value)) return false;
175 if (value.length < 8) return false;
176 if (/^[a-z][a-z0-9._-]*$/.test(value)) return false;
177 return true;
178}
179
2c34075Claude180async function analyzeSecurityScore(
181 repoDir: string,
182 ref: string
183): Promise<{ score: number; issues: SecurityIssue[] }> {
184 const issues: SecurityIssue[] = [];
185
186 // Get all text files
187 const { stdout: files } = await exec(
188 ["git", "ls-tree", "-r", "--name-only", ref],
189 repoDir
190 );
191
192 const filePaths = files.trim().split("\n").filter(Boolean);
193
2415bdfccantynz-alt194 // Check for .env files committed (excluding committed *templates*)
2c34075Claude195 for (const f of filePaths) {
2415bdfccantynz-alt196 const base = f.split("/").pop() || "";
197 if (/^\.env(?:\.|$)/.test(base) && !ENV_FILE_ALLOWLIST.test(base)) {
2c34075Claude198 issues.push({
199 severity: "critical",
200 file: f,
201 message: "Environment file committed to repository",
202 rule: "no-env-files",
203 });
204 }
205 }
206
207 // Scan source files for patterns (sample up to 100 files)
208 const sourceFiles = filePaths
209 .filter((f) => /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|sh|yaml|yml|json)$/.test(f))
210 .slice(0, 100);
211
212 for (const filePath of sourceFiles) {
213 const { stdout: content, exitCode } = await exec(
214 ["git", "show", `${ref}:${filePath}`],
215 repoDir
216 );
217 if (exitCode !== 0) continue;
218
2415bdfccantynz-alt219 const isFixture = isTestOrFixtureFile(filePath);
2c34075Claude220 const lines = content.split("\n");
221 for (let i = 0; i < lines.length; i++) {
2415bdfccantynz-alt222 const line = lines[i];
223 if (line.includes("secrets-ok")) continue;
224 // Test/fixture data (e.g. a hardcoded TEST_PASSWORD for e2e login)
225 // isn't a real leak at any severity — this is a health heuristic,
226 // not the push-time secret gate.
227 if (isFixture) continue;
228
2c34075Claude229 for (const rule of SECURITY_PATTERNS) {
2415bdfccantynz-alt230 const match = line.match(rule.pattern);
231 if (!match) continue;
232
233 if (rule.rule === "no-hardcoded-secrets") {
234 const valueMatch = line.match(/["']([^"']+)["']/);
235 if (!valueMatch || !looksLikeRealSecret(valueMatch[1])) continue;
2c34075Claude236 }
2415bdfccantynz-alt237
238 issues.push({
239 severity: rule.severity,
240 file: filePath,
241 line: i + 1,
242 message: rule.message,
243 rule: rule.rule,
244 });
2c34075Claude245 }
246 }
247 }
248
249 const criticals = issues.filter((i) => i.severity === "critical").length;
250 const highs = issues.filter((i) => i.severity === "high").length;
251 const mediums = issues.filter((i) => i.severity === "medium").length;
252
253 let score = 100;
254 score -= criticals * 25;
255 score -= highs * 10;
256 score -= mediums * 3;
257
258 return { score: Math.max(0, Math.min(100, score)), issues };
259}
260
261// ─── TESTING ─────────────────────────────────────────────────
262
263async function analyzeTestingScore(
264 repoDir: string,
265 ref: string
266): Promise<{
267 score: number;
268 hasTests: boolean;
269 testFileCount: number;
270 estimatedCoverage: string;
271}> {
272 const { stdout: files } = await exec(
273 ["git", "ls-tree", "-r", "--name-only", ref],
274 repoDir
275 );
276 const allFiles = files.trim().split("\n").filter(Boolean);
277 const sourceFiles = allFiles.filter((f) =>
278 /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php)$/.test(f) && !f.includes("test") && !f.includes("spec")
279 );
280 const testFiles = allFiles.filter((f) =>
281 /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(f) ||
282 f.includes("__tests__/") ||
283 f.includes("test_") ||
284 f.endsWith("_test.go") ||
285 f.endsWith("_test.py")
286 );
287
288 const hasTests = testFiles.length > 0;
289 const ratio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
290
291 let estimatedCoverage: string;
292 let score: number;
293
294 if (!hasTests) {
295 estimatedCoverage = "None";
296 score = 0;
297 } else if (ratio >= 0.8) {
298 estimatedCoverage = "High (>80%)";
299 score = 95;
300 } else if (ratio >= 0.5) {
301 estimatedCoverage = "Good (50-80%)";
302 score = 75;
303 } else if (ratio >= 0.2) {
304 estimatedCoverage = "Moderate (20-50%)";
305 score = 50;
306 } else {
307 estimatedCoverage = "Low (<20%)";
308 score = 25;
309 }
310
311 return { score, hasTests, testFileCount: testFiles.length, estimatedCoverage };
312}
313
314// ─── COMPLEXITY ──────────────────────────────────────────────
315
316async function analyzeComplexityScore(
317 repoDir: string,
318 ref: string
319): Promise<{
320 score: number;
321 avgFileSize: number;
322 largestFiles: FileMetric[];
323 totalFiles: number;
324}> {
325 const { stdout } = await exec(
326 ["git", "ls-tree", "-r", "-l", ref],
327 repoDir
328 );
329
330 const entries = stdout
331 .trim()
332 .split("\n")
333 .filter(Boolean)
334 .map((line) => {
335 const match = line.match(/^\d+ blob [0-9a-f]+ +(\d+)\t(.+)$/);
336 if (!match) return null;
337 return { path: match[2], lines: parseInt(match[1], 10) };
338 })
339 .filter((e): e is FileMetric => e !== null)
340 .filter((e) => /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|c|cpp|h)$/.test(e.path));
341
342 if (entries.length === 0) {
343 return { score: 100, avgFileSize: 0, largestFiles: [], totalFiles: 0 };
344 }
345
346 // Get actual line counts for top files by size
347 const sorted = entries.sort((a, b) => b.lines - a.lines);
348 const largestFiles = sorted.slice(0, 5);
349
350 const avgSize = entries.reduce((s, e) => s + e.lines, 0) / entries.length;
351
352 // Penalize large average file size and mega-files
353 let score = 100;
354 if (avgSize > 10000) score -= 30;
355 else if (avgSize > 5000) score -= 20;
356 else if (avgSize > 2000) score -= 10;
357 else if (avgSize > 1000) score -= 5;
358
359 // Penalize any file over 500 lines (by byte size as proxy)
360 const megaFiles = entries.filter((e) => e.lines > 50000);
361 score -= megaFiles.length * 10;
362
363 return {
364 score: Math.max(0, Math.min(100, score)),
365 avgFileSize: Math.round(avgSize),
366 largestFiles,
367 totalFiles: entries.length,
368 };
369}
370
371// ─── DEPENDENCIES ────────────────────────────────────────────
372
373async function analyzeDependencyScore(
374 repoDir: string,
375 ref: string
376): Promise<{
377 score: number;
378 total: number;
379 outdatedEstimate: number;
380 lockfileExists: boolean;
381}> {
382 const { stdout: tree } = await exec(
383 ["git", "ls-tree", "--name-only", ref],
384 repoDir
385 );
386 const files = tree.trim().split("\n");
387
388 const hasLockfile =
389 files.includes("bun.lock") ||
390 files.includes("package-lock.json") ||
391 files.includes("yarn.lock") ||
392 files.includes("pnpm-lock.yaml") ||
393 files.includes("Cargo.lock") ||
394 files.includes("go.sum") ||
395 files.includes("Gemfile.lock") ||
396 files.includes("poetry.lock");
397
398 const hasPackageJson = files.includes("package.json");
399 const hasCargoToml = files.includes("Cargo.toml");
400 const hasGoMod = files.includes("go.mod");
401 const hasRequirements = files.includes("requirements.txt");
402
403 let total = 0;
404
405 if (hasPackageJson) {
406 try {
407 const { stdout: content } = await exec(
408 ["git", "show", `${ref}:package.json`],
409 repoDir
410 );
411 const pkg = JSON.parse(content);
412 total =
413 Object.keys(pkg.dependencies || {}).length +
414 Object.keys(pkg.devDependencies || {}).length;
415 } catch {
416 // parse error
417 }
418 }
419
420 let score = 100;
421 if (!hasLockfile && total > 0) score -= 20; // No lockfile with deps is bad
422 if (total > 100) score -= 10; // Too many deps
423 if (total > 200) score -= 10;
424
425 return {
426 score: Math.max(0, score),
427 total,
428 outdatedEstimate: 0, // Would need network access to check
429 lockfileExists: hasLockfile,
430 };
431}
432
433// ─── DOCUMENTATION ───────────────────────────────────────────
434
435async function analyzeDocumentationScore(
436 repoDir: string,
437 ref: string
438): Promise<{
439 score: number;
440 hasReadme: boolean;
441 hasLicense: boolean;
442 hasContributing: boolean;
443 hasChangelog: boolean;
444 docFileCount: number;
445}> {
446 const { stdout: tree } = await exec(
447 ["git", "ls-tree", "--name-only", ref],
448 repoDir
449 );
450 const files = tree.trim().split("\n").map((f) => f.toLowerCase());
451
452 const hasReadme = files.some((f) => f.startsWith("readme"));
453 const hasLicense = files.some((f) => f.startsWith("license") || f.startsWith("licence"));
454 const hasContributing = files.some((f) => f.startsWith("contributing"));
455 const hasChangelog = files.some((f) => f.startsWith("changelog") || f.startsWith("changes"));
456
457 const { stdout: allFiles } = await exec(
458 ["git", "ls-tree", "-r", "--name-only", ref],
459 repoDir
460 );
461 const docFiles = allFiles
462 .trim()
463 .split("\n")
464 .filter((f) => /\.(md|rst|txt|adoc)$/i.test(f));
465
466 let score = 0;
467 if (hasReadme) score += 40;
468 if (hasLicense) score += 20;
469 if (hasContributing) score += 15;
470 if (hasChangelog) score += 15;
471 score += Math.min(10, docFiles.length * 2);
472
473 return {
474 score: Math.min(100, score),
475 hasReadme,
476 hasLicense,
477 hasContributing,
478 hasChangelog,
479 docFileCount: docFiles.length,
480 };
481}
482
483// ─── ACTIVITY ────────────────────────────────────────────────
484
485async function analyzeActivityScore(
486 repoDir: string,
487 ref: string
488): Promise<{
489 score: number;
490 recentCommits: number;
491 uniqueContributors: number;
492 lastPushDaysAgo: number;
493}> {
494 // Recent commits (last 30 days)
495 const { stdout: recent } = await exec(
496 ["git", "rev-list", "--count", "--since=30 days ago", ref],
497 repoDir
498 );
499 const recentCommits = parseInt(recent.trim(), 10) || 0;
500
501 // Unique contributors
502 const { stdout: authors } = await exec(
503 ["git", "shortlog", "-sn", ref],
504 repoDir
505 );
506 const uniqueContributors = authors.trim().split("\n").filter(Boolean).length;
507
508 // Last commit date
509 const { stdout: lastDate } = await exec(
510 ["git", "log", "-1", "--format=%aI", ref],
511 repoDir
512 );
513 const lastPush = new Date(lastDate.trim());
514 const lastPushDaysAgo = Math.floor(
515 (Date.now() - lastPush.getTime()) / (1000 * 60 * 60 * 24)
516 );
517
518 let score = 0;
519 // Recent activity
520 if (recentCommits >= 20) score += 40;
521 else if (recentCommits >= 10) score += 30;
522 else if (recentCommits >= 3) score += 20;
523 else if (recentCommits >= 1) score += 10;
524
525 // Contributors
526 if (uniqueContributors >= 5) score += 30;
527 else if (uniqueContributors >= 3) score += 20;
528 else if (uniqueContributors >= 2) score += 15;
529 else score += 5;
530
531 // Freshness
532 if (lastPushDaysAgo <= 7) score += 30;
533 else if (lastPushDaysAgo <= 30) score += 20;
534 else if (lastPushDaysAgo <= 90) score += 10;
535
536 return {
537 score: Math.min(100, score),
538 recentCommits,
539 uniqueContributors,
540 lastPushDaysAgo,
541 };
542}
543
544// ─── PUSH ANALYSIS ───────────────────────────────────────────
545
546export async function analyzePush(
547 owner: string,
548 repo: string,
549 beforeSha: string,
550 afterSha: string
551): Promise<PushAnalysis> {
552 const repoDir = getRepoPath(owner, repo);
553 const isInitial = beforeSha.startsWith("0000");
554
555 // Diff stats
556 let filesChanged = 0;
557 let linesAdded = 0;
558 let linesRemoved = 0;
559
560 if (!isInitial) {
561 const { stdout: stat } = await exec(
562 ["git", "diff", "--shortstat", `${beforeSha}..${afterSha}`],
563 repoDir
564 );
565 const match = stat.match(
566 /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/
567 );
568 if (match) {
569 filesChanged = parseInt(match[1], 10) || 0;
570 linesAdded = parseInt(match[2], 10) || 0;
571 linesRemoved = parseInt(match[3], 10) || 0;
572 }
573 }
574
575 const riskFactors: string[] = [];
576 const breakingChangeSignals: string[] = [];
577 const securityIssues: SecurityIssue[] = [];
578
579 // Get changed files
580 let changedFiles: string[] = [];
581 if (!isInitial) {
582 const { stdout: diff } = await exec(
583 ["git", "diff", "--name-only", `${beforeSha}..${afterSha}`],
584 repoDir
585 );
586 changedFiles = diff.trim().split("\n").filter(Boolean);
587 }
588
589 // Risk factors
590 if (filesChanged > 50) riskFactors.push(`Large changeset: ${filesChanged} files`);
591 if (linesAdded + linesRemoved > 2000) riskFactors.push(`High churn: ${linesAdded + linesRemoved} lines`);
592
593 // Check for high-risk file changes
594 const riskFiles = changedFiles.filter((f) =>
595 /^(\.env|docker-compose|Dockerfile|\.github\/workflows|package\.json|Cargo\.toml|go\.mod)/i.test(f)
596 );
597 if (riskFiles.length > 0) {
598 riskFactors.push(`Infrastructure files changed: ${riskFiles.join(", ")}`);
599 }
600
601 // Breaking change detection
602 if (!isInitial) {
603 const { stdout: diffContent } = await exec(
604 ["git", "diff", `${beforeSha}..${afterSha}`],
605 repoDir
606 );
607
608 // Detect removed exports
609 const removedExports = (diffContent.match(/^-export /gm) || []).length;
610 const addedExports = (diffContent.match(/^\+export /gm) || []).length;
611 if (removedExports > addedExports) {
612 breakingChangeSignals.push(
613 `${removedExports - addedExports} exports removed — potential breaking change`
614 );
615 }
616
617 // Detect renamed/removed public functions
618 const removedFunctions = (diffContent.match(/^-(?:export )?(?:async )?function \w+/gm) || []).length;
619 if (removedFunctions > 0) {
620 breakingChangeSignals.push(`${removedFunctions} function(s) removed or renamed`);
621 }
622
623 // Detect API route changes
624 const routeChanges = (diffContent.match(/^[+-].*\.(get|post|put|delete|patch)\s*\(/gm) || []).length;
625 if (routeChanges > 0) {
626 breakingChangeSignals.push(`API route changes detected (${routeChanges} modifications)`);
627 }
628
629 // Security scan the diff
630 for (const rule of SECURITY_PATTERNS) {
631 const matches = diffContent.match(new RegExp(`^\\+.*${rule.pattern.source}`, "gm"));
632 if (matches && matches.length > 0) {
633 securityIssues.push({
634 severity: rule.severity,
635 file: "diff",
636 message: rule.message,
637 rule: rule.rule,
638 });
639 }
640 }
641 }
642
643 // Hot files (most changed in last 30 days)
644 const { stdout: hotOutput } = await exec(
645 [
646 "git",
647 "log",
648 "--since=30 days ago",
649 "--format=",
650 "--name-only",
651 afterSha,
652 ],
653 repoDir
654 );
655 const fileCounts: Record<string, number> = {};
656 for (const f of hotOutput.trim().split("\n").filter(Boolean)) {
657 fileCounts[f] = (fileCounts[f] || 0) + 1;
658 }
659 const hotFiles = Object.entries(fileCounts)
660 .sort((a, b) => b[1] - a[1])
661 .slice(0, 5)
662 .map(([f]) => f);
663
664 // Risk score
665 let riskScore = 0;
666 riskScore += Math.min(30, filesChanged * 0.5);
667 riskScore += Math.min(20, (linesAdded + linesRemoved) * 0.005);
668 riskScore += riskFactors.length * 10;
669 riskScore += breakingChangeSignals.length * 15;
670 riskScore += securityIssues.filter((i) => i.severity === "critical").length * 25;
671 riskScore += securityIssues.filter((i) => i.severity === "high").length * 10;
672 riskScore = Math.min(100, Math.round(riskScore));
673
674 const summary = generatePushSummary(
675 filesChanged,
676 linesAdded,
677 linesRemoved,
678 riskScore,
679 breakingChangeSignals,
680 securityIssues
681 );
682
683 return {
684 filesChanged,
685 linesAdded,
686 linesRemoved,
687 riskScore,
688 riskFactors,
689 breakingChangeSignals,
690 securityIssues,
691 hotFiles,
692 summary,
693 };
694}
695
696// ─── ZERO-CONFIG CI ──────────────────────────────────────────
697
698export interface CIConfig {
699 projectType: string;
700 runtime: string;
701 commands: { name: string; command: string }[];
702 detected: string[];
703}
704
705export async function detectCIConfig(
706 owner: string,
707 repo: string,
708 ref: string
709): Promise<CIConfig> {
710 const repoDir = getRepoPath(owner, repo);
711
712 const { stdout: tree } = await exec(
713 ["git", "ls-tree", "--name-only", ref],
714 repoDir
715 );
716 const rootFiles = tree.trim().split("\n");
717
718 const detected: string[] = [];
719 const commands: { name: string; command: string }[] = [];
720 let projectType = "unknown";
721 let runtime = "unknown";
722
723 // Node.js / Bun
724 if (rootFiles.includes("package.json")) {
725 const { stdout: pkg } = await exec(
726 ["git", "show", `${ref}:package.json`],
727 repoDir
728 );
729 try {
730 const parsed = JSON.parse(pkg);
731 const scripts = parsed.scripts || {};
732
733 if (rootFiles.includes("bun.lock") || rootFiles.includes("bunfig.toml")) {
734 runtime = "bun";
735 detected.push("Bun project detected");
736 } else if (rootFiles.includes("yarn.lock")) {
737 runtime = "yarn";
738 detected.push("Yarn project detected");
739 } else if (rootFiles.includes("pnpm-lock.yaml")) {
740 runtime = "pnpm";
741 detected.push("pnpm project detected");
742 } else {
743 runtime = "npm";
744 detected.push("Node.js project detected");
745 }
746
747 projectType = "javascript";
748
749 // TypeScript?
750 if (rootFiles.includes("tsconfig.json") || parsed.devDependencies?.typescript) {
751 detected.push("TypeScript detected");
752 projectType = "typescript";
753 commands.push({ name: "Type check", command: `${runtime === "bun" ? "bun" : "npx"} tsc --noEmit` });
754 }
755
756 // Framework detection
757 if (parsed.dependencies?.hono) detected.push("Hono framework");
758 if (parsed.dependencies?.next) detected.push("Next.js framework");
759 if (parsed.dependencies?.react) detected.push("React");
760 if (parsed.dependencies?.vue) detected.push("Vue.js");
761 if (parsed.dependencies?.express) detected.push("Express.js");
762
763 // Add available scripts
764 if (scripts.lint) commands.push({ name: "Lint", command: `${runtime} run lint` });
765 if (scripts.test) commands.push({ name: "Test", command: `${runtime} ${runtime === "bun" ? "test" : "run test"}` });
766 if (scripts.build) commands.push({ name: "Build", command: `${runtime} run build` });
767 if (scripts.typecheck) commands.push({ name: "Type check", command: `${runtime} run typecheck` });
768
769 // If no lint but eslint exists
770 if (!scripts.lint && (parsed.devDependencies?.eslint || parsed.dependencies?.eslint)) {
771 commands.push({ name: "Lint", command: `${runtime === "bun" ? "bun" : "npx"} eslint .` });
772 }
773
774 // If no test script but test framework exists
775 if (!scripts.test) {
776 if (parsed.devDependencies?.vitest) {
777 commands.push({ name: "Test", command: `${runtime === "bun" ? "bun" : "npx"} vitest run` });
778 } else if (parsed.devDependencies?.jest) {
779 commands.push({ name: "Test", command: `${runtime === "bun" ? "bun" : "npx"} jest` });
780 }
781 }
782 } catch {
783 // JSON parse error
784 }
785 }
786
787 // Rust
788 if (rootFiles.includes("Cargo.toml")) {
789 projectType = "rust";
790 runtime = "cargo";
791 detected.push("Rust project detected");
792 commands.push({ name: "Check", command: "cargo check" });
793 commands.push({ name: "Test", command: "cargo test" });
794 commands.push({ name: "Clippy", command: "cargo clippy -- -D warnings" });
795 commands.push({ name: "Format check", command: "cargo fmt -- --check" });
796 }
797
798 // Go
799 if (rootFiles.includes("go.mod")) {
800 projectType = "go";
801 runtime = "go";
802 detected.push("Go project detected");
803 commands.push({ name: "Build", command: "go build ./..." });
804 commands.push({ name: "Test", command: "go test ./..." });
805 commands.push({ name: "Vet", command: "go vet ./..." });
806 }
807
808 // Python
809 if (
810 rootFiles.includes("requirements.txt") ||
811 rootFiles.includes("pyproject.toml") ||
812 rootFiles.includes("setup.py")
813 ) {
814 projectType = "python";
815 runtime = "python";
816 detected.push("Python project detected");
817 if (rootFiles.includes("pyproject.toml")) {
818 detected.push("pyproject.toml found");
819 }
820 commands.push({ name: "Test", command: "python -m pytest" });
821 commands.push({ name: "Type check", command: "python -m mypy ." });
822 }
823
824 return { projectType, runtime, commands, detected };
825}
826
827// ─── HELPERS ─────────────────────────────────────────────────
828
829function generateInsights(breakdown: RepoHealthReport["breakdown"]): string[] {
830 const insights: string[] = [];
831
832 if (breakdown.security.issues.length === 0) {
833 insights.push("No security issues detected — clean codebase");
834 } else {
835 const criticals = breakdown.security.issues.filter((i) => i.severity === "critical").length;
836 if (criticals > 0) {
837 insights.push(`${criticals} critical security issue${criticals > 1 ? "s" : ""} found — immediate action recommended`);
838 }
839 }
840
841 if (!breakdown.testing.hasTests) {
842 insights.push("No tests found — adding tests would significantly improve code reliability");
843 } else if (breakdown.testing.testFileCount > 10) {
844 insights.push(`Strong test suite with ${breakdown.testing.testFileCount} test files`);
845 }
846
847 if (!breakdown.documentation.hasReadme) {
848 insights.push("No README — new contributors won't know how to get started");
849 }
850
851 if (!breakdown.documentation.hasLicense) {
852 insights.push("No LICENSE file — open source projects need a license to be usable");
853 }
854
855 if (!breakdown.dependencies.lockfileExists && breakdown.dependencies.total > 0) {
856 insights.push("No lockfile — builds may not be reproducible");
857 }
858
859 if (breakdown.activity.lastPushDaysAgo > 90) {
860 insights.push("No activity in 90+ days — project may be dormant");
861 } else if (breakdown.activity.recentCommits > 20) {
862 insights.push("Very active project — strong development momentum");
863 }
864
865 if (breakdown.activity.uniqueContributors === 1) {
866 insights.push("Single contributor — consider inviting collaborators for code review");
867 }
868
869 return insights;
870}
871
872function generatePushSummary(
873 filesChanged: number,
874 linesAdded: number,
875 linesRemoved: number,
876 riskScore: number,
877 breakingChanges: string[],
878 securityIssues: SecurityIssue[]
879): string {
880 const parts: string[] = [];
881 parts.push(`${filesChanged} files changed (+${linesAdded} -${linesRemoved})`);
882
883 if (riskScore <= 20) parts.push("Low risk push");
884 else if (riskScore <= 50) parts.push("Moderate risk — review recommended");
885 else parts.push("High risk — careful review required");
886
887 if (breakingChanges.length > 0) {
888 parts.push(`${breakingChanges.length} potential breaking change(s)`);
889 }
890
891 const critSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
892 if (critSec > 0) {
893 parts.push(`${critSec} security concern(s)`);
894 }
895
896 return parts.join(" | ");
897}