diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c21b11a..d231b2d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -46,9 +46,9 @@ - [ ] I have performed a self-review of my own code - [ ] I rebuilt `dist/` with `pnpm build` and committed it, if I touched anything under `src/` - [ ] I updated the relevant section of any `CLAUDE.md` my change affects -- [ ] Changed inputs, outputs or defaults are reflected in `action.yml`, the wiki and the README -- [ ] New or redefined domain vocabulary is in `CONTEXT.md`; a hard-to-reverse decision has an ADR -- [ ] `docs/docs-consistency.test.ts` passes, so the docs and the code still agree +- [ ] Changed inputs, outputs or defaults are reflected in [`action.yml`](../action.yml), the wiki and the README +- [ ] New or redefined domain vocabulary is in [`CONTEXT.md`](../CONTEXT.md); a hard-to-reverse decision has an ADR +- [ ] [`docs/docs-consistency.test.ts`](../docs/docs-consistency.test.ts) passes, so the docs and the code still agree - [ ] My changes generate no new warnings or errors - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f423670..a6c4f46 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -50,7 +50,7 @@ Every arrow is an import a layer is allowed to make; anything not drawn is forbi functional core**: no I/O, no network, no filesystem, and no clock beyond an injectable `now`. **Red is the imperative shell**, and each red layer owns a different side effect: `@config` reads the action inputs and one YAML file, `@infrastructure` owns everything outbound (REST, `git`, the filesystem, SMTP), `@application` -writes the Action log and the outputs, and `src/index.ts` starts the run. `@config` reading `node:fs` is the +writes the Action log and the outputs, and [`src/index.ts`](./src/index.ts) starts the run. `@config` reading `node:fs` is the detail most easily missed, and the rule it illustrates is stated once in [CLAUDE.md](./CLAUDE.md#conventions): `@infrastructure` is the only layer that reaches the network, not the only one that performs I/O. @@ -75,7 +75,7 @@ The decision to layer the tree this way is [ADR 0004](./docs/adr/0004-layered-so ## 2. A run, end to end -The numbers below are this table's own, in the order `trackStars` executes the calls. `src/application/tracker.ts` +The numbers below are this table's own, in the order `trackStars` executes the calls. [`src/application/tracker.ts`](./src/application/tracker.ts) carries no comments, so there are no step markers in the source to match them against. | # | Call | Layer | Notes | @@ -100,7 +100,7 @@ carries no comments, so there are no step markers in the source to match them ag | 18 | `settleNotification({ changed, thresholdReached, delivery, history, totalStars })` | domain/notification | Returns `shouldNotify`, `notificationSent` and `historyToPersist` as one outcome; calls `recordNotification` only when the baseline may advance | | 19 | `writeHtmlReport({ htmlReport })` | infrastructure/persistence | Writes to `RUNNER_TEMP`, falling back to the working directory, so the file is off the Data Branch and outlives the run. Deliberately **before** `publish`: a failing write must not end a run that has already committed and pushed | | 20 | `branch.publish({ history, stargazerMap, report, badge, csv, charts, commitMessage })` | infrastructure/persistence | Writes every data-branch artefact, prunes the `charts/*.svg` this run did not produce, then commits and pushes, unless `readOnly`, where the write happens and the push does not | -| 21 | `setOutputs(...)` | application | Eleven outputs, exactly matching the `outputs:` block of `action.yml` | +| 21 | `setOutputs(...)` | application | Eleven outputs, exactly matching the `outputs:` block of [`action.yml`](./action.yml) | Failure policy: everything is wrapped in one `try/catch` that ends in `core.setFailed('Star Tracker failed: ')` plus `core.debug(stack)`. Email is the only inner failure that is deliberately non-fatal. @@ -135,9 +135,9 @@ State has to survive between runs of a stateless Action. Artifacts expire and ar | `stars-data.csv` | `@presentation/csv` `generateCsvReport` | `writeArtefact` | | `stars-data.json` | `@domain/snapshot` `addSnapshot` | `writeHistory` | | `stargazers.json` | `@domain/stargazers` `buildStargazerMap` | `writeStargazers` | -| `stars-badge.svg` | `@presentation/badge` `generateBadge` | `writeArtefact` | +| [`stars-badge.svg`](./examples/stars-badge.svg) | `@presentation/badge` `generateBadge` | `writeArtefact` | | `charts/*.svg` | `@presentation/charts` -> `@presentation/svg-chart` | `writeChart` | -| Email chart images | `@presentation/chart` (quickchart.io URLs, no SVG) | embedded by `html.ts` | +| Email chart images | `@presentation/chart` (quickchart.io URLs, no SVG) | embedded by [`html.ts`](./src/presentation/html.ts) | | Email | `@presentation/html` body | `@infrastructure/notification/email` `sendEmail` | | Action outputs (11) | - | `setOutputs` in `tracker.ts` | @@ -147,9 +147,9 @@ The eleven action outputs, alphabetically as `action.yml` declares them: `lost-s The scripts, Biome settings and git hooks are listed once in [CLAUDE.md](./CLAUDE.md#commands); this section covers what happens to the bundle and the release, which lives nowhere else. -- **Bundling.** `esbuild.config.ts` (run via `tsx`) bundles `src/index.ts` into `dist/index.js`, `platform: node`, `target: node24`, `format: cjs`, `sourcemap: true`, with the alias map derived from `tsconfig.json`. `dist/` is **committed** because GitHub runs a JS action straight from the repository at the referenced ref: there is no install step, so the bundle must be in the tree ([ADR 0003](./docs/adr/0003-commit-the-bundled-dist-directory.md)). -- **Node version.** Three pins move together and only two of them are asserted: `engines.node` and `packageManager` in `package.json`, plus `.nvmrc`, which is what `ci.yml` and `release.yml` actually install through `node-version-file`. Nothing checks `.nvmrc`, so move it by hand. -- **Release.** `.releaserc.json`: semantic-release on `main` with commit-analyzer, release-notes-generator, changelog, npm (`npmPublish: false`), git (commits `package.json`, `pnpm-lock.yaml`, `CHANGELOG.md` and `dist/`) and github plugins. `release.yml` gates it behind `pnpm verify`. +- **Bundling.** [`esbuild.config.ts`](./esbuild.config.ts) (run via `tsx`) bundles `src/index.ts` into [`dist/index.js`](./dist/index.js), `platform: node`, `target: node24`, `format: cjs`, `sourcemap: true`, with the alias map derived from [`tsconfig.json`](./tsconfig.json). `dist/` is **committed** because GitHub runs a JS action straight from the repository at the referenced ref: there is no install step, so the bundle must be in the tree ([ADR 0003](./docs/adr/0003-commit-the-bundled-dist-directory.md)). +- **Node version.** Three pins move together and only two of them are asserted: `engines.node` and `packageManager` in [`package.json`](./package.json), plus `.nvmrc`, which is what [`ci.yml`](./.github/workflows/ci.yml) and [`release.yml`](./.github/workflows/release.yml) actually install through `node-version-file`. Nothing checks `.nvmrc`, so move it by hand. +- **Release.** [`.releaserc.json`](./.releaserc.json): semantic-release on `main` with commit-analyzer, release-notes-generator, changelog, npm (`npmPublish: false`), git (commits `package.json`, [`pnpm-lock.yaml`](./pnpm-lock.yaml), [`CHANGELOG.md`](./CHANGELOG.md) and `dist/`) and github plugins. `release.yml` gates it behind `pnpm verify`. `.github/workflows/`: @@ -157,12 +157,12 @@ The scripts, Biome settings and git hooks are listed once in [CLAUDE.md](./CLAUD | --- | --- | | `ci.yml` | On push/PR to `main`: install, `pnpm verify` (which ends in `pnpm build`), then the Codecov upload, which runs even when `check` fails so threshold failures still report. It used to close with a staleness check that failed any pull request touching bundled sources without touching `dist/`. That check is gone: `release.yml` runs `verify` and so rebuilds the bundle immediately before `semantic-release` commits it, meaning a published version can never carry a bundle built from other sources and the check never stood between a stale `dist/` and a user ([ADR 0003](./docs/adr/0003-commit-the-bundled-dist-directory.md)) | | `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 | -| `commit-message.yml` | Runs commitlint on the **pull request title**. `main` takes squash merges and the repository is set to `PR_TITLE`, so that title, not the branch's commits, is the message that lands and the one semantic-release reads. The `commit-msg` hook validates commits the squash then discards, so this is the only guard on the string that ships | -| `dependabot-auto-merge.yml` | Auto-approves and squash-merges Dependabot patch/minor/dev/indirect updates | -| `renovate-auto-approve.yml` | Auto-approves Renovate PRs labelled patch/minor/pin/lock-maintenance | -| `sync-wiki.yml` | Publishes `docs/wiki/` to the repository's GitHub Wiki with `rsync --delete`, so the wiki is generated and direct edits to it are overwritten | +| [`zizmor.yml`](./.github/workflows/zizmor.yml) | zizmor static analysis of the workflow files themselves | +| [`dependency-review.yml`](./.github/workflows/dependency-review.yml) | Fails a PR that introduces a dependency with a known vulnerability | +| [`commit-message.yml`](./.github/workflows/commit-message.yml) | Runs commitlint on the **pull request title**. `main` takes squash merges and the repository is set to `PR_TITLE`, so that title, not the branch's commits, is the message that lands and the one semantic-release reads. The `commit-msg` hook validates commits the squash then discards, so this is the only guard on the string that ships | +| [`dependabot-auto-merge.yml`](./.github/workflows/dependabot-auto-merge.yml) | Auto-approves and squash-merges Dependabot patch/minor/dev/indirect updates | +| [`renovate-auto-approve.yml`](./.github/workflows/renovate-auto-approve.yml) | Auto-approves Renovate PRs labelled patch/minor/pin/lock-maintenance | +| [`sync-wiki.yml`](./.github/workflows/sync-wiki.yml) | Publishes `docs/wiki/` to the repository's GitHub Wiki with `rsync --delete`, so the wiki is generated and direct edits to it are overwritten | ## 6. Where things live @@ -209,10 +209,10 @@ One guide per layer, no deeper: the four `infrastructure/` adapters and `shared/ | Task | Files to touch | | --- | --- | -| **Add an action input** | `action.yml` (declare it, `default: ''` so the config file can win, and state the real default in the description prose, see [ADR 0020](./docs/adr/0020-overridable-inputs-declare-an-empty-default.md)); `src/config/types.ts` (`Config` field); `src/config/defaults.ts` (`DEFAULTS` entry, which also makes the snake_case/kebab-case config-file key work automatically); `src/config/loader.ts` (**one row in `FIELD_SOURCES`**, naming the input parser and the file parser; the action input name is derived from the key); consume it in the relevant layer; update `src/config/action-inputs.test.ts` and `README.md`/`docs/wiki`. | +| **Add an action input** | `action.yml` (declare it, `default: ''` so the config file can win, and state the real default in the description prose, see [ADR 0020](./docs/adr/0020-overridable-inputs-declare-an-empty-default.md)); [`src/config/types.ts`](./src/config/types.ts) (`Config` field); [`src/config/defaults.ts`](./src/config/defaults.ts) (`DEFAULTS` entry, which also makes the snake_case/kebab-case config-file key work automatically); [`src/config/loader.ts`](./src/config/loader.ts) (**one row in `FIELD_SOURCES`**, naming the input parser and the file parser; the action input name is derived from the key); consume it in the relevant layer; update [`src/config/action-inputs.test.ts`](./src/config/action-inputs.test.ts) and `README.md`/`docs/wiki`. | | **Add a locale** | Add the JSON bundle under `src/i18n/`; add it to `LOCALE_MAP` and to the `TRANSLATIONS: Record` map (a missing key is a type error, an extra key is not). `LOCALES` is derived from `LOCALE_MAP` with `Object.keys`, so it needs no edit. Extend the `locale` description in `action.yml` and the locale tables in the wiki. | -| **Add a report format** | New pure renderer in `src/presentation/` (data in, string out, no I/O) reading `buildReportModel` rather than re-deriving sections, plus a colocated test; one field on `RenderedRun` and one line in `renderRun` (`run.ts`); an `Artefact` entry and filename in `@infrastructure/persistence/storage`, plus a field on `PublishedArtefacts`; add an output to `action.yml` and `setOutputs` if it should be exposed. | -| **Add a chart option** | Input plumbing as above; thread it through the `style` object in `src/presentation/charts.ts`. If it changes **what** is plotted it belongs on the matching `ChartRequest` variant or on `ChartSpec` in `src/presentation/chart-spec.ts` and both adapters read it; if it only changes **how**, implement it in `src/presentation/svg-chart.ts` (all SVG primitives live behind the private `renderSvg`) and mirror it in `src/presentation/chart.ts` if email charts should honour it ([ADR 0014](./docs/adr/0014-charts-are-built-as-a-spec-and-rendered-by-adapters.md)); add a sample SVG under `examples/`. | +| **Add a report format** | New pure renderer in `src/presentation/` (data in, string out, no I/O) reading `buildReportModel` rather than re-deriving sections, plus a colocated test; one field on `RenderedRun` and one line in `renderRun` ([`run.ts`](./src/presentation/run.ts)); an `Artefact` entry and filename in `@infrastructure/persistence/storage`, plus a field on `PublishedArtefacts`; add an output to `action.yml` and `setOutputs` if it should be exposed. | +| **Add a chart option** | Input plumbing as above; thread it through the `style` object in [`src/presentation/charts.ts`](./src/presentation/charts.ts). If it changes **what** is plotted it belongs on the matching `ChartRequest` variant or on `ChartSpec` in [`src/presentation/chart-spec.ts`](./src/presentation/chart-spec.ts) and both adapters read it; if it only changes **how**, implement it in [`src/presentation/svg-chart.ts`](./src/presentation/svg-chart.ts) (all SVG primitives live behind the private `renderSvg`) and mirror it in [`src/presentation/chart.ts`](./src/presentation/chart.ts) if email charts should honour it ([ADR 0014](./docs/adr/0014-charts-are-built-as-a-spec-and-rendered-by-adapters.md)); add a sample SVG under `examples/`. | | **Add a chart kind** | One variant on `ChartRequest` and one `case` in `buildChartSpec` (`src/presentation/chart-spec.ts`), plus the spec builder itself. Neither adapter changes, because `renderSvgChart` and `chartImageUrl` take any request. Then emit it from `buildChartFiles` (`charts.ts`) and/or `html.ts`, and add a filename to `CHART_FILES`. | ## 8. Known inconsistencies diff --git a/CLAUDE.md b/CLAUDE.md index b93235e..5117fc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,8 @@ the big picture: layer map, end-to-end run, the data branch, build and release. ## What this is -A JavaScript action (TypeScript sources bundled by esbuild into `dist/index.js`, `runs.using: node24` per -`action.yml`). On each run it lists the token owner's repositories, compares their star counts against a +A JavaScript action (TypeScript sources bundled by esbuild into [`dist/index.js`](./dist/index.js), `runs.using: node24` per +[`action.yml`](./action.yml)). On each run it lists the token owner's repositories, compares their star counts against a snapshot stored on a dedicated data branch, and commits a markdown report, JSON/CSV data, a badge and animated SVG charts back to that branch. It exposes eleven action outputs and can send an HTML digest over SMTP. There is exactly one use case: `trackStars()`. @@ -26,15 +26,15 @@ SMTP. There is exactly one use case: `trackStars()`. ## Versions (pinned by hand, and asserted against `package.json` by the docs test) - Node **26.2.0** (`engines.node`) -- Node **26.2.0** again in `.nvmrc`, which `ci.yml` and `release.yml` install from via `node-version-file` +- Node **26.2.0** again in `.nvmrc`, which [`ci.yml`](./.github/workflows/ci.yml) and [`release.yml`](./.github/workflows/release.yml) install from via `node-version-file` - pnpm **11.21.0** (`packageManager`): always use pnpm, never npm/yarn All three are deliberate pins rather than dependency ranges, so bumping one means editing this section in the -same commit. Only `engines.node` and `packageManager` are asserted against `package.json`; nothing compares +same commit. Only `engines.node` and `packageManager` are asserted against [`package.json`](./package.json); nothing compares `.nvmrc` with either, so move it by hand in the same edit. **`engines.node` is the development pin; the shipped runtime is `node24`** (`action.yml` `runs.using`, and -`esbuild.config.ts` `target`). Those are different numbers on purpose. The gap is a trap: `@types/node` tracks +[`esbuild.config.ts`](./esbuild.config.ts) `target`). Those are different numbers on purpose. The gap is a trap: `@types/node` tracks the *development* version, and esbuild's `target` lowers syntax without shimming runtime APIs, so a `node:*` API that landed after 24.x type-checks, bundles, passes `pnpm verify` and then throws `TypeError: … is not a function` on a GitHub runner. It fails in a user's workflow rather than in CI, because @@ -84,15 +84,15 @@ not a layer at all, `assets/`, holding the brand files the README embeds. `index | `shared/` | `@shared/*` | Cross-cutting code owning no layer (today: test factories) | Tests are colocated next to the file they cover, as `src/**/*.test.ts`. Two test files cover no module: -`src/config/action-inputs.test.ts`, which asserts against `action.yml` rather than against a module, and -`docs/docs-consistency.test.ts`, the docs guard described below, which lives with the documents it checks +[`src/config/action-inputs.test.ts`](./src/config/action-inputs.test.ts), which asserts against `action.yml` rather than against a module, and +[`docs/docs-consistency.test.ts`](./docs/docs-consistency.test.ts), the docs guard described below, which lives with the documents it checks instead of under `src/`. -Aliases are declared **once**, in `tsconfig.json` `compilerOptions.paths`. `esbuild.config.ts` derives its -`alias` map from that object at build time and `vitest.config.ts` sets `resolve.tsconfigPaths: true`, so a +Aliases are declared **once**, in [`tsconfig.json`](./tsconfig.json) `compilerOptions.paths`. `esbuild.config.ts` derives its +`alias` map from that object at build time and [`vitest.config.ts`](./vitest.config.ts) sets `resolve.tsconfigPaths: true`, so a new alias needs exactly one edit, in `tsconfig.json` rather than in the build or test config. `@i18n` is a **file** alias (`"@i18n": ["./src/i18n/index.ts"]`), not a glob: `@i18n/types` does not resolve, so -re-export from `src/i18n/index.ts` instead. +re-export from [`src/i18n/index.ts`](./src/i18n/index.ts) instead. **Nested guides.** Read the one for the layer you are touching; they carry the detail this file omits. @@ -139,15 +139,15 @@ promise, not a fix. `docs/docs-consistency.test.ts` makes the mechanical half of that contract executable. It reads every document and asserts the checkable claims against the repo: no dead markdown links, no citation of a source -or test file that does not exist, no sample chart in `examples/README.md` without its SVG, every `action.yml` +or test file that does not exist, no sample chart in [`examples/README.md`](./examples/README.md) without its SVG, every `action.yml` input and output named on the surfaces that list them **and listed alphabetically** there, the translation-key table in -`docs/wiki/Internationalization-(i18n).md` matching `src/i18n/en.json` section for section and key for key, +`docs/wiki/Internationalization-(i18n).md` matching [`src/i18n/en.json`](./src/i18n/en.json) section for section and key for key, every documented `stars-data.json` example showing the `version` the writer actually stamps, the Node and pnpm pins above matching `package.json`, every overridable `action.yml` input stating its real default in prose and saying the config file can override it, and the ADR set held to its template (sequential numbering, `NNNN-kebab-title.md` filenames, the `# N. Title` / date / status / *Context* / *Decision* / -*Consequences* shape, a row in the `ARCHITECTURE.md` index, and, the one that rots quietly, a link from +*Consequences* shape, a row in the [`ARCHITECTURE.md`](./ARCHITECTURE.md) index, and, the one that rots quietly, a link from some document **other** than that index, since an ADR only the index points at will not be read). It runs with `pnpm test:ut`, so in CI on every PR. A failure means the docs and the code disagree; fix whichever is wrong. It cannot check prose or rationale; that part is still on you. Keep its assertions **aggregated** @@ -158,7 +158,7 @@ wrong. It cannot check prose or rationale; that part is still on you. Keep its a | What a domain word means, or introduce a new one | [`CONTEXT.md`](./CONTEXT.md), the glossary: vocabulary only | | A behaviour a doc states as an invariant or a gotcha | that bullet, or delete it if it stopped being true | | A layer's rules, or the files a concept is made of | that layer's nested `CLAUDE.md` (table above) | -| A default, an input name, or an output | `action.yml`, `docs/wiki/Configuration.md`, `docs/wiki/API-Reference.md`, the README table, `docs/wiki/Viewing-Reports.md`, and the *Outputs* section of `src/application/CLAUDE.md`, always **alphabetically** and never appended at the end (`github-token` stays pinned first) | +| A default, an input name, or an output | `action.yml`, [`docs/wiki/Configuration.md`](./docs/wiki/Configuration.md), [`docs/wiki/API-Reference.md`](./docs/wiki/API-Reference.md), the README table, [`docs/wiki/Viewing-Reports.md`](./docs/wiki/Viewing-Reports.md), and the *Outputs* section of [`src/application/CLAUDE.md`](./src/application/CLAUDE.md), always **alphabetically** and never appended at the end (`github-token` stays pinned first) | | A package script, a path alias, or a layer boundary | the *Commands* / *Structure & aliases* sections here | | The run order, the layer map, or the build pipeline | [`ARCHITECTURE.md`](./ARCHITECTURE.md) | | A decision an ADR records | that ADR: amend it, or supersede it with a new one and say so in both `## Status` blocks | @@ -181,17 +181,17 @@ the moment anything above it moves, so prefer naming the symbol. `verify` before `semantic-release`, which commits `dist/` as a release asset, not any pull-request check. Between releases `main` can still carry a bundle behind its sources, since a `refactor` or `chore` commit cuts no release; that is what committing your rebuild alongside the source is for. -- **Defaults live in `src/config/defaults.ts`, not in `action.yml`.** Overridable inputs deliberately carry +- **Defaults live in [`src/config/defaults.ts`](./src/config/defaults.ts), not in `action.yml`.** Overridable inputs deliberately carry an empty `default:` so the config file can win ([ADR 0020](./docs/adr/0020-overridable-inputs-declare-an-empty-default.md)); `src/config/action-inputs.test.ts` reads the real `action.yml` and fails if you add one, and [`src/config/`](./src/config/CLAUDE.md) names the handful of inputs that do carry a default, and why. -- **Coverage is global at 85%** for lines/functions/branches/statements. Excluded: `src/index.ts`, +- **Coverage is global at 85%** for lines/functions/branches/statements. Excluded: [`src/index.ts`](./src/index.ts), `src/**/{types,defaults,constants}.ts`, `src/**/*.test.ts`, `src/shared/tests/**`. Changing a constant therefore produces no coverage signal, but many tests assert the resulting literals, so expect failures far from the edit. - **One test file can cover two modules.** Exactly one such pair is sanctioned and [`src/infrastructure/`](./src/infrastructure/CLAUDE.md) names it; `src/config/action-inputs.test.ts` covers - the manifest rather than a module. `client.ts` is the sole module with no colocated test, so anything else + the manifest rather than a module. [`client.ts`](./src/infrastructure/github/client.ts) is the sole module with no colocated test, so anything else missing one is drift, not a convention. - **Biome allows no suppressions.** Fix the root cause instead of `biome-ignore`. 120-col, tabs, LF, double quotes: Biome's defaults bar the line width, and the same config every sibling repo runs; diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9f95018..67bcb34 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to GitHub Star Tracker -This action is a TypeScript codebase bundled into a committed `dist/index.js`. Two things catch out most +This action is a TypeScript codebase bundled into a committed [`dist/index.js`](./dist/index.js). Two things catch out most first pull requests: the bundle is expected to be rebuilt in the same commit as the source, which no check enforces, and the documentation set is verified by a test, which does. Both are covered below. @@ -40,7 +40,7 @@ Found a typo? Something unclear? Documentation improvements are always welcome: - README updates - Wiki pages: edit the `docs/wiki/*.md` files **in this repository**. The GitHub Wiki is generated from - that folder by `.github/workflows/sync-wiki.yml`, which runs `rsync -a --delete` on every push touching + that folder by [`.github/workflows/sync-wiki.yml`](./.github/workflows/sync-wiki.yml), which runs `rsync -a --delete` on every push touching `docs/wiki/**`, so an edit made in the wiki UI is overwritten on the next docs commit - Per-folder `CLAUDE.md` notes - Examples and tutorials @@ -64,13 +64,13 @@ Found a typo? Something unclear? Documentation improvements are always welcome: ``` The Node version lives in three places that must agree: [`.nvmrc`](./.nvmrc), which both CI workflows - read through `node-version-file`; `engines.node` in `package.json`; and the *Versions* section of the - root [`CLAUDE.md`](./CLAUDE.md), where `docs/docs-consistency.test.ts` asserts it against + read through `node-version-file`; `engines.node` in [`package.json`](./package.json); and the *Versions* section of the + root [`CLAUDE.md`](./CLAUDE.md), where [`docs/docs-consistency.test.ts`](./docs/docs-consistency.test.ts) asserts it against `package.json`. Bumping Node means editing all three in one commit. The pnpm version lives in `packageManager` and in that same `CLAUDE.md` section. Note that `engines.node` is the *development* pin. The shipped runtime is `node24`, set by - `runs.using` in `action.yml` and by the esbuild `target`, so a `node:*` API newer than Node 24 will + `runs.using` in [`action.yml`](./action.yml) and by the esbuild `target`, so a `node:*` API newer than Node 24 will type-check and bundle here and then fail on a runner. 3. **Create a branch** @@ -107,7 +107,7 @@ Found a typo? Something unclear? Documentation improvements are always welcome: `dist/index.js` and `dist/index.js.map` alongside your source changes. Nothing fails your pull request if you forget, and a release will not ship the stale bundle either; - `release.yml` rebuilds it before publishing. What you are keeping honest is `main` itself: a commit type + [`release.yml`](./.github/workflows/release.yml) rebuilds it before publishing. What you are keeping honest is `main` itself: a commit type that does not cut a release (`refactor`, `chore`, `test`, `docs`, `ci`) leaves `main`'s `dist/` behind its sources until the next `feat` or `fix`, which anyone referencing `@main` would run. The `pre-push` hook rebuilds the bundle for you, so in practice this is a matter of committing what it leaves behind. @@ -148,7 +148,7 @@ pnpm run format `pnpm run verify` is the wider gate: it adds type-checking, the coverage run and the bundle on top of `lint`. **Guidelines:** -- TypeScript: strict type-checking is on by default in the pinned version, so `tsconfig.json` does not +- TypeScript: strict type-checking is on by default in the pinned version, so [`tsconfig.json`](./tsconfig.json) does not declare it - Functional programming style preferred - No `any` types (use `unknown` if needed) @@ -229,7 +229,7 @@ locally before a message can land. | `build` | Build system changes | None | `build: update esbuild config` | | `revert` | Revert previous commit | Patch | `revert: feat: add feature X` | -A scope in parentheses is optional and unconstrained: `commitlint.config.ts` extends +A scope in parentheses is optional and unconstrained: [`commitlint.config.ts`](./commitlint.config.ts) extends `@commitlint/config-conventional` and declares no `scope-enum`. ### Breaking Changes @@ -278,9 +278,9 @@ release only happens if lint, type-check, coverage and the build all pass. seman 1. Analyzes the commits since the last release 2. Determines the version bump from the commit types 3. Updates `package.json` -4. Generates `CHANGELOG.md` from the commits +4. Generates [`CHANGELOG.md`](./CHANGELOG.md) from the commits 5. Creates the git tag and the GitHub release -6. Commits `package.json`, `pnpm-lock.yaml`, `CHANGELOG.md` and `dist/` back to `main` as +6. Commits `package.json`, [`pnpm-lock.yaml`](./pnpm-lock.yaml), `CHANGELOG.md` and `dist/` back to `main` as `chore(release): [skip ci]` A final workflow step force-updates the floating `v1` tag to the new release, which is the tag consumers @@ -368,7 +368,7 @@ github-star-tracker/ `src/domain/` is the largest layer and holds one module per concept: run measurement, comparison, snapshots, forecasting, velocity, growth, stargazer diffing, star-history reconstruction, tracked-set resolution, sampling, notification settlement, formatting and time parsing, plus `types.ts` and -`constants.ts`. [`src/domain/CLAUDE.md`](./src/domain/CLAUDE.md) is the guide. +[`constants.ts`](./src/domain/constants.ts). [`src/domain/CLAUDE.md`](./src/domain/CLAUDE.md) is the guide. > [!TIP] > **Path aliases:** cross-layer imports use `@application/*`, `@assets/*`, `@config/*`, `@domain/*`, @@ -386,9 +386,9 @@ them leaves them lying: | Document | Answers | Update it when | | --- | --- | --- | | `CLAUDE.md` (root) | *How do I work in this repo?* Commands, aliases, conventions, the maintenance contract | You change a script, an alias, a convention, or a repo-wide invariant | -| `CONTEXT.md` (root) | *What does this word mean?* A domain glossary, and nothing else: no file names, no libraries, no implementation detail | A domain term changes meaning, or a new one appears | +| [`CONTEXT.md`](./CONTEXT.md) (root) | *What does this word mean?* A domain glossary, and nothing else: no file names, no libraries, no implementation detail | A domain term changes meaning, or a new one appears | | `src//CLAUDE.md` | *What does this layer guarantee?* Invariants and gotchas, one guide per layer | You change an invariant, or a rule the guide states | -| `ARCHITECTURE.md` | *How does it fit together?* Layer map, end-to-end run, data branch, build and release | You change the run order, the layering, or the pipeline | +| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | *How does it fit together?* Layer map, end-to-end run, data branch, build and release | You change the run order, the layering, or the pipeline | | `docs/adr/` | *Why is it like this?* One decision per file | You make a decision that is hard to reverse, surprising without context, **and** the result of a real trade-off | The root [`CLAUDE.md`](./CLAUDE.md) has the full table of what to update for a given change. @@ -403,19 +403,19 @@ whole documentation set and fails on, among other things: - A `file.ts:123` citation anywhere; name the symbol instead, because line numbers rot silently - An `action.yml` input or output missing from a surface that lists them, or listed out of alphabetical order -- An overridable input whose documented default is not the one `src/config/defaults.ts` declares, or whose +- An overridable input whose documented default is not the one [`src/config/defaults.ts`](./src/config/defaults.ts) declares, or whose documentation does not say the config file can override it - A `pnpm` script named in the root `CLAUDE.md` that `package.json` does not declare - An ADR that breaks the template shape, is numbered out of sequence, is missing from the `ARCHITECTURE.md` index, or has no contextual link from any document other than that index - A translation-key table in `docs/wiki/Internationalization-(i18n).md` that does not match - `src/i18n/en.json` section for section and key for key + [`src/i18n/en.json`](./src/i18n/en.json) section for section and key for key - A documented `stars-data.json` example whose `version` is not the one the writer stamps - A function or arrow taking two or more positional parameters -- A sample chart in `examples/README.md` with no corresponding SVG +- A sample chart in [`examples/README.md`](./examples/README.md) with no corresponding SVG "The whole documentation set" is meant literally: the root guides, everything under `docs/` and `.github/`, -every layer `CLAUDE.md`, `examples/README.md`, and this file along with `SECURITY.md` and +every layer `CLAUDE.md`, `examples/README.md`, and this file along with [`SECURITY.md`](./SECURITY.md) and `CODE_OF_CONDUCT.md`. If you edit any of them, the test reads what you wrote. A failure means the docs and the code disagree, so fix whichever is wrong. The test cannot check prose or diff --git a/SECURITY.md b/SECURITY.md index 6ae2f4c..ea2c371 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,7 @@ config file, and writes rendered files to a branch. That shapes what is interest Two defences are worth knowing about before you write a report, because they already hold: -- Git is invoked through `execFileSync` with an argument array (`src/infrastructure/git/commands.ts`), so +- Git is invoked through `execFileSync` with an argument array ([`src/infrastructure/git/commands.ts`](./src/infrastructure/git/commands.ts)), so no config value reaches a shell. There is no `git` string to break out of. - The git credential header is a base64 blob registered with `core.setSecret` the moment it is built, so it is masked in the log even when git echoes the command. diff --git a/docs/adr/0000-adr-template.md b/docs/adr/0000-adr-template.md index 97a5c8c..5b0345e 100644 --- a/docs/adr/0000-adr-template.md +++ b/docs/adr/0000-adr-template.md @@ -24,4 +24,4 @@ What follows from this, including what it costs. The bullets someone needs befor - What is now load-bearing and must not be removed, and what breaks if it is. - What this makes harder, slower, or impossible. An ADR with no cost recorded is usually not describing a real trade-off. -- Where the decision bites in the rest of the docs: the Gotchas bullet in a folder's `CLAUDE.md`, the `CONTEXT.md` entry, the wiki page that has to link back here. An ADR that only the index links to will not be read. +- Where the decision bites in the rest of the docs: the Gotchas bullet in a folder's `CLAUDE.md`, the [`CONTEXT.md`](../../CONTEXT.md) entry, the wiki page that has to link back here. An ADR that only the index links to will not be read. diff --git a/docs/adr/0001-star-data-lives-on-a-dedicated-data-branch.md b/docs/adr/0001-star-data-lives-on-a-dedicated-data-branch.md index 5898281..bd2d110 100644 --- a/docs/adr/0001-star-data-lives-on-a-dedicated-data-branch.md +++ b/docs/adr/0001-star-data-lives-on-a-dedicated-data-branch.md @@ -18,11 +18,11 @@ A GitHub Action gets a fresh workspace on every run, so anything the tracker nee The Stored History and every published artefact (Report, Charts, Badge) live on a separate branch in the same repository, checked out alongside the code for the duration of a Run. Nothing generated is written to the code branch, and no store outside the repository is involved. -`withDataBranch` in `src/infrastructure/persistence/data-branch.ts` is the only surface that touches that branch. It calls `initializeDataBranch` in `src/infrastructure/git/worktree.ts` to open the worktree, hands the caller a handle over it, and closes it again afterwards; `dataDir` never leaves the layer. +`withDataBranch` in [`src/infrastructure/persistence/data-branch.ts`](../../src/infrastructure/persistence/data-branch.ts) is the only surface that touches that branch. It calls `initializeDataBranch` in [`src/infrastructure/git/worktree.ts`](../../src/infrastructure/git/worktree.ts) to open the worktree, hands the caller a handle over it, and closes it again afterwards; `dataDir` never leaves the layer. ## Consequences - The branch is unrelated to the code branch and carries its own history, which is expected to be noisy: one commit per Run. - Because a single branch is the write target, two workflows pointed at the same Data Branch will compete to write it; that is what a Read-Only Run exists to avoid. -- **The loser of that race loses its Snapshot, after it has already published.** `commitAndPush` in `src/infrastructure/persistence/storage.ts` matches the push failure against `PUSH_REJECTED_PATTERN` and, on a match, throws a message that says the run's snapshot was not recorded and that its report and any email have already gone out. The Run fails at the last step, having sent everything except the one thing it exists to keep. Re-running records it; a `concurrency` group on the workflow, or `read-only` on whichever workflow should not be the writer, stops the race happening at all. +- **The loser of that race loses its Snapshot, after it has already published.** `commitAndPush` in [`src/infrastructure/persistence/storage.ts`](../../src/infrastructure/persistence/storage.ts) matches the push failure against `PUSH_REJECTED_PATTERN` and, on a match, throws a message that says the run's snapshot was not recorded and that its report and any email have already gone out. The Run fails at the last step, having sent everything except the one thing it exists to keep. Re-running records it; a `concurrency` group on the workflow, or `read-only` on whichever workflow should not be the writer, stops the race happening at all. - Raw URLs on this branch are the public interface of the artefacts, so renaming the files on it is a breaking change for anyone embedding them. diff --git a/docs/adr/0002-require-a-personal-access-token.md b/docs/adr/0002-require-a-personal-access-token.md index 472dd62..9fe78a0 100644 --- a/docs/adr/0002-require-a-personal-access-token.md +++ b/docs/adr/0002-require-a-personal-access-token.md @@ -8,7 +8,7 @@ Accepted ## Context -The tracker enumerates every Repository the token can see, which by default includes ones it does not own: `DEFAULTS.visibility` is `all`, and `VISIBILITY_PARAMS.all` in `src/infrastructure/github/client.ts` sends `visibility: 'all'` with no `affiliation`, so GitHub returns repositories the account collaborates on and organization repositories alongside its own. Only `visibility: owned` narrows that to `affiliation: 'owner'`. +The tracker enumerates every Repository the token can see, which by default includes ones it does not own: `DEFAULTS.visibility` is `all`, and `VISIBILITY_PARAMS.all` in [`src/infrastructure/github/client.ts`](../../src/infrastructure/github/client.ts) sends `visibility: 'all'` with no `affiliation`, so GitHub returns repositories the account collaborates on and organization repositories alongside its own. Only `visibility: owned` narrows that to `affiliation: 'owner'`. The `GITHUB_TOKEN` that GitHub Actions injects automatically is scoped to the triggering repository alone and cannot list anything beyond it. There is no token GitHub issues automatically that can do the job, so the setup friction is not something a better default could remove; the only way around it would be to silently track the triggering repository and call that the product. diff --git a/docs/adr/0003-commit-the-bundled-dist-directory.md b/docs/adr/0003-commit-the-bundled-dist-directory.md index d7bb8b6..a2952d3 100644 --- a/docs/adr/0003-commit-the-bundled-dist-directory.md +++ b/docs/adr/0003-commit-the-bundled-dist-directory.md @@ -17,6 +17,6 @@ The entire source and all dependencies are bundled into a single committed file ## Consequences - Generated output is tracked in git, so `dist/` appears in diffs and must never be hand-edited. It is regenerated by the `pre-push` hook and committed by contributors alongside the source change, and re-committed by semantic-release as part of a release. -- **"Build runs as part of validation" is the load-bearing artefact, not any pull-request check.** `verify` ends in `pnpm build`, and `release.yml` runs `pnpm run verify` immediately before `npx semantic-release`, whose `@semantic-release/git` plugin lists `dist/` among its assets. The release therefore rebuilds the bundle from the sources of the commit it is releasing and commits the result into the release commit the tag points at, so **a published version cannot carry a bundle built from different sources**, whatever a contributor did or forgot. Keep `build` at the end of `verify`, and `dist/` in that asset list: the guarantee lives in those two lines and nowhere else. +- **"Build runs as part of validation" is the load-bearing artefact, not any pull-request check.** `verify` ends in `pnpm build`, and [`release.yml`](../../.github/workflows/release.yml) runs `pnpm run verify` immediately before `npx semantic-release`, whose `@semantic-release/git` plugin lists `dist/` among its assets. The release therefore rebuilds the bundle from the sources of the commit it is releasing and commits the result into the release commit the tag points at, so **a published version cannot carry a bundle built from different sources**, whatever a contributor did or forgot. Keep `build` at the end of `verify`, and `dist/` in that asset list: the guarantee lives in those two lines and nowhere else. - **What is not guaranteed is `main` between releases.** semantic-release publishes only for a releasing commit type (`feat`, `fix`, `perf`), so a `refactor`, `chore`, `test`, `docs` or `ci` commit that changes bundled sources lands on `main` with `dist/` untouched and no release to repair it, until the next releasing commit arrives. A consumer pinned to a version, or to the floating major tag that `release.yml` force-moves to the latest tag, never sees that; one referencing `@main` or a raw sha does. This is why contributors are still asked to rebuild and commit the bundle alongside the source, and why the `pre-push` hook rebuilds it for them: housekeeping for `main`'s own coherence, not the thing that keeps releases honest. -- An earlier version of this decision enforced that rebuild with a *Verify dist was rebuilt* step in `.github/workflows/ci.yml`, failing any pull request that touched a non-test file under `src/` without touching `dist/`, and this document called that step the load-bearing artefact. That was wrong on the facts: the release path above already rebuilds and re-commits, so the step never stood between a stale bundle and a user. It was removed once it began costing false positives: a pull request touching only `*.test.ts` and the fixtures under `src/shared/tests/`, neither of which the bundle reaches, was told to commit a rebuild that would have been byte-identical. +- An earlier version of this decision enforced that rebuild with a *Verify dist was rebuilt* step in [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml), failing any pull request that touched a non-test file under `src/` without touching `dist/`, and this document called that step the load-bearing artefact. That was wrong on the facts: the release path above already rebuilds and re-commits, so the step never stood between a stale bundle and a user. It was removed once it began costing false positives: a pull request touching only `*.test.ts` and the fixtures under `src/shared/tests/`, neither of which the bundle reaches, was told to commit a rebuild that would have been byte-identical. diff --git a/docs/adr/0005-charts-are-reconstructed-from-stargazer-timestamps.md b/docs/adr/0005-charts-are-reconstructed-from-stargazer-timestamps.md index 19a0926..304f73c 100644 --- a/docs/adr/0005-charts-are-reconstructed-from-stargazer-timestamps.md +++ b/docs/adr/0005-charts-are-reconstructed-from-stargazer-timestamps.md @@ -14,7 +14,7 @@ Stored History only begins on the day a user installs the action, so charts buil Charts are built from a Reconstructed History derived from when each Stargazer actually starred, which yields the repository's full curve on the very first Run. When that source yields too little to work with, charts fall back to the Stored History. -`buildStarHistory` in `src/domain/star-history.ts` does the reconstruction: it buckets every parseable `starredAt` into a fixed number of edges and accumulates. `resolveChartHistories` in `src/presentation/charts.ts` owns the fallback, and it is one rule applied twice: a reconstructed History with fewer than `MIN_SNAPSHOTS_FOR_CHART` snapshots is discarded in favour of the Stored History for the aggregate chart, and yields `null` for a per-repository one. +`buildStarHistory` in [`src/domain/star-history.ts`](../../src/domain/star-history.ts) does the reconstruction: it buckets every parseable `starredAt` into a fixed number of edges and accumulates. `resolveChartHistories` in [`src/presentation/charts.ts`](../../src/presentation/charts.ts) owns the fallback, and it is one rule applied twice: a reconstructed History with fewer than `MIN_SNAPSHOTS_FOR_CHART` snapshots is discarded in favour of the Stored History for the aggregate chart, and yields `null` for a per-repository one. ## Consequences diff --git a/docs/adr/0007-bridge-unreachable-history-with-a-ramp.md b/docs/adr/0007-bridge-unreachable-history-with-a-ramp.md index 2b1c31d..8a15120 100644 --- a/docs/adr/0007-bridge-unreachable-history-with-a-ramp.md +++ b/docs/adr/0007-bridge-unreachable-history-with-a-ramp.md @@ -14,7 +14,7 @@ Reachable Stargazers stop at a fixed ceiling and are listed oldest first, so for The reachable portion is scaled to the count it genuinely covers, and a Ramped Tail is drawn from there to the true present-day Star Count. The ramp is triggered whenever coverage fell short of the true Star Count: from the ceiling, but equally from a fetch that was cut short by an error or by Smart Sampling ([ADR 0008](./0008-sampled-repositories-are-excluded-from-stargazer-diffing.md)), so a small Repository can get a ramped tail too. A Repository whose Stargazers were fully enumerated is untouched. -`src/domain/star-history.ts` holds both halves of that rule as two sibling functions, and which one runs is the whole decision. `scaleToTrueTotal` is the untouched case: it rescales the bucket counts so the final point is the true Star Count, and draws no ramp. `scaleCappedToTrueTotal` is the ramp: it scales the counts to the *reachable* total instead, walks back from the end to find where the fetched counts stopped growing, and interpolates linearly from that point to the true Star Count. Both then enforce monotonicity and pin the last point exactly. +[`src/domain/star-history.ts`](../../src/domain/star-history.ts) holds both halves of that rule as two sibling functions, and which one runs is the whole decision. `scaleToTrueTotal` is the untouched case: it rescales the bucket counts so the final point is the true Star Count, and draws no ramp. `scaleCappedToTrueTotal` is the ramp: it scales the counts to the *reachable* total instead, walks back from the end to find where the fetched counts stopped growing, and interpolates linearly from that point to the true Star Count. Both then enforce monotonicity and pin the last point exactly. ## Consequences diff --git a/docs/adr/0010-quickchart-renders-the-email-charts.md b/docs/adr/0010-quickchart-renders-the-email-charts.md index 61f9978..21a4182 100644 --- a/docs/adr/0010-quickchart-renders-the-email-charts.md +++ b/docs/adr/0010-quickchart-renders-the-email-charts.md @@ -25,7 +25,7 @@ The HTML Report embeds `quickchart.io` image URLs built from a Chart.js config, ## Consequences - Charts have **two independent renderers** with different capabilities, and a change to one does not change the other. The QuickChart path is a deliberate lower-fidelity approximation, and this is the whole list of what it drops: - 1. It is fixed at 30 points regardless of `chart-max-points`, spread across the selected range rather than taken from the end, because `chartImageUrl` in `src/presentation/chart.ts` never passes `maxPoints` and `buildChartSpec` falls back to `CHART.maxDataPoints`. + 1. It is fixed at 30 points regardless of `chart-max-points`, spread across the selected range rather than taken from the end, because `chartImageUrl` in [`src/presentation/chart.ts`](../../src/presentation/chart.ts) never passes `maxPoints` and `buildChartSpec` falls back to `CHART.maxDataPoints`. 2. It collapses the four `ChartCurve` types onto two Chart.js shapes: `CURVE_PROPS` maps `monotone` and `rounded-step` onto a monotone cubic interpolation and `catmull-rom` and `cubic-bezier` onto a plain smooth tension. 3. It ignores `chart-animation`, because a PNG cannot animate. `chartImageUrl` has no `animate` parameter at all. 4. It ignores `chart-y-axis-side`. `chartImageUrl` has no `yAxisSide` parameter, so the email chart's Y axis is always where Chart.js puts it. diff --git a/docs/adr/0011-the-notification-baseline-advances-only-on-delivery.md b/docs/adr/0011-the-notification-baseline-advances-only-on-delivery.md index f48c7dd..a5e3568 100644 --- a/docs/adr/0011-the-notification-baseline-advances-only-on-delivery.md +++ b/docs/adr/0011-the-notification-baseline-advances-only-on-delivery.md @@ -23,5 +23,5 @@ The run sends the notification *before* persisting, and advances the baseline on - **A failed send warns and the Run continues.** `trackStars` wraps `sendEmail` in `try`/`catch`, logs `Failed to send email: …` through `core.warning`, and records the attempt as a failed Delivery. That policy is what makes this ordering safe: moving the send in front of the write only costs durability if a throw can skip the write, and it cannot. The Report, the Charts and the Snapshot are published either way; only the baseline is withheld. - The per-channel definition matters for `should-notify`: gating it on a send that never happened would leave the output stuck true forever after the first trip. - A withheld advance covers both an SMTP rejection and the quieter case of `smtp-host` set with an empty `email-to`, where `sendEmail` returns `false` without throwing. Treating a non-null `EmailConfig` as proof that mail went out is the trap this decision exists to avoid. -- The only new exposure is a hung SMTP connection. Nothing here configures a bound on it: `nodemailer.createTransport` in `src/infrastructure/notification/email.ts` sets `host`, `port`, `secure` and `auth` and no `connectionTimeout`, `greetingTimeout` or `socketTimeout`, so how long a Run can stall waiting on the transport is whatever nodemailer defaults to, plus the workflow's own job timeout above it. +- The only new exposure is a hung SMTP connection. Nothing here configures a bound on it: `nodemailer.createTransport` in [`src/infrastructure/notification/email.ts`](../../src/infrastructure/notification/email.ts) sets `host`, `port`, `secure` and `auth` and no `connectionTimeout`, `greetingTimeout` or `socketTimeout`, so how long a Run can stall waiting on the transport is whatever nodemailer defaults to, plus the workflow's own job timeout above it. - **This rule is now executable rather than written down.** `settleNotification` in `@domain/notification` takes the run's `changed` and `thresholdReached` plus one `Delivery` and returns `shouldNotify`, `notificationSent` and `historyToPersist` together, so the baseline can only advance on a decision that held and a delivery that did not fail. `@application` reports what the transport did and reads the outcome; it no longer tracks three booleans across a `try`/`catch`, which is how the `notification-sent` output once came to report `false` after a successful courtesy send. diff --git a/docs/adr/0012-unreadable-stargazer-lists-keep-their-previous-logins.md b/docs/adr/0012-unreadable-stargazer-lists-keep-their-previous-logins.md index 616c0e5..e465890 100644 --- a/docs/adr/0012-unreadable-stargazer-lists-keep-their-previous-logins.md +++ b/docs/adr/0012-unreadable-stargazer-lists-keep-their-previous-logins.md @@ -21,7 +21,7 @@ Smart Sampling produced the identical defect by a different route. A sample is n **A Repository whose current Stargazer list cannot be trusted keeps whatever logins were last stored for it, and is skipped by the diff rather than compared against a list known to be incomplete.** "Cannot be trusted" is carried on a `RepoStargazers` as two flags, and `diffStargazers` and -`buildStargazerMap` in `src/domain/stargazers.ts` both skip a Repository carrying either. `sampled` marks a +`buildStargazerMap` in [`src/domain/stargazers.ts`](../../src/domain/stargazers.ts) both skip a Repository carrying either. `sampled` marks a Repository read by Smart Sampling ([ADR 0008](./0008-sampled-repositories-are-excluded-from-stargazer-diffing.md)). `incomplete` marks the three ways a full read can fail to be one: the fetch threw, the fetch was truncated at the reachable ceiling, or it returned no Stargazers for a Repository that has Stars. One rule, not four: diff --git a/docs/adr/0013-a-run-is-measured-in-one-place.md b/docs/adr/0013-a-run-is-measured-in-one-place.md index df59e40..bb15803 100644 --- a/docs/adr/0013-a-run-is-measured-in-one-place.md +++ b/docs/adr/0013-a-run-is-measured-in-one-place.md @@ -58,8 +58,8 @@ acts, not one. accumulated threshold on a courtesy send. - **`droppedSnapshots` is reported, not logged.** The domain layer is pure and cannot warn; `@application` raises the `max-history` warning from that number. -- **The ordering rules are now tested against the real implementation**, in `measurement.test.ts`, rather - than asserted as a call sequence against mocks. `tracker.test.ts` now mocks `@domain/measurement` alone +- **The ordering rules are now tested against the real implementation**, in [`measurement.test.ts`](../../src/domain/measurement.test.ts), rather + than asserted as a call sequence against mocks. [`tracker.test.ts`](../../src/application/tracker.test.ts) now mocks `@domain/measurement` alone where it used to mock `comparison`, `snapshot` and `notification`, and its remaining assertions are about wiring rather than about arithmetic. It still carries seventeen `vi.mock` calls in total, because it also substitutes `@actions/*`, `@config`, `@infrastructure` and the renderers; what this decision removed is the diff --git a/docs/adr/0014-charts-are-built-as-a-spec-and-rendered-by-adapters.md b/docs/adr/0014-charts-are-built-as-a-spec-and-rendered-by-adapters.md index 13aa54c..2e82fa2 100644 --- a/docs/adr/0014-charts-are-built-as-a-spec-and-rendered-by-adapters.md +++ b/docs/adr/0014-charts-are-built-as-a-spec-and-rendered-by-adapters.md @@ -24,7 +24,7 @@ other, lossily. The consequence was drift with no test that could catch it. A fix to the comparison cap or the label heuristic landed on whichever side the author was editing, and the README chart and the email chart quietly -stopped agreeing. `charts.ts`, which projects `Config` onto the shared style, had no colocated test at all +stopped agreeing. [`charts.ts`](../../src/presentation/charts.ts), which projects `Config` onto the shared style, had no colocated test at all and was carried indirectly by the tracker's suite. Collapsing the two renderers into one was never an option; that is what ADR 0006 and ADR 0010 already @@ -42,7 +42,7 @@ Milestones to draw, each already filtered to the visible ones and carrying both `label`), or onto `null` when there is too little history to plot. `starHistorySpec`, `perRepoSpec`, `comparisonSpec` and `forecastSpec` are the private cases behind it. -`svg-chart.ts` and `chart.ts` are adapters over that seam, each with **one** entry point, `renderSvgChart` +[`svg-chart.ts`](../../src/presentation/svg-chart.ts) and [`chart.ts`](../../src/presentation/chart.ts) are adapters over that seam, each with **one** entry point, `renderSvgChart` and `chartImageUrl`, taking a request plus its own style. Each maps a `ChartSpec` onto its own dialect and owns nothing else about the Chart's content. @@ -69,7 +69,7 @@ a dash array or a point radius. Each adapter maps them through its own table. because leaving the label to the adapters is what let `chart.ts` format with a hardcoded `en-US` while `svg-chart.ts` used the run's Locale. Anything a reader reads is decided here; only how it is drawn is the adapter's. -- **That rule is not yet fully honoured, and the exceptions are in `chart-spec.ts` itself.** The `PER_REPO` +- **That rule is not yet fully honoured, and the exceptions are in [`chart-spec.ts`](../../src/presentation/chart-spec.ts) itself.** The `PER_REPO` case of `buildChartSpec` falls back to an English title built inline from the repository's full name and the words "Star History" rather than reading the Locale bundle, so a per-repository chart is titled in English whatever `locale` is set to; the star-history and forecast kinds do read the bundle. Both `starHistorySpec` and @@ -85,14 +85,14 @@ a dash array or a point radius. Each adapter maps them through its own table. - **`AxisLabels` stopped being a per-call parameter.** It is now an adapter constant, and `forecastSpec` `Omit`s it from its params rather than accepting a value it overrides. The shape now says what the code always did. -- **`chart-spec.test.ts` is the seam's own test surface.** Content rules were previously asserted only - through `svg-chart.test.ts` and `chart.test.ts`, in duplicate and through rendered strings; those two are +- **[`chart-spec.test.ts`](../../src/presentation/chart-spec.test.ts) is the seam's own test surface.** Content rules were previously asserted only + through [`svg-chart.test.ts`](../../src/presentation/svg-chart.test.ts) and [`chart.test.ts`](../../src/presentation/chart.test.ts), in duplicate and through rendered strings; those two are now free to be about appearance. - **The two renderers can no longer drift on content.** Window, cap, colours, labels and Milestone visibility are computed once. They can still drift on appearance, which is the point. - **The cost is a layer of indirection and a vocabulary to learn** (`ChartSpec`, `AxisLabels`, `SeriesDash`, `SeriesWeight`) before either renderer makes sense. Reading `svg-chart.ts` alone no longer tells you where its data came from. -- `charts.ts` now has a colocated `charts.test.ts`, so the `Config`-to-style projection and the +- `charts.ts` now has a colocated [`charts.test.ts`](../../src/presentation/charts.test.ts), so the `Config`-to-style projection and the per-repo history fallback are asserted directly rather than through the tracker. - Where this bites is recorded in [`src/presentation/CLAUDE.md`](../../src/presentation/CLAUDE.md). diff --git a/docs/adr/0016-the-report-renderers-read-config-themselves.md b/docs/adr/0016-the-report-renderers-read-config-themselves.md index acb15f3..e346375 100644 --- a/docs/adr/0016-the-report-renderers-read-config-themselves.md +++ b/docs/adr/0016-the-report-renderers-read-config-themselves.md @@ -6,7 +6,7 @@ Date: 2026-08-17 Accepted -> **Amended after this decision.** `renderRun` (`src/presentation/run.ts`) became the layer's single entry +> **Amended after this decision.** `renderRun` ([`src/presentation/run.ts`](../../src/presentation/run.ts)) became the layer's single entry > point shortly afterwards, so `@application` no longer calls the renderers at all and they now take > `{ model, config }`; `ReportParams` is `buildReportModel`'s input. The rule this ADR records is unchanged: > the renderers read `Config` themselves and the shell relays no chart options. @@ -25,14 +25,14 @@ Fifteen of those fields were `config.` copied across by hand, eleven orchestrator therefore had to know what a curve, a Milestone and a trend line were in order to relay them, and the git history shows the cost plainly: `chart-show-points`, `chart-animation`, `chart-milestones`, `chart-range`, `chart-trend-line`, `chart-curve` and `chart-line-color`/`chart-line-width` each arrived as a -`feat:` commit that edited `tracker.ts`, a file with no stake in any of them. `tracker.ts` is the most +`feat:` commit that edited [`tracker.ts`](../../src/application/tracker.ts), a file with no stake in any of them. `tracker.ts` is the most churned source file in the repository. Two defects came out of the same shape. `ReportParams` declared ten fields and `GenerateHtmlReportParams` twenty-one, so the split was real at the declaration, but `reportParams` was a *variable*, which switches off TypeScript's excess-property check, so the markdown renderer silently accepted and discarded eleven chart-style fields it does not read. The whole guarantee rested on one spread on one line, and -`src/application/CLAUDE.md` had to carry a written warning naming it as the regression to watch for. It had +[`src/application/CLAUDE.md`](../../src/application/CLAUDE.md) had to carry a written warning naming it as the regression to watch for. It had already happened once, leaving dark-mode readers a white chart background. `@presentation/charts` was already doing the opposite thing, and doing it well: `buildChartFiles({ config, @@ -44,7 +44,7 @@ orchestrator in the business of relaying options it does not understand. ## Decision -`ReportParams` in `src/presentation/shared.ts` carries the `Config` the run was produced under as its first +`ReportParams` in [`src/presentation/shared.ts`](../../src/presentation/shared.ts) carries the `Config` the run was produced under as its first field, alongside the run's own data: the comparison results and the previous timestamp, which are required, and then a set of optional, nullable extras the renderers use when they are available (the Stored History, a separate history for Velocity, the Stargazer diff, the Forecast data, the resolved chart histories, an @@ -56,7 +56,7 @@ this decision and a verbatim-looking code block that is no longer verbatim is wo Read `ReportParams` for the current list; read this ADR for why `config` is in it. `locale`, `includeCharts`, `topRepos` and `velocityMetrics` are read off `config` inside `buildReportModel`. -The email chart style is projected by `emailChartStyle(config)` in `shared.ts`, which `html.ts` calls, and +The email chart style is projected by `emailChartStyle(config)` in `shared.ts`, which [`html.ts`](../../src/presentation/html.ts) calls, and `html.ts` is also where `emailTheme` is read, so the Notification picks its own theme rather than being handed one. @@ -75,8 +75,8 @@ rather than the enums alone. - **The two renderers can no longer be given different data.** They take one type; the only remaining difference is which `Config` fields each chooses to read. `generateMarkdownReport` reads no chart style at all, which is what "markdown has no use for chart styling" always meant. -- **`emailTheme` vs `chartTheme` is now a presentation rule, tested in `html.test.ts`** against the rendered - `color-scheme` and palette, rather than in `tracker.test.ts` against the shape of a mock call. The tracker +- **`emailTheme` vs `chartTheme` is now a presentation rule, tested in [`html.test.ts`](../../src/presentation/html.test.ts)** against the rendered + `color-scheme` and palette, rather than in [`tracker.test.ts`](../../src/application/tracker.test.ts) against the shape of a mock call. The tracker test that pinned `theme` on a params object it did not read is gone. - **`@presentation` now depends on the whole `Config` shape**, not on a hand-picked subset. That is the cost: a renderer's interface no longer states which options it honours, so the layer's `CLAUDE.md` has to. In diff --git a/docs/adr/0017-velocity-and-forecast-read-unparseable-timestamps-differently.md b/docs/adr/0017-velocity-and-forecast-read-unparseable-timestamps-differently.md index b3008a8..d7b04b3 100644 --- a/docs/adr/0017-velocity-and-forecast-read-unparseable-timestamps-differently.md +++ b/docs/adr/0017-velocity-and-forecast-read-unparseable-timestamps-differently.md @@ -37,7 +37,7 @@ The two readings are not arbitrary, because the two figures need different thing timestamp is unreadable would report a stars-per-day figure derived from a week that never happened. There is no honest number to return, so it returns none. -The alternative considered was giving `growth.ts` one policy and a parameter to select it. That moves the +The alternative considered was giving [`growth.ts`](../../src/domain/growth.ts) one policy and a parameter to select it. That moves the choice into a signature but does not remove it, and it invites a caller to pass the wrong one, which is the same class of mistake [ADR 0013](./0013-a-run-is-measured-in-one-place.md) removed by making the wrong order unreachable rather than documented. @@ -66,7 +66,7 @@ does **not** own is "how a History becomes a day axis", because that is not one - **Do not make `calendarDays` drop points instead.** A Forecast fit over a series whose spacing silently changed is worse than one over a stated approximation, and the `< 3 snapshots` guard is the only thing standing between the fit and a degenerate one. -- `growth.test.ts` and `velocity.test.ts` both exercise the `MIN_RATE_INTERVAL_DAYS` skip, which is +- [`growth.test.ts`](../../src/domain/growth.test.ts) and [`velocity.test.ts`](../../src/domain/velocity.test.ts) both exercise the `MIN_RATE_INTERVAL_DAYS` skip, which is redundant but harmless: one asserts the shared rule, the other asserts that Velocity crosses it. - The rule was previously prose in [`src/domain/CLAUDE.md`](../../src/domain/CLAUDE.md) marked "deliberately different" with no reason attached. The prose stays; this ADR is the reason it pointed at nothing. diff --git a/docs/adr/0018-loadconfig-reads-the-ambient-action-inputs.md b/docs/adr/0018-loadconfig-reads-the-ambient-action-inputs.md index 22acf8f..ab24deb 100644 --- a/docs/adr/0018-loadconfig-reads-the-ambient-action-inputs.md +++ b/docs/adr/0018-loadconfig-reads-the-ambient-action-inputs.md @@ -18,10 +18,10 @@ be made a third time, and because the reasoning against it is not obvious from r Should `loadConfig()` take the action inputs and the config-file contents as parameters instead of reading `core.getInput` and `node:fs` itself? -`loadConfig()` takes no arguments. It reads `core.getInput` at six sites in `src/config/loader.ts`: one +`loadConfig()` takes no arguments. It reads `core.getInput` at six sites in [`src/config/loader.ts`](../../src/config/loader.ts): one inside `resolveTabledFields`, which runs it once per tabled key, and five standalone reads for `visibility`, `data-branch`, `chart-custom-milestones`, `config-path` and `send-on-no-changes`. It reads `node:fs` at two, -both inside `loadConfigFile`. Every one of the ninety-four `loadConfig()` call sites in `loader.test.ts` +both inside `loadConfigFile`. Every one of the ninety-four `loadConfig()` call sites in [`loader.test.ts`](../../src/config/loader.test.ts) therefore goes through `vi.mock('@actions/core')` and `vi.mock('node:fs')` plus a local `mockInputs` helper. That reads like the textbook case for accepting dependencies rather than creating them, and two separate @@ -68,7 +68,7 @@ inputs" reading them ambiently is the truthful shape, not an accident. that `@config` was the sole exception in an otherwise dependency-injected tree, was simply wrong about the code: -- **`getEmailConfig` in `src/infrastructure/notification/email.ts` reads six ambient inputs**: `smtp-host`, +- **`getEmailConfig` in [`src/infrastructure/notification/email.ts`](../../src/infrastructure/notification/email.ts) reads six ambient inputs**: `smtp-host`, `smtp-port`, `smtp-username`, `smtp-password`, `email-to` and `email-from`. It takes only a `Locale`, for the default `from` name. It owns the SMTP input group the way `loadConfig` owns the tracking one, and it calls `core.setSecret` on the password, which is a reason to keep the read where the value is produced @@ -84,7 +84,7 @@ between reading an input group and consuming one, not between `@config` and the - **`loader.test.ts` keeps two `vi.mock` prologues and its `mockInputs` helper.** That is the accepted cost, and it is smaller than it was recorded as being: at 933 lines the file is the *second* largest test file in - the tree, behind `svg-chart.test.ts` at 1153. The earlier claim that it was the largest was the headline + the tree, behind [`svg-chart.test.ts`](../../src/presentation/svg-chart.test.ts) at 1153. The earlier claim that it was the largest was the headline cost of this decision and it was false, which weakens the argument by exactly that much: the cost is real but ordinary, and it is worth re-checking rather than assuming if this is ever reconsidered. - **The seam that does exist stays unused.** `loadConfigFile` is exported and separately tested, but @@ -93,6 +93,6 @@ between reading an input group and consuming one, not between `@config` and the - **Adding an input group means adding another ambient reader, not another parameter.** `getEmailConfig` is the precedent to copy: one function, one group, read at the point of use, with the caller told nothing about input names. -- The parser half of the old `loader.test.ts` now lives in `parsers.test.ts`, colocated with the module it +- The parser half of the old `loader.test.ts` now lives in [`parsers.test.ts`](../../src/config/parsers.test.ts), colocated with the module it covers. Those 246 lines never needed a mock at all, and their presence in the loader's test overstated how much of that file the ambient reads were responsible for. diff --git a/docs/adr/0019-the-stargazer-map-retains-untracked-repositories.md b/docs/adr/0019-the-stargazer-map-retains-untracked-repositories.md index 1a904d4..cbe8bc1 100644 --- a/docs/adr/0019-the-stargazer-map-retains-untracked-repositories.md +++ b/docs/adr/0019-the-stargazer-map-retains-untracked-repositories.md @@ -42,7 +42,7 @@ overwritten, never removed by a Run. can accumulate real weight, and `writeStargazers` rewrites the whole file every Run, so each Run commits a fresh blob of it to the Data Branch. - **Untracking a Repository no longer withdraws its published logins.** The remedies - `docs/wiki/Known-Limitations.md` offers for the privacy exposure, keeping the Data Branch in a private + [`docs/wiki/Known-Limitations.md`](../wiki/Known-Limitations.md) offers for the privacy exposure, keeping the Data Branch in a private repository or leaving `track-stargazers` off, are unaffected and remain the supported ones. Removing an entry is now a manual edit of `stargazers.json` on the Data Branch. - **A run that publishes nothing still cannot lose data**, because the seed means a partial or failed diff --git a/docs/adr/0020-overridable-inputs-declare-an-empty-default.md b/docs/adr/0020-overridable-inputs-declare-an-empty-default.md index 0dfcd63..483e0c1 100644 --- a/docs/adr/0020-overridable-inputs-declare-an-empty-default.md +++ b/docs/adr/0020-overridable-inputs-declare-an-empty-default.md @@ -10,12 +10,12 @@ Accepted Every tracking option can be set two ways: as an action input in the workflow, or as a key in `star-tracker.yml` on the code branch. The intended precedence is input first, then config file, then a -built-in default, and `resolveTabledFields` in `src/config/loader.ts` implements exactly that: it reads +built-in default, and `resolveTabledFields` in [`src/config/loader.ts`](../../src/config/loader.ts) implements exactly that: it reads `core.getInput(name)`, falls back to `fileConfig[key]`, and falls back again to `DEFAULTS[key]`. That chain rests on one property GitHub Actions does not provide. **`core.getInput` cannot distinguish "the user did not set this input" from "the user left it at the default the manifest declares".** Both arrive as -the same string. So the moment `action.yml` declares a real `default:` for an option, `core.getInput` returns +the same string. So the moment [`action.yml`](../../action.yml) declares a real `default:` for an option, `core.getInput` returns that value on every run, the first link in the chain always matches, and the config file can never win. The config file would still parse, still validate, still warn about bad values, and still be silently ignored. @@ -26,7 +26,7 @@ just an option that does not take effect. ## Decision **An input that the config file may override declares `default: ''` in `action.yml`.** The real default -lives in `DEFAULTS` in `src/config/defaults.ts`, which is the last link of the resolution chain and the only +lives in `DEFAULTS` in [`src/config/defaults.ts`](../../src/config/defaults.ts), which is the last link of the resolution chain and the only place any of these values is written down as a value. Only the three inputs with **no** config-file counterpart carry a non-empty default, because for them there @@ -47,10 +47,10 @@ invisible precedence bug with a visible magic value in every user's workflow fil for 43 of its 47 inputs; the other four are the three above and `github-token`, which declares no default at all because it is required. Anyone reading the manifest for a real value must read the description prose or `src/config/defaults.ts`. Two tests are what keep that prose honest: - - `src/config/action-inputs.test.ts` asserts that every key of `DEFAULTS` except `sendOnNoChanges` has an + - [`src/config/action-inputs.test.ts`](../../src/config/action-inputs.test.ts) asserts that every key of `DEFAULTS` except `sendOnNoChanges` has an input whose default is empty, and that the complete set of inputs carrying a non-empty default is exactly `config-path`, `send-on-no-changes` and `smtp-port`. Adding a default to an overridable input fails it. - - `docs/docs-consistency.test.ts` parses `(default X)` out of each description and compares it against the + - [`docs/docs-consistency.test.ts`](../docs-consistency.test.ts) parses `(default X)` out of each description and compares it against the matching `DEFAULTS` value, and separately requires every overridable input to say `(overrides config file)`. Changing a default in `defaults.ts` without changing the prose fails it. - **This is hard to reverse in the direction that matters.** Giving the manifest real defaults is a one-line diff --git a/docs/adr/0021-an-unreadable-stored-history-fails-the-run.md b/docs/adr/0021-an-unreadable-stored-history-fails-the-run.md index 6bf1430..5752f33 100644 --- a/docs/adr/0021-an-unreadable-stored-history-fails-the-run.md +++ b/docs/adr/0021-an-unreadable-stored-history-fails-the-run.md @@ -13,7 +13,7 @@ the Badge and the CSV are all derived from a single observation and regenerated accumulates one Snapshot per Run and exists nowhere else. The reflexive way to read a file like that is `catch { return { snapshots: [] } }`, and the shape of -`src/infrastructure/persistence/storage.ts` invites it: `readJsonFile` takes a `fallback`, and both readers +[`src/infrastructure/persistence/storage.ts`](../../src/infrastructure/persistence/storage.ts) invites it: `readJsonFile` takes a `fallback`, and both readers pass one. For `readStargazers` the fallback is the whole answer, because an empty `StargazerMap` is exactly what "no file yet" means and rebuilding it costs one Run. For `readHistory` the fallback is deliberately scoped to *absence only*: it covers the first Run, when the file does not exist, and it is never reached by a diff --git a/docs/wiki/API-Reference.md b/docs/wiki/API-Reference.md index eea9faf..64531bb 100644 --- a/docs/wiki/API-Reference.md +++ b/docs/wiki/API-Reference.md @@ -257,9 +257,9 @@ To embed any of these, see **[Viewing Reports](Viewing-Reports#method-2-badges)* | File | Description | Always Present | |---|---|---| -| `README.md` | Markdown report with charts | Yes | +| [`README.md`](https://github.com/fbuireu/github-star-tracker/blob/main/README.md) | Markdown report with charts | Yes | | `stars-data.json` | Historical snapshots | Yes | -| `stars-badge.svg` | Star count badge | Yes | +| [`stars-badge.svg`](https://github.com/fbuireu/github-star-tracker/blob/main/examples/stars-badge.svg) | Star count badge | Yes | | `stars-data.csv` | CSV report with current star data | Yes | | `charts/star-history.svg` | Total star trend chart | After first run (when the repo has stargazers and `include-charts` is on) | | `charts/comparison.svg` | Top repos comparison | After first run (with multiple repos and `include-charts` on) | diff --git a/docs/wiki/Data-Management.md b/docs/wiki/Data-Management.md index 12cfbf8..a4bc8d0 100644 --- a/docs/wiki/Data-Management.md +++ b/docs/wiki/Data-Management.md @@ -21,10 +21,10 @@ The working directory for the branch is derived from the name: a dot followed by | File | Description | When Created | |---|---|---| -| `README.md` | Markdown report with embedded charts | Every run | +| [`README.md`](https://github.com/fbuireu/github-star-tracker/blob/main/README.md) | Markdown report with embedded charts | Every run | | `stars-data.json` | Historical snapshot data | Every run | | `stars-data.csv` | Flat per-repo export (`repository,owner,name,stars,previous,delta,status`) | Every run | -| `stars-badge.svg` | Star count badge | Every run | +| [`stars-badge.svg`](https://github.com/fbuireu/github-star-tracker/blob/main/examples/stars-badge.svg) | Star count badge | Every run | | `stargazers.json` | Stargazer login map | Only with `track-stargazers: true` | | `charts/star-history.svg` | Total stars chart | Charts on, once the charted series has at least 2 points | | `charts/comparison.svg` | Top repos comparison | Same condition, plus at least one top repository | @@ -135,7 +135,7 @@ this run if you want to keep them. Once that run pushes, they are gone; the data ### How Pruning Works -Pruning is a pure domain function (`addSnapshot()` in `src/domain/snapshot.ts`). It returns a new `History` object with the snapshot appended and old entries trimmed - no mutation, no side effects. +Pruning is a pure domain function (`addSnapshot()` in [`src/domain/snapshot.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/snapshot.ts)). It returns a new `History` object with the snapshot appended and old entries trimmed - no mutation, no side effects. The infrastructure layer (`writeHistory()`) only handles serialization to disk. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index ec42806..e1c30a2 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -63,10 +63,10 @@ After the first run: 1. Go to your repository's branch selector and look for `star-tracker-data` 2. Navigate to `https://github.com/YOUR_USER/YOUR_REPO/tree/star-tracker-data` 3. You should see: - - `README.md`, the full Markdown report + - [`README.md`](https://github.com/fbuireu/github-star-tracker/blob/main/README.md), the full Markdown report - `stars-data.json`, the historical data - `stars-data.csv`, the same run as a flat CSV - - `stars-badge.svg`, the star count badge + - [`stars-badge.svg`](https://github.com/fbuireu/github-star-tracker/blob/main/examples/stars-badge.svg), the star count badge - `charts/`, the SVG charts the report embeds ### If the Branch Never Appears diff --git a/docs/wiki/How-It-Works.md b/docs/wiki/How-It-Works.md index b190b0d..b83a3f9 100644 --- a/docs/wiki/How-It-Works.md +++ b/docs/wiki/How-It-Works.md @@ -89,7 +89,7 @@ Two edges in that diagram are easy to miss, and both matter: ### Entry Point -**File:** `src/index.ts` +**File:** [`src/index.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/index.ts) A two-line bootstrap delegating to the application orchestrator: @@ -100,13 +100,13 @@ trackStars(); ### Orchestrator -**File:** `src/application/tracker.ts` > `trackStars()` +**File:** [`src/application/tracker.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/application/tracker.ts) > `trackStars()` Coordinates all layers inside one `try`/`catch` that ends in `core.setFailed`: PAT extraction, Octokit instantiation, configuration loading, i18n bootstrap, and the full data pipeline. ### Configuration Resolution -**File:** `src/config/loader.ts` > `loadConfig()` +**File:** [`src/config/loader.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/config/loader.ts) > `loadConfig()` Configuration follows a **layered precedence model**: @@ -176,7 +176,7 @@ velocity_metrics: false ### Repository Enumeration -**File:** `src/infrastructure/github/client.ts` > `fetchRepos()` +**File:** [`src/infrastructure/github/client.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/infrastructure/github/client.ts) > `fetchRepos()` Queries `GET /user/repos` with pagination (`100` per page). The `visibility` config maps to API params: @@ -189,13 +189,13 @@ Queries `GET /user/repos` with pagination (`100` per page). The `visibility` con ### Data Transformation -**File:** `src/infrastructure/github/filters.ts` > `mapRepos()` +**File:** [`src/infrastructure/github/filters.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/infrastructure/github/filters.ts) > `mapRepos()` Transforms GitHub API objects into the domain `RepoInfo` schema, flattening `owner.login`, normalizing `stargazers_count` to `stars`, etc. This happens **before** filtering, so every filter below is expressed over the domain shape rather than GitHub's. ### Repository Filtering -**File:** `src/domain/tracked-set.ts` > `resolveTrackedSet()` +**File:** [`src/domain/tracked-set.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/tracked-set.ts) > `resolveTrackedSet()` Client-side filtering pipeline, in this order: @@ -217,7 +217,7 @@ What survives is the **Tracked Set**. The rules are pure and return counts rathe ### Data Branch Initialization -**File:** `src/infrastructure/git/worktree.ts` > `initializeDataBranch()` +**File:** [`src/infrastructure/git/worktree.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/infrastructure/git/worktree.ts) > `initializeDataBranch()` Creates or accesses a Git worktree for the data branch, isolating persistence from the source code checkout. @@ -253,14 +253,14 @@ Runs in a `finally` block, removing the worktree with `--force` regardless of su ## Phase 4: State Comparison The run does not call the steps below one by one. `measureRun()` -(`src/domain/measurement.ts`) composes baseline selection, diffing, snapshotting and the threshold check in +([`src/domain/measurement.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/measurement.ts)) composes baseline selection, diffing, snapshotting and the threshold check in the one order that is correct, and returns the whole measurement in a single value. The reasoning is [ADR 0013](https://github.com/fbuireu/github-star-tracker/blob/main/docs/adr/0013-a-run-is-measured-in-one-place.md). The sections that follow describe what it does inside. ### Baseline Selection -**File:** `src/domain/snapshot.ts` > `getBaselineSnapshot()` +**File:** [`src/domain/snapshot.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/snapshot.ts) > `getBaselineSnapshot()` Before any diffing happens, the Stored History is deserialized from `stars-data.json` and one snapshot is picked as the **baseline snapshot**. The `compare-against` input (config key `compare_against`) decides which one: @@ -296,7 +296,7 @@ it an average over a chart bucket whose width follows `chart-max-points`. ### Delta Computation -**File:** `src/domain/comparison.ts` > `compareStars()` +**File:** [`src/domain/comparison.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/comparison.ts) > `compareStars()` Pure function computing the diff between current repos and the selected baseline snapshot: @@ -330,7 +330,7 @@ limit, the run logs a warning naming how many it is about to drop and inviting y ## Phase 5: Stargazer Tracking -**Files:** `src/infrastructure/github/stargazers.ts`, `src/domain/stargazers.ts` +**Files:** [`src/infrastructure/github/stargazers.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/infrastructure/github/stargazers.ts), [`src/domain/stargazers.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/stargazers.ts) Stargazers are fetched whenever charts are enabled (`include-charts: true`, the default) **OR** `track-stargazers: true`, because the Reconstructed History behind the charts needs each star's `starred_at` date. @@ -349,7 +349,7 @@ repository whose list came back `incomplete` is skipped **silently**. Either way ## Phase 5b: Reconstructed History -**File:** `src/domain/star-history.ts` > `buildStarHistory()` +**File:** [`src/domain/star-history.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/star-history.ts) > `buildStarHistory()` When charts are enabled, `buildStarHistory()` turns the fetched stargazers' `starred_at` dates into a **Reconstructed History**: a cumulative `History` over real time, used by the charts and by the forecast. Each star is placed on the date it was actually given and the cumulative total is rebuilt from the repo's first star up to now, so a multi-point curve is available on the **first run**. It is rebuilt from scratch every run and never stored. @@ -363,7 +363,7 @@ Pair high-star repos with `smart-sampling` to keep within rate limits. ## Phase 6: Growth Forecast -**File:** `src/domain/forecast.ts` > `computeForecast()` +**File:** [`src/domain/forecast.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/forecast.ts) > `computeForecast()` Requires at least **3 points** (`MIN_SNAPSHOTS_FOR_FORECAST`). Projects **4 calendar weeks ahead** (`FORECAST_WEEKS`): "Week 1" means 7 real days after the latest point, "Week 4" means 28 days after it. When charts are enabled, the History passed to forecast generation is the aggregate **Reconstructed History**, not the Stored History, so the 3-point minimum refers to points in that reconstruction. @@ -387,7 +387,7 @@ Forecasts are computed for: ## Phase 6b: Growth Velocity -**File:** `src/domain/velocity.ts` > `computeVelocity()` +**File:** [`src/domain/velocity.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/velocity.ts) > `computeVelocity()` Opt-in via `velocity-metrics` (config key `velocity_metrics`), off by default. Where the forecast looks forward, velocity describes how fast the tracked set is moving **right now**, and it is the one section @@ -415,19 +415,19 @@ forecast heading, without one it is a top-level section of its own. ## Phase 7: Report Generation -**File:** `src/presentation/run.ts` > `renderRun()` +**File:** [`src/presentation/run.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/run.ts) > `renderRun()` The presentation layer's single entry point. It builds one `ReportModel` and hands the same one to both report dialects, then returns the Markdown, HTML, CSV, badge and chart files together. One model means both reports carry the same date and the same Top Repositories. ### Shared Data Preparation -**File:** `src/presentation/report-model.ts` > `buildReportModel()`, over `shared.ts` > `prepareReportData()` +**File:** [`src/presentation/report-model.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/report-model.ts) > `buildReportModel()`, over [`shared.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/shared.ts) > `prepareReportData()` Decides which sections a report has and what is in them: filters active/new/removed repos, sorts by stars, formats dates, and resolves the chart history, Velocity figures and Stargazer outcome once. ### Markdown Report -**File:** `src/presentation/markdown.ts` > `generateMarkdownReport()` +**File:** [`src/presentation/markdown.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/markdown.ts) > `generateMarkdownReport()` Produces GitHub Flavored Markdown with: @@ -441,17 +441,17 @@ Produces GitHub Flavored Markdown with: 8. Forecast section (growth velocity first, then aggregate table and collapsible per-repo tables) 9. Footer -**Output:** committed as `README.md` on the data branch. +**Output:** committed as [`README.md`](https://github.com/fbuireu/github-star-tracker/blob/main/README.md) on the data branch. ### HTML Report -**File:** `src/presentation/html.ts` > `generateHtmlReport()` +**File:** [`src/presentation/html.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/html.ts) > `generateHtmlReport()` Self-contained HTML with inline CSS for email compatibility. Uses QuickChart.io URLs for chart images (since SVGs with CSS animations aren't supported in email clients). No `
` elements (not supported in email). ### CSV Report -**File:** `src/presentation/csv.ts` > `generateCsvReport()` +**File:** [`src/presentation/csv.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/csv.ts) > `generateCsvReport()` Machine-readable CSV with one row per tracked repository. Columns: `repository`, `owner`, `name`, `stars`, `previous`, `delta`, `status`. Fields containing commas or double quotes are escaped per RFC 4180. @@ -462,7 +462,7 @@ Available as both a file on the data branch (`stars-data.csv`) and an action out ### SVG Charts -**File:** `src/presentation/svg-chart.ts` +**File:** [`src/presentation/svg-chart.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/svg-chart.ts) Generates self-contained animated SVG charts committed to `charts/` on the data branch: @@ -479,21 +479,21 @@ When charts are enabled, the History passed to chart generation is the **Reconst ### QuickChart URLs (HTML reports) -**File:** `src/presentation/chart.ts` +**File:** [`src/presentation/chart.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/chart.ts) Generates Chart.js configuration encoded as QuickChart.io URLs for embedding in HTML emails. Same chart types but rendered as static PNG images. ### SVG Badge -**File:** `src/presentation/badge.ts` > `generateBadge()` +**File:** [`src/presentation/badge.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/presentation/badge.ts) > `generateBadge()` -Creates a Shields.io-style SVG badge with the localized "Total Stars" label and a compact-formatted count (e.g. `1.5K`). Committed as `stars-badge.svg`. +Creates a Shields.io-style SVG badge with the localized "Total Stars" label and a compact-formatted count (e.g. `1.5K`). Committed as [`stars-badge.svg`](https://github.com/fbuireu/github-star-tracker/blob/main/examples/stars-badge.svg). --- ## Phase 8: Persistence & Commit -**File:** `src/infrastructure/persistence/storage.ts` +**File:** [`src/infrastructure/persistence/storage.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/infrastructure/persistence/storage.ts) | Function | File Written | |---|---| @@ -596,7 +596,7 @@ Because of that, "email me every 500 stars" is expressed as `notification-thresh ### Notification Threshold -**File:** `src/domain/notification.ts` > `shouldNotify()` +**File:** [`src/domain/notification.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/domain/notification.ts) > `shouldNotify()` Controls when notifications fire. A notification always requires that something actually changed: @@ -634,7 +634,7 @@ On a fresh data branch there is no stored baseline, so the first run fires immed ### Email -**File:** `src/infrastructure/notification/email.ts` +**File:** [`src/infrastructure/notification/email.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/infrastructure/notification/email.ts) - `getEmailConfig()` reads SMTP inputs; returns `null` if `smtp-host` is not set - `sendEmail()` uses `nodemailer` with auto-detected `secure` mode (port 465 = SSL, else STARTTLS) diff --git a/docs/wiki/Internationalization-(i18n).md b/docs/wiki/Internationalization-(i18n).md index 6250202..d30e21d 100644 --- a/docs/wiki/Internationalization-(i18n).md +++ b/docs/wiki/Internationalization-(i18n).md @@ -77,7 +77,7 @@ src/i18n/ ``` `index.ts` validates nothing. Checking that a configured `locale` is one of the four is the config loader's -job (`src/config/loader.ts`), which warns before this folder is ever reached. +job ([`src/config/loader.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/config/loader.ts)), which warns before this folder is ever reached. ### Translation Keys @@ -137,22 +137,22 @@ crashing. To contribute a new language: -1. Copy `src/i18n/en.json` to `src/i18n/{code}.json` +1. Copy [`src/i18n/en.json`](https://github.com/fbuireu/github-star-tracker/blob/main/src/i18n/en.json) to `src/i18n/{code}.json` 2. Translate all values (keys stay in English) 3. Keep `{placeholder}` tokens untranslated, spelled exactly as in `en.json` -4. Add the import in `src/i18n/index.ts` +4. Add the import in [`src/i18n/index.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/i18n/index.ts) 5. Add the locale and its Intl code to `LOCALE_MAP` in `src/i18n/index.ts` (`LOCALES` and the `Locale` type derive from it, so there is no second list to maintain) 6. Register the imported bundle in the `TRANSLATIONS` map in `src/i18n/index.ts` 7. Run `pnpm verify` to check everything passes -`src/i18n/types.ts` needs **no** change. `resolveJsonModule` is on, so the new `.json` bundle is type-checked +[`src/i18n/types.ts`](https://github.com/fbuireu/github-star-tracker/blob/main/src/i18n/types.ts) needs **no** change. `resolveJsonModule` is on, so the new `.json` bundle is type-checked against the existing `Translations` interface at compile time: a missing or mistyped key is a build error. Note that *extra* keys are silently accepted, because an imported module is not a fresh object literal and gets no excess-property check, which is why `pnpm typecheck` is the check that matters here. Three documentation edits belong in the same commit, none of them derived from the code: -- the `locale` input description in `action.yml`, which hard-codes the four locale names +- the `locale` input description in [`action.yml`](https://github.com/fbuireu/github-star-tracker/blob/main/action.yml), which hard-codes the four locale names - the **Supported Locales** and **Localized Email Subjects** tables on this page - the `locale` section of [Configuration](Configuration#locale) diff --git a/docs/wiki/Known-Limitations.md b/docs/wiki/Known-Limitations.md index 322fb83..248cd3e 100644 --- a/docs/wiki/Known-Limitations.md +++ b/docs/wiki/Known-Limitations.md @@ -167,7 +167,7 @@ The chrome is styled by CSS carried inside the SVG, which a `prefers-color-schem Set [`chart-theme`](Configuration#chart-theme) to `light` or `dark` explicitly. That drops the media query, picks the palette before rendering and recolours the series along with the chrome. What is not available is a single file that recolours its data per reader. -The badge (`stars-badge.svg`) has no theming at all, in either direction: it is always the light palette. Its fixed dark label with an accent-coloured value is legible on both backgrounds, which is why it was left alone. +The badge ([`stars-badge.svg`](https://github.com/fbuireu/github-star-tracker/blob/main/examples/stars-badge.svg)) has no theming at all, in either direction: it is always the light palette. Its fixed dark label with an accent-coloured value is legible on both backgrounds, which is why it was left alone. How the two palettes differ, and where the media query reaches at all, is in **[Star Trend Charts](Star-Trend-Charts#dark--light-mode)**. diff --git a/docs/wiki/Star-Trend-Charts.md b/docs/wiki/Star-Trend-Charts.md index 1bac893..e9678de 100644 --- a/docs/wiki/Star-Trend-Charts.md +++ b/docs/wiki/Star-Trend-Charts.md @@ -79,7 +79,7 @@ GitHub Star Tracker uses two complementary chart systems: | System | Format | Used In | Features | |---|---|---|---| -| **SVG Charts** | Animated SVG | Data branch `README.md` | CSS animations, self-contained, no external deps | +| **SVG Charts** | Animated SVG | Data branch [`README.md`](https://github.com/fbuireu/github-star-tracker/blob/main/README.md) | CSS animations, self-contained, no external deps | | **QuickChart URLs** | PNG via URL | HTML email reports | Compatible with email clients | ### Why Two Systems? @@ -212,7 +212,7 @@ Where the media query reaches: | HTML email | No. Gmail strips `