CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
repo-size.test.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.
| ce08e97 | 1 | /** |
| 2 | * Block J31 — Repository size audit. Pure helper tests. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import { | |
| 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 | ||
| 20 | const KB = 1024; | |
| 21 | const MB = 1024 * 1024; | |
| 22 | ||
| 23 | describe("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 | ||
| 45 | describe("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 | ||
| 74 | describe("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 | ||
| 124 | describe("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 | ||
| 150 | describe("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 | ||
| 225 | describe("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 | ||
| 269 | describe("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 | ||
| 300 | describe("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 | ||
| 315 | describe("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 | }); |