Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitce08e97unknown_key

feat(BLOCK-J): J31 repository size audit

feat(BLOCK-J): J31 repository size audit

Adds `/:owner/:repo/insights/size` — a "where are the bytes?" view
covering total size + largest-file leaderboard + top-level directory
breakdown + a five-tier size-class histogram (tiny / small / medium /
large / xlarge). Pure rollup in `src/lib/repo-size.ts`: `summariseSize`,
`bucketBySize`, `topLargestFiles` (non-mutating, stable tie-break),
`summariseByTopDir` (root bucket sinks on ties), `buildSizeReport`
one-shot.

Reuses `listTreeRecursive` from J30 + `formatBytes`/`formatPercent`
from language-stats, so the route is a thin shell. `top=N` capped at
200; `ref` sanitised against git-refspec metacharacters. softAuth;
private repos 404 for non-owner viewers; git failures degrade to an
empty-state report — never 500. Linked from the Insights page header.

33 new tests (1419 passing total).

https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
Claude committed on April 15, 2026Parent: 07efa08
6 files changed+9111ce08e975ca1da6a551b4d44210439b2e59ff43a7
6 changed files+911−1
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
152152| Issue duplicate suggestions | ✅ | J28 — `GET /:owner/:repo/issues/similar.json?q=<title>[&limit][&state=open\|closed]` returns ranked matches as JSON for new-issue-form inline suggestions. `GET /:owner/:repo/issues/:n/similar` is a standalone HTML page ranking related issues against the target title. Pure token-Jaccard ranker in `src/lib/issue-similarity.ts`: `tokeniseTitle` lowercases + strips non-`\p{L}\p{N}_-` + drops stopwords + drops tokens <2 chars; `jaccard` is Unicode-safe `|A∩B| / |A∪B|`; `rankCandidates` sorts score-desc with createdAt-desc + number-desc tie-breaks, honours `minScore` (default 0.15) / `limit` (default 5) / `excludeId` / `excludeNumber` / `state`. `formatSimilarityPercent` clamps to [0,1] and emits `"47%"`. Candidates capped at last 500 issues per repo. softAuth; private repos 404 for non-owner viewers. |
153153| PR lead-time metric | ✅ | J29 — `GET /:owner/:repo/insights/lead-time[?window=7\|30\|90\|365\|0]` renders p50/mean/p90/fastest/slowest PR lead times (created→merged), a four-bucket merge-time distribution, and the oldest still-open non-draft PRs. Pure `computeLeadTime` / `computePrStats` (anchors the window on `mergedAt` for merged PRs, `createdAt` otherwise, so a PR merged yesterday with origin 60 days ago still lands in a 30-day window) / `summariseLeadTimes` (inclusive-method percentiles; separate counters for merged / openNonDraft / openDraft / closedUnmerged) / `bucketLeadTimes` / `buildLeadTimeReport` in `src/lib/pr-lead-time.ts`. Reuses `parseWindow` + `formatDuration` + `VALID_WINDOWS` from Block J25's `response-time.ts` to stay DRY. Linked from the Insights page header alongside Response time + Pulse. softAuth; private repos 404 for non-owner viewers. |
154154| Repository language breakdown | ✅ | J30 — `GET /:owner/:repo/languages[?vendored=1&fold=N&ref=<branch>]` renders a GitHub-parity stacked percentage bar + legend + per-language table (bytes / file count / share) against the repo's default branch (or an explicit `ref`). Pure `detectLanguage` (extension map ~80 entries + filename map for Dockerfile/Makefile/Rakefile/CMakeLists.txt/meson.build/etc., case-insensitive, dotfiles are null, last-extension wins), `isVendoredOrGenerated` (prefixes `node_modules/`, `vendor/`, `dist/`, `build/`, `.next/`, `.nuxt/`, `coverage/`, `.git/`, `target/`, `bin/` + lockfile basenames `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lockb`, `Cargo.lock`, `composer.lock`, `Gemfile.lock`, `poetry.lock`), `computeLanguageStats` (vendored opt-out, per-file `maxFileSize` cap so a 500MB blob can't bias the pie, `minLanguageBytes` fold, zero/negative sizes skipped), `foldIntoOther` (idempotent rebucketing, Other always sorts last), `formatBytes` (B/KB/MB/GB/TB with 10+ threshold for dropping decimals), `formatPercent` (clamp [0,100], NaN → "0%"), `buildLanguageReport` one-shot, plus `LANGUAGE_COLORS` GitHub-ish palette in `src/lib/language-stats.ts`. New `listTreeRecursive(owner, name, ref)` helper in `src/git/repository.ts` uses `git ls-tree -r -l -z` (null-delimited for safety), skips trees/submodules and mode `120000` symlinks so their target-length doesn't pollute sizes, cached under `${owner}/${name}:tree-recursive:${ref}`, returns `[]` on git failure. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers. |
155| Repository size audit | ✅ | J31 — `GET /:owner/:repo/insights/size[?top=N&ref=<branch>]` renders "where are the bytes?" for the default branch (or an explicit ref): five KPI cards (file count / total / largest / median / mean), a five-bucket size-class histogram (tiny <1KB / small <100KB / medium <1MB / large <10MB / xlarge ≥10MB), a top-level directory breakdown with percentage shares, and the largest N files (default 25, capped at 200) linked through to the blob viewer. Pure `topLevelDir`, `classifyFileSize`, `summariseSize` (total/mean/median/largest/smallest over valid entries, drops NaN/negative/non-string-path input), `bucketBySize`, `topLargestFiles` (non-mutating, stable alphabetical tie-break, optional `minBytes` floor), `summariseByTopDir` (groups by first path segment, `.` is the root bucket sorting last on ties), `buildSizeReport` one-shot in `src/lib/repo-size.ts`. Reuses `listTreeRecursive` from J30 + `formatBytes`/`formatPercent` from `language-stats.ts` so the route stays a thin shell. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers; git failures degrade to an empty-state report — never 500. |
155156| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
156157| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
157158| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
553554- `src/lib/language-stats.ts` (Block J30) — pure language-breakdown helpers. `EXTENSION_MAP` (~80 entries) maps extensions (ts/tsx/js/mjs/py/rb/go/rs/java/kt/swift/c/cpp/cs/php/scala/clj/ex/hs/lua/ml/dart/sh/ps1/sql/html/css/scss/vue/svelte/astro/md/mdx/yaml/json/toml/proto/graphql/zig/nim/…) to language names. `FILENAME_MAP` handles extensionless filenames (`dockerfile`, `makefile`, `gnumakefile`, `rakefile`, `gemfile`, `jenkinsfile`, `cmakelists.txt`, `meson.build`, `build.gradle`, `build.gradle.kts`, `pom.xml`, `package.json`, `tsconfig.json`). `LANGUAGE_COLORS` is a GitHub-ish palette; unmapped languages render with `DEFAULT_LANGUAGE_COLOR='#888888'`. `VENDORED_PREFIXES = [node_modules/, vendor/, dist/, build/, .next/, .nuxt/, coverage/, .git/, target/, bin/]` and `GENERATED_SUFFIXES` covers the full set of common lockfiles. `detectLanguage(path)` tries filename first then extension (case-insensitive; dotfiles return null since `.env`/`.gitignore` have no extension in the real-extension sense). `isVendoredOrGenerated(path)` matches both top-level and nested prefixes + lockfile basenames. `computeLanguageStats(entries, {ignoreVendored=true, maxFileSize, minLanguageBytes})` returns `{totalBytes, totalFiles, countedFiles, buckets: Array<{language, bytes, fileCount, percent, color}>, primary}` sorted bytes-desc with language-name alphabetical tie-break and "Other" always last. `foldIntoOther(report, thresholdPercent)` is idempotent + never mutates. `formatBytes` uses B/KB/MB/GB/TB with the 10+ threshold for dropping decimal places. `formatPercent(p, digits=1)` clamps to [0,100] and returns "0%" for NaN. `buildLanguageReport({entries, foldUnderPercent, …})` is the one-shot. `__internal` re-exports.
554555- `src/routes/languages.tsx` (Block J30) — serves `GET /:owner/:repo/languages[?vendored=1&fold=N&ref=<branch>]`. softAuth; private repos 404 for non-owner viewers. Resolves the analyzed ref via `getDefaultBranch` unless `?ref=` is supplied (validated against whitespace / `..` / git-refspec metacharacters). Runs `listTreeRecursive``buildLanguageReport`, renders a stacked percentage bar + colour legend + per-language table (files / size / share). Git failures or missing default branch degrade to an empty-state panel — never 500. Include-vendored checkbox + "Fold <N%" selector auto-submit. Linked from the Insights page header.
555556- `src/git/repository.ts` (Block J30 additions) — `listTreeRecursive(owner, name, ref)` runs `git ls-tree -r -l -z <ref>` (null-delimited so paths containing newlines don't break parsing), skips non-blobs (trees, submodules), skips mode `120000` symlinks so their target-length isn't counted as file size, returns `Array<{path, size}>` (`[]` on any git failure). Cached under `${owner}/${name}:tree-recursive:${ref}`.
557- `src/lib/repo-size.ts` (Block J31) — pure size-audit helpers. `DEFAULT_TOP_N=25`. `SIZE_CLASSES` is the five-tier taxonomy (tiny<1KB, small<100KB, medium<1MB, large<10MB, xlarge≥10MB) with inclusive-below boundaries + human labels. `topLevelDir(path)` extracts the first segment (returns `"."` for root files, tolerates leading `/` + non-string input). `classifyFileSize(size)` bucketizes (NaN/negative → tiny). `summariseSize(entries)` returns `{totalFiles, countedFiles, totalBytes, averageBytes, medianBytes, largestBytes, smallestBytes}``totalFiles` is the raw input count, `countedFiles` is the post-validation count. `bucketBySize` always returns all five buckets so the UI can render zero-count cells. `topLargestFiles(entries, {limit, minBytes})` is non-mutating, sorts bytes-desc with path-asc stable tie-break, defaults invalid limits to `DEFAULT_TOP_N`, and populates each result's `topDir`. `summariseByTopDir` groups by first segment, sorts bytes-desc with the root (`.`) bucket sinking to the bottom on ties. `buildSizeReport({entries, topN, minBytesForLargest})` one-shot. `__internal` re-exports.
558- `src/routes/repo-size.tsx` (Block J31) — serves `GET /:owner/:repo/insights/size`. softAuth; private repos 404 for non-owner viewers. Resolves ref via `getDefaultBranch` unless `?ref=` is supplied (sanitised against whitespace / `..` / git-refspec metacharacters). Calls `listTreeRecursive``buildSizeReport`. `top=N` capped at 200; `min=B` capped at 1 GiB; both default to sane values on garbage input. Largest-files table links each path through to `/:owner/:repo/blob/<ref>/<path>` with proper encodeURIComponent per segment. Empty trees render an empty-state panel. Linked from the Insights page header. Must be mounted before `insightsRoutes` so the static `/insights/size` path wins over any future `/insights/:id` dynamic route.
556559
557560### 4.7 Views (locked contracts)
558561- `src/views/layout.tsx``Layout` accepts `title`, `user`, `notificationCount`
587590```bash
588591bun install
589592bun dev # hot reload
590bun test # 1386 tests currently pass
593bun test # 1419 tests currently pass
591594bun run db:migrate
592595```
593596
Addedsrc/__tests__/repo-size.test.ts+329−0View fileUnifiedSplit
1/**
2 * Block J31 — Repository size audit. Pure helper tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 DEFAULT_TOP_N,
8 SIZE_CLASSES,
9 topLevelDir,
10 classifyFileSize,
11 summariseSize,
12 bucketBySize,
13 topLargestFiles,
14 summariseByTopDir,
15 buildSizeReport,
16 __internal,
17 type RepoSizeEntry,
18} from "../lib/repo-size";
19
20const KB = 1024;
21const MB = 1024 * 1024;
22
23describe("repo-size — topLevelDir", () => {
24 it("extracts the first segment", () => {
25 expect(topLevelDir("src/app.ts")).toBe("src");
26 expect(topLevelDir("apps/web/main.ts")).toBe("apps");
27 });
28 it("returns '.' for root-level files", () => {
29 expect(topLevelDir("README.md")).toBe(".");
30 expect(topLevelDir("package.json")).toBe(".");
31 });
32 it("handles leading slash", () => {
33 expect(topLevelDir("/src/app.ts")).toBe("src");
34 expect(topLevelDir("/README.md")).toBe(".");
35 });
36 it("handles empty / non-string", () => {
37 expect(topLevelDir("")).toBe(".");
38 // @ts-expect-error
39 expect(topLevelDir(null)).toBe(".");
40 // @ts-expect-error
41 expect(topLevelDir(undefined)).toBe(".");
42 });
43});
44
45describe("repo-size — classifyFileSize", () => {
46 it("maps to the five classes by boundary", () => {
47 expect(classifyFileSize(0)).toBe("tiny");
48 expect(classifyFileSize(KB - 1)).toBe("tiny");
49 expect(classifyFileSize(KB)).toBe("small");
50 expect(classifyFileSize(50 * KB)).toBe("small");
51 expect(classifyFileSize(100 * KB)).toBe("medium");
52 expect(classifyFileSize(500 * KB)).toBe("medium");
53 expect(classifyFileSize(MB)).toBe("large");
54 expect(classifyFileSize(5 * MB)).toBe("large");
55 expect(classifyFileSize(10 * MB)).toBe("xlarge");
56 expect(classifyFileSize(50 * MB)).toBe("xlarge");
57 });
58 it("defaults bogus values to tiny", () => {
59 expect(classifyFileSize(Number.NaN)).toBe("tiny");
60 expect(classifyFileSize(-5)).toBe("tiny");
61 });
62 it("has exactly five ordered classes", () => {
63 expect(SIZE_CLASSES).toHaveLength(5);
64 expect(SIZE_CLASSES.map((c) => c.key)).toEqual([
65 "tiny",
66 "small",
67 "medium",
68 "large",
69 "xlarge",
70 ]);
71 });
72});
73
74describe("repo-size — summariseSize", () => {
75 it("aggregates bytes and computes mean/median", () => {
76 const entries: RepoSizeEntry[] = [
77 { path: "a", size: 100 },
78 { path: "b", size: 200 },
79 { path: "c", size: 300 },
80 { path: "d", size: 400 },
81 ];
82 const s = summariseSize(entries);
83 expect(s.totalBytes).toBe(1000);
84 expect(s.totalFiles).toBe(4);
85 expect(s.countedFiles).toBe(4);
86 expect(s.averageBytes).toBe(250);
87 expect(s.medianBytes).toBe(250); // (200+300)/2
88 expect(s.largestBytes).toBe(400);
89 expect(s.smallestBytes).toBe(100);
90 });
91 it("median picks middle of odd-length series", () => {
92 const s = summariseSize([
93 { path: "a", size: 10 },
94 { path: "b", size: 50 },
95 { path: "c", size: 500 },
96 ]);
97 expect(s.medianBytes).toBe(50);
98 });
99 it("drops invalid entries but keeps totalFiles on raw count", () => {
100 const s = summariseSize([
101 { path: "a", size: 100 },
102 // @ts-expect-error
103 { path: 42, size: 100 },
104 { path: "b", size: Number.NaN },
105 { path: "c", size: -5 },
106 { path: "d", size: 900 },
107 ]);
108 expect(s.totalFiles).toBe(5);
109 expect(s.countedFiles).toBe(2);
110 expect(s.totalBytes).toBe(1000);
111 });
112 it("handles empty input", () => {
113 const s = summariseSize([]);
114 expect(s.totalBytes).toBe(0);
115 expect(s.totalFiles).toBe(0);
116 expect(s.countedFiles).toBe(0);
117 expect(s.averageBytes).toBe(0);
118 expect(s.medianBytes).toBe(0);
119 expect(s.largestBytes).toBe(0);
120 expect(s.smallestBytes).toBe(0);
121 });
122});
123
124describe("repo-size — bucketBySize", () => {
125 it("distributes into five class buckets", () => {
126 const entries: RepoSizeEntry[] = [
127 { path: "a", size: 500 }, // tiny
128 { path: "b", size: 10 * KB }, // small
129 { path: "c", size: 500 * KB }, // medium
130 { path: "d", size: 5 * MB }, // large
131 { path: "e", size: 20 * MB }, // xlarge
132 { path: "f", size: 5 * KB }, // small
133 ];
134 const b = bucketBySize(entries);
135 const by = Object.fromEntries(b.map((x) => [x.key, x]));
136 expect(by.tiny!.fileCount).toBe(1);
137 expect(by.small!.fileCount).toBe(2);
138 expect(by.medium!.fileCount).toBe(1);
139 expect(by.large!.fileCount).toBe(1);
140 expect(by.xlarge!.fileCount).toBe(1);
141 expect(by.small!.bytes).toBe(10 * KB + 5 * KB);
142 });
143 it("returns all zero buckets for empty input", () => {
144 const b = bucketBySize([]);
145 expect(b).toHaveLength(5);
146 expect(b.every((x) => x.fileCount === 0 && x.bytes === 0)).toBe(true);
147 });
148});
149
150describe("repo-size — topLargestFiles", () => {
151 const entries: RepoSizeEntry[] = [
152 { path: "src/a.ts", size: 100 },
153 { path: "src/b.ts", size: 500 },
154 { path: "bundle.js", size: 10_000 },
155 { path: "img.png", size: 2_000 },
156 { path: "tiny.md", size: 10 },
157 ];
158
159 it("returns sorted-desc by size", () => {
160 const out = topLargestFiles(entries);
161 expect(out.map((f) => f.path)).toEqual([
162 "bundle.js",
163 "img.png",
164 "src/b.ts",
165 "src/a.ts",
166 "tiny.md",
167 ]);
168 });
169
170 it("respects limit", () => {
171 const out = topLargestFiles(entries, { limit: 2 });
172 expect(out).toHaveLength(2);
173 expect(out[0]!.path).toBe("bundle.js");
174 expect(out[1]!.path).toBe("img.png");
175 });
176
177 it("applies minBytes floor", () => {
178 const out = topLargestFiles(entries, { minBytes: 1_000 });
179 expect(out.map((f) => f.path)).toEqual(["bundle.js", "img.png"]);
180 });
181
182 it("percentages sum to 100 across all returned files when limit is generous", () => {
183 const out = topLargestFiles(entries, { limit: 99 });
184 const sum = out.reduce((acc, f) => acc + f.percent, 0);
185 expect(Math.round(sum)).toBe(100);
186 });
187
188 it("tie-breaks by path alphabetical", () => {
189 const same: RepoSizeEntry[] = [
190 { path: "zebra.bin", size: 100 },
191 { path: "alpha.bin", size: 100 },
192 ];
193 const out = topLargestFiles(same);
194 expect(out[0]!.path).toBe("alpha.bin");
195 expect(out[1]!.path).toBe("zebra.bin");
196 });
197
198 it("populates topDir", () => {
199 const out = topLargestFiles(entries);
200 expect(out.find((f) => f.path === "src/b.ts")!.topDir).toBe("src");
201 expect(out.find((f) => f.path === "bundle.js")!.topDir).toBe(".");
202 });
203
204 it("does not mutate the input array", () => {
205 const copy = [...entries];
206 topLargestFiles(entries);
207 expect(entries).toEqual(copy);
208 });
209
210 it("returns [] for empty input", () => {
211 expect(topLargestFiles([])).toEqual([]);
212 });
213
214 it("defaults limit to DEFAULT_TOP_N when missing or invalid", () => {
215 const many: RepoSizeEntry[] = Array.from({ length: 100 }, (_, i) => ({
216 path: `f${i}.bin`,
217 size: i + 1,
218 }));
219 expect(topLargestFiles(many)).toHaveLength(DEFAULT_TOP_N);
220 expect(topLargestFiles(many, { limit: 0 })).toHaveLength(DEFAULT_TOP_N);
221 expect(topLargestFiles(many, { limit: -5 })).toHaveLength(DEFAULT_TOP_N);
222 });
223});
224
225describe("repo-size — summariseByTopDir", () => {
226 const entries: RepoSizeEntry[] = [
227 { path: "src/a.ts", size: 100 },
228 { path: "src/sub/b.ts", size: 400 },
229 { path: "tests/t.ts", size: 50 },
230 { path: "README.md", size: 10 },
231 { path: "package.json", size: 40 },
232 ];
233
234 it("groups by first segment", () => {
235 const out = summariseByTopDir(entries);
236 const by = Object.fromEntries(out.map((d) => [d.name, d]));
237 expect(by.src!.bytes).toBe(500);
238 expect(by.src!.fileCount).toBe(2);
239 expect(by.tests!.bytes).toBe(50);
240 expect(by["."]!.bytes).toBe(50);
241 expect(by["."]!.fileCount).toBe(2);
242 });
243
244 it("sorts by bytes desc", () => {
245 const out = summariseByTopDir(entries);
246 expect(out.map((d) => d.name)).toEqual(["src", "tests", "."]);
247 });
248
249 it("percentages sum to 100", () => {
250 const out = summariseByTopDir(entries);
251 const sum = out.reduce((acc, d) => acc + d.percent, 0);
252 expect(Math.round(sum)).toBe(100);
253 });
254
255 it("root bucket sorts last on byte ties", () => {
256 const tied: RepoSizeEntry[] = [
257 { path: "src/a.ts", size: 100 },
258 { path: "README.md", size: 100 },
259 ];
260 const out = summariseByTopDir(tied);
261 expect(out.map((d) => d.name)).toEqual(["src", "."]);
262 });
263
264 it("empty input gives empty array", () => {
265 expect(summariseByTopDir([])).toEqual([]);
266 });
267});
268
269describe("repo-size — buildSizeReport", () => {
270 const entries: RepoSizeEntry[] = [
271 { path: "src/a.ts", size: 100 },
272 { path: "src/big.bin", size: 5 * MB },
273 { path: "docs/readme.md", size: 800 },
274 ];
275
276 it("assembles summary + buckets + directories + largest", () => {
277 const r = buildSizeReport({ entries });
278 expect(r.summary.countedFiles).toBe(3);
279 expect(r.summary.totalBytes).toBe(100 + 5 * MB + 800);
280 expect(r.buckets).toHaveLength(5);
281 expect(r.directories.map((d) => d.name)).toEqual(["src", "docs"]);
282 expect(r.largest[0]!.path).toBe("src/big.bin");
283 });
284
285 it("honours topN", () => {
286 const r = buildSizeReport({ entries, topN: 1 });
287 expect(r.largest).toHaveLength(1);
288 expect(r.largest[0]!.path).toBe("src/big.bin");
289 });
290
291 it("honours minBytesForLargest", () => {
292 const r = buildSizeReport({
293 entries,
294 minBytesForLargest: 1000,
295 });
296 expect(r.largest.map((f) => f.path)).toEqual(["src/big.bin"]);
297 });
298});
299
300describe("repo-size — routes", () => {
301 it("GET /:o/:r/insights/size returns 200 or 404 (never 500)", async () => {
302 const { default: app } = await import("../app");
303 const res = await app.request("/alice/repo/insights/size");
304 expect([200, 404]).toContain(res.status);
305 });
306 it("ignores bogus query params", async () => {
307 const { default: app } = await import("../app");
308 const res = await app.request(
309 "/alice/repo/insights/size?top=wow&min=nope"
310 );
311 expect([200, 404]).toContain(res.status);
312 });
313});
314
315describe("repo-size — __internal parity", () => {
316 it("re-exports every helper", () => {
317 expect(__internal.SIZE_CLASSES).toBe(SIZE_CLASSES);
318 expect(__internal.DEFAULT_TOP_N).toBe(DEFAULT_TOP_N);
319 expect(__internal.topLevelDir).toBe(topLevelDir);
320 expect(__internal.classifyFileSize).toBe(classifyFileSize);
321 expect(__internal.summariseSize).toBe(summariseSize);
322 expect(__internal.bucketBySize).toBe(bucketBySize);
323 expect(__internal.topLargestFiles).toBe(topLargestFiles);
324 expect(__internal.summariseByTopDir).toBe(summariseByTopDir);
325 expect(__internal.buildSizeReport).toBe(buildSizeReport);
326 expect(typeof __internal.median).toBe("function");
327 expect(typeof __internal.validEntries).toBe("function");
328 });
329});
Modifiedsrc/app.tsx+6−0View fileUnifiedSplit
8989import issueSimilarityRoutes from "./routes/issue-similarity";
9090import prLeadTimeRoutes from "./routes/pr-lead-time";
9191import languageRoutes from "./routes/languages";
92import repoSizeRoutes from "./routes/repo-size";
9293import webRoutes from "./routes/web";
9394
9495const app = new Hono();
325326// Repository language breakdown — /:owner/:repo/languages (Block J30)
326327app.route("/", languageRoutes);
327328
329// Repository size audit — /:owner/:repo/insights/size (Block J31)
330// Must be mounted BEFORE insightsRoutes so the static `/insights/size` path
331// wins over any future dynamic `/insights/:id` route.
332app.route("/", repoSizeRoutes);
333
328334// Insights + milestones
329335app.route("/", insightsRoutes);
330336
Addedsrc/lib/repo-size.ts+246−0View fileUnifiedSplit
1/**
2 * Block J31 — Repository size audit.
3 *
4 * Pure helpers that answer "where are the bytes?" for a working-tree
5 * snapshot at a given ref: total size, largest files, distribution by
6 * top-level directory, and a size-class histogram.
7 *
8 * Consumes the same `{path, size}` shape produced by
9 * `git ls-tree -r -l -z` (via `listTreeRecursive`), so the route stays
10 * a thin shell over `buildSizeReport`.
11 */
12export interface RepoSizeEntry {
13 path: string;
14 size: number;
15}
16
17export const DEFAULT_TOP_N = 25;
18
19/** Five size classes, ranked tiny → xlarge. Boundaries are inclusive-below. */
20export const SIZE_CLASSES = [
21 { key: "tiny", label: "< 1 KB", max: 1024 },
22 { key: "small", label: "1 KB – 100 KB", max: 100 * 1024 },
23 { key: "medium", label: "100 KB – 1 MB", max: 1024 * 1024 },
24 { key: "large", label: "1 MB – 10 MB", max: 10 * 1024 * 1024 },
25 { key: "xlarge", label: "≥ 10 MB", max: Number.POSITIVE_INFINITY },
26] as const;
27
28export type SizeClassKey = (typeof SIZE_CLASSES)[number]["key"];
29
30export interface SizeSummary {
31 totalFiles: number;
32 countedFiles: number;
33 totalBytes: number;
34 averageBytes: number;
35 medianBytes: number;
36 largestBytes: number;
37 smallestBytes: number;
38}
39
40export interface SizeBucket {
41 key: SizeClassKey;
42 label: string;
43 fileCount: number;
44 bytes: number;
45}
46
47export interface DirectoryBucket {
48 /** Top-level segment. Root files live under the "." pseudo-bucket. */
49 name: string;
50 fileCount: number;
51 bytes: number;
52 percent: number;
53}
54
55export interface LargestFile {
56 path: string;
57 size: number;
58 percent: number;
59 /** First path segment (or "." for root files). */
60 topDir: string;
61}
62
63export interface RepoSizeReport {
64 summary: SizeSummary;
65 buckets: SizeBucket[];
66 directories: DirectoryBucket[];
67 largest: LargestFile[];
68}
69
70/** Path → top-level segment (`src/foo/bar.ts` → `src`, `README.md` → `.`). */
71export function topLevelDir(path: string): string {
72 if (typeof path !== "string" || path.length === 0) return ".";
73 const normal = path.startsWith("/") ? path.slice(1) : path;
74 const i = normal.indexOf("/");
75 return i === -1 ? "." : normal.slice(0, i);
76}
77
78/** Keep only sane, finite, non-negative-sized string paths. */
79function validEntries(
80 entries: readonly RepoSizeEntry[]
81): RepoSizeEntry[] {
82 const out: RepoSizeEntry[] = [];
83 for (const e of entries) {
84 if (!e || typeof e.path !== "string" || e.path.length === 0) continue;
85 const sz = Number(e.size);
86 if (!Number.isFinite(sz) || sz < 0) continue;
87 out.push({ path: e.path, size: sz });
88 }
89 return out;
90}
91
92function median(sortedAsc: readonly number[]): number {
93 const n = sortedAsc.length;
94 if (n === 0) return 0;
95 if (n % 2 === 1) return sortedAsc[(n - 1) / 2]!;
96 const a = sortedAsc[n / 2 - 1]!;
97 const b = sortedAsc[n / 2]!;
98 return Math.round((a + b) / 2);
99}
100
101export function summariseSize(entries: readonly RepoSizeEntry[]): SizeSummary {
102 const valid = validEntries(entries);
103 const sizes = valid.map((e) => e.size).sort((a, b) => a - b);
104 const total = sizes.reduce((acc, n) => acc + n, 0);
105 const n = sizes.length;
106 return {
107 totalFiles: entries.length,
108 countedFiles: n,
109 totalBytes: total,
110 averageBytes: n === 0 ? 0 : Math.round(total / n),
111 medianBytes: median(sizes),
112 largestBytes: n === 0 ? 0 : sizes[n - 1]!,
113 smallestBytes: n === 0 ? 0 : sizes[0]!,
114 };
115}
116
117export function classifyFileSize(size: number): SizeClassKey {
118 if (!Number.isFinite(size) || size < 0) return "tiny";
119 for (const c of SIZE_CLASSES) {
120 if (size < c.max) return c.key;
121 }
122 // Math says we can't get here (last class max = Infinity), but be defensive.
123 return "xlarge";
124}
125
126export function bucketBySize(
127 entries: readonly RepoSizeEntry[]
128): SizeBucket[] {
129 const valid = validEntries(entries);
130 const out: SizeBucket[] = SIZE_CLASSES.map((c) => ({
131 key: c.key,
132 label: c.label,
133 fileCount: 0,
134 bytes: 0,
135 }));
136 for (const e of valid) {
137 const key = classifyFileSize(e.size);
138 const b = out.find((x) => x.key === key)!;
139 b.fileCount++;
140 b.bytes += e.size;
141 }
142 return out;
143}
144
145export interface TopLargestOptions {
146 /** Max files to return. Default `DEFAULT_TOP_N`. */
147 limit?: number;
148 /** Minimum bytes for inclusion. Default 0 (= no floor). */
149 minBytes?: number;
150}
151
152export function topLargestFiles(
153 entries: readonly RepoSizeEntry[],
154 opts: TopLargestOptions = {}
155): LargestFile[] {
156 const limit =
157 opts.limit !== undefined && opts.limit > 0
158 ? Math.floor(opts.limit)
159 : DEFAULT_TOP_N;
160 const minBytes = opts.minBytes ?? 0;
161
162 const valid = validEntries(entries).filter((e) => e.size >= minBytes);
163 const total = valid.reduce((acc, e) => acc + e.size, 0);
164
165 const sorted = valid.slice().sort((a, b) => {
166 if (a.size !== b.size) return b.size - a.size;
167 return a.path.localeCompare(b.path);
168 });
169
170 return sorted.slice(0, limit).map((e) => ({
171 path: e.path,
172 size: e.size,
173 percent: total === 0 ? 0 : (e.size / total) * 100,
174 topDir: topLevelDir(e.path),
175 }));
176}
177
178export function summariseByTopDir(
179 entries: readonly RepoSizeEntry[]
180): DirectoryBucket[] {
181 const valid = validEntries(entries);
182 const total = valid.reduce((acc, e) => acc + e.size, 0);
183 const byDir = new Map<string, { fileCount: number; bytes: number }>();
184
185 for (const e of valid) {
186 const key = topLevelDir(e.path);
187 const agg = byDir.get(key) ?? { fileCount: 0, bytes: 0 };
188 agg.fileCount++;
189 agg.bytes += e.size;
190 byDir.set(key, agg);
191 }
192
193 const out: DirectoryBucket[] = [];
194 for (const [name, { fileCount, bytes }] of byDir) {
195 out.push({
196 name,
197 fileCount,
198 bytes,
199 percent: total === 0 ? 0 : (bytes / total) * 100,
200 });
201 }
202
203 out.sort((a, b) => {
204 if (a.bytes !== b.bytes) return b.bytes - a.bytes;
205 // Root bucket sorts last on ties so real directories surface first.
206 if (a.name === "." && b.name !== ".") return 1;
207 if (b.name === "." && a.name !== ".") return -1;
208 return a.name.localeCompare(b.name);
209 });
210
211 return out;
212}
213
214export interface BuildSizeReportOptions {
215 entries: readonly RepoSizeEntry[];
216 topN?: number;
217 minBytesForLargest?: number;
218}
219
220export function buildSizeReport(
221 opts: BuildSizeReportOptions
222): RepoSizeReport {
223 return {
224 summary: summariseSize(opts.entries),
225 buckets: bucketBySize(opts.entries),
226 directories: summariseByTopDir(opts.entries),
227 largest: topLargestFiles(opts.entries, {
228 limit: opts.topN,
229 minBytes: opts.minBytesForLargest,
230 }),
231 };
232}
233
234export const __internal = {
235 SIZE_CLASSES,
236 DEFAULT_TOP_N,
237 topLevelDir,
238 classifyFileSize,
239 summariseSize,
240 bucketBySize,
241 topLargestFiles,
242 summariseByTopDir,
243 buildSizeReport,
244 median,
245 validEntries,
246};
Modifiedsrc/routes/insights.tsx+6−0View fileUnifiedSplit
176176 >
177177 Languages &rarr;
178178 </a>
179 <a
180 href={`/${owner}/${repo}/insights/size`}
181 style="font-size: 12px; color: var(--accent)"
182 >
183 Size audit &rarr;
184 </a>
179185 </div>
180186 </div>
181187
Addedsrc/routes/repo-size.tsx+320−0View fileUnifiedSplit
1/**
2 * Block J31 — Repository size audit.
3 *
4 * GET /:owner/:repo/insights/size[?top=N&min=B&ref=<branch>]
5 *
6 * Renders "where are the bytes?" for the given ref (default branch by
7 * default): summary stats, a size-class histogram, a top-level directory
8 * breakdown, and the largest N files.
9 *
10 * softAuth, read-only. Reuses `listTreeRecursive` from J30. Git failures
11 * degrade to an empty report — never 500.
12 */
13
14import { Hono } from "hono";
15import { and, eq } from "drizzle-orm";
16import { db } from "../db";
17import { repositories, users } from "../db/schema";
18import { Layout } from "../views/layout";
19import { RepoHeader } from "../views/components";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { getDefaultBranch, listTreeRecursive } from "../git/repository";
23import {
24 buildSizeReport,
25 DEFAULT_TOP_N,
26 type RepoSizeEntry,
27} from "../lib/repo-size";
28import { formatBytes, formatPercent } from "../lib/language-stats";
29
30const MAX_TOP_N = 200;
31const ABS_MAX_MIN_BYTES = 1024 * 1024 * 1024; // 1 GiB cap on user-supplied floor
32
33const repoSizeRoutes = new Hono<AuthEnv>();
34
35repoSizeRoutes.use("*", softAuth);
36
37async function resolveRepo(ownerName: string, repoName: string) {
38 try {
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44 if (!owner) return null;
45 const [repo] = await db
46 .select()
47 .from(repositories)
48 .where(
49 and(
50 eq(repositories.ownerId, owner.id),
51 eq(repositories.name, repoName)
52 )
53 )
54 .limit(1);
55 if (!repo) return null;
56 return { owner, repo };
57 } catch {
58 return null;
59 }
60}
61
62function parsePositiveInt(
63 raw: string | undefined,
64 fallback: number,
65 max: number
66): number {
67 if (!raw) return fallback;
68 const n = Number(raw);
69 if (!Number.isFinite(n) || n < 0) return fallback;
70 return Math.min(Math.floor(n), max);
71}
72
73function sanitiseRef(raw: string | undefined): string | null {
74 if (!raw) return null;
75 const trimmed = raw.trim();
76 if (!trimmed || trimmed.length > 200) return null;
77 if (/\.\./.test(trimmed)) return null;
78 if (/[\s~^:?*[\\]/.test(trimmed)) return null;
79 return trimmed;
80}
81
82repoSizeRoutes.get("/:owner/:repo/insights/size", async (c) => {
83 const { owner: ownerName, repo: repoName } = c.req.param();
84 const user = c.get("user");
85
86 const topN = parsePositiveInt(c.req.query("top"), DEFAULT_TOP_N, MAX_TOP_N);
87 const minBytes = parsePositiveInt(c.req.query("min"), 0, ABS_MAX_MIN_BYTES);
88 const refParam = sanitiseRef(c.req.query("ref"));
89
90 const resolved = await resolveRepo(ownerName, repoName);
91 if (!resolved) {
92 return c.html(
93 <Layout title="Not Found" user={user}>
94 <div class="empty-state">
95 <h2>Repository not found</h2>
96 </div>
97 </Layout>,
98 404
99 );
100 }
101
102 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
103 return c.html(
104 <Layout title="Not Found" user={user}>
105 <div class="empty-state">
106 <h2>Repository not found</h2>
107 </div>
108 </Layout>,
109 404
110 );
111 }
112
113 let ref: string | null = refParam;
114 if (!ref) {
115 try {
116 ref = await getDefaultBranch(ownerName, repoName);
117 } catch {
118 ref = null;
119 }
120 }
121
122 let entries: RepoSizeEntry[] = [];
123 if (ref) {
124 try {
125 entries = await listTreeRecursive(ownerName, repoName, ref);
126 } catch {
127 entries = [];
128 }
129 }
130
131 const report = buildSizeReport({
132 entries,
133 topN,
134 minBytesForLargest: minBytes > 0 ? minBytes : undefined,
135 });
136
137 const empty = report.summary.countedFiles === 0;
138
139 const kpi = (label: string, value: string) => (
140 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; background: var(--bg-secondary)">
141 <div style="font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin-bottom: 6px">
142 {label}
143 </div>
144 <div style="font-size: 20px; font-weight: 600; font-family: var(--font-mono)">
145 {value}
146 </div>
147 </div>
148 );
149
150 return c.html(
151 <Layout title={`Size audit — ${ownerName}/${repoName}`} user={user}>
152 <RepoHeader owner={ownerName} repo={repoName} />
153 <div style="max-width: 920px">
154 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
155 <h2 style="margin: 0">Size audit</h2>
156 <form
157 method="GET"
158 action={`/${ownerName}/${repoName}/insights/size`}
159 style="display: flex; gap: 10px; align-items: center; font-size: 12px"
160 >
161 <label style="display: flex; align-items: center; gap: 4px">
162 Top
163 <select
164 name="top"
165 onchange="this.form.submit()"
166 style="padding: 2px 6px; font-size: 12px"
167 >
168 {[10, 25, 50, 100].map((n) => (
169 <option value={String(n)} selected={n === topN}>
170 {n}
171 </option>
172 ))}
173 </select>
174 </label>
175 {refParam ? (
176 <input type="hidden" name="ref" value={refParam} />
177 ) : null}
178 </form>
179 </div>
180 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
181 {ref ? (
182 <>
183 Analyzed <code>{ref}</code>. Includes everything in the working
184 tree, vendored files and all — this is a raw disk-footprint view.
185 </>
186 ) : (
187 <>No default branch detected — repository may be empty.</>
188 )}
189 </p>
190
191 {empty ? (
192 <div class="empty-state">
193 <h3>No files to audit</h3>
194 <p>The repository appears to be empty.</p>
195 </div>
196 ) : (
197 <>
198 <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 24px">
199 {kpi("Files", report.summary.countedFiles.toLocaleString())}
200 {kpi("Total size", formatBytes(report.summary.totalBytes))}
201 {kpi("Largest", formatBytes(report.summary.largestBytes))}
202 {kpi("Median", formatBytes(report.summary.medianBytes))}
203 {kpi("Mean", formatBytes(report.summary.averageBytes))}
204 </div>
205
206 <h3 style="margin-bottom: 10px">Size-class distribution</h3>
207 <div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 24px">
208 {report.buckets.map((b) => (
209 <div style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; text-align: center">
210 <div style="font-size: 11px; color: var(--text-muted); margin-bottom: 4px">
211 {b.label}
212 </div>
213 <div style="font-size: 18px; font-weight: 600">
214 {b.fileCount}
215 </div>
216 <div style="font-size: 11px; color: var(--text-muted); margin-top: 2px; font-family: var(--font-mono)">
217 {formatBytes(b.bytes)}
218 </div>
219 </div>
220 ))}
221 </div>
222
223 <h3 style="margin-bottom: 10px">Top-level directories</h3>
224 <table style="width: 100%; border-collapse: collapse; margin-bottom: 24px">
225 <thead>
226 <tr>
227 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
228 Path
229 </th>
230 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
231 Files
232 </th>
233 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
234 Size
235 </th>
236 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
237 Share
238 </th>
239 </tr>
240 </thead>
241 <tbody>
242 {report.directories.map((d) => (
243 <tr>
244 <td style="padding: 8px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 13px">
245 {d.name === "." ? "(root)" : `${d.name}/`}
246 </td>
247 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
248 {d.fileCount.toLocaleString()}
249 </td>
250 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
251 {formatBytes(d.bytes)}
252 </td>
253 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
254 {formatPercent(d.percent)}
255 </td>
256 </tr>
257 ))}
258 </tbody>
259 </table>
260
261 <h3 style="margin-bottom: 10px">
262 Largest files ({report.largest.length})
263 </h3>
264 {report.largest.length === 0 ? (
265 <div class="empty-state">
266 <p>No files match the filter.</p>
267 </div>
268 ) : (
269 <table style="width: 100%; border-collapse: collapse">
270 <thead>
271 <tr>
272 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
273 Path
274 </th>
275 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 120px">
276 Size
277 </th>
278 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 100px">
279 Share
280 </th>
281 </tr>
282 </thead>
283 <tbody>
284 {report.largest.map((f) => (
285 <tr>
286 <td style="padding: 8px; border-bottom: 1px solid var(--border); font-family: var(--font-mono); font-size: 12px; word-break: break-all">
287 {ref ? (
288 <a
289 href={`/${ownerName}/${repoName}/blob/${encodeURIComponent(
290 ref
291 )}/${f.path
292 .split("/")
293 .map((s) => encodeURIComponent(s))
294 .join("/")}`}
295 >
296 {f.path}
297 </a>
298 ) : (
299 f.path
300 )}
301 </td>
302 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
303 {formatBytes(f.size)}
304 </td>
305 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
306 {formatPercent(f.percent)}
307 </td>
308 </tr>
309 ))}
310 </tbody>
311 </table>
312 )}
313 </>
314 )}
315 </div>
316 </Layout>
317 );
318});
319
320export default repoSizeRoutes;
0321