CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(health): "how to improve" engine + honest scoring on the health page #5439

Merged⚡ AI-generatedXSccantynz wants to mergefeat/health-improvement-enginemainopened 24d ago
3 changed files+592−52
Addedsrc/__tests__/health-improvements.test.ts+176−0View fileUnifiedSplit
1import { describe, expect, it } from "bun:test";
2import {
3 computeImprovements,
4 healthWeights,
5 scoreSecurityIssues,
6 securityPenaltiesByRule,
7 type RepoHealthReport,
8 type SecurityIssue,
9} from "../lib/intelligence";
10
11const mediumFlood = (n: number): SecurityIssue[] =>
12 Array.from({ length: n }, (_, i) => ({
13 severity: "medium" as const,
14 file: `frontend/js/app.js`,
15 line: i + 1,
16 message: "Direct innerHTML assignment — potential XSS",
17 rule: "no-inner-html",
18 }));
19
20describe("scoreSecurityIssues", () => {
21 it("caps a single rule's medium flood instead of zeroing the score", () => {
22 // 65 mediums × 3 = 195 uncapped → old score 0. Capped per rule at 20.
23 expect(scoreSecurityIssues(mediumFlood(65))).toBe(80);
24 });
25
26 it("criticals stay uncapped — each leaked secret is its own incident", () => {
27 const criticals: SecurityIssue[] = Array.from({ length: 4 }, (_, i) => ({
28 severity: "critical",
29 file: `src/config${i}.ts`,
30 message: "Possible hardcoded password",
31 rule: "no-hardcoded-secrets",
32 }));
33 expect(scoreSecurityIssues(criticals)).toBe(0);
34 });
35
36 it("distinct rules accumulate separately", () => {
37 const issues = [
38 ...mediumFlood(65), // capped at 20
39 {
40 severity: "high" as const,
41 file: "a.ts",
42 message: "Use of eval()",
43 rule: "no-eval",
44 }, // 10
45 ];
46 expect(scoreSecurityIssues(issues)).toBe(70);
47 const penalties = securityPenaltiesByRule(issues);
48 expect(penalties.get("no-inner-html")).toBe(20);
49 expect(penalties.get("no-eval")).toBe(10);
50 });
51});
52
53describe("healthWeights", () => {
54 it("sums to 1 with and without a dependency manifest", () => {
55 for (const found of [true, false]) {
56 const w = healthWeights(found);
57 const sum = Object.values(w).reduce((s, x) => s + x, 0);
58 expect(Math.abs(sum - 1)).toBeLessThan(1e-9);
59 }
60 });
61
62 it("excludes dependencies entirely when no manifest exists", () => {
63 expect(healthWeights(false).dependencies).toBe(0);
64 });
65});
66
67function reportWith(
68 overrides: Partial<RepoHealthReport["breakdown"]>
69): RepoHealthReport {
70 const breakdown: RepoHealthReport["breakdown"] = {
71 security: { score: 100, issues: [] },
72 testing: {
73 score: 95,
74 hasTests: true,
75 testFileCount: 80,
76 estimatedCoverage: "High (>80%)",
77 },
78 complexity: {
79 score: 100,
80 avgFileSize: 800,
81 largestFiles: [],
82 totalFiles: 100,
83 },
84 dependencies: {
85 score: 100,
86 total: 10,
87 outdatedEstimate: 0,
88 lockfileExists: true,
89 manifestFound: true,
90 manifests: ["package.json"],
91 },
92 documentation: {
93 score: 100,
94 hasReadme: true,
95 hasLicense: true,
96 hasContributing: true,
97 hasChangelog: true,
98 docFileCount: 10,
99 },
100 activity: {
101 score: 100,
102 recentCommits: 25,
103 uniqueContributors: 5,
104 lastPushDaysAgo: 1,
105 },
106 ...overrides,
107 };
108 return {
109 score: 50,
110 grade: "D",
111 breakdown,
112 insights: [],
113 generatedAt: new Date().toISOString(),
114 };
115}
116
117describe("computeImprovements", () => {
118 it("a perfect repo has nothing to improve", () => {
119 expect(computeImprovements(reportWith({}))).toEqual([]);
120 });
121
122 it("prices a missing license from the real documentation constants", () => {
123 const report = reportWith({
124 documentation: {
125 score: 80,
126 hasReadme: true,
127 hasLicense: false,
128 hasContributing: true,
129 hasChangelog: true,
130 docFileCount: 10,
131 },
132 });
133 const license = computeImprovements(report).find((i) =>
134 i.action.includes("LICENSE")
135 );
136 // 20 documentation points × 0.10 weight = 2 overall points.
137 expect(license?.overallGain).toBe(2);
138 });
139
140 it("attributes security gains per rule and ranks by overall gain", () => {
141 const issues = mediumFlood(65);
142 const report = reportWith({
143 security: { score: scoreSecurityIssues(issues), issues },
144 documentation: {
145 score: 80,
146 hasReadme: true,
147 hasLicense: false,
148 hasContributing: true,
149 hasChangelog: true,
150 docFileCount: 10,
151 },
152 });
153 const imps = computeImprovements(report);
154 // innerHTML fix: 20 capped points × 0.25 = 5 overall — ranked above the
155 // license's 2.
156 expect(imps[0].category).toBe("security");
157 expect(imps[0].overallGain).toBe(5);
158 expect(imps[0].detail).toContain("65 occurrences");
159 });
160
161 it("never advertises gains for a category that cannot be scored", () => {
162 const report = reportWith({
163 dependencies: {
164 score: 100,
165 total: 0,
166 outdatedEstimate: 0,
167 lockfileExists: false,
168 manifestFound: false,
169 manifests: [],
170 },
171 });
172 expect(
173 computeImprovements(report).filter((i) => i.category === "dependencies")
174 ).toEqual([]);
175 });
176});
Modifiedsrc/lib/intelligence.ts+320−35View fileUnifiedSplit
2323 security: { score: number; issues: SecurityIssue[] };
2424 testing: { score: number; hasTests: boolean; testFileCount: number; estimatedCoverage: string };
2525 complexity: { score: number; avgFileSize: number; largestFiles: FileMetric[]; totalFiles: number };
26 dependencies: { score: number; total: number; outdatedEstimate: number; lockfileExists: boolean };
26 dependencies: { score: number; total: number; outdatedEstimate: number; lockfileExists: boolean; manifestFound: boolean; manifests: string[] };
2727 documentation: { score: number; hasReadme: boolean; hasLicense: boolean; hasContributing: boolean; hasChangelog: boolean; docFileCount: number };
2828 activity: { score: number; recentCommits: number; uniqueContributors: number; lastPushDaysAgo: number };
2929 };
7272
7373// ─── HEALTH SCORE ────────────────────────────────────────────
7474
75export const HEALTH_WEIGHTS = {
76 security: 0.25,
77 testing: 0.2,
78 complexity: 0.15,
79 dependencies: 0.15,
80 documentation: 0.1,
81 activity: 0.15,
82} as const;
83
84export type HealthCategory = keyof typeof HEALTH_WEIGHTS;
85
86/**
87 * Effective weights for a repo. When no dependency manifest exists there is
88 * nothing to score, so that category is excluded and the remaining weights
89 * renormalize to 1 — "not analyzed" must not silently count as 100/100.
90 */
91export function healthWeights(
92 depsManifestFound: boolean
93): Record<HealthCategory, number> {
94 if (depsManifestFound) return { ...HEALTH_WEIGHTS };
95 const rest = 1 - HEALTH_WEIGHTS.dependencies;
96 const scaled = Object.fromEntries(
97 Object.entries(HEALTH_WEIGHTS).map(([k, w]) => [k, w / rest])
98 ) as Record<HealthCategory, number>;
99 scaled.dependencies = 0;
100 return scaled;
101}
102
103export interface HealthImprovement {
104 category: HealthCategory;
105 /** Imperative one-liner: what to do. */
106 action: string;
107 /** Why / where — the specifics that make the action executable. */
108 detail: string;
109 /** Estimated gain to the OVERALL 0-100 score if completed. */
110 overallGain: number;
111}
112
113/**
114 * Turn a health report into a ranked to-do list: "do X, gain +N points".
115 * Every gain is computed from the same constants the scorer uses, so the
116 * advice can never drift from the score. This is the answer to "I have no
117 * way of knowing how to improve the score."
118 */
119export function computeImprovements(
120 report: RepoHealthReport
121): HealthImprovement[] {
122 const b = report.breakdown;
123 const w = healthWeights(b.dependencies.manifestFound);
124 const out: HealthImprovement[] = [];
125 const overall = (category: HealthCategory, categoryGain: number) =>
126 Math.round(categoryGain * w[category] * 10) / 10;
127
128 // ── Security: each rule's capped penalty is recoverable by fixing it.
129 // If penalties overshoot the 0 floor, scale attribution so gains sum to
130 // what the score can actually recover.
131 const penalties = securityPenaltiesByRule(b.security.issues);
132 const rawPenalty = [...penalties.values()].reduce((s, p) => s + p, 0);
133 const scale =
134 rawPenalty > 0 ? Math.min(1, (100 - b.security.score) / rawPenalty) : 0;
135 if (rawPenalty > 0) {
136 const byRule = new Map<string, SecurityIssue[]>();
137 for (const i of b.security.issues) {
138 byRule.set(i.rule, [...(byRule.get(i.rule) ?? []), i]);
139 }
140 for (const [rule, penalty] of [...penalties.entries()].sort(
141 (a, z) => z[1] - a[1]
142 )) {
143 const issues = byRule.get(rule) ?? [];
144 const files = [...new Set(issues.map((i) => i.file))];
145 out.push({
146 category: "security",
147 action: `Fix ${rule}: ${issues[0]?.message ?? rule}`,
148 detail: `${issues.length} occurrence${issues.length !== 1 ? "s" : ""} in ${files.length} file${files.length !== 1 ? "s" : ""} (${files.slice(0, 3).join(", ")}${files.length > 3 ? ", …" : ""})`,
149 overallGain: overall("security", penalty * scale),
150 });
151 }
152 }
153
154 // ── Testing: gain to the NEXT coverage tier, with the file count needed.
155 if (b.testing.score < 95) {
156 const tiers = [
157 { ratio: 0.2, score: 50, label: "20%" },
158 { ratio: 0.5, score: 75, label: "50%" },
159 { ratio: 0.8, score: 95, label: "80%" },
160 ];
161 const next = tiers.find((t) => t.score > b.testing.score);
162 if (next) {
163 // testFileCount / sourceCount >= ratio → source count from report
164 const sourceCount =
165 b.testing.testFileCount > 0 && b.testing.score > 0
166 ? Math.round(b.testing.testFileCount / ratioForScore(b.testing.score))
167 : b.complexity.totalFiles;
168 const needed = Math.max(
169 1,
170 Math.ceil(sourceCount * next.ratio - b.testing.testFileCount)
171 );
172 out.push({
173 category: "testing",
174 action: b.testing.hasTests
175 ? `Add ~${needed} test files to reach a ${next.label} test-to-source ratio`
176 : "Add a test suite — any tests at all move the score off zero",
177 detail: `${b.testing.testFileCount} test files today against ~${sourceCount} source files (${b.testing.estimatedCoverage})`,
178 overallGain: overall("testing", next.score - b.testing.score),
179 });
180 }
181 }
182
183 // ── Complexity: shrink the average file below the next threshold.
184 if (b.complexity.score < 100 && b.complexity.avgFileSize > 1000) {
185 const tiers = [
186 { above: 10000, penalty: 30, next: "10,000" },
187 { above: 5000, penalty: 20, next: "5,000" },
188 { above: 2000, penalty: 10, next: "2,000" },
189 { above: 1000, penalty: 5, next: "1,000" },
190 ];
191 const current = tiers.find((t) => b.complexity.avgFileSize > t.above);
192 if (current) {
193 const prevTier = tiers[tiers.indexOf(current) + 1];
194 const gain = current.penalty - (prevTier?.penalty ?? 0);
195 const worst = b.complexity.largestFiles
196 .slice(0, 3)
197 .map((f) => f.path.split("/").pop())
198 .join(", ");
199 out.push({
200 category: "complexity",
201 action: `Split the largest files to bring average size under ${current.next} bytes`,
202 detail: `Average is ${b.complexity.avgFileSize.toLocaleString()} bytes across ${b.complexity.totalFiles} files; start with ${worst}`,
203 overallGain: overall("complexity", gain),
204 });
205 }
206 }
207
208 // ── Dependencies
209 if (b.dependencies.manifestFound) {
210 if (!b.dependencies.lockfileExists && b.dependencies.total > 0) {
211 out.push({
212 category: "dependencies",
213 action: "Commit a lockfile",
214 detail: `${b.dependencies.total} dependencies in ${b.dependencies.manifests.join(", ")} with no lockfile — builds are not reproducible`,
215 overallGain: overall("dependencies", 20),
216 });
217 }
218 }
219
220 // ── Documentation: each missing artifact has an exact price.
221 const docs: Array<[boolean, string, string, number]> = [
222 [b.documentation.hasReadme, "Add a README", "The front door of the repo — 40 of documentation's 100 points", 40],
223 [b.documentation.hasLicense, "Add a LICENSE file", "Unlicensed code is legally unusable by anyone else", 20],
224 [b.documentation.hasContributing, "Add CONTRIBUTING.md", "Tells collaborators how to work on the repo", 15],
225 [b.documentation.hasChangelog, "Add a CHANGELOG", "Lets users see what changed between versions", 15],
226 ];
227 for (const [has, action, detail, pts] of docs) {
228 if (!has) {
229 out.push({
230 category: "documentation",
231 action,
232 detail,
233 overallGain: overall("documentation", pts),
234 });
235 }
236 }
237
238 // ── Activity: only actionable when the repo has gone quiet.
239 if (b.activity.lastPushDaysAgo > 30) {
240 out.push({
241 category: "activity",
242 action: "Push again — freshness decays the score",
243 detail: `Last push ${b.activity.lastPushDaysAgo} days ago; pushing within 7 days is worth 30 of activity's 100 points`,
244 overallGain: overall(
245 "activity",
246 b.activity.lastPushDaysAgo > 90 ? 30 : 10
247 ),
248 });
249 }
250
251 return out
252 .filter((i) => i.overallGain > 0)
253 .sort((a, z) => z.overallGain - a.overallGain);
254}
255
256/** Inverse of the testing tier table: score → the ratio floor it implies. */
257function ratioForScore(score: number): number {
258 if (score >= 95) return 0.8;
259 if (score >= 75) return 0.5;
260 if (score >= 50) return 0.2;
261 return 0.05;
262}
263
75264export async function computeHealthScore(
76265 owner: string,
77266 repo: string
95284 analyzeActivityScore(repoDir, ref),
96285 ]);
97286
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 };
287 const weights = healthWeights(dependencies.manifestFound);
106288
107289 const score = Math.round(
108290 security.score * weights.security +
269451 }
270452 }
271453
272 const criticals = issues.filter((i) => i.severity === "critical").length;
273 const highs = issues.filter((i) => i.severity === "high").length;
274 const mediums = issues.filter((i) => i.severity === "medium").length;
454 return { score: scoreSecurityIssues(issues), issues };
455}
275456
276 let score = 100;
277 score -= criticals * 25;
278 score -= highs * 10;
279 score -= mediums * 3;
457// Per-instance penalties, capped PER RULE for high/medium. Uncapped linear
458// scoring meant 65 instances of one innerHTML pattern scored 0/100 — the
459// same as a repo with committed AWS keys — and the 40th instance of a rule
460// carries no information the 5th didn't. Criticals stay uncapped: each
461// leaked secret is a separate incident.
462const SECURITY_PENALTIES = {
463 critical: { each: 25, ruleCap: Infinity },
464 high: { each: 10, ruleCap: 40 },
465 medium: { each: 3, ruleCap: 20 },
466} as const;
280467
281 return { score: Math.max(0, Math.min(100, score)), issues };
468/**
469 * Score from issue list, penalty grouped and capped per rule. Exported so
470 * computeImprovements can attribute exactly what each rule costs.
471 */
472export function scoreSecurityIssues(issues: SecurityIssue[]): number {
473 return Math.max(
474 0,
475 Math.min(
476 100,
477 100 -
478 [...securityPenaltiesByRule(issues).values()].reduce(
479 (s, p) => s + p,
480 0
481 )
482 )
483 );
484}
485
486/** Map of rule → capped penalty it contributes to the security score. */
487export function securityPenaltiesByRule(
488 issues: SecurityIssue[]
489): Map<string, number> {
490 const counts = new Map<string, { severity: SecurityIssue["severity"]; n: number }>();
491 for (const i of issues) {
492 const c = counts.get(i.rule) ?? { severity: i.severity, n: 0 };
493 c.n++;
494 counts.set(i.rule, c);
495 }
496 const penalties = new Map<string, number>();
497 for (const [rule, { severity, n }] of counts) {
498 const p = SECURITY_PENALTIES[severity as keyof typeof SECURITY_PENALTIES];
499 if (!p) continue; // low/info carry no score penalty
500 penalties.set(rule, Math.min(n * p.each, p.ruleCap));
501 }
502 return penalties;
282503}
283504
284505// ─── TESTING ─────────────────────────────────────────────────
401622 total: number;
402623 outdatedEstimate: number;
403624 lockfileExists: boolean;
625 manifestFound: boolean;
626 manifests: string[];
404627}> {
405628 const { stdout: tree } = await exec(
406629 ["git", "ls-tree", "--name-only", ref],
408631 );
409632 const files = tree.trim().split("\n");
410633
411 const hasLockfile =
412 files.includes("bun.lock") ||
413 files.includes("package-lock.json") ||
414 files.includes("yarn.lock") ||
415 files.includes("pnpm-lock.yaml") ||
416 files.includes("Cargo.lock") ||
417 files.includes("go.sum") ||
418 files.includes("Gemfile.lock") ||
419 files.includes("poetry.lock");
420
421 const hasPackageJson = files.includes("package.json");
422 const hasCargoToml = files.includes("Cargo.toml");
423 const hasGoMod = files.includes("go.mod");
424 const hasRequirements = files.includes("requirements.txt");
634 const hasLockfile = [
635 "bun.lock",
636 "bun.lockb",
637 "package-lock.json",
638 "yarn.lock",
639 "pnpm-lock.yaml",
640 "Cargo.lock",
641 "go.sum",
642 "Gemfile.lock",
643 "poetry.lock",
644 "Pipfile.lock",
645 "uv.lock",
646 "composer.lock",
647 ].some((l) => files.includes(l));
648
649 // A repo scored "100 — 0 dependencies" on a Python project because only
650 // package.json/requirements.txt counted as manifests: pyproject.toml was
651 // invisible, and "nothing found" was reported as a PERFECT score. Unknown
652 // must never render as perfect — when no manifest exists, manifestFound
653 // is false and the caller excludes this category from the overall score.
654 const MANIFESTS = [
655 "package.json",
656 "pyproject.toml",
657 "requirements.txt",
658 "Pipfile",
659 "Cargo.toml",
660 "go.mod",
661 "Gemfile",
662 "composer.json",
663 ];
664 const manifests = MANIFESTS.filter((m) => files.includes(m));
665 const manifestFound = manifests.length > 0;
425666
426667 let total = 0;
427668
428 if (hasPackageJson) {
669 if (files.includes("package.json")) {
429670 try {
430671 const { stdout: content } = await exec(
431672 ["git", "show", `${ref}:package.json`],
432673 repoDir
433674 );
434675 const pkg = JSON.parse(content);
435 total =
676 total +=
436677 Object.keys(pkg.dependencies || {}).length +
437678 Object.keys(pkg.devDependencies || {}).length;
438679 } catch {
440681 }
441682 }
442683
684 if (files.includes("pyproject.toml")) {
685 try {
686 const { stdout: content } = await exec(
687 ["git", "show", `${ref}:pyproject.toml`],
688 repoDir
689 );
690 // Count entries in [project] dependencies = [...] arrays and
691 // [tool.poetry.dependencies]-style tables. A rough count is enough —
692 // the score only cares about magnitude bands.
693 const arrayDeps =
694 content.match(/^\s*["'][a-zA-Z0-9_.\[\]-]+[^"']*["']\s*,?\s*$/gm)
695 ?.length ?? 0;
696 const tableDeps =
697 content.match(/^[a-zA-Z0-9_-]+\s*=\s*["'{^~>=<]/gm)?.length ?? 0;
698 total += Math.max(arrayDeps, tableDeps);
699 } catch {
700 // parse error
701 }
702 }
703
704 if (files.includes("requirements.txt")) {
705 try {
706 const { stdout: content } = await exec(
707 ["git", "show", `${ref}:requirements.txt`],
708 repoDir
709 );
710 total += content
711 .split("\n")
712 .filter((l) => l.trim() && !l.trim().startsWith("#")).length;
713 } catch {
714 // read error
715 }
716 }
717
443718 let score = 100;
444719 if (!hasLockfile && total > 0) score -= 20; // No lockfile with deps is bad
445720 if (total > 100) score -= 10; // Too many deps
450725 total,
451726 outdatedEstimate: 0, // Would need network access to check
452727 lockfileExists: hasLockfile,
728 manifestFound,
729 manifests,
453730 };
454731}
455732
8631140
8641141 if (!breakdown.testing.hasTests) {
8651142 insights.push("No tests found — adding tests would significantly improve code reliability");
866 } else if (breakdown.testing.testFileCount > 10) {
867 insights.push(`Strong test suite with ${breakdown.testing.testFileCount} test files`);
1143 } else if (breakdown.testing.score >= 75) {
1144 // Only praise coverage the score agrees with — "Strong test suite"
1145 // beside a 50/100 Testing card reads as a contradiction, not an insight.
1146 insights.push(`Strong test suite with ${breakdown.testing.testFileCount} test files (${breakdown.testing.estimatedCoverage})`);
1147 } else {
1148 insights.push(`${breakdown.testing.testFileCount} test files but coverage is ${breakdown.testing.estimatedCoverage.toLowerCase()} relative to codebase size`);
1149 }
1150
1151 if (!breakdown.dependencies.manifestFound) {
1152 insights.push("No dependency manifest found — the Dependencies category is excluded from the score rather than counted as perfect");
8681153 }
8691154
8701155 if (!breakdown.documentation.hasReadme) {
Modifiedsrc/routes/health.tsx+96−17View fileUnifiedSplit
1313import { RepoHeader, RepoNav } from "../views/components";
1414import {
1515 computeHealthScore,
16 computeImprovements,
1617 detectCIConfig,
18 healthWeights,
1719 type RepoHealthReport,
1820 type SecurityIssue,
1921} from "../lib/intelligence";
4042 ]);
4143
4244 const securityGroups = groupSecurityIssues(report.breakdown.security.issues);
45 const improvements = computeImprovements(report);
46 const weights = healthWeights(report.breakdown.dependencies.manifestFound);
47 const potential = Math.min(
48 100,
49 report.score +
50 Math.round(improvements.reduce((s, i) => s + i.overallGain, 0))
51 );
4352
4453 const gradeColor =
4554 report.grade === "A+" || report.grade === "A"
8190 </div>
8291 </div>
8392
93 {improvements.length > 0 && (
94 <div style="margin-bottom: 32px">
95 <h3 style="margin-bottom: 4px">How to improve this score</h3>
96 <div style="font-size: 13px; color: var(--text-muted); margin-bottom: 12px">
97 Completing everything below would take this repo from{" "}
98 <strong>{report.score}</strong> to about{" "}
99 <strong>{potential}</strong>/100. Gains are computed from the
100 same formula that produced the score.
101 </div>
102 <div class="issue-list">
103 {improvements.map((imp, idx) => (
104 <div class="issue-item" style="display: flex; gap: 12px; align-items: baseline">
105 <span style="font-size: 14px; font-weight: 700; color: var(--green); flex-shrink: 0; min-width: 52px; text-align: right; font-family: var(--font-mono)">
106 +{imp.overallGain}
107 </span>
108 <div style="min-width: 0">
109 <div style="font-size: 14px; font-weight: 500">
110 {idx + 1}. {imp.action}
111 <span class="badge" style="margin-left: 8px; font-size: 10px; text-transform: capitalize">
112 {imp.category}
113 </span>
114 </div>
115 <div style="font-size: 12px; color: var(--text-muted)">
116 {imp.detail}
117 </div>
118 </div>
119 </div>
120 ))}
121 </div>
122 </div>
123 )}
124
84125 <div class="card-grid" style="grid-template-columns: repeat(auto-fill, minmax(280px, 1fr))">
85126 <ScoreCard
86127 title="Security"
87128 score={report.breakdown.security.score}
129 weightPct={Math.round(weights.security * 100)}
88130 details={[
89 `${report.breakdown.security.issues.length} issue${report.breakdown.security.issues.length !== 1 ? "s" : ""} found`,
90 `${report.breakdown.security.issues.filter((i) => i.severity === "critical").length} critical`,
91 `${report.breakdown.security.issues.filter((i) => i.severity === "high").length} high`,
131 `${report.breakdown.security.issues.length} finding${report.breakdown.security.issues.length !== 1 ? "s" : ""} across ${securityGroups.length} rule${securityGroups.length !== 1 ? "s" : ""}`,
132 `${report.breakdown.security.issues.filter((i) => i.severity === "critical").length} critical · ${report.breakdown.security.issues.filter((i) => i.severity === "high").length} high · ${report.breakdown.security.issues.filter((i) => i.severity === "medium").length} medium`,
92133 ]}
93134 />
94135 <ScoreCard
95136 title="Testing"
96137 score={report.breakdown.testing.score}
138 weightPct={Math.round(weights.testing * 100)}
97139 details={[
98140 report.breakdown.testing.hasTests ? `${report.breakdown.testing.testFileCount} test files` : "No tests found",
99141 `Coverage estimate: ${report.breakdown.testing.estimatedCoverage}`,
102144 <ScoreCard
103145 title="Complexity"
104146 score={report.breakdown.complexity.score}
147 weightPct={Math.round(weights.complexity * 100)}
105148 details={[
106149 `${report.breakdown.complexity.totalFiles} source files`,
107 `Avg file size: ${report.breakdown.complexity.avgFileSize} bytes`,
108 ]}
109 />
110 <ScoreCard
111 title="Dependencies"
112 score={report.breakdown.dependencies.score}
113 details={[
114 `${report.breakdown.dependencies.total} dependencies`,
115 report.breakdown.dependencies.lockfileExists ? "Lockfile present" : "No lockfile",
150 `Avg file size: ${report.breakdown.complexity.avgFileSize.toLocaleString()} bytes`,
151 ...(report.breakdown.complexity.largestFiles[0]
152 ? [`Largest: ${report.breakdown.complexity.largestFiles[0].path.split("/").pop()} (${report.breakdown.complexity.largestFiles[0].lines.toLocaleString()} bytes)`]
153 : []),
116154 ]}
117155 />
156 {report.breakdown.dependencies.manifestFound ? (
157 <ScoreCard
158 title="Dependencies"
159 score={report.breakdown.dependencies.score}
160 weightPct={Math.round(weights.dependencies * 100)}
161 details={[
162 `${report.breakdown.dependencies.total} dependencies (${report.breakdown.dependencies.manifests.join(", ")})`,
163 report.breakdown.dependencies.lockfileExists ? "Lockfile present" : "No lockfile",
164 ]}
165 />
166 ) : (
167 <ScoreCard
168 title="Dependencies"
169 score={null}
170 weightPct={0}
171 details={[
172 "No dependency manifest found",
173 "Excluded from the score — not counted as perfect",
174 ]}
175 />
176 )}
118177 <ScoreCard
119178 title="Documentation"
120179 score={report.breakdown.documentation.score}
180 weightPct={Math.round(weights.documentation * 100)}
121181 details={[
122182 report.breakdown.documentation.hasReadme ? "README found" : "No README",
123183 report.breakdown.documentation.hasLicense ? "License present" : "No license",
127187 <ScoreCard
128188 title="Activity"
129189 score={report.breakdown.activity.score}
190 weightPct={Math.round(weights.activity * 100)}
130191 details={[
131192 `${report.breakdown.activity.recentCommits} commits (30d)`,
132193 `${report.breakdown.activity.uniqueContributors} contributors`,
326387const ScoreCard = ({
327388 title,
328389 score,
390 weightPct,
329391 details,
330392}: {
331393 title: string;
332 score: number;
394 /** null = category not analyzable for this repo (rendered as "—"). */
395 score: number | null;
396 /** Share of the overall score this category carries, in percent. */
397 weightPct: number;
333398 details: string[];
334399}) => {
335 const color =
336 score >= 80 ? "var(--green)" : score >= 50 ? "var(--yellow)" : "var(--red)";
400 const notScored = score === null;
401 const color = notScored
402 ? "var(--text-muted)"
403 : score >= 80
404 ? "var(--green)"
405 : score >= 50
406 ? "var(--yellow)"
407 : "var(--red)";
408 const contribution = notScored
409 ? null
410 : Math.round((score * weightPct) / 100);
337411 return (
338412 <div class="card">
339413 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px">
341415 <span
342416 style={`font-size: 18px; font-weight: 700; color: ${color}`}
343417 >
344 {score}
418 {notScored ? "—" : score}
345419 </span>
346420 </div>
347421 <div
348422 style="height: 4px; background: var(--bg-tertiary); border-radius: 2px; margin-bottom: 8px; overflow: hidden"
349423 >
350424 <div
351 style={`height: 100%; width: ${score}%; background: ${color}; border-radius: 2px; transition: width 0.3s;`}
425 style={`height: 100%; width: ${notScored ? 0 : score}%; background: ${color}; border-radius: 2px; transition: width 0.3s;`}
352426 />
353427 </div>
428 <div style="font-size: 11px; color: var(--text-faint); margin-bottom: 4px">
429 {notScored
430 ? "Not counted in the overall score"
431 : `${contribution} of ${weightPct} possible overall points (weight ${weightPct}%)`}
432 </div>
354433 {details.map((d) => (
355434 <div style="font-size: 12px; color: var(--text-muted); margin-top: 2px">
356435 {d}
357436
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts