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.tsBlame862 lines · 1 contributor
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
158async function analyzeSecurityScore(
159 repoDir: string,
160 ref: string
161): Promise<{ score: number; issues: SecurityIssue[] }> {
162 const issues: SecurityIssue[] = [];
163
164 // Get all text files
165 const { stdout: files } = await exec(
166 ["git", "ls-tree", "-r", "--name-only", ref],
167 repoDir
168 );
169
170 const filePaths = files.trim().split("\n").filter(Boolean);
171
172 // Check for .env files committed
173 for (const f of filePaths) {
174 if (/^\.env(?:\.|$)/.test(f.split("/").pop() || "")) {
175 issues.push({
176 severity: "critical",
177 file: f,
178 message: "Environment file committed to repository",
179 rule: "no-env-files",
180 });
181 }
182 }
183
184 // Scan source files for patterns (sample up to 100 files)
185 const sourceFiles = filePaths
186 .filter((f) => /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|sh|yaml|yml|json)$/.test(f))
187 .slice(0, 100);
188
189 for (const filePath of sourceFiles) {
190 const { stdout: content, exitCode } = await exec(
191 ["git", "show", `${ref}:${filePath}`],
192 repoDir
193 );
194 if (exitCode !== 0) continue;
195
196 const lines = content.split("\n");
197 for (let i = 0; i < lines.length; i++) {
198 for (const rule of SECURITY_PATTERNS) {
199 if (rule.pattern.test(lines[i])) {
200 // Don't flag test files for most rules
201 if (filePath.includes("test") && rule.severity !== "critical") continue;
202 issues.push({
203 severity: rule.severity,
204 file: filePath,
205 line: i + 1,
206 message: rule.message,
207 rule: rule.rule,
208 });
209 }
210 }
211 }
212 }
213
214 const criticals = issues.filter((i) => i.severity === "critical").length;
215 const highs = issues.filter((i) => i.severity === "high").length;
216 const mediums = issues.filter((i) => i.severity === "medium").length;
217
218 let score = 100;
219 score -= criticals * 25;
220 score -= highs * 10;
221 score -= mediums * 3;
222
223 return { score: Math.max(0, Math.min(100, score)), issues };
224}
225
226// ─── TESTING ─────────────────────────────────────────────────
227
228async function analyzeTestingScore(
229 repoDir: string,
230 ref: string
231): Promise<{
232 score: number;
233 hasTests: boolean;
234 testFileCount: number;
235 estimatedCoverage: string;
236}> {
237 const { stdout: files } = await exec(
238 ["git", "ls-tree", "-r", "--name-only", ref],
239 repoDir
240 );
241 const allFiles = files.trim().split("\n").filter(Boolean);
242 const sourceFiles = allFiles.filter((f) =>
243 /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php)$/.test(f) && !f.includes("test") && !f.includes("spec")
244 );
245 const testFiles = allFiles.filter((f) =>
246 /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(f) ||
247 f.includes("__tests__/") ||
248 f.includes("test_") ||
249 f.endsWith("_test.go") ||
250 f.endsWith("_test.py")
251 );
252
253 const hasTests = testFiles.length > 0;
254 const ratio = sourceFiles.length > 0 ? testFiles.length / sourceFiles.length : 0;
255
256 let estimatedCoverage: string;
257 let score: number;
258
259 if (!hasTests) {
260 estimatedCoverage = "None";
261 score = 0;
262 } else if (ratio >= 0.8) {
263 estimatedCoverage = "High (>80%)";
264 score = 95;
265 } else if (ratio >= 0.5) {
266 estimatedCoverage = "Good (50-80%)";
267 score = 75;
268 } else if (ratio >= 0.2) {
269 estimatedCoverage = "Moderate (20-50%)";
270 score = 50;
271 } else {
272 estimatedCoverage = "Low (<20%)";
273 score = 25;
274 }
275
276 return { score, hasTests, testFileCount: testFiles.length, estimatedCoverage };
277}
278
279// ─── COMPLEXITY ──────────────────────────────────────────────
280
281async function analyzeComplexityScore(
282 repoDir: string,
283 ref: string
284): Promise<{
285 score: number;
286 avgFileSize: number;
287 largestFiles: FileMetric[];
288 totalFiles: number;
289}> {
290 const { stdout } = await exec(
291 ["git", "ls-tree", "-r", "-l", ref],
292 repoDir
293 );
294
295 const entries = stdout
296 .trim()
297 .split("\n")
298 .filter(Boolean)
299 .map((line) => {
300 const match = line.match(/^\d+ blob [0-9a-f]+ +(\d+)\t(.+)$/);
301 if (!match) return null;
302 return { path: match[2], lines: parseInt(match[1], 10) };
303 })
304 .filter((e): e is FileMetric => e !== null)
305 .filter((e) => /\.(ts|tsx|js|jsx|py|rb|go|rs|java|php|c|cpp|h)$/.test(e.path));
306
307 if (entries.length === 0) {
308 return { score: 100, avgFileSize: 0, largestFiles: [], totalFiles: 0 };
309 }
310
311 // Get actual line counts for top files by size
312 const sorted = entries.sort((a, b) => b.lines - a.lines);
313 const largestFiles = sorted.slice(0, 5);
314
315 const avgSize = entries.reduce((s, e) => s + e.lines, 0) / entries.length;
316
317 // Penalize large average file size and mega-files
318 let score = 100;
319 if (avgSize > 10000) score -= 30;
320 else if (avgSize > 5000) score -= 20;
321 else if (avgSize > 2000) score -= 10;
322 else if (avgSize > 1000) score -= 5;
323
324 // Penalize any file over 500 lines (by byte size as proxy)
325 const megaFiles = entries.filter((e) => e.lines > 50000);
326 score -= megaFiles.length * 10;
327
328 return {
329 score: Math.max(0, Math.min(100, score)),
330 avgFileSize: Math.round(avgSize),
331 largestFiles,
332 totalFiles: entries.length,
333 };
334}
335
336// ─── DEPENDENCIES ────────────────────────────────────────────
337
338async function analyzeDependencyScore(
339 repoDir: string,
340 ref: string
341): Promise<{
342 score: number;
343 total: number;
344 outdatedEstimate: number;
345 lockfileExists: boolean;
346}> {
347 const { stdout: tree } = await exec(
348 ["git", "ls-tree", "--name-only", ref],
349 repoDir
350 );
351 const files = tree.trim().split("\n");
352
353 const hasLockfile =
354 files.includes("bun.lock") ||
355 files.includes("package-lock.json") ||
356 files.includes("yarn.lock") ||
357 files.includes("pnpm-lock.yaml") ||
358 files.includes("Cargo.lock") ||
359 files.includes("go.sum") ||
360 files.includes("Gemfile.lock") ||
361 files.includes("poetry.lock");
362
363 const hasPackageJson = files.includes("package.json");
364 const hasCargoToml = files.includes("Cargo.toml");
365 const hasGoMod = files.includes("go.mod");
366 const hasRequirements = files.includes("requirements.txt");
367
368 let total = 0;
369
370 if (hasPackageJson) {
371 try {
372 const { stdout: content } = await exec(
373 ["git", "show", `${ref}:package.json`],
374 repoDir
375 );
376 const pkg = JSON.parse(content);
377 total =
378 Object.keys(pkg.dependencies || {}).length +
379 Object.keys(pkg.devDependencies || {}).length;
380 } catch {
381 // parse error
382 }
383 }
384
385 let score = 100;
386 if (!hasLockfile && total > 0) score -= 20; // No lockfile with deps is bad
387 if (total > 100) score -= 10; // Too many deps
388 if (total > 200) score -= 10;
389
390 return {
391 score: Math.max(0, score),
392 total,
393 outdatedEstimate: 0, // Would need network access to check
394 lockfileExists: hasLockfile,
395 };
396}
397
398// ─── DOCUMENTATION ───────────────────────────────────────────
399
400async function analyzeDocumentationScore(
401 repoDir: string,
402 ref: string
403): Promise<{
404 score: number;
405 hasReadme: boolean;
406 hasLicense: boolean;
407 hasContributing: boolean;
408 hasChangelog: boolean;
409 docFileCount: number;
410}> {
411 const { stdout: tree } = await exec(
412 ["git", "ls-tree", "--name-only", ref],
413 repoDir
414 );
415 const files = tree.trim().split("\n").map((f) => f.toLowerCase());
416
417 const hasReadme = files.some((f) => f.startsWith("readme"));
418 const hasLicense = files.some((f) => f.startsWith("license") || f.startsWith("licence"));
419 const hasContributing = files.some((f) => f.startsWith("contributing"));
420 const hasChangelog = files.some((f) => f.startsWith("changelog") || f.startsWith("changes"));
421
422 const { stdout: allFiles } = await exec(
423 ["git", "ls-tree", "-r", "--name-only", ref],
424 repoDir
425 );
426 const docFiles = allFiles
427 .trim()
428 .split("\n")
429 .filter((f) => /\.(md|rst|txt|adoc)$/i.test(f));
430
431 let score = 0;
432 if (hasReadme) score += 40;
433 if (hasLicense) score += 20;
434 if (hasContributing) score += 15;
435 if (hasChangelog) score += 15;
436 score += Math.min(10, docFiles.length * 2);
437
438 return {
439 score: Math.min(100, score),
440 hasReadme,
441 hasLicense,
442 hasContributing,
443 hasChangelog,
444 docFileCount: docFiles.length,
445 };
446}
447
448// ─── ACTIVITY ────────────────────────────────────────────────
449
450async function analyzeActivityScore(
451 repoDir: string,
452 ref: string
453): Promise<{
454 score: number;
455 recentCommits: number;
456 uniqueContributors: number;
457 lastPushDaysAgo: number;
458}> {
459 // Recent commits (last 30 days)
460 const { stdout: recent } = await exec(
461 ["git", "rev-list", "--count", "--since=30 days ago", ref],
462 repoDir
463 );
464 const recentCommits = parseInt(recent.trim(), 10) || 0;
465
466 // Unique contributors
467 const { stdout: authors } = await exec(
468 ["git", "shortlog", "-sn", ref],
469 repoDir
470 );
471 const uniqueContributors = authors.trim().split("\n").filter(Boolean).length;
472
473 // Last commit date
474 const { stdout: lastDate } = await exec(
475 ["git", "log", "-1", "--format=%aI", ref],
476 repoDir
477 );
478 const lastPush = new Date(lastDate.trim());
479 const lastPushDaysAgo = Math.floor(
480 (Date.now() - lastPush.getTime()) / (1000 * 60 * 60 * 24)
481 );
482
483 let score = 0;
484 // Recent activity
485 if (recentCommits >= 20) score += 40;
486 else if (recentCommits >= 10) score += 30;
487 else if (recentCommits >= 3) score += 20;
488 else if (recentCommits >= 1) score += 10;
489
490 // Contributors
491 if (uniqueContributors >= 5) score += 30;
492 else if (uniqueContributors >= 3) score += 20;
493 else if (uniqueContributors >= 2) score += 15;
494 else score += 5;
495
496 // Freshness
497 if (lastPushDaysAgo <= 7) score += 30;
498 else if (lastPushDaysAgo <= 30) score += 20;
499 else if (lastPushDaysAgo <= 90) score += 10;
500
501 return {
502 score: Math.min(100, score),
503 recentCommits,
504 uniqueContributors,
505 lastPushDaysAgo,
506 };
507}
508
509// ─── PUSH ANALYSIS ───────────────────────────────────────────
510
511export async function analyzePush(
512 owner: string,
513 repo: string,
514 beforeSha: string,
515 afterSha: string
516): Promise<PushAnalysis> {
517 const repoDir = getRepoPath(owner, repo);
518 const isInitial = beforeSha.startsWith("0000");
519
520 // Diff stats
521 let filesChanged = 0;
522 let linesAdded = 0;
523 let linesRemoved = 0;
524
525 if (!isInitial) {
526 const { stdout: stat } = await exec(
527 ["git", "diff", "--shortstat", `${beforeSha}..${afterSha}`],
528 repoDir
529 );
530 const match = stat.match(
531 /(\d+) files? changed(?:, (\d+) insertions?)?(?:, (\d+) deletions?)?/
532 );
533 if (match) {
534 filesChanged = parseInt(match[1], 10) || 0;
535 linesAdded = parseInt(match[2], 10) || 0;
536 linesRemoved = parseInt(match[3], 10) || 0;
537 }
538 }
539
540 const riskFactors: string[] = [];
541 const breakingChangeSignals: string[] = [];
542 const securityIssues: SecurityIssue[] = [];
543
544 // Get changed files
545 let changedFiles: string[] = [];
546 if (!isInitial) {
547 const { stdout: diff } = await exec(
548 ["git", "diff", "--name-only", `${beforeSha}..${afterSha}`],
549 repoDir
550 );
551 changedFiles = diff.trim().split("\n").filter(Boolean);
552 }
553
554 // Risk factors
555 if (filesChanged > 50) riskFactors.push(`Large changeset: ${filesChanged} files`);
556 if (linesAdded + linesRemoved > 2000) riskFactors.push(`High churn: ${linesAdded + linesRemoved} lines`);
557
558 // Check for high-risk file changes
559 const riskFiles = changedFiles.filter((f) =>
560 /^(\.env|docker-compose|Dockerfile|\.github\/workflows|package\.json|Cargo\.toml|go\.mod)/i.test(f)
561 );
562 if (riskFiles.length > 0) {
563 riskFactors.push(`Infrastructure files changed: ${riskFiles.join(", ")}`);
564 }
565
566 // Breaking change detection
567 if (!isInitial) {
568 const { stdout: diffContent } = await exec(
569 ["git", "diff", `${beforeSha}..${afterSha}`],
570 repoDir
571 );
572
573 // Detect removed exports
574 const removedExports = (diffContent.match(/^-export /gm) || []).length;
575 const addedExports = (diffContent.match(/^\+export /gm) || []).length;
576 if (removedExports > addedExports) {
577 breakingChangeSignals.push(
578 `${removedExports - addedExports} exports removed — potential breaking change`
579 );
580 }
581
582 // Detect renamed/removed public functions
583 const removedFunctions = (diffContent.match(/^-(?:export )?(?:async )?function \w+/gm) || []).length;
584 if (removedFunctions > 0) {
585 breakingChangeSignals.push(`${removedFunctions} function(s) removed or renamed`);
586 }
587
588 // Detect API route changes
589 const routeChanges = (diffContent.match(/^[+-].*\.(get|post|put|delete|patch)\s*\(/gm) || []).length;
590 if (routeChanges > 0) {
591 breakingChangeSignals.push(`API route changes detected (${routeChanges} modifications)`);
592 }
593
594 // Security scan the diff
595 for (const rule of SECURITY_PATTERNS) {
596 const matches = diffContent.match(new RegExp(`^\\+.*${rule.pattern.source}`, "gm"));
597 if (matches && matches.length > 0) {
598 securityIssues.push({
599 severity: rule.severity,
600 file: "diff",
601 message: rule.message,
602 rule: rule.rule,
603 });
604 }
605 }
606 }
607
608 // Hot files (most changed in last 30 days)
609 const { stdout: hotOutput } = await exec(
610 [
611 "git",
612 "log",
613 "--since=30 days ago",
614 "--format=",
615 "--name-only",
616 afterSha,
617 ],
618 repoDir
619 );
620 const fileCounts: Record<string, number> = {};
621 for (const f of hotOutput.trim().split("\n").filter(Boolean)) {
622 fileCounts[f] = (fileCounts[f] || 0) + 1;
623 }
624 const hotFiles = Object.entries(fileCounts)
625 .sort((a, b) => b[1] - a[1])
626 .slice(0, 5)
627 .map(([f]) => f);
628
629 // Risk score
630 let riskScore = 0;
631 riskScore += Math.min(30, filesChanged * 0.5);
632 riskScore += Math.min(20, (linesAdded + linesRemoved) * 0.005);
633 riskScore += riskFactors.length * 10;
634 riskScore += breakingChangeSignals.length * 15;
635 riskScore += securityIssues.filter((i) => i.severity === "critical").length * 25;
636 riskScore += securityIssues.filter((i) => i.severity === "high").length * 10;
637 riskScore = Math.min(100, Math.round(riskScore));
638
639 const summary = generatePushSummary(
640 filesChanged,
641 linesAdded,
642 linesRemoved,
643 riskScore,
644 breakingChangeSignals,
645 securityIssues
646 );
647
648 return {
649 filesChanged,
650 linesAdded,
651 linesRemoved,
652 riskScore,
653 riskFactors,
654 breakingChangeSignals,
655 securityIssues,
656 hotFiles,
657 summary,
658 };
659}
660
661// ─── ZERO-CONFIG CI ──────────────────────────────────────────
662
663export interface CIConfig {
664 projectType: string;
665 runtime: string;
666 commands: { name: string; command: string }[];
667 detected: string[];
668}
669
670export async function detectCIConfig(
671 owner: string,
672 repo: string,
673 ref: string
674): Promise<CIConfig> {
675 const repoDir = getRepoPath(owner, repo);
676
677 const { stdout: tree } = await exec(
678 ["git", "ls-tree", "--name-only", ref],
679 repoDir
680 );
681 const rootFiles = tree.trim().split("\n");
682
683 const detected: string[] = [];
684 const commands: { name: string; command: string }[] = [];
685 let projectType = "unknown";
686 let runtime = "unknown";
687
688 // Node.js / Bun
689 if (rootFiles.includes("package.json")) {
690 const { stdout: pkg } = await exec(
691 ["git", "show", `${ref}:package.json`],
692 repoDir
693 );
694 try {
695 const parsed = JSON.parse(pkg);
696 const scripts = parsed.scripts || {};
697
698 if (rootFiles.includes("bun.lock") || rootFiles.includes("bunfig.toml")) {
699 runtime = "bun";
700 detected.push("Bun project detected");
701 } else if (rootFiles.includes("yarn.lock")) {
702 runtime = "yarn";
703 detected.push("Yarn project detected");
704 } else if (rootFiles.includes("pnpm-lock.yaml")) {
705 runtime = "pnpm";
706 detected.push("pnpm project detected");
707 } else {
708 runtime = "npm";
709 detected.push("Node.js project detected");
710 }
711
712 projectType = "javascript";
713
714 // TypeScript?
715 if (rootFiles.includes("tsconfig.json") || parsed.devDependencies?.typescript) {
716 detected.push("TypeScript detected");
717 projectType = "typescript";
718 commands.push({ name: "Type check", command: `${runtime === "bun" ? "bun" : "npx"} tsc --noEmit` });
719 }
720
721 // Framework detection
722 if (parsed.dependencies?.hono) detected.push("Hono framework");
723 if (parsed.dependencies?.next) detected.push("Next.js framework");
724 if (parsed.dependencies?.react) detected.push("React");
725 if (parsed.dependencies?.vue) detected.push("Vue.js");
726 if (parsed.dependencies?.express) detected.push("Express.js");
727
728 // Add available scripts
729 if (scripts.lint) commands.push({ name: "Lint", command: `${runtime} run lint` });
730 if (scripts.test) commands.push({ name: "Test", command: `${runtime} ${runtime === "bun" ? "test" : "run test"}` });
731 if (scripts.build) commands.push({ name: "Build", command: `${runtime} run build` });
732 if (scripts.typecheck) commands.push({ name: "Type check", command: `${runtime} run typecheck` });
733
734 // If no lint but eslint exists
735 if (!scripts.lint && (parsed.devDependencies?.eslint || parsed.dependencies?.eslint)) {
736 commands.push({ name: "Lint", command: `${runtime === "bun" ? "bun" : "npx"} eslint .` });
737 }
738
739 // If no test script but test framework exists
740 if (!scripts.test) {
741 if (parsed.devDependencies?.vitest) {
742 commands.push({ name: "Test", command: `${runtime === "bun" ? "bun" : "npx"} vitest run` });
743 } else if (parsed.devDependencies?.jest) {
744 commands.push({ name: "Test", command: `${runtime === "bun" ? "bun" : "npx"} jest` });
745 }
746 }
747 } catch {
748 // JSON parse error
749 }
750 }
751
752 // Rust
753 if (rootFiles.includes("Cargo.toml")) {
754 projectType = "rust";
755 runtime = "cargo";
756 detected.push("Rust project detected");
757 commands.push({ name: "Check", command: "cargo check" });
758 commands.push({ name: "Test", command: "cargo test" });
759 commands.push({ name: "Clippy", command: "cargo clippy -- -D warnings" });
760 commands.push({ name: "Format check", command: "cargo fmt -- --check" });
761 }
762
763 // Go
764 if (rootFiles.includes("go.mod")) {
765 projectType = "go";
766 runtime = "go";
767 detected.push("Go project detected");
768 commands.push({ name: "Build", command: "go build ./..." });
769 commands.push({ name: "Test", command: "go test ./..." });
770 commands.push({ name: "Vet", command: "go vet ./..." });
771 }
772
773 // Python
774 if (
775 rootFiles.includes("requirements.txt") ||
776 rootFiles.includes("pyproject.toml") ||
777 rootFiles.includes("setup.py")
778 ) {
779 projectType = "python";
780 runtime = "python";
781 detected.push("Python project detected");
782 if (rootFiles.includes("pyproject.toml")) {
783 detected.push("pyproject.toml found");
784 }
785 commands.push({ name: "Test", command: "python -m pytest" });
786 commands.push({ name: "Type check", command: "python -m mypy ." });
787 }
788
789 return { projectType, runtime, commands, detected };
790}
791
792// ─── HELPERS ─────────────────────────────────────────────────
793
794function generateInsights(breakdown: RepoHealthReport["breakdown"]): string[] {
795 const insights: string[] = [];
796
797 if (breakdown.security.issues.length === 0) {
798 insights.push("No security issues detected — clean codebase");
799 } else {
800 const criticals = breakdown.security.issues.filter((i) => i.severity === "critical").length;
801 if (criticals > 0) {
802 insights.push(`${criticals} critical security issue${criticals > 1 ? "s" : ""} found — immediate action recommended`);
803 }
804 }
805
806 if (!breakdown.testing.hasTests) {
807 insights.push("No tests found — adding tests would significantly improve code reliability");
808 } else if (breakdown.testing.testFileCount > 10) {
809 insights.push(`Strong test suite with ${breakdown.testing.testFileCount} test files`);
810 }
811
812 if (!breakdown.documentation.hasReadme) {
813 insights.push("No README — new contributors won't know how to get started");
814 }
815
816 if (!breakdown.documentation.hasLicense) {
817 insights.push("No LICENSE file — open source projects need a license to be usable");
818 }
819
820 if (!breakdown.dependencies.lockfileExists && breakdown.dependencies.total > 0) {
821 insights.push("No lockfile — builds may not be reproducible");
822 }
823
824 if (breakdown.activity.lastPushDaysAgo > 90) {
825 insights.push("No activity in 90+ days — project may be dormant");
826 } else if (breakdown.activity.recentCommits > 20) {
827 insights.push("Very active project — strong development momentum");
828 }
829
830 if (breakdown.activity.uniqueContributors === 1) {
831 insights.push("Single contributor — consider inviting collaborators for code review");
832 }
833
834 return insights;
835}
836
837function generatePushSummary(
838 filesChanged: number,
839 linesAdded: number,
840 linesRemoved: number,
841 riskScore: number,
842 breakingChanges: string[],
843 securityIssues: SecurityIssue[]
844): string {
845 const parts: string[] = [];
846 parts.push(`${filesChanged} files changed (+${linesAdded} -${linesRemoved})`);
847
848 if (riskScore <= 20) parts.push("Low risk push");
849 else if (riskScore <= 50) parts.push("Moderate risk — review recommended");
850 else parts.push("High risk — careful review required");
851
852 if (breakingChanges.length > 0) {
853 parts.push(`${breakingChanges.length} potential breaking change(s)`);
854 }
855
856 const critSec = securityIssues.filter((i) => i.severity === "critical" || i.severity === "high").length;
857 if (critSec > 0) {
858 parts.push(`${critSec} security concern(s)`);
859 }
860
861 return parts.join(" | ");
862}