diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31170c3..6788103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: run: | git fetch --no-tags --depth=1 origin "$BASE_SHA" changed=$(git diff --name-only "$BASE_SHA" HEAD) - bundled=$(echo "$changed" | grep -E '^src/.*\.ts$' | grep -v '\.test\.ts$' || true) + bundled=$(echo "$changed" | grep -E '^src/.*\.ts$' | grep -v '\.test\.ts$' | grep -v '^src/shared/tests/' || true) rebuilt=$(echo "$changed" | grep -E '^dist/' || true) if [ -n "$bundled" ] && [ -z "$rebuilt" ]; then echo "::error::Bundled sources changed but dist/ did not. Run 'pnpm build' and commit the result — action.yml executes dist/index.js, so a source change is not shipped until it is rebuilt." diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7188de9..02dd8a3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -155,7 +155,7 @@ The scripts, Biome settings and git hooks are listed once in [CLAUDE.md](./CLAUD | Workflow | Purpose | | --- | --- | -| `ci.yml` | On push/PR to `main`: install, `pnpm verify`, Codecov upload (also when `check` fails, so threshold failures still report), build, then a staleness check that fails a PR touching bundled sources without touching `dist/`. It compares *which files changed*, not bytes, because `dist/index.js` is not reproducible across platforms: esbuild embeds `node_modules/.pnpm/...` paths and pnpm hashes those names on Windows but not on Linux | +| `ci.yml` | On push/PR to `main`: install, `pnpm verify`, Codecov upload (also when `check` fails, so threshold failures still report), build, then a staleness check that fails a PR touching bundled sources without touching `dist/`. `src/shared/tests/` is excluded alongside `*.test.ts`: nothing there is reachable from `src/index.ts`, so editing a fixture cannot change the bundle and must not demand a rebuild. It compares *which files changed*, not bytes, because `dist/index.js` is not reproducible across platforms: esbuild embeds `node_modules/.pnpm/...` paths and pnpm hashes those names on Windows but not on Linux | | `release.yml` | On push to `main`: `pnpm verify` then `semantic-release`, plus a major-version tag update | | `zizmor.yml` | zizmor static analysis of the workflow files themselves | | `dependency-review.yml` | Fails a PR that introduces a dependency with a known vulnerability | diff --git a/CLAUDE.md b/CLAUDE.md index 2a589cf..13f6a69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,11 +112,14 @@ re-export from `src/i18n/index.ts` instead. - **Cross-layer imports use the alias; same-layer imports stay relative.** `@domain/snapshot` from `presentation`, `./snapshot` from inside `domain`. Mixed forms of the same module break Biome's import sorting and duplicate it in the bundle. "Same layer" means all of `src/infrastructure`, not one adapter. -- **Named params for 2+ arguments.** Any function taking two or more arguments takes one destructured - object typed by an interface: `function foo({ a, b }: FooParams)`. Single-argument functions stay - positional. Some of the fixture factories in `src/shared/tests` are the sanctioned exception, and - `docs/docs-consistency.test.ts` asserts the rule over the whole tree. It had drifted in nine places before - it was executable, three of them adjacent same-typed numbers a caller could silently swap. +- **One argument is positional; two or more are one object, typed `Params`.** + `coveredStars(totalStars)`, `describeFetchError(error)`; `makeRepoInfo({ name, stars }): MakeRepoInfoParams`, + `repoStargazers({ fullName, dates, sampled }): RepoStargazersParams`. The interface is named after the + function, not after the concept, so a reader landing on the type knows what takes it. A comparator handed + to `sort` is the exception — it is called back positionally, so `alphabetically` keeps its two arguments. + `docs/docs-consistency.test.ts` asserts the rule over the whole of `src`, with no exemption: the fixture + factories in `src/shared/tests` and the co-located test helpers used to be excused, which is exactly where + the rule had drifted, and a fixture is the code a reader copies from. - **No explanatory comments in `.ts` files**, without exception; the tree contains none. These `CLAUDE.md` files carry the explanation instead. If something needs explaining it goes in the folder's *Invariants* or *Gotchas* section, not above the line. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6302023..90d705a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,7 +148,10 @@ pnpm run format declare it - Functional programming style preferred - No `any` types (use `unknown` if needed) -- Functions with 2+ parameters should use destructured named parameters +- One argument is positional and two or more are a single object typed `Params` — + `makeRepoInfo({ name, stars }: MakeRepoInfoParams)`. The exception is a function a runtime calls back, + such as the `sort` comparator `alphabetically`. `docs/docs-consistency.test.ts` asserts this over the + whole of `src`, fixtures included - Constants for magic numbers and strings - No explanatory comments. The tree carries none by design; the `CLAUDE.md` guides carry the explanation instead @@ -286,7 +289,8 @@ reference. No manual versioning is needed. ### Before Submitting - [ ] **All checks pass**: `pnpm run verify` succeeds -- [ ] **`dist/` is rebuilt and committed** if you touched any non-test file under `src/` +- [ ] **`dist/` is rebuilt and committed** if you touched any bundled file under `src/` — that is, + anything except `*.test.ts` and the fixtures in `src/shared/tests/`, neither of which the bundle reaches - [ ] **Code is formatted**: run `pnpm run format` - [ ] **Types are correct**: no TypeScript errors - [ ] **Documentation updated**: see the maintenance contract in the root [`CLAUDE.md`](./CLAUDE.md) diff --git a/docs/docs-consistency.test.ts b/docs/docs-consistency.test.ts index e28b750..a14ff91 100644 --- a/docs/docs-consistency.test.ts +++ b/docs/docs-consistency.test.ts @@ -52,13 +52,18 @@ const I18N_KEY_PATTERN = /`([\w.]+)`/g; const LINE_CITATION_ALLOWLIST = new Set([GUIDE, CONTRIBUTOR_GUIDE, ADR_TEMPLATE]); -function walk(dir: string, keep: (filename: string) => boolean): string[] { +interface WalkParams { + dir: string; + keep: (filename: string) => boolean; +} + +function walk({ dir, keep }: WalkParams): string[] { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = path.join(dir, entry.name); - if (entry.isDirectory()) return walk(full, keep); + if (entry.isDirectory()) return walk({ dir: full, keep }); return keep(entry.name) ? [full] : []; }); @@ -77,20 +82,22 @@ const DOCS = [ "SECURITY.md", "examples/README.md", ].filter((doc) => fs.existsSync(doc)), - ...walk(".github", isMarkdown), - ...walk("docs", isMarkdown), - ...walk("src", (filename) => filename === "CLAUDE.md"), + ...walk({ dir: ".github", keep: isMarkdown }), + ...walk({ dir: "docs", keep: isMarkdown }), + ...walk({ dir: "src", keep: (filename) => filename === "CLAUDE.md" }), ]; const isTestFile = (filename: string): boolean => filename.endsWith(".test.ts"); const TEST_FILENAMES = new Set( - [...walk("src", isTestFile), ...walk("tests", isTestFile)].map((file) => path.basename(file)), + [...walk({ dir: "src", keep: isTestFile }), ...walk({ dir: "tests", keep: isTestFile })].map((file) => + path.basename(file), + ), ); const toPosix = (file: string): string => file.split(path.sep).join("/"); -const ADR_FILES = walk(ADR_DIRECTORY, isMarkdown).map(toPosix).sort(); +const ADR_FILES = walk({ dir: ADR_DIRECTORY, keep: isMarkdown }).map(toPosix).sort(); const adrNumber = (file: string): string => path.basename(file).slice(0, 4); @@ -734,10 +741,12 @@ describe("the documented data-branch format matches the writer", () => { }); describe("the source follows the named-parameter convention", () => { + it("is the rule the root guide states", () => { + expect(read("CLAUDE.md")).toContain("One argument is positional; two or more are one object"); + }); + it("declares no function or arrow taking two or more positional parameters", () => { - const sources = walk("src", (filename) => filename.endsWith(".ts")).filter( - (file) => !file.endsWith(".test.ts") && !toPosix(file).startsWith("src/shared/tests/"), - ); + const sources = walk({ dir: "src", keep: (filename) => filename.endsWith(".ts") }); const declaration = /(?:function\s+\w+|=)\s*\(\s*\w+\s*:\s*[^,()]+,\s*\w+\s*:/g; const offenders = sources.flatMap((file) => [...read(file).matchAll(declaration)].map( diff --git a/src/application/tracker.test.ts b/src/application/tracker.test.ts index 5b03359..e11ce10 100644 --- a/src/application/tracker.test.ts +++ b/src/application/tracker.test.ts @@ -83,7 +83,7 @@ const defaultSummary = { lostStars: 2, changed: true, }; -const defaultRepos = [makeRepoInfo("repo-a", 60), makeRepoInfo("repo-b", 40)]; +const defaultRepos = [makeRepoInfo({ name: "repo-a", stars: 60 }), makeRepoInfo({ name: "repo-b", stars: 40 })]; const defaultHistory = { snapshots: [] }; const defaultSnapshot = { timestamp: "2026-01-01T00:00:00Z", totalStars: 100, repos: [] }; const defaultUpdatedHistory = { snapshots: [defaultSnapshot] }; @@ -385,14 +385,14 @@ describe("trackStars", () => { }); it("draws each per-repo chart on its own timeline, not the shared global one", async () => { vi.mocked(getRepos).mockResolvedValue([ - makeRepoInfo("old", 100, { owner: "u", fullName: "u/old" }), - makeRepoInfo("new", 30, { owner: "u", fullName: "u/new" }), + makeRepoInfo({ name: "old", stars: 100, overrides: { owner: "u", fullName: "u/old" } }), + makeRepoInfo({ name: "new", stars: 30, overrides: { owner: "u", fullName: "u/new" } }), ]); mockMeasurement({ results: { repos: [ - makeRepoResult("old", { fullName: "u/old", owner: "u", current: 100 }), - makeRepoResult("new", { fullName: "u/new", owner: "u", current: 30 }), + makeRepoResult({ name: "old", overrides: { fullName: "u/old", owner: "u", current: 100 } }), + makeRepoResult({ name: "new", overrides: { fullName: "u/new", owner: "u", current: 30 } }), ], summary: defaultSummary, }, @@ -424,13 +424,13 @@ describe("trackStars", () => { }); it("falls back to stored snapshots for a repo whose stargazers were unreachable (#148)", async () => { vi.mocked(getRepos).mockResolvedValue([ - makeRepoInfo("reachable", 100, { owner: "u", fullName: "u/reachable" }), - makeRepoInfo("restricted", 54_000, { owner: "u", fullName: "u/restricted" }), + makeRepoInfo({ name: "reachable", stars: 100, overrides: { owner: "u", fullName: "u/reachable" } }), + makeRepoInfo({ name: "restricted", stars: 54_000, overrides: { owner: "u", fullName: "u/restricted" } }), ]); const unreachableResults = { repos: [ - makeRepoResult("reachable", { fullName: "u/reachable", owner: "u", current: 100 }), - makeRepoResult("restricted", { fullName: "u/restricted", owner: "u", current: 54_000 }), + makeRepoResult({ name: "reachable", overrides: { fullName: "u/reachable", owner: "u", current: 100 } }), + makeRepoResult({ name: "restricted", overrides: { fullName: "u/restricted", owner: "u", current: 54_000 } }), ], summary: defaultSummary, }; @@ -526,9 +526,9 @@ describe("trackStars", () => { }; const resultsWithRepos = { repos: [ - makeRepoResult("repo-a", { current: 60 }), - makeRepoResult("repo-b", { current: 40 }), - makeRepoResult("repo-c", { current: 10, isRemoved: true }), + makeRepoResult({ name: "repo-a", overrides: { current: 60 } }), + makeRepoResult({ name: "repo-b", overrides: { current: 40 } }), + makeRepoResult({ name: "repo-c", overrides: { current: 10, isRemoved: true } }), ], summary: defaultSummary, }; diff --git a/src/domain/comparison.test.ts b/src/domain/comparison.test.ts index 69e4e22..1b4acb4 100644 --- a/src/domain/comparison.test.ts +++ b/src/domain/comparison.test.ts @@ -5,7 +5,7 @@ import type { Snapshot } from "./types"; describe("compareStars", () => { it("handles first run with no previous snapshot", () => { - const repos = [makeRepoInfo("repo-a", 10), makeRepoInfo("repo-b", 20)]; + const repos = [makeRepoInfo({ name: "repo-a", stars: 10 }), makeRepoInfo({ name: "repo-b", stars: 20 })]; const result = compareStars({ currentRepos: repos, previousSnapshot: null }); expect(result.summary.totalStars).toBe(30); @@ -27,7 +27,7 @@ describe("compareStars", () => { }; const result = compareStars({ - currentRepos: [makeRepoInfo("repo-a", 50)], + currentRepos: [makeRepoInfo({ name: "repo-a", stars: 50 })], previousSnapshot: previous, }); @@ -37,7 +37,7 @@ describe("compareStars", () => { }); it("computes deltas against previous snapshot", () => { - const repos = [makeRepoInfo("repo-a", 15), makeRepoInfo("repo-b", 18)]; + const repos = [makeRepoInfo({ name: "repo-a", stars: 15 }), makeRepoInfo({ name: "repo-b", stars: 18 })]; const previous: Snapshot = { timestamp: "2026-01-01T00:00:00Z", totalStars: 30, @@ -64,7 +64,7 @@ describe("compareStars", () => { }); it("detects removed repositories", () => { - const repos = [makeRepoInfo("repo-a", 10)]; + const repos = [makeRepoInfo({ name: "repo-a", stars: 10 })]; const previous: Snapshot = { timestamp: "2026-01-01T00:00:00Z", totalStars: 30, @@ -84,7 +84,7 @@ describe("compareStars", () => { }); it("detects newly added repositories", () => { - const repos = [makeRepoInfo("repo-a", 10), makeRepoInfo("new-repo", 5)]; + const repos = [makeRepoInfo({ name: "repo-a", stars: 10 }), makeRepoInfo({ name: "new-repo", stars: 5 })]; const previous: Snapshot = { timestamp: "2026-01-01T00:00:00Z", totalStars: 10, @@ -100,7 +100,7 @@ describe("compareStars", () => { }); it("reports no changes when stars are identical", () => { - const repos = [makeRepoInfo("repo-a", 10)]; + const repos = [makeRepoInfo({ name: "repo-a", stars: 10 })]; const previous: Snapshot = { timestamp: "2026-01-01T00:00:00Z", totalStars: 10, @@ -115,7 +115,7 @@ describe("compareStars", () => { describe("createSnapshot", () => { it("creates a snapshot with timestamp and repo data", () => { - const repos = [makeRepoInfo("repo-a", 10)]; + const repos = [makeRepoInfo({ name: "repo-a", stars: 10 })]; const summary = { totalStars: 10, totalPrevious: 0, @@ -141,10 +141,10 @@ describe("createSnapshot", () => { describe("topRepositories", () => { const repos = [ - makeRepoResult("small", { current: 5 }), - makeRepoResult("large", { current: 90 }), - makeRepoResult("gone", { current: 0, isRemoved: true }), - makeRepoResult("middling", { current: 40 }), + makeRepoResult({ name: "small", overrides: { current: 5 } }), + makeRepoResult({ name: "large", overrides: { current: 90 } }), + makeRepoResult({ name: "gone", overrides: { current: 0, isRemoved: true } }), + makeRepoResult({ name: "middling", overrides: { current: 40 } }), ]; it("ranks by Star Count, descending", () => { diff --git a/src/domain/growth.test.ts b/src/domain/growth.test.ts index 1b4f886..c72d7b3 100644 --- a/src/domain/growth.test.ts +++ b/src/domain/growth.test.ts @@ -2,7 +2,12 @@ import { describe, expect, it } from "vitest"; import { calendarDays, fitTrend, latestRateInterval, type SeriesPoint, weightedDailyRate } from "./growth"; import type { History } from "./types"; -function series(values: number[], step = 1): SeriesPoint[] { +interface SeriesParams { + values: number[]; + step?: number; +} + +function series({ values, step = 1 }: SeriesParams): SeriesPoint[] { return values.map((value, index) => ({ day: index * step, value })); } @@ -42,12 +47,12 @@ describe("calendarDays", () => { describe("latestRateInterval", () => { it("returns null below two points", () => { - expect(latestRateInterval(series([10]))).toBeNull(); + expect(latestRateInterval(series({ values: [10] }))).toBeNull(); expect(latestRateInterval([])).toBeNull(); }); it("pairs the newest point with the one immediately before it when far enough back", () => { - const interval = latestRateInterval(series([10, 20, 30])); + const interval = latestRateInterval(series({ values: [10, 20, 30] })); expect(interval?.from.value).toBe(20); expect(interval?.to.value).toBe(30); @@ -86,27 +91,27 @@ describe("latestRateInterval", () => { describe("weightedDailyRate", () => { it("returns 0 for fewer than 2 values", () => { - expect(weightedDailyRate(series([10]))).toBe(0); - expect(weightedDailyRate(series([]))).toBe(0); + expect(weightedDailyRate(series({ values: [10] }))).toBe(0); + expect(weightedDailyRate(series({ values: [] }))).toBe(0); }); it("computes weighted average for constant deltas", () => { - expect(weightedDailyRate(series([10, 20, 30, 40]))).toBeCloseTo(10); + expect(weightedDailyRate(series({ values: [10, 20, 30, 40] }))).toBeCloseTo(10); }); it("weights recent deltas more heavily (accelerating)", () => { - expect(weightedDailyRate(series([10, 11, 13, 18]))).toBeGreaterThan(2); + expect(weightedDailyRate(series({ values: [10, 11, 13, 18] }))).toBeGreaterThan(2); }); it("weights recent deltas more heavily (decelerating)", () => { - const resultAccel = weightedDailyRate(series([10, 20, 25, 26])); - const resultConst = weightedDailyRate(series([10, 14, 18, 22])); + const resultAccel = weightedDailyRate(series({ values: [10, 20, 25, 26] })); + const resultConst = weightedDailyRate(series({ values: [10, 14, 18, 22] })); expect(resultAccel).toBeLessThan(resultConst); }); it("normalizes the rate by real day spacing", () => { - expect(weightedDailyRate(series([10, 20, 30, 40], 10))).toBeCloseTo(1); + expect(weightedDailyRate(series({ values: [10, 20, 30, 40], step: 10 }))).toBeCloseTo(1); }); it("skips zero-duration intervals", () => { @@ -132,35 +137,35 @@ describe("weightedDailyRate", () => { describe("fitTrend", () => { it("returns slope=0 for constant values", () => { - const result = fitTrend(series([10, 10, 10, 10])); + const result = fitTrend(series({ values: [10, 10, 10, 10] })); expect(result.slope).toBeCloseTo(0); expect(result.intercept).toBeCloseTo(10); }); it("computes correct slope for linear growth", () => { - const result = fitTrend(series([10, 20, 30, 40])); + const result = fitTrend(series({ values: [10, 20, 30, 40] })); expect(result.slope).toBeCloseTo(10); expect(result.intercept).toBeCloseTo(10); }); it("computes correct slope for decreasing values", () => { - const result = fitTrend(series([40, 30, 20, 10])); + const result = fitTrend(series({ values: [40, 30, 20, 10] })); expect(result.slope).toBeCloseTo(-10); expect(result.intercept).toBeCloseTo(40); }); it("handles single value", () => { - const result = fitTrend(series([42])); + const result = fitTrend(series({ values: [42] })); expect(result.slope).toBe(0); expect(result.intercept).toBe(42); }); it("normalizes slope by real day spacing", () => { - const result = fitTrend(series([10, 20, 30, 40], 10)); + const result = fitTrend(series({ values: [10, 20, 30, 40], step: 10 })); expect(result.slope).toBeCloseTo(1); expect(result.intercept).toBeCloseTo(10); diff --git a/src/domain/measurement.test.ts b/src/domain/measurement.test.ts index d41c954..5633374 100644 --- a/src/domain/measurement.test.ts +++ b/src/domain/measurement.test.ts @@ -17,7 +17,7 @@ describe("measureRun", () => { it("measures a first run against no baseline", () => { const measurement = measureRun({ ...BASE, - trackedSet: [makeRepoInfo("repo-a", 10), makeRepoInfo("repo-b", 5)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 10 }), makeRepoInfo({ name: "repo-b", stars: 5 })], storedHistory: EMPTY_HISTORY, }); @@ -33,14 +33,14 @@ describe("measureRun", () => { const lastRun = measureRun({ ...BASE, comparisonWindow: CompareAgainst.LAST_RUN, - trackedSet: [makeRepoInfo("repo-a", 100)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 100 })], storedHistory, now: new Date(storedHistory.snapshots[2].timestamp), }); const monthly = measureRun({ ...BASE, comparisonWindow: CompareAgainst.D30, - trackedSet: [makeRepoInfo("repo-a", 100)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 100 })], storedHistory, now: new Date(storedHistory.snapshots[2].timestamp), }); @@ -56,7 +56,7 @@ describe("measureRun", () => { const measurement = measureRun({ ...BASE, - trackedSet: [makeRepoInfo("repo-a", 20)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 20 })], storedHistory, }); @@ -66,7 +66,7 @@ describe("measureRun", () => { }); it("snapshots the same repositories it compared, so the totals cannot diverge", () => { - const trackedSet = [makeRepoInfo("repo-a", 7), makeRepoInfo("repo-b", 3)]; + const trackedSet = [makeRepoInfo({ name: "repo-a", stars: 7 }), makeRepoInfo({ name: "repo-b", stars: 3 })]; const measurement = measureRun({ ...BASE, trackedSet, storedHistory: EMPTY_HISTORY }); const appended = measurement.updatedHistory.snapshots.at(-1); @@ -82,7 +82,7 @@ describe("measureRun", () => { measureRun({ ...BASE, maxHistory: 2, - trackedSet: [makeRepoInfo("repo-a", 4)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 4 })], storedHistory, }).droppedSnapshots, ).toBe(2); @@ -93,7 +93,7 @@ describe("measureRun", () => { measureRun({ ...BASE, maxHistory: 30, - trackedSet: [makeRepoInfo("repo-a", 4)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 4 })], storedHistory: EMPTY_HISTORY, }).droppedSnapshots, ).toBe(0); @@ -104,7 +104,7 @@ describe("measureRun", () => { const measurement = measureRun({ ...BASE, maxHistory: 0, - trackedSet: [makeRepoInfo("repo-a", 3)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 3 })], storedHistory, }); @@ -118,7 +118,7 @@ describe("measureRun", () => { const measurement = measureRun({ ...BASE, maxHistory: 2, - trackedSet: [makeRepoInfo("repo-a", 4)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 4 })], storedHistory, }); @@ -134,13 +134,13 @@ describe("measureRun", () => { const belowThreshold = measureRun({ ...BASE, notificationThreshold: 20, - trackedSet: [makeRepoInfo("repo-a", 110)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 110 })], storedHistory, }); const aboveThreshold = measureRun({ ...BASE, notificationThreshold: 20, - trackedSet: [makeRepoInfo("repo-a", 125)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 125 })], storedHistory, }); @@ -157,7 +157,7 @@ describe("measureRun", () => { const measurement = measureRun({ ...BASE, notificationThreshold: 5, - trackedSet: [makeRepoInfo("repo-a", 130)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 130 })], storedHistory, }); @@ -170,7 +170,7 @@ describe("measureRun", () => { ...makeMultiRepoHistory([{ "user/repo-a": 100 }]), starsAtLastNotification: 100, }; - const lost = { ...BASE, notificationThreshold: 20, trackedSet: [makeRepoInfo("repo-a", 70)] }; + const lost = { ...BASE, notificationThreshold: 20, trackedSet: [makeRepoInfo({ name: "repo-a", stars: 70 })] }; expect(measureRun({ ...lost, storedHistory, notificationMode: NotificationMode.NET }).thresholdReached).toBe(true); expect(measureRun({ ...lost, storedHistory, notificationMode: NotificationMode.GAINS }).thresholdReached).toBe( @@ -183,7 +183,7 @@ describe("measureRun", () => { const measurement = measureRun({ ...BASE, - trackedSet: [makeRepoInfo("repo-a", 10)], + trackedSet: [makeRepoInfo({ name: "repo-a", stars: 10 })], storedHistory: EMPTY_HISTORY, now, }); diff --git a/src/domain/star-history.test.ts b/src/domain/star-history.test.ts index aa1f1af..47200ac 100644 --- a/src/domain/star-history.test.ts +++ b/src/domain/star-history.test.ts @@ -7,13 +7,24 @@ import type { SnapshotRepo } from "./types"; const NOW = new Date("2026-06-25T00:00:00Z"); const MAX_REACHABLE_STARS = 40_000; -function repoTotal(fullName: string, stars: number): SnapshotRepo { +interface RepoTotalParams { + fullName: string; + stars: number; +} + +function repoTotal({ fullName, stars }: RepoTotalParams): SnapshotRepo { const [owner, name] = fullName.split("/"); return { fullName, name, owner, stars }; } -function repoStargazers(fullName: string, dates: string[], sampled = false): RepoStargazers { +interface RepoStargazersParams { + fullName: string; + dates: string[]; + sampled?: boolean; +} + +function repoStargazers({ fullName, dates, sampled = false }: RepoStargazersParams): RepoStargazers { return { repoFullName: fullName, stargazers: dates.map((starredAt) => makeStargazer({ starredAt })), @@ -24,8 +35,8 @@ function repoStargazers(fullName: string, dates: string[], sampled = false): Rep describe("buildStarHistory", () => { it("returns an empty history when there are no valid starred_at dates", () => { const result = buildStarHistory({ - repoStargazers: [repoStargazers("user/a", [])], - repos: [repoTotal("user/a", 0)], + repoStargazers: [repoStargazers({ fullName: "user/a", dates: [] })], + repos: [repoTotal({ fullName: "user/a", stars: 0 })], maxPoints: 30, now: NOW, }); @@ -36,10 +47,13 @@ describe("buildStarHistory", () => { it("builds a cumulative, monotonic curve ending at the true total", () => { const result = buildStarHistory({ repoStargazers: [ - repoStargazers("user/a", ["2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"]), - repoStargazers("user/b", ["2026-01-15T00:00:00Z", "2026-04-01T00:00:00Z"]), + repoStargazers({ + fullName: "user/a", + dates: ["2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"], + }), + repoStargazers({ fullName: "user/b", dates: ["2026-01-15T00:00:00Z", "2026-04-01T00:00:00Z"] }), ], - repos: [repoTotal("user/a", 3), repoTotal("user/b", 2)], + repos: [repoTotal({ fullName: "user/a", stars: 3 }), repoTotal({ fullName: "user/b", stars: 2 })], maxPoints: 30, now: NOW, }); @@ -60,9 +74,12 @@ describe("buildStarHistory", () => { it("uses exact cumulative counts when nothing is sampled", () => { const result = buildStarHistory({ repoStargazers: [ - repoStargazers("user/a", ["2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"]), + repoStargazers({ + fullName: "user/a", + dates: ["2026-01-01T00:00:00Z", "2026-02-01T00:00:00Z", "2026-03-01T00:00:00Z"], + }), ], - repos: [repoTotal("user/a", 3)], + repos: [repoTotal({ fullName: "user/a", stars: 3 })], maxPoints: 2, now: NOW, }); @@ -75,9 +92,13 @@ describe("buildStarHistory", () => { it("scales a sampled repo up so the terminal equals the true total", () => { const result = buildStarHistory({ repoStargazers: [ - repoStargazers("user/huge", ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z", "2026-05-01T00:00:00Z"], true), + repoStargazers({ + fullName: "user/huge", + dates: ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z", "2026-05-01T00:00:00Z"], + sampled: true, + }), ], - repos: [repoTotal("user/huge", 9000)], + repos: [repoTotal({ fullName: "user/huge", stars: 9000 })], maxPoints: 10, now: NOW, }); @@ -93,13 +114,13 @@ describe("buildStarHistory", () => { it("ramps a >40k repo up to the true total instead of flattening the tail", () => { const result = buildStarHistory({ repoStargazers: [ - repoStargazers( - "user/massive", - ["2024-01-01T00:00:00Z", "2024-06-01T00:00:00Z", "2025-01-01T00:00:00Z", "2025-09-01T00:00:00Z"], - true, - ), + repoStargazers({ + fullName: "user/massive", + dates: ["2024-01-01T00:00:00Z", "2024-06-01T00:00:00Z", "2025-01-01T00:00:00Z", "2025-09-01T00:00:00Z"], + sampled: true, + }), ], - repos: [repoTotal("user/massive", 50000)], + repos: [repoTotal({ fullName: "user/massive", stars: 50000 })], maxPoints: 20, now: NOW, }); @@ -125,8 +146,11 @@ describe("buildStarHistory", () => { it("holds a repo with stars but no fetched dates flat at its true total", () => { const result = buildStarHistory({ - repoStargazers: [repoStargazers("user/a", ["2026-01-01T00:00:00Z"]), repoStargazers("user/b", [])], - repos: [repoTotal("user/a", 1), repoTotal("user/b", 500)], + repoStargazers: [ + repoStargazers({ fullName: "user/a", dates: ["2026-01-01T00:00:00Z"] }), + repoStargazers({ fullName: "user/b", dates: [] }), + ], + repos: [repoTotal({ fullName: "user/a", stars: 1 }), repoTotal({ fullName: "user/b", stars: 500 })], maxPoints: 5, now: NOW, }); @@ -138,10 +162,13 @@ describe("buildStarHistory", () => { it("does not fabricate a straight 0→total ramp for a >40k repo with no fetched dates (#148)", () => { const result = buildStarHistory({ repoStargazers: [ - repoStargazers("user/reachable", ["2023-10-01T00:00:00Z", "2026-06-01T00:00:00Z"]), - repoStargazers("user/restricted", []), + repoStargazers({ fullName: "user/reachable", dates: ["2023-10-01T00:00:00Z", "2026-06-01T00:00:00Z"] }), + repoStargazers({ fullName: "user/restricted", dates: [] }), + ], + repos: [ + repoTotal({ fullName: "user/reachable", stars: 2 }), + repoTotal({ fullName: "user/restricted", stars: 54_000 }), ], - repos: [repoTotal("user/reachable", 2), repoTotal("user/restricted", 54_000)], maxPoints: 30, now: NOW, }); @@ -153,16 +180,14 @@ describe("buildStarHistory", () => { }); it("anchors a partially covered repo at its real coverage instead of the 40k cap", () => { - const entry = repoStargazers("user/deep", [ - "2024-02-01T00:00:00Z", - "2024-08-01T00:00:00Z", - "2025-02-01T00:00:00Z", - "2025-06-01T00:00:00Z", - ]); + const entry = repoStargazers({ + fullName: "user/deep", + dates: ["2024-02-01T00:00:00Z", "2024-08-01T00:00:00Z", "2025-02-01T00:00:00Z", "2025-06-01T00:00:00Z"], + }); const result = buildStarHistory({ repoStargazers: [{ ...entry, coveredStars: 20_000 }], - repos: [repoTotal("user/deep", 54_000)], + repos: [repoTotal({ fullName: "user/deep", stars: 54_000 })], maxPoints: 10, now: NOW, }); @@ -180,10 +205,10 @@ describe("buildStarHistory", () => { it("keeps a zero-star repo at 0 in every snapshot", () => { const result = buildStarHistory({ repoStargazers: [ - repoStargazers("user/a", ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"]), - repoStargazers("user/empty", []), + repoStargazers({ fullName: "user/a", dates: ["2026-01-01T00:00:00Z", "2026-03-01T00:00:00Z"] }), + repoStargazers({ fullName: "user/empty", dates: [] }), ], - repos: [repoTotal("user/a", 2), repoTotal("user/empty", 0)], + repos: [repoTotal({ fullName: "user/a", stars: 2 }), repoTotal({ fullName: "user/empty", stars: 0 })], maxPoints: 8, now: NOW, }); @@ -196,8 +221,8 @@ describe("buildStarHistory", () => { it("produces at least two snapshots for a single star", () => { const result = buildStarHistory({ - repoStargazers: [repoStargazers("user/a", ["2026-06-01T00:00:00Z"])], - repos: [repoTotal("user/a", 1)], + repoStargazers: [repoStargazers({ fullName: "user/a", dates: ["2026-06-01T00:00:00Z"] })], + repos: [repoTotal({ fullName: "user/a", stars: 1 })], maxPoints: 30, now: NOW, }); @@ -208,8 +233,8 @@ describe("buildStarHistory", () => { it("ignores invalid starred_at values", () => { const result = buildStarHistory({ - repoStargazers: [repoStargazers("user/a", ["not-a-date", "2026-01-01T00:00:00Z", ""])], - repos: [repoTotal("user/a", 2)], + repoStargazers: [repoStargazers({ fullName: "user/a", dates: ["not-a-date", "2026-01-01T00:00:00Z", ""] })], + repos: [repoTotal({ fullName: "user/a", stars: 2 })], maxPoints: 4, now: NOW, }); @@ -219,8 +244,8 @@ describe("buildStarHistory", () => { it("respects maxPoints and never drops the earliest history for a long span", () => { const result = buildStarHistory({ - repoStargazers: [repoStargazers("user/a", ["2021-01-01T00:00:00Z", "2026-06-01T00:00:00Z"])], - repos: [repoTotal("user/a", 2)], + repoStargazers: [repoStargazers({ fullName: "user/a", dates: ["2021-01-01T00:00:00Z", "2026-06-01T00:00:00Z"] })], + repos: [repoTotal({ fullName: "user/a", stars: 2 })], maxPoints: 30, now: NOW, }); @@ -232,8 +257,8 @@ describe("buildStarHistory", () => { it("reconstructs a weekly-cadence full history when maxPoints is 0", () => { const earliest = "2026-01-01T00:00:00Z"; const result = buildStarHistory({ - repoStargazers: [repoStargazers("user/a", [earliest, "2026-06-01T00:00:00Z"])], - repos: [repoTotal("user/a", 2)], + repoStargazers: [repoStargazers({ fullName: "user/a", dates: [earliest, "2026-06-01T00:00:00Z"] })], + repos: [repoTotal({ fullName: "user/a", stars: 2 })], maxPoints: 0, now: NOW, }); @@ -248,8 +273,8 @@ describe("buildStarHistory", () => { it("caps the full-history reconstruction for very old repositories", () => { const result = buildStarHistory({ - repoStargazers: [repoStargazers("user/a", ["2000-01-01T00:00:00Z", "2026-06-01T00:00:00Z"])], - repos: [repoTotal("user/a", 2)], + repoStargazers: [repoStargazers({ fullName: "user/a", dates: ["2000-01-01T00:00:00Z", "2026-06-01T00:00:00Z"] })], + repos: [repoTotal({ fullName: "user/a", stars: 2 })], maxPoints: 0, now: NOW, }); @@ -259,8 +284,8 @@ describe("buildStarHistory", () => { it("honors a maxPoints value above the legacy 30-point limit", () => { const result = buildStarHistory({ - repoStargazers: [repoStargazers("user/a", ["2024-01-01T00:00:00Z", "2026-06-01T00:00:00Z"])], - repos: [repoTotal("user/a", 2)], + repoStargazers: [repoStargazers({ fullName: "user/a", dates: ["2024-01-01T00:00:00Z", "2026-06-01T00:00:00Z"] })], + repos: [repoTotal({ fullName: "user/a", stars: 2 })], maxPoints: 50, now: NOW, }); diff --git a/src/domain/stargazers.test.ts b/src/domain/stargazers.test.ts index 73e993c..1864310 100644 --- a/src/domain/stargazers.test.ts +++ b/src/domain/stargazers.test.ts @@ -2,14 +2,20 @@ import { makeStargazer } from "@shared/tests"; import { describe, expect, it } from "vitest"; import type { RepoStargazers, Stargazer, StargazerMap } from "./stargazers"; -const makeStar = (login: string, starredAt = "2026-01-15"): Stargazer => makeStargazer({ login, starredAt }); +interface MakeStarParams { + login: string; + starredAt?: string; +} + +const makeStar = ({ login, starredAt = "2026-01-15" }: MakeStarParams): Stargazer => + makeStargazer({ login, starredAt }); import { buildStargazerMap, diffStargazers } from "./stargazers"; describe("diffStargazers", () => { it("treats all as new when previous map is empty (first run)", () => { const current: RepoStargazers[] = [ - { repoFullName: "user/repo-a", stargazers: [makeStar("alice"), makeStar("bob")] }, + { repoFullName: "user/repo-a", stargazers: [makeStar({ login: "alice" }), makeStar({ login: "bob" })] }, ]; const result = diffStargazers({ current, previousMap: {} }); @@ -20,7 +26,7 @@ describe("diffStargazers", () => { it("returns empty when no changes", () => { const current: RepoStargazers[] = [ - { repoFullName: "user/repo-a", stargazers: [makeStar("alice"), makeStar("bob")] }, + { repoFullName: "user/repo-a", stargazers: [makeStar({ login: "alice" }), makeStar({ login: "bob" })] }, ]; const previousMap: StargazerMap = { "user/repo-a": ["alice", "bob"] }; const result = diffStargazers({ current, previousMap }); @@ -33,7 +39,11 @@ describe("diffStargazers", () => { const current: RepoStargazers[] = [ { repoFullName: "user/repo-a", - stargazers: [makeStar("alice", "2026-01-10"), makeStar("bob", "2026-01-20"), makeStar("charlie", "2026-01-15")], + stargazers: [ + makeStar({ login: "alice", starredAt: "2026-01-10" }), + makeStar({ login: "bob", starredAt: "2026-01-20" }), + makeStar({ login: "charlie", starredAt: "2026-01-15" }), + ], }, ]; const previousMap: StargazerMap = { "user/repo-a": ["alice"] }; @@ -47,7 +57,11 @@ describe("diffStargazers", () => { const current: RepoStargazers[] = [ { repoFullName: "user/repo-a", - stargazers: [makeStar("alice", "2026-01-01"), makeStar("bob", "2026-01-20"), makeStar("charlie", "2026-01-10")], + stargazers: [ + makeStar({ login: "alice", starredAt: "2026-01-01" }), + makeStar({ login: "bob", starredAt: "2026-01-20" }), + makeStar({ login: "charlie", starredAt: "2026-01-10" }), + ], }, ]; const result = diffStargazers({ current, previousMap: {} }); @@ -56,7 +70,7 @@ describe("diffStargazers", () => { }); it("handles newly added repo", () => { - const current: RepoStargazers[] = [{ repoFullName: "user/new-repo", stargazers: [makeStar("alice")] }]; + const current: RepoStargazers[] = [{ repoFullName: "user/new-repo", stargazers: [makeStar({ login: "alice" })] }]; const previousMap: StargazerMap = { "user/old-repo": ["bob"] }; const result = diffStargazers({ current, previousMap }); @@ -66,8 +80,8 @@ describe("diffStargazers", () => { it("excludes sampled repos from the diff and reports them in sampledRepos", () => { const current: RepoStargazers[] = [ - { repoFullName: "user/repo-a", stargazers: [makeStar("alice")] }, - { repoFullName: "user/huge", stargazers: [makeStar("bob")], sampled: true }, + { repoFullName: "user/repo-a", stargazers: [makeStar({ login: "alice" })] }, + { repoFullName: "user/huge", stargazers: [makeStar({ login: "bob" })], sampled: true }, ]; const result = diffStargazers({ current, previousMap: {} }); @@ -77,7 +91,7 @@ describe("diffStargazers", () => { }); it("omits sampledRepos when no repo is sampled", () => { - const current: RepoStargazers[] = [{ repoFullName: "user/repo-a", stargazers: [makeStar("alice")] }]; + const current: RepoStargazers[] = [{ repoFullName: "user/repo-a", stargazers: [makeStar({ login: "alice" })] }]; const result = diffStargazers({ current, previousMap: {} }); expect(result.sampledRepos).toBeUndefined(); @@ -85,8 +99,8 @@ describe("diffStargazers", () => { it("handles multiple repos with mixed changes", () => { const current: RepoStargazers[] = [ - { repoFullName: "user/repo-a", stargazers: [makeStar("alice"), makeStar("bob")] }, - { repoFullName: "user/repo-b", stargazers: [makeStar("charlie")] }, + { repoFullName: "user/repo-a", stargazers: [makeStar({ login: "alice" }), makeStar({ login: "bob" })] }, + { repoFullName: "user/repo-b", stargazers: [makeStar({ login: "charlie" })] }, ]; const previousMap: StargazerMap = { "user/repo-a": ["alice", "bob"], @@ -103,8 +117,8 @@ describe("diffStargazers", () => { describe("buildStargazerMap", () => { it("builds map from repo stargazers", () => { const repoStargazers: RepoStargazers[] = [ - { repoFullName: "user/repo-a", stargazers: [makeStar("alice"), makeStar("bob")] }, - { repoFullName: "user/repo-b", stargazers: [makeStar("charlie")] }, + { repoFullName: "user/repo-a", stargazers: [makeStar({ login: "alice" }), makeStar({ login: "bob" })] }, + { repoFullName: "user/repo-b", stargazers: [makeStar({ login: "charlie" })] }, ]; const map = buildStargazerMap({ repoStargazers, previousMap: {} }); @@ -122,8 +136,8 @@ describe("buildStargazerMap", () => { it("skips sampled repos so partial lists do not corrupt the next diff", () => { const repoStargazers: RepoStargazers[] = [ - { repoFullName: "user/repo-a", stargazers: [makeStar("alice")] }, - { repoFullName: "user/huge", stargazers: [makeStar("bob")], sampled: true }, + { repoFullName: "user/repo-a", stargazers: [makeStar({ login: "alice" })] }, + { repoFullName: "user/huge", stargazers: [makeStar({ login: "bob" })], sampled: true }, ]; const map = buildStargazerMap({ repoStargazers, previousMap: {} }); @@ -132,7 +146,7 @@ describe("buildStargazerMap", () => { it("carries previously known logins forward for a sampled repo", () => { const repoStargazers: RepoStargazers[] = [ - { repoFullName: "user/huge", stargazers: [makeStar("bob")], sampled: true }, + { repoFullName: "user/huge", stargazers: [makeStar({ login: "bob" })], sampled: true }, ]; const map = buildStargazerMap({ repoStargazers, @@ -161,7 +175,7 @@ describe("buildStargazerMap", () => { it("keeps a repository that left the Tracked Set, so its return is not a fabricated spike", () => { const repoStargazers: RepoStargazers[] = [ - { repoFullName: "user/still-tracked", stargazers: [makeStar("octocat")] }, + { repoFullName: "user/still-tracked", stargazers: [makeStar({ login: "octocat" })] }, ]; const previousMap = { "user/still-tracked": ["octocat"], @@ -177,7 +191,7 @@ describe("buildStargazerMap", () => { const repoStargazers: RepoStargazers[] = [ { repoFullName: "user/big", - stargazers: [makeStar("oldest-1"), makeStar("oldest-2")], + stargazers: [makeStar({ login: "oldest-1" }), makeStar({ login: "oldest-2" })], incomplete: true, coveredStars: 2, }, diff --git a/src/domain/tracked-set.test.ts b/src/domain/tracked-set.test.ts index 165d987..0282781 100644 --- a/src/domain/tracked-set.test.ts +++ b/src/domain/tracked-set.test.ts @@ -21,14 +21,14 @@ function tracked(params: Tracked): RepoInfo[] { describe("resolveTrackedSet", () => { it("matches only-repos by regex, like its sibling filters", () => { - const repos = [makeRepoInfo("app-web"), makeRepoInfo("docs")]; + const repos = [makeRepoInfo({ name: "app-web" }), makeRepoInfo({ name: "docs" })]; const config = makeConfig({ onlyRepos: ["/^app-/"] }); expect(tracked({ repos, filters: config }).map((repo) => repo.name)).toEqual(["app-web"]); }); it("warns and skips a malformed regex pattern instead of failing the run", () => { - const repos = [makeRepoInfo("keep-me"), makeRepoInfo("drop-me")]; + const repos = [makeRepoInfo({ name: "keep-me" }), makeRepoInfo({ name: "drop-me" })]; const config = makeConfig({ excludeRepos: ["/[unclosed/", "drop-me"] }); const filtered = trackedSet({ repos, filters: config }); @@ -38,13 +38,16 @@ describe("resolveTrackedSet", () => { }); it("returns all repos with default config", () => { - const repos = [makeRepoInfo("test-repo"), makeRepoInfo("other")]; + const repos = [makeRepoInfo({ name: "test-repo" }), makeRepoInfo({ name: "other" })]; expect(tracked({ repos })).toHaveLength(2); }); it("filters out archived repos by default", () => { - const repos = [makeRepoInfo("test-repo"), makeRepoInfo("archived", 10, { archived: true })]; + const repos = [ + makeRepoInfo({ name: "test-repo" }), + makeRepoInfo({ name: "archived", stars: 10, overrides: { archived: true } }), + ]; const result = tracked({ repos }); expect(result).toHaveLength(1); @@ -52,34 +55,47 @@ describe("resolveTrackedSet", () => { }); it("includes archived repos when configured", () => { - const repos = [makeRepoInfo("test-repo"), makeRepoInfo("archived", 10, { archived: true })]; + const repos = [ + makeRepoInfo({ name: "test-repo" }), + makeRepoInfo({ name: "archived", stars: 10, overrides: { archived: true } }), + ]; const config = { ...defaultConfig, includeArchived: true }; expect(tracked({ repos, filters: config })).toHaveLength(2); }); it("filters out forks by default", () => { - const repos = [makeRepoInfo("test-repo"), makeRepoInfo("forked", 10, { fork: true })]; + const repos = [ + makeRepoInfo({ name: "test-repo" }), + makeRepoInfo({ name: "forked", stars: 10, overrides: { fork: true } }), + ]; expect(tracked({ repos })).toHaveLength(1); }); it("includes forks when configured", () => { - const repos = [makeRepoInfo("test-repo"), makeRepoInfo("forked", 10, { fork: true })]; + const repos = [ + makeRepoInfo({ name: "test-repo" }), + makeRepoInfo({ name: "forked", stars: 10, overrides: { fork: true } }), + ]; const config = { ...defaultConfig, includeForks: true }; expect(tracked({ repos, filters: config })).toHaveLength(2); }); it("excludes repos by name", () => { - const repos = [makeRepoInfo("test-repo"), makeRepoInfo("excluded")]; + const repos = [makeRepoInfo({ name: "test-repo" }), makeRepoInfo({ name: "excluded" })]; const config = { ...defaultConfig, excludeRepos: ["excluded"] }; expect(tracked({ repos, filters: config })).toHaveLength(1); }); it("excludes repos by regex pattern", () => { - const repos = [makeRepoInfo("my-app"), makeRepoInfo("test-utils"), makeRepoInfo("test-helpers")]; + const repos = [ + makeRepoInfo({ name: "my-app" }), + makeRepoInfo({ name: "test-utils" }), + makeRepoInfo({ name: "test-helpers" }), + ]; const config = { ...defaultConfig, excludeRepos: ["/^test-/"] }; const result = tracked({ repos, filters: config }); @@ -89,10 +105,10 @@ describe("resolveTrackedSet", () => { it("supports mixed exact names and regex patterns in exclude", () => { const repos = [ - makeRepoInfo("keep-me"), - makeRepoInfo("drop-this"), - makeRepoInfo("experiment-1"), - makeRepoInfo("experiment-2"), + makeRepoInfo({ name: "keep-me" }), + makeRepoInfo({ name: "drop-this" }), + makeRepoInfo({ name: "experiment-1" }), + makeRepoInfo({ name: "experiment-2" }), ]; const config = { ...defaultConfig, excludeRepos: ["drop-this", "/^experiment-/"] }; const result = tracked({ repos, filters: config }); @@ -102,7 +118,11 @@ describe("resolveTrackedSet", () => { }); it("supports regex flags in exclude pattern", () => { - const repos = [makeRepoInfo("MyProject"), makeRepoInfo("mylib"), makeRepoInfo("other")]; + const repos = [ + makeRepoInfo({ name: "MyProject" }), + makeRepoInfo({ name: "mylib" }), + makeRepoInfo({ name: "other" }), + ]; const config = { ...defaultConfig, excludeRepos: ["/^my/i"] }; const result = tracked({ repos, filters: config }); @@ -111,7 +131,7 @@ describe("resolveTrackedSet", () => { }); it("filters by minimum stars", () => { - const repos = [makeRepoInfo("test-repo", 5), makeRepoInfo("popular", 50)]; + const repos = [makeRepoInfo({ name: "test-repo", stars: 5 }), makeRepoInfo({ name: "popular", stars: 50 })]; const config = { ...defaultConfig, minStars: 10 }; const result = tracked({ repos, filters: config }); @@ -120,7 +140,10 @@ describe("resolveTrackedSet", () => { }); it("only_repos overrides all other filters", () => { - const repos = [makeRepoInfo("wanted", 10, { archived: true, fork: true }), makeRepoInfo("unwanted")]; + const repos = [ + makeRepoInfo({ name: "wanted", stars: 10, overrides: { archived: true, fork: true } }), + makeRepoInfo({ name: "unwanted" }), + ]; const config = { ...defaultConfig, onlyRepos: ["wanted"] }; const result = tracked({ repos, filters: config }); @@ -129,7 +152,7 @@ describe("resolveTrackedSet", () => { }); it("returns empty array when no repos match only_repos", () => { - const repos = [makeRepoInfo("test-repo")]; + const repos = [makeRepoInfo({ name: "test-repo" })]; const config = { ...defaultConfig, onlyRepos: ["nonexistent"] }; expect(tracked({ repos, filters: config })).toHaveLength(0); @@ -137,8 +160,8 @@ describe("resolveTrackedSet", () => { it("filters by org with only-orgs", () => { const repos = [ - makeRepoInfo("a", 10, { owner: "org-a", fullName: "org-a/a" }), - makeRepoInfo("b", 10, { owner: "org-b", fullName: "org-b/b" }), + makeRepoInfo({ name: "a", stars: 10, overrides: { owner: "org-a", fullName: "org-a/a" } }), + makeRepoInfo({ name: "b", stars: 10, overrides: { owner: "org-b", fullName: "org-b/b" } }), ]; const config = { ...defaultConfig, onlyOrgs: ["org-a"] }; const result = tracked({ repos, filters: config }); @@ -149,9 +172,9 @@ describe("resolveTrackedSet", () => { it("supports regex pattern in only-orgs", () => { const repos = [ - makeRepoInfo("web", 10, { owner: "acme-web", fullName: "acme-web/web" }), - makeRepoInfo("api", 10, { owner: "acme-api", fullName: "acme-api/api" }), - makeRepoInfo("x", 10, { owner: "other", fullName: "other/x" }), + makeRepoInfo({ name: "web", stars: 10, overrides: { owner: "acme-web", fullName: "acme-web/web" } }), + makeRepoInfo({ name: "api", stars: 10, overrides: { owner: "acme-api", fullName: "acme-api/api" } }), + makeRepoInfo({ name: "x", stars: 10, overrides: { owner: "other", fullName: "other/x" } }), ]; const config = { ...defaultConfig, onlyOrgs: ["/^acme-/"] }; @@ -160,8 +183,8 @@ describe("resolveTrackedSet", () => { it("excludes repos by org with exclude-orgs", () => { const repos = [ - makeRepoInfo("a", 10, { owner: "keep", fullName: "keep/a" }), - makeRepoInfo("b", 10, { owner: "drop", fullName: "drop/b" }), + makeRepoInfo({ name: "a", stars: 10, overrides: { owner: "keep", fullName: "keep/a" } }), + makeRepoInfo({ name: "b", stars: 10, overrides: { owner: "drop", fullName: "drop/b" } }), ]; const config = { ...defaultConfig, excludeOrgs: ["drop"] }; const result = tracked({ repos, filters: config }); @@ -172,9 +195,9 @@ describe("resolveTrackedSet", () => { it("supports mixed exact names and regex in exclude-orgs", () => { const repos = [ - makeRepoInfo("a", 10, { owner: "keep", fullName: "keep/a" }), - makeRepoInfo("b", 10, { owner: "drop-this", fullName: "drop-this/b" }), - makeRepoInfo("c", 10, { owner: "experiment-1", fullName: "experiment-1/c" }), + makeRepoInfo({ name: "a", stars: 10, overrides: { owner: "keep", fullName: "keep/a" } }), + makeRepoInfo({ name: "b", stars: 10, overrides: { owner: "drop-this", fullName: "drop-this/b" } }), + makeRepoInfo({ name: "c", stars: 10, overrides: { owner: "experiment-1", fullName: "experiment-1/c" } }), ]; const config = { ...defaultConfig, excludeOrgs: ["drop-this", "/^experiment-/"] }; const result = tracked({ repos, filters: config }); @@ -184,7 +207,7 @@ describe("resolveTrackedSet", () => { }); it("matches orgs case-sensitively", () => { - const repos = [makeRepoInfo("a", 10, { owner: "Org-A", fullName: "Org-A/a" })]; + const repos = [makeRepoInfo({ name: "a", stars: 10, overrides: { owner: "Org-A", fullName: "Org-A/a" } })]; const config = { ...defaultConfig, onlyOrgs: ["org-a"] }; expect(tracked({ repos, filters: config })).toHaveLength(0); @@ -192,14 +215,18 @@ describe("resolveTrackedSet", () => { it("applies only-orgs before the only-repos override on the narrowed set", () => { const repos = [ - makeRepoInfo("wanted", 10, { - owner: "org-a", - fullName: "org-a/wanted", - archived: true, - fork: true, + makeRepoInfo({ + name: "wanted", + stars: 10, + overrides: { + owner: "org-a", + fullName: "org-a/wanted", + archived: true, + fork: true, + }, }), - makeRepoInfo("wanted", 10, { owner: "org-b", fullName: "org-b/wanted" }), - makeRepoInfo("unwanted", 10, { owner: "org-a", fullName: "org-a/unwanted" }), + makeRepoInfo({ name: "wanted", stars: 10, overrides: { owner: "org-b", fullName: "org-b/wanted" } }), + makeRepoInfo({ name: "unwanted", stars: 10, overrides: { owner: "org-a", fullName: "org-a/unwanted" } }), ]; const config = { ...defaultConfig, onlyOrgs: ["org-a"], onlyRepos: ["wanted"] }; const result = tracked({ repos, filters: config }); @@ -211,8 +238,8 @@ describe("resolveTrackedSet", () => { it("does not filter by org when org lists are empty", () => { const repos = [ - makeRepoInfo("a", 10, { owner: "org-a", fullName: "org-a/a" }), - makeRepoInfo("b", 10, { owner: "org-b", fullName: "org-b/b" }), + makeRepoInfo({ name: "a", stars: 10, overrides: { owner: "org-a", fullName: "org-a/a" } }), + makeRepoInfo({ name: "b", stars: 10, overrides: { owner: "org-b", fullName: "org-b/b" } }), ]; expect(tracked({ repos })).toHaveLength(2); diff --git a/src/infrastructure/git/worktree.test.ts b/src/infrastructure/git/worktree.test.ts index b060eac..5ad9546 100644 --- a/src/infrastructure/git/worktree.test.ts +++ b/src/infrastructure/git/worktree.test.ts @@ -23,7 +23,12 @@ function ranGit(...args: string[]): boolean { return vi.mocked(execute).mock.calls.some(([params]) => JSON.stringify(params.args) === JSON.stringify(args)); } -function failGitWhen(matches: (args: string[]) => boolean, error = new Error("git failed")): void { +interface FailGitWhenParams { + matches: (args: string[]) => boolean; + error?: Error; +} + +function failGitWhen({ matches, error = new Error("git failed") }: FailGitWhenParams): void { vi.mocked(execute).mockImplementation(({ args }) => { if (matches(args)) throw error; @@ -72,7 +77,7 @@ describe("initializeDataBranch", () => { }); it("lets a failing remote probe through instead of reading it as an absent branch", () => { - failGitWhen(isRemoteProbe, new Error("fatal: could not read Username for https://github.com")); + failGitWhen({ matches: isRemoteProbe, error: new Error("fatal: could not read Username for https://github.com") }); expect(() => initializeDataBranch({ dataBranch: BRANCH })).toThrow(/could not read Username/); expect(ranGit("checkout", "--orphan", BRANCH)).toBe(false); @@ -111,7 +116,7 @@ describe("initializeDataBranch", () => { }); it("throws an actionable error when not inside a checked-out repository", () => { - failGitWhen((args) => args[0] === "rev-parse"); + failGitWhen({ matches: (args) => args[0] === "rev-parse" }); expect(() => initializeDataBranch({ dataBranch: BRANCH })).toThrow( 'This action must run inside a checked-out repository. Add an "actions/checkout" step before this action in your workflow.', @@ -128,7 +133,7 @@ describe("initializeDataBranch", () => { it("carries on when the stale worktree cannot be removed", () => { vi.mocked(fs.existsSync).mockReturnValue(true); - failGitWhen(isWorktreeRemove); + failGitWhen({ matches: isWorktreeRemove }); expect(() => initializeDataBranch({ dataBranch: BRANCH })).not.toThrow(); expect(core.debug).toHaveBeenCalledWith(`Could not remove existing worktree at ${DATA_DIR}, proceeding anyway`); @@ -160,7 +165,7 @@ describe("initializeDataBranch", () => { }); it("carries on when the new orphan branch has nothing to clear", () => { - failGitWhen((args) => args[0] === "rm"); + failGitWhen({ matches: (args) => args[0] === "rm" }); expect(() => initializeDataBranch({ dataBranch: BRANCH })).not.toThrow(); expect(core.debug).toHaveBeenCalledWith("Nothing to remove on the new orphan branch, proceeding anyway"); @@ -191,7 +196,7 @@ describe("cleanup", () => { }); it("never rethrows, so it is safe in a finally", () => { - failGitWhen(isWorktreeRemove, new Error("Worktree not found")); + failGitWhen({ matches: isWorktreeRemove, error: new Error("Worktree not found") }); expect(() => cleanup("/data")).not.toThrow(); expect(core.debug).toHaveBeenCalledWith('Worktree cleanup for "/data" failed, it may have already been removed'); diff --git a/src/infrastructure/github/stargazers.test.ts b/src/infrastructure/github/stargazers.test.ts index 4dbdb4b..a918207 100644 --- a/src/infrastructure/github/stargazers.test.ts +++ b/src/infrastructure/github/stargazers.test.ts @@ -15,7 +15,12 @@ const samplingOff = makeConfig({ smartSamplingPages: 30, }); -function makeStargazerResponse(login: string, date = "2026-01-15T00:00:00Z") { +interface MakeStargazerResponseParams { + login: string; + date?: string; +} + +function makeStargazerResponse({ login, date = "2026-01-15T00:00:00Z" }: MakeStargazerResponseParams) { return { user: { login, @@ -34,12 +39,12 @@ describe("fetchAllStargazers", () => { it("fetches stargazers for a single repo", async () => { const octokit = { request: vi.fn().mockResolvedValue({ - data: [makeStargazerResponse("alice"), makeStargazerResponse("bob")], + data: [makeStargazerResponse({ login: "alice" }), makeStargazerResponse({ login: "bob" })], }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("repo-a")], + repos: [makeRepoInfo({ name: "repo-a" })], config: samplingOff, }); @@ -51,14 +56,14 @@ describe("fetchAllStargazers", () => { }); it("handles pagination", async () => { - const page1 = Array.from({ length: 100 }, (_, index) => makeStargazerResponse(`user-${index}`)); - const page2 = [makeStargazerResponse("last-user")]; + const page1 = Array.from({ length: 100 }, (_, index) => makeStargazerResponse({ login: `user-${index}` })); + const page2 = [makeStargazerResponse({ login: "last-user" })]; const octokit = { request: vi.fn().mockResolvedValueOnce({ data: page1 }).mockResolvedValueOnce({ data: page2 }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("repo-a")], + repos: [makeRepoInfo({ name: "repo-a" })], config: samplingOff, }); @@ -71,12 +76,12 @@ describe("fetchAllStargazers", () => { request: vi .fn() .mockRejectedValueOnce(new Error("rate limited")) - .mockResolvedValueOnce({ data: [makeStargazerResponse("alice")] }), + .mockResolvedValueOnce({ data: [makeStargazerResponse({ login: "alice" })] }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("repo-a"), makeRepoInfo("repo-b")], + repos: [makeRepoInfo({ name: "repo-a" }), makeRepoInfo({ name: "repo-b" })], config: samplingOff, }); @@ -93,7 +98,7 @@ describe("fetchAllStargazers", () => { const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("repo-a")], + repos: [makeRepoInfo({ name: "repo-a" })], config: samplingOff, }); @@ -101,7 +106,7 @@ describe("fetchAllStargazers", () => { }); it("keeps already-fetched pages when a later page fails mid-pagination", async () => { - const page1 = Array.from({ length: 100 }, (_, index) => makeStargazerResponse(`user-${index}`)); + const page1 = Array.from({ length: 100 }, (_, index) => makeStargazerResponse({ login: `user-${index}` })); const octokit = { request: vi .fn() @@ -111,7 +116,7 @@ describe("fetchAllStargazers", () => { const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("repo-a", 150)], + repos: [makeRepoInfo({ name: "repo-a", stars: 150 })], config: samplingOff, }); @@ -125,12 +130,12 @@ describe("fetchAllStargazers", () => { it("reports no coverage limit when the fetch completes", async () => { const octokit = { - request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse("alice")] }), + request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse({ login: "alice" })] }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("repo-a")], + repos: [makeRepoInfo({ name: "repo-a" })], config: samplingOff, }); @@ -141,14 +146,14 @@ describe("fetchAllStargazers", () => { const octokit = { request: vi .fn() - .mockResolvedValueOnce({ data: [makeStargazerResponse("alice")] }) - .mockResolvedValueOnce({ data: [makeStargazerResponse("bob")] }) + .mockResolvedValueOnce({ data: [makeStargazerResponse({ login: "alice" })] }) + .mockResolvedValueOnce({ data: [makeStargazerResponse({ login: "bob" })] }) .mockRejectedValue(Object.assign(new Error(""), { status: 403 })), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("huge", 5000)], + repos: [makeRepoInfo({ name: "huge", stars: 5000 })], config: makeConfig({ smartSampling: true, smartSamplingThreshold: 1500, @@ -164,14 +169,14 @@ describe("fetchAllStargazers", () => { const octokit = { request: vi .fn() - .mockResolvedValueOnce({ data: [makeStargazerResponse("alice")] }) + .mockResolvedValueOnce({ data: [makeStargazerResponse({ login: "alice" })] }) .mockRejectedValueOnce(Object.assign(new Error(""), { status: 403 })) - .mockResolvedValue({ data: [makeStargazerResponse("bob")] }), + .mockResolvedValue({ data: [makeStargazerResponse({ login: "bob" })] }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("huge", 5000)], + repos: [makeRepoInfo({ name: "huge", stars: 5000 })], config: makeConfig({ smartSampling: true, smartSamplingThreshold: 1500, @@ -193,7 +198,7 @@ describe("fetchAllStargazers", () => { const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("huge", 5000)], + repos: [makeRepoInfo({ name: "huge", stars: 5000 })], config: makeConfig({ smartSampling: true, smartSamplingThreshold: 1500, @@ -214,7 +219,7 @@ describe("fetchAllStargazers", () => { const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("repo-a")], + repos: [makeRepoInfo({ name: "repo-a" })], config: samplingOff, }); @@ -223,7 +228,7 @@ describe("fetchAllStargazers", () => { }); it("warns when stargazers come back without usable starred_at dates", async () => { - const rows = [makeStargazerResponse("alice"), makeStargazerResponse("bob")].map((row) => ({ + const rows = [makeStargazerResponse({ login: "alice" }), makeStargazerResponse({ login: "bob" })].map((row) => ({ ...row, starred_at: undefined, })); @@ -233,7 +238,7 @@ describe("fetchAllStargazers", () => { await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("repo-a", 2)], + repos: [makeRepoInfo({ name: "repo-a", stars: 2 })], config: samplingOff, }); @@ -249,7 +254,7 @@ describe("fetchAllStargazers", () => { await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("restricted", 54000)], + repos: [makeRepoInfo({ name: "restricted", stars: 54000 })], config: samplingOff, }); @@ -265,7 +270,7 @@ describe("fetchAllStargazers", () => { await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("empty", 0)], + repos: [makeRepoInfo({ name: "empty", stars: 0 })], config: samplingOff, }); @@ -274,12 +279,12 @@ describe("fetchAllStargazers", () => { it("samples evenly-spaced pages when stars exceed the threshold", async () => { const octokit = { - request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse("alice")] }), + request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse({ login: "alice" })] }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("huge", 5000)], + repos: [makeRepoInfo({ name: "huge", stars: 5000 })], config: makeConfig({ smartSampling: true, smartSamplingThreshold: 1500, @@ -297,12 +302,12 @@ describe("fetchAllStargazers", () => { it("fetches all pages normally when stars are at or below the threshold", async () => { const octokit = { - request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse("alice")] }), + request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse({ login: "alice" })] }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("mid", 1000)], + repos: [makeRepoInfo({ name: "mid", stars: 1000 })], config: makeConfig({ smartSampling: true, smartSamplingThreshold: 1500, @@ -321,7 +326,7 @@ describe("fetchAllStargazers", () => { const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("huge", 50000)], + repos: [makeRepoInfo({ name: "huge", stars: 50000 })], config: samplingOff, }); @@ -330,12 +335,12 @@ describe("fetchAllStargazers", () => { it("falls back to fetching all pages when total pages do not exceed maxPages", async () => { const octokit = { - request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse("alice")] }), + request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse({ login: "alice" })] }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("huge", 2000)], + repos: [makeRepoInfo({ name: "huge", stars: 2000 })], config: makeConfig({ smartSampling: true, smartSamplingThreshold: 100, @@ -349,12 +354,12 @@ describe("fetchAllStargazers", () => { it("never samples a page beyond the 40,000-star reachable window", async () => { const octokit = { - request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse("alice")] }), + request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse({ login: "alice" })] }), }; await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("massive", 50000)], + repos: [makeRepoInfo({ name: "massive", stars: 50000 })], config: makeConfig({ smartSampling: true, smartSamplingThreshold: 1500, @@ -369,13 +374,13 @@ describe("fetchAllStargazers", () => { it("stops the full fetch at the reachable page cap for repos above 40,000 stars", async () => { const octokit = { request: vi.fn().mockResolvedValue({ - data: Array.from({ length: 100 }, (_, index) => makeStargazerResponse(`user-${index}`)), + data: Array.from({ length: 100 }, (_, index) => makeStargazerResponse({ login: `user-${index}` })), }), }; const result = await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("massive", 50000)], + repos: [makeRepoInfo({ name: "massive", stars: 50000 })], config: samplingOff, }); @@ -388,12 +393,12 @@ describe("fetchAllStargazers", () => { it("fetches only the first page when maxPages is 1", async () => { const octokit = { - request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse("alice")] }), + request: vi.fn().mockResolvedValue({ data: [makeStargazerResponse({ login: "alice" })] }), }; await fetchAllStargazers({ octokit: octokit as unknown as Octokit, - repos: [makeRepoInfo("huge", 5000)], + repos: [makeRepoInfo({ name: "huge", stars: 5000 })], config: makeConfig({ smartSampling: true, smartSamplingThreshold: 1500, diff --git a/src/infrastructure/persistence/data-branch.test.ts b/src/infrastructure/persistence/data-branch.test.ts index bc6a7e2..d65e23d 100644 --- a/src/infrastructure/persistence/data-branch.test.ts +++ b/src/infrastructure/persistence/data-branch.test.ts @@ -115,7 +115,12 @@ describe("withDataBranch", () => { }); describe("publish", () => { - async function publish(artefacts: PublishedArtefacts, readOnly = false): Promise { + interface PublishParams { + artefacts: PublishedArtefacts; + readOnly?: boolean; + } + + async function publish({ artefacts, readOnly = false }: PublishParams): Promise { await withDataBranch({ ...BASE, readOnly, @@ -126,7 +131,7 @@ describe("publish", () => { it("writes every data-branch artefact into the worktree", async () => { const history = { snapshots: [] }; - await publish(makeArtefacts({ history })); + await publish({ artefacts: makeArtefacts({ history }) }); expect(writeHistory).toHaveBeenCalledWith({ dataDir: DATA_DIR, history }); expect(writeArtefact).toHaveBeenCalledWith({ @@ -147,11 +152,11 @@ describe("publish", () => { }); it("writes the stargazer map only when the run produced one", async () => { - await publish(makeArtefacts()); + await publish({ artefacts: makeArtefacts() }); expect(writeStargazers).not.toHaveBeenCalled(); - await publish(makeArtefacts({ stargazerMap: { "user/repo": ["a"] } })); + await publish({ artefacts: makeArtefacts({ stargazerMap: { "user/repo": ["a"] } }) }); expect(writeStargazers).toHaveBeenCalledWith({ dataDir: DATA_DIR, @@ -160,14 +165,14 @@ describe("publish", () => { }); it("writes each chart and prunes the ones this run did not produce", async () => { - await publish( - makeArtefacts({ + await publish({ + artefacts: makeArtefacts({ charts: [ { filename: "star-history.svg", svg: "a" }, { filename: "comparison.svg", svg: "b" }, ], }), - ); + }); expect(writeChart).toHaveBeenCalledWith({ dataDir: DATA_DIR, @@ -181,7 +186,7 @@ describe("publish", () => { }); it("commits and pushes with the run message", async () => { - await publish(makeArtefacts({ commitMessage: "Update star data: 100 total (+10)" })); + await publish({ artefacts: makeArtefacts({ commitMessage: "Update star data: 100 total (+10)" }) }); expect(commitAndPush).toHaveBeenCalledWith({ dataDir: DATA_DIR, @@ -192,7 +197,7 @@ describe("publish", () => { }); it("writes everything but never pushes on a read-only run", async () => { - await publish(makeArtefacts({ charts: [{ filename: "a.svg", svg: "" }] }), true); + await publish({ artefacts: makeArtefacts({ charts: [{ filename: "a.svg", svg: "" }] }), readOnly: true }); expect(writeHistory).toHaveBeenCalled(); expect(writeChart).toHaveBeenCalled(); @@ -213,7 +218,7 @@ describe("publish", () => { return true; }); - await publish(makeArtefacts({ charts: [{ filename: "a.svg", svg: "" }] })); + await publish({ artefacts: makeArtefacts({ charts: [{ filename: "a.svg", svg: "" }] }) }); expect(order).toEqual(["write", "chart", "push"]); }); diff --git a/src/presentation/report-model.test.ts b/src/presentation/report-model.test.ts index 11c4b5e..dc43a80 100644 --- a/src/presentation/report-model.test.ts +++ b/src/presentation/report-model.test.ts @@ -56,10 +56,10 @@ describe("buildReportModel", () => { describe("Top Repositories", () => { const results = makeComparisonResults({ repos: [ - makeRepoResult("small", { current: 5, delta: 1 }), - makeRepoResult("large", { current: 90, delta: -4 }), - makeRepoResult("gone", { current: 0, isRemoved: true }), - makeRepoResult("middling", { current: 40, delta: 0 }), + makeRepoResult({ name: "small", overrides: { current: 5, delta: 1 } }), + makeRepoResult({ name: "large", overrides: { current: 90, delta: -4 } }), + makeRepoResult({ name: "gone", overrides: { current: 0, isRemoved: true } }), + makeRepoResult({ name: "middling", overrides: { current: 40, delta: 0 } }), ], }); diff --git a/src/presentation/run.test.ts b/src/presentation/run.test.ts index 6ea7b48..65a2d7d 100644 --- a/src/presentation/run.test.ts +++ b/src/presentation/run.test.ts @@ -211,9 +211,9 @@ describe("the two Report dialects stay in step", () => { }; const withRemoved = makeComparisonResults({ repos: [ - makeRepoResult("kept", { current: 60, delta: 10 }), - makeRepoResult("fresh", { current: 7, previous: null, isNew: true }), - makeRepoResult("gone", { current: 0, previous: 3, delta: -3, isRemoved: true }), + makeRepoResult({ name: "kept", overrides: { current: 60, delta: 10 } }), + makeRepoResult({ name: "fresh", overrides: { current: 7, previous: null, isNew: true } }), + makeRepoResult({ name: "gone", overrides: { current: 0, previous: 3, delta: -3, isRemoved: true } }), ], }); diff --git a/src/presentation/shared.test.ts b/src/presentation/shared.test.ts index 45885a7..c8ccc87 100644 --- a/src/presentation/shared.test.ts +++ b/src/presentation/shared.test.ts @@ -7,10 +7,10 @@ import { colorSchemeFor, prepareReportData } from "./shared"; function makeResults(overrides: Partial = {}): ComparisonResults { return makeComparisonResults({ repos: [ - makeRepoResult("repo-a", { current: 15, previous: 10, delta: 5 }), - makeRepoResult("repo-b", { current: 8, previous: 10, delta: -2 }), - makeRepoResult("repo-c", { current: 0, previous: 3, delta: -3, isRemoved: true }), - makeRepoResult("repo-d", { current: 5, previous: null, delta: 5, isNew: true }), + makeRepoResult({ name: "repo-a", overrides: { current: 15, previous: 10, delta: 5 } }), + makeRepoResult({ name: "repo-b", overrides: { current: 8, previous: 10, delta: -2 } }), + makeRepoResult({ name: "repo-c", overrides: { current: 0, previous: 3, delta: -3, isRemoved: true } }), + makeRepoResult({ name: "repo-d", overrides: { current: 5, previous: null, delta: 5, isNew: true } }), ], summary: { totalStars: 28, diff --git a/src/presentation/svg-chart.test.ts b/src/presentation/svg-chart.test.ts index b35099c..9a7a455 100644 --- a/src/presentation/svg-chart.test.ts +++ b/src/presentation/svg-chart.test.ts @@ -22,7 +22,12 @@ const THOUSANDS_AXIS_LABEL = />\d+(\.\d+)?K<\/text>/; const FEBRUARY_AXIS_LABEL = />Feb \d/; const CONSECUTIVE_XML_ATTRIBUTES = /="[^"]*"="[^"]*"/; -function makeSnapshot(timestamp: string, totalStars: number): Snapshot { +interface MakeSnapshotParams { + timestamp: string; + totalStars: number; +} + +function makeSnapshot({ timestamp, totalStars }: MakeSnapshotParams): Snapshot { return { timestamp, totalStars, @@ -34,12 +39,17 @@ function makeHistory(starCounts: number[]): History { return { snapshots: starCounts.map((stars, index) => { const date = new Date(2026, 0, index + 1).toISOString(); - return makeSnapshot(date, stars); + return makeSnapshot({ timestamp: date, totalStars: stars }); }), }; } -function makeMultiRepoSnapshot(timestamp: string, repoStars: Record): Snapshot { +interface MakeMultiRepoSnapshotParams { + timestamp: string; + repoStars: Record; +} + +function makeMultiRepoSnapshot({ timestamp, repoStars }: MakeMultiRepoSnapshotParams): Snapshot { const repos = Object.entries(repoStars).map(([fullName, stars]) => { const [owner, name] = fullName.split("/"); return { name, owner, fullName, stars }; @@ -53,7 +63,7 @@ function makeMultiRepoHistory(snapshots: { repoStars: Record }[] return { snapshots: snapshots.map((snapshot, index) => { const date = new Date(2026, 0, index + 1).toISOString(); - return makeMultiRepoSnapshot(date, snapshot.repoStars); + return makeMultiRepoSnapshot({ timestamp: date, repoStars: snapshot.repoStars }); }), }; } @@ -92,10 +102,10 @@ describe("renderSvgChart: star history", () => { it("labels the x-axis by year for multi-year histories", () => { const history: History = { snapshots: [ - makeSnapshot("2023-02-01T12:00:00Z", 10), - makeSnapshot("2023-09-01T12:00:00Z", 40), - makeSnapshot("2024-04-01T12:00:00Z", 90), - makeSnapshot("2025-01-01T12:00:00Z", 150), + makeSnapshot({ timestamp: "2023-02-01T12:00:00Z", totalStars: 10 }), + makeSnapshot({ timestamp: "2023-09-01T12:00:00Z", totalStars: 40 }), + makeSnapshot({ timestamp: "2024-04-01T12:00:00Z", totalStars: 90 }), + makeSnapshot({ timestamp: "2025-01-01T12:00:00Z", totalStars: 150 }), ], }; @@ -267,7 +277,10 @@ describe("renderSvgChart: star history", () => { it("respects locale for date labels", () => { const history: History = { - snapshots: [makeSnapshot("2026-03-15T00:00:00Z", 10), makeSnapshot("2026-06-20T00:00:00Z", 20)], + snapshots: [ + makeSnapshot({ timestamp: "2026-03-15T00:00:00Z", totalStars: 10 }), + makeSnapshot({ timestamp: "2026-06-20T00:00:00Z", totalStars: 20 }), + ], }; const enResult = expectSvg(renderSvgChart({ request: { kind: ChartKind.STAR_HISTORY, history }, locale: "en" })); const esResult = expectSvg(renderSvgChart({ request: { kind: ChartKind.STAR_HISTORY, history }, locale: "es" })); @@ -1038,7 +1051,10 @@ describe("renderSvgChart: forecast", () => { it("respects locale", () => { const history: History = { - snapshots: [makeSnapshot("2026-03-15T00:00:00Z", 10), makeSnapshot("2026-06-20T00:00:00Z", 20)], + snapshots: [ + makeSnapshot({ timestamp: "2026-03-15T00:00:00Z", totalStars: 10 }), + makeSnapshot({ timestamp: "2026-06-20T00:00:00Z", totalStars: 20 }), + ], }; const enResult = expectSvg( diff --git a/src/shared/tests/index.ts b/src/shared/tests/index.ts index 8ef1f2b..d5b75b0 100644 --- a/src/shared/tests/index.ts +++ b/src/shared/tests/index.ts @@ -11,7 +11,13 @@ export function makeConfig(overrides: Partial = {}): Config { return { ...DEFAULTS, ...overrides }; } -export function makeRepoInfo(name: string, stars = 10, overrides: Partial = {}): RepoInfo { +export interface MakeRepoInfoParams { + name: string; + stars?: number; + overrides?: Partial; +} + +export function makeRepoInfo({ name, stars = 10, overrides = {} }: MakeRepoInfoParams): RepoInfo { return { owner: "user", name, @@ -56,7 +62,13 @@ export function makeStargazerSeries({ ); } -export function makeSnapshot(timestamp: string, totalStars: number, repos: SnapshotRepo[] = []): Snapshot { +export interface MakeSnapshotParams { + timestamp: string; + totalStars: number; + repos?: SnapshotRepo[]; +} + +export function makeSnapshot({ timestamp, totalStars, repos = [] }: MakeSnapshotParams): Snapshot { return { timestamp, totalStars, repos }; } @@ -71,12 +83,17 @@ export function makeHistory( ): History { return { snapshots: starCounts.map((totalStars, index) => - makeSnapshot(new Date(startMs + index * stepDays * MS_PER_DAY).toISOString(), totalStars), + makeSnapshot({ timestamp: new Date(startMs + index * stepDays * MS_PER_DAY).toISOString(), totalStars }), ), }; } -export function makeMultiRepoSnapshot(timestamp: string, repoStars: Record): Snapshot { +export interface MakeMultiRepoSnapshotParams { + timestamp: string; + repoStars: Record; +} + +export function makeMultiRepoSnapshot({ timestamp, repoStars }: MakeMultiRepoSnapshotParams): Snapshot { const repos = Object.entries(repoStars).map(([fullName, stars]) => ({ fullName, name: fullName.split("/")[1], @@ -97,12 +114,17 @@ export function makeMultiRepoHistory( ): History { return { snapshots: snapshots.map((repoStars, index) => - makeMultiRepoSnapshot(new Date(startMs + index * stepDays * MS_PER_DAY).toISOString(), repoStars), + makeMultiRepoSnapshot({ timestamp: new Date(startMs + index * stepDays * MS_PER_DAY).toISOString(), repoStars }), ), }; } -export function makeRepoResult(name: string, overrides: Partial = {}): RepoResult { +export interface MakeRepoResultParams { + name: string; + overrides?: Partial; +} + +export function makeRepoResult({ name, overrides = {} }: MakeRepoResultParams): RepoResult { return { name, fullName: `user/${name}`, @@ -119,8 +141,8 @@ export function makeRepoResult(name: string, overrides: Partial = {} export function makeComparisonResults(overrides: Partial = {}): ComparisonResults { return { repos: [ - makeRepoResult("repo-a", { current: 15, previous: 10, delta: 5 }), - makeRepoResult("repo-b", { current: 8, previous: 10, delta: -2 }), + makeRepoResult({ name: "repo-a", overrides: { current: 15, previous: 10, delta: 5 } }), + makeRepoResult({ name: "repo-b", overrides: { current: 8, previous: 10, delta: -2 } }), ], summary: { totalStars: 23,