From 31fbf95d1b35b7fb3f7942f19e38d9a127862c8c Mon Sep 17 00:00:00 2001 From: Ferran Buireu Date: Sat, 22 Aug 2026 21:59:12 +0000 Subject: [PATCH 1/3] refactor: hold the named-parameter rule in the fixtures too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The convention was already asserted, but the assertion excused src/shared/tests and every co-located .test.ts — which is where it had drifted. makeRepoInfo(name, stars, overrides) and repoStargazers(fullName, dates, sampled) are precisely the shapes a caller transposes without the compiler noticing, and a fixture is the code a reader copies from. Fourteen helpers now take one destructured object typed as Params, with their call sites converted in the same commit — makeRepoInfo alone had 100. The exemption is gone from the test, so the rule now covers all of src. alphabetically stays positional on purpose: Array.prototype.sort calls a comparator with two arguments and would hand it undefined otherwise. --- CLAUDE.md | 7 +- docs/docs-consistency.test.ts | 25 ++-- src/application/tracker.test.ts | 24 ++-- src/domain/comparison.test.ts | 22 ++-- src/domain/growth.test.ts | 35 +++--- src/domain/measurement.test.ts | 28 ++--- src/domain/star-history.test.ts | 113 +++++++++++------- src/domain/stargazers.test.ts | 50 +++++--- src/domain/tracked-set.test.ts | 101 ++++++++++------ src/infrastructure/git/worktree.test.ts | 17 ++- src/infrastructure/github/stargazers.test.ts | 81 +++++++------ .../persistence/data-branch.test.ts | 25 ++-- src/presentation/report-model.test.ts | 8 +- src/presentation/run.test.ts | 6 +- src/presentation/shared.test.ts | 8 +- src/presentation/svg-chart.test.ts | 36 ++++-- src/shared/tests/index.ts | 38 ++++-- 17 files changed, 377 insertions(+), 247 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2a589cf..8b79d73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,9 +114,10 @@ re-export from `src/i18n/index.ts` instead. 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. + positional. `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 — `makeRepoInfo(name, stars, overrides)` and `repoStargazers(fullName, + dates, sampled)` are the shapes a caller swaps by accident, and they are the ones a reader copies. - **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/docs/docs-consistency.test.ts b/docs/docs-consistency.test.ts index e28b750..4d91b90 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[] { +type 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); @@ -735,9 +742,7 @@ describe("the documented data-branch format matches the writer", () => { describe("the source follows the named-parameter convention", () => { 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..d156a78 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[] { +type 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..789e09a 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 { +type 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 { +type 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..e416286 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 }); +type 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..b45f0e5 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 { +type 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..54c3c42 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") { +type 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..b9ea5d0 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 { + type 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..d2a8c22 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 { +type 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 { +type 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..529bd37 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 type 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 type 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 type 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 type 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, From 911365d5830c822e809fee1c641cef4581796a91 Mon Sep 17 00:00:00 2001 From: Ferran Buireu Date: Sun, 23 Aug 2026 06:59:45 +0000 Subject: [PATCH 2/3] refactor: type the named parameters with interfaces, and stop the fixtures demanding a rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md already said the object is 'typed by an interface' and the conversion used type aliases, so the code contradicted its own guide on the very rule it was enforcing. All fourteen are interfaces now, and CONTRIBUTING states the rule with the comparator exception rather than one line saying 2+ parameters should be destructured. The dist staleness check counted src/shared/tests as a bundled source. It is not: nothing there is reachable from src/index.ts, which a rebuild proves — the bundle is byte-identical after this change. Editing a fixture therefore failed a PR by demanding a rebuild that could not produce a diff. It is now excluded alongside *.test.ts, and both guides say so. --- .github/workflows/ci.yml | 2 +- ARCHITECTURE.md | 2 +- CLAUDE.md | 4 +++- CONTRIBUTING.md | 9 +++++++-- docs/docs-consistency.test.ts | 4 ++-- src/domain/growth.test.ts | 4 ++-- src/domain/star-history.test.ts | 8 ++++---- src/domain/stargazers.test.ts | 4 ++-- src/infrastructure/git/worktree.test.ts | 4 ++-- src/infrastructure/github/stargazers.test.ts | 4 ++-- .../persistence/data-branch.test.ts | 4 ++-- src/presentation/svg-chart.test.ts | 8 ++++---- src/shared/tests/index.ts | 16 ++++++++-------- 13 files changed, 40 insertions(+), 33 deletions(-) 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 8b79d73..951621f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,9 @@ re-export from `src/i18n/index.ts` instead. `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 + object typed by an interface named `Params`: `function foo({ a, b }: FooParams)`. A + comparator handed to `sort` is the exception — it is called back positionally, so `alphabetically` + keeps its two arguments. Single-argument functions stay positional. `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 — `makeRepoInfo(name, stars, overrides)` and `repoStargazers(fullName, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6302023..f565910 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,7 +148,11 @@ 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 +- Functions with 2+ parameters take one destructured object typed by an **interface** named + `Params` — `function makeRepoInfo({ name, stars }: MakeRepoInfoParams)`. A single parameter + stays positional. The exception is a function a runtime calls back, such as a `sort` comparator like + `alphabetically`, which is handed its arguments one at a time and must stay positional. + `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 +290,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 4d91b90..b03a271 100644 --- a/docs/docs-consistency.test.ts +++ b/docs/docs-consistency.test.ts @@ -52,10 +52,10 @@ const I18N_KEY_PATTERN = /`([\w.]+)`/g; const LINE_CITATION_ALLOWLIST = new Set([GUIDE, CONTRIBUTOR_GUIDE, ADR_TEMPLATE]); -type WalkParams = { +interface WalkParams { dir: string; keep: (filename: string) => boolean; -}; +} function walk({ dir, keep }: WalkParams): string[] { if (!fs.existsSync(dir)) return []; diff --git a/src/domain/growth.test.ts b/src/domain/growth.test.ts index d156a78..c72d7b3 100644 --- a/src/domain/growth.test.ts +++ b/src/domain/growth.test.ts @@ -2,10 +2,10 @@ import { describe, expect, it } from "vitest"; import { calendarDays, fitTrend, latestRateInterval, type SeriesPoint, weightedDailyRate } from "./growth"; import type { History } from "./types"; -type SeriesParams = { +interface SeriesParams { values: number[]; step?: number; -}; +} function series({ values, step = 1 }: SeriesParams): SeriesPoint[] { return values.map((value, index) => ({ day: index * step, value })); diff --git a/src/domain/star-history.test.ts b/src/domain/star-history.test.ts index 789e09a..47200ac 100644 --- a/src/domain/star-history.test.ts +++ b/src/domain/star-history.test.ts @@ -7,10 +7,10 @@ import type { SnapshotRepo } from "./types"; const NOW = new Date("2026-06-25T00:00:00Z"); const MAX_REACHABLE_STARS = 40_000; -type RepoTotalParams = { +interface RepoTotalParams { fullName: string; stars: number; -}; +} function repoTotal({ fullName, stars }: RepoTotalParams): SnapshotRepo { const [owner, name] = fullName.split("/"); @@ -18,11 +18,11 @@ function repoTotal({ fullName, stars }: RepoTotalParams): SnapshotRepo { return { fullName, name, owner, stars }; } -type RepoStargazersParams = { +interface RepoStargazersParams { fullName: string; dates: string[]; sampled?: boolean; -}; +} function repoStargazers({ fullName, dates, sampled = false }: RepoStargazersParams): RepoStargazers { return { diff --git a/src/domain/stargazers.test.ts b/src/domain/stargazers.test.ts index e416286..1864310 100644 --- a/src/domain/stargazers.test.ts +++ b/src/domain/stargazers.test.ts @@ -2,10 +2,10 @@ import { makeStargazer } from "@shared/tests"; import { describe, expect, it } from "vitest"; import type { RepoStargazers, Stargazer, StargazerMap } from "./stargazers"; -type MakeStarParams = { +interface MakeStarParams { login: string; starredAt?: string; -}; +} const makeStar = ({ login, starredAt = "2026-01-15" }: MakeStarParams): Stargazer => makeStargazer({ login, starredAt }); diff --git a/src/infrastructure/git/worktree.test.ts b/src/infrastructure/git/worktree.test.ts index b45f0e5..5ad9546 100644 --- a/src/infrastructure/git/worktree.test.ts +++ b/src/infrastructure/git/worktree.test.ts @@ -23,10 +23,10 @@ function ranGit(...args: string[]): boolean { return vi.mocked(execute).mock.calls.some(([params]) => JSON.stringify(params.args) === JSON.stringify(args)); } -type FailGitWhenParams = { +interface FailGitWhenParams { matches: (args: string[]) => boolean; error?: Error; -}; +} function failGitWhen({ matches, error = new Error("git failed") }: FailGitWhenParams): void { vi.mocked(execute).mockImplementation(({ args }) => { diff --git a/src/infrastructure/github/stargazers.test.ts b/src/infrastructure/github/stargazers.test.ts index 54c3c42..a918207 100644 --- a/src/infrastructure/github/stargazers.test.ts +++ b/src/infrastructure/github/stargazers.test.ts @@ -15,10 +15,10 @@ const samplingOff = makeConfig({ smartSamplingPages: 30, }); -type MakeStargazerResponseParams = { +interface MakeStargazerResponseParams { login: string; date?: string; -}; +} function makeStargazerResponse({ login, date = "2026-01-15T00:00:00Z" }: MakeStargazerResponseParams) { return { diff --git a/src/infrastructure/persistence/data-branch.test.ts b/src/infrastructure/persistence/data-branch.test.ts index b9ea5d0..d65e23d 100644 --- a/src/infrastructure/persistence/data-branch.test.ts +++ b/src/infrastructure/persistence/data-branch.test.ts @@ -115,10 +115,10 @@ describe("withDataBranch", () => { }); describe("publish", () => { - type PublishParams = { + interface PublishParams { artefacts: PublishedArtefacts; readOnly?: boolean; - }; + } async function publish({ artefacts, readOnly = false }: PublishParams): Promise { await withDataBranch({ diff --git a/src/presentation/svg-chart.test.ts b/src/presentation/svg-chart.test.ts index d2a8c22..9a7a455 100644 --- a/src/presentation/svg-chart.test.ts +++ b/src/presentation/svg-chart.test.ts @@ -22,10 +22,10 @@ const THOUSANDS_AXIS_LABEL = />\d+(\.\d+)?K<\/text>/; const FEBRUARY_AXIS_LABEL = />Feb \d/; const CONSECUTIVE_XML_ATTRIBUTES = /="[^"]*"="[^"]*"/; -type MakeSnapshotParams = { +interface MakeSnapshotParams { timestamp: string; totalStars: number; -}; +} function makeSnapshot({ timestamp, totalStars }: MakeSnapshotParams): Snapshot { return { @@ -44,10 +44,10 @@ function makeHistory(starCounts: number[]): History { }; } -type MakeMultiRepoSnapshotParams = { +interface MakeMultiRepoSnapshotParams { timestamp: string; repoStars: Record; -}; +} function makeMultiRepoSnapshot({ timestamp, repoStars }: MakeMultiRepoSnapshotParams): Snapshot { const repos = Object.entries(repoStars).map(([fullName, stars]) => { diff --git a/src/shared/tests/index.ts b/src/shared/tests/index.ts index 529bd37..d5b75b0 100644 --- a/src/shared/tests/index.ts +++ b/src/shared/tests/index.ts @@ -11,11 +11,11 @@ export function makeConfig(overrides: Partial = {}): Config { return { ...DEFAULTS, ...overrides }; } -export type MakeRepoInfoParams = { +export interface MakeRepoInfoParams { name: string; stars?: number; overrides?: Partial; -}; +} export function makeRepoInfo({ name, stars = 10, overrides = {} }: MakeRepoInfoParams): RepoInfo { return { @@ -62,11 +62,11 @@ export function makeStargazerSeries({ ); } -export type MakeSnapshotParams = { +export interface MakeSnapshotParams { timestamp: string; totalStars: number; repos?: SnapshotRepo[]; -}; +} export function makeSnapshot({ timestamp, totalStars, repos = [] }: MakeSnapshotParams): Snapshot { return { timestamp, totalStars, repos }; @@ -88,10 +88,10 @@ export function makeHistory( }; } -export type MakeMultiRepoSnapshotParams = { +export interface MakeMultiRepoSnapshotParams { timestamp: string; repoStars: Record; -}; +} export function makeMultiRepoSnapshot({ timestamp, repoStars }: MakeMultiRepoSnapshotParams): Snapshot { const repos = Object.entries(repoStars).map(([fullName, stars]) => ({ @@ -119,10 +119,10 @@ export function makeMultiRepoHistory( }; } -export type MakeRepoResultParams = { +export interface MakeRepoResultParams { name: string; overrides?: Partial; -}; +} export function makeRepoResult({ name, overrides = {} }: MakeRepoResultParams): RepoResult { return { From cbb38719bd5947886e610e4678be51b96e465857 Mon Sep 17 00:00:00 2001 From: Ferran Buireu Date: Sun, 23 Aug 2026 07:13:05 +0000 Subject: [PATCH 3/3] docs: phrase the named-parameter rule the way the sibling repos do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same rule, same words, and the same shape of example — the positional one-argument case beside the object one, with the interface named after the function rather than the concept. The contract also asserts the guide states it, so deleting the bullet fails a pull request instead of leaving an assertion policing a rule nobody wrote down. --- CLAUDE.md | 16 ++++++++-------- CONTRIBUTING.md | 9 ++++----- docs/docs-consistency.test.ts | 4 ++++ 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 951621f..13f6a69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,14 +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 named `Params`: `function foo({ a, b }: FooParams)`. A - comparator handed to `sort` is the exception — it is called back positionally, so `alphabetically` - keeps its two arguments. Single-argument functions stay - positional. `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 — `makeRepoInfo(name, stars, overrides)` and `repoStargazers(fullName, - dates, sampled)` are the shapes a caller swaps by accident, and they are the ones a reader copies. +- **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 f565910..90d705a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,11 +148,10 @@ pnpm run format declare it - Functional programming style preferred - No `any` types (use `unknown` if needed) -- Functions with 2+ parameters take one destructured object typed by an **interface** named - `Params` — `function makeRepoInfo({ name, stars }: MakeRepoInfoParams)`. A single parameter - stays positional. The exception is a function a runtime calls back, such as a `sort` comparator like - `alphabetically`, which is handed its arguments one at a time and must stay positional. - `docs/docs-consistency.test.ts` asserts this over the whole of `src`, fixtures included +- 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 diff --git a/docs/docs-consistency.test.ts b/docs/docs-consistency.test.ts index b03a271..a14ff91 100644 --- a/docs/docs-consistency.test.ts +++ b/docs/docs-consistency.test.ts @@ -741,6 +741,10 @@ 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({ dir: "src", keep: (filename) => filename.endsWith(".ts") }); const declaration = /(?:function\s+\w+|=)\s*\(\s*\w+\s*:\s*[^,()]+,\s*\w+\s*:/g;