Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
13 changes: 8 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<FunctionName>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.
Expand Down
8 changes: 6 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<FunctionName>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
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 19 additions & 10 deletions docs/docs-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] : [];
});
Expand All @@ -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);

Expand Down Expand Up @@ -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(
Expand Down
24 changes: 12 additions & 12 deletions src/application/tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] };
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
};
Expand Down
22 changes: 11 additions & 11 deletions src/domain/comparison.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -27,7 +27,7 @@ describe("compareStars", () => {
};

const result = compareStars({
currentRepos: [makeRepoInfo("repo-a", 50)],
currentRepos: [makeRepoInfo({ name: "repo-a", stars: 50 })],
previousSnapshot: previous,
});

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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", () => {
Expand Down
35 changes: 20 additions & 15 deletions src/domain/growth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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);
Expand Down
Loading