CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
spec-context.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.
| 23d1a81 | 1 | import { describe, it, expect } from "bun:test"; |
| 2 | import { join } from "path"; | |
| 3 | import { buildSpecContext, scoreFile } from "../lib/spec-context"; | |
| 4 | ||
| 5 | describe("buildSpecContext", () => { | |
| 6 | it("returns ok:false for nonexistent repo path", async () => { | |
| 7 | const bogus = join( | |
| 8 | "/tmp", | |
| 9 | `spec-context-does-not-exist-${Date.now()}-${Math.random()}` | |
| 10 | ); | |
| 11 | const result = await buildSpecContext({ | |
| 12 | repoDiskPath: bogus, | |
| 13 | spec: "add a new auth endpoint", | |
| 14 | }); | |
| 15 | expect(result.ok).toBe(false); | |
| 16 | if (!result.ok) { | |
| 17 | expect(typeof result.error).toBe("string"); | |
| 18 | expect(result.error.length).toBeGreaterThan(0); | |
| 19 | } | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | describe("scoreFile", () => { | |
| 24 | it("scores keyword-matched filenames higher than unrelated ones", () => { | |
| 25 | const tokens = ["auth", "login", "session"]; | |
| 26 | const hot = scoreFile("src/routes/auth.ts", tokens); | |
| 27 | const cold = scoreFile("src/views/layout.tsx", tokens); | |
| 28 | expect(hot).toBeGreaterThan(cold); | |
| 29 | ||
| 30 | // The spec word "login" matching a filename should also dominate over a | |
| 31 | // fully unrelated path with no token overlap. | |
| 32 | const loginHit = scoreFile("src/pages/login-form.ts", tokens); | |
| 33 | const unrelated = scoreFile("src/utils/strings.ts", tokens); | |
| 34 | expect(loginHit).toBeGreaterThan(unrelated); | |
| 35 | }); | |
| 36 | }); | |
| 37 | ||
| 38 | describe("scoreFile ranking & caps", () => { | |
| 39 | it("caps file list at 500 and relevantFiles at maxRelevantFiles", async () => { | |
| 40 | // We can exercise the ranking/cap logic without a real git repo by | |
| 41 | // simulating the scoring step directly. This covers the public contract | |
| 42 | // that `scoreFile` + downstream sort gives a stable ranking, and the | |
| 43 | // numeric caps declared in the module are respected. | |
| 44 | const tokens = ["widget"]; | |
| 45 | const paths: string[] = []; | |
| 46 | for (let i = 0; i < 750; i++) { | |
| 47 | // Mix in some matches so sorting has signal. | |
| 48 | paths.push(i % 7 === 0 ? `src/widget-${i}.ts` : `src/file-${i}.ts`); | |
| 49 | } | |
| 50 | const capped = paths.slice(0, 500); | |
| 51 | expect(capped.length).toBe(500); | |
| 52 | ||
| 53 | const scored = capped | |
| 54 | .map((p) => ({ p, s: scoreFile(p, tokens) })) | |
| 55 | .sort((a, b) => { | |
| 56 | if (b.s !== a.s) return b.s - a.s; | |
| 57 | return a.p.length - b.p.length; | |
| 58 | }); | |
| 59 | ||
| 60 | // Top of ranking should be a widget-matched path. | |
| 61 | expect(scored[0].p).toContain("widget"); | |
| 62 | ||
| 63 | // Applying the maxRelevantFiles cap produces exactly that many entries. | |
| 64 | const maxRelevantFiles = 20; | |
| 65 | const top = scored.slice(0, maxRelevantFiles); | |
| 66 | expect(top.length).toBe(maxRelevantFiles); | |
| 67 | ||
| 68 | // And every top entry should score at least as high as any dropped one. | |
| 69 | const tailMax = Math.max(...scored.slice(maxRelevantFiles).map((x) => x.s)); | |
| 70 | const topMin = Math.min(...top.map((x) => x.s)); | |
| 71 | expect(topMin).toBeGreaterThanOrEqual(tailMax); | |
| 72 | }); | |
| 73 | }); |