Guidance for Claude Code in this repository, kept in the spirit of compounding engineering
(Every): every
review, bug and correction becomes a rule, and each rule links the change holding its full story.
Durable rules live here; pending work lives in .claude/scratch/ (untracked).
- Don't commit, push, open a PR or comment on one unless asked. Leave changes in the working tree, and when a branch is wanted, say which commits are on it.
- A change to
packages/*carries a changeset, unasked (#15); a visitor-visibleapps/docschange carries one on the server (#86). See Changesets. - Run
pnpm check-allbefore handing work over: it is every check CI runs. - Redraw
apps/docs/public/cardswithpnpm docs:cardswhen a card's output changes (#225). - Never hand-edit
packages/core/src/graphql/generated/**; change the.graphqland regenerate. - Keep the rule files true: fix a rule in the change that broke it, unasked.
A pnpm monorepo: the library that renders GitHub stats as SVG cards, the catalog of what each card accepts, a CLI, an HTTP server, and the documentation site.
| Path | What it is |
|---|---|
packages/core |
The library: fetchers, card renderers, themes, api handlers |
packages/catalog |
What each card accepts: every option's param, label, kind and values |
packages/cli |
github-stats-forge: prompts for a card's options, writes the SVG |
apps/server |
The HTTP server over core's api handlers; an image on GHCR, not npm |
apps/docs |
The site, Astro + Starlight; not published |
scripts/ |
Repo tooling such as check-all.ts, via tsconfig.scripts.json |
What a thing publishes to decides its rules, not its folder (#79):
packages/*go to npm and carry a changeset and alint:publish;apps/servercarries a changeset only, releasing with the npm packages;apps/docscarries neither.build:packagesandlint:publishstay filtered to./packages/*. The roottypecheckcovers the packages, both apps andscripts/; the root vitest the packages andapps/server.scripts/has no tests.privatePackages: { version: true, tag: true }plus aversiononapps/server/package.jsonreleases the server (#133);apps/docshas noversion, on purpose.tag: trueonly adds the git tag and release: changesets never publishes aprivatepackage.updateInternalDependencies: patchbumps a core release's dependents, so a card change reaches the image unnamed. Confirm withpnpm exec changeset status.
A rule about one directory lives in that directory (#106). Each AGENTS.md has a one-line
CLAUDE.md importing it, since Claude Code discovers only CLAUDE.md.
| File | Covers |
|---|---|
packages/core/AGENTS.md |
The api layer as trust boundary; generated GraphQL types |
packages/core/src/cards/AGENTS.md |
Card branding, text and translations |
packages/catalog/AGENTS.md |
The catalog |
packages/cli/AGENTS.md |
The CLI |
apps/server/AGENTS.md |
The server and its image |
apps/docs/AGENTS.md |
The site |
apps/docs/src/anvil/AGENTS.md |
The anvil and its browser tests |
packages/core/srcisfetchers/(network) →cards/(render) →api/(query-string handlers), pluscommon/,themes/andgraphql/.- A card is a folder under
cards/:index.tsrenders,locales.tstranslates; onlycards/options.tssits loose (#61). A card too big for one file moves its layouts besideindex.ts, which keeps the options and the render, astop-languages/does. - Every source file is TypeScript except
packages/cli/bin.js; generators are typechecked through atsconfig.scripts.json(#39).
- The first line is a conventional commit with no scope (
feat: …,fix!: …): it lands in the changelog under the package's name (#78). The body is one or two lines a consumer must act on, or none. - Pre-1.0, a breaking change is
minor(#15). A dependent package needs no entry of its own. - A visitor-visible
apps/docschange is a server changeset (#86): the image ships the site. - A tooling change that leaves the published output alone may go without one: ask the owner.
Run from the repo root.
pnpm test # vitest, whole workspace, one run (`pnpm exec vitest` watches)
pnpm test:coverage # the same run, with the coverage config
pnpm typecheck # build, then tsc over the packages and scripts/
pnpm lint # oxlint over the whole workspace
pnpm lint:ci # oxlint --format=github (what CI runs, for annotations)
pnpm lint:fix # oxlint --fix
pnpm lint:knip # unused files/exports/deps
pnpm lint:publish # attw + publint in each package — guards what gets published
pnpm format # oxfmt --write . (`format:check` in CI)
pnpm build:packages # build packages/*
pnpm cli --help # build, then run the CLI (add any of its flags)
pnpm server:standalone # the HTTP server against the root .env, watching for edits; cards only
pnpm server:hosted # build the site as the image does, then the server with the site attached
pnpm run docs # build, then the docs site's dev server (`run` is required — see below)
pnpm docs:build # build the docs site (what CI runs)
pnpm docs:cards # build, then redraw the docs site's card previews
pnpm docs:cards:check # build, then fail if a committed preview is out of date
pnpm docs:e2e # build the site, then run the anvil's browser tests
pnpm check-all # every check CI runs, cheapest first, in one command
pnpm test:live # draw every card from the live APIs; needs PAT_1 in the root .envAfter touching any src/graphql/queries/*.graphql:
pnpm --filter ./packages/core/ run generate-graphql-types # rewrite src/graphql/generated
pnpm --filter ./packages/core/ run check-graphql-types # what CI runs; fails on driftpnpm run docsneeds therun:docsis a pnpm builtin, and barepnpm docsexits 0 (#58).check-allstops at the first failure;-kruns them all. Keepscripts/check-all.tsin step with.github/workflows/ci.yml.- One package:
pnpm exec tsc -p tsconfig.typecheck.json, orpnpm exec vitestinside it. - A lint script is per-package only when the tool is (#42): oxlint runs once from the root;
lint:publishchecks one tarball, so the root runs it throughpnpm -r --filter "./packages/*". - Ordering is
pnpm -r's topological order;typecheckbuilds first because eachtsconfig.typecheck.jsonresolves core through its.d.ts. turbo cost more than it saved (#41); measure before reinstating one. - vitest applies
@stats/sourceonly when it is also underssr.resolve.conditions; seepackages/cli/vitest.config.ts.
- A token never appears in a command the docs tell someone to run (#107): it lands in shell
history and
docker inspect. Show--env-file cards.env,-e PAT_1, a secret manager or--pat "$token", and link the self-hosting page's "Where the token lives". pnpm docs:cardsdrawsapps/docs/cardsthrough the built CLI against the fictional account inapps/docs/scripts/fixtures.ts(#225): no token, deterministic, anddocs:cards:checkfails CI on drift. The fixtures are typed as their queries' results, so a.graphqlchange failspnpm typecheckuntil they follow.- Prose never states a count that grows with the code (#220); only generated pages may.
- Never call the person reading the docs "the reader" (#30): say the browser for the user
agent, and you, anyone or a visitor for a person.
screen readerstays. pnpm test:livealone touches the live APIs, and is not in CI (#226). It draws each card with the anvil'smaximaloptions (accounts overridable withLIVE_TEST_*) and asserts only that it drew, since live numbers drift..github/workflows/test-live.ymlruns it weekly, on demand, and on a push tomaintouchingfetchers/,graphql/or the test, with thetest-liveenvironment'sLIVE_TEST_PAT. It stays off PRs, where it hit the rate limit (#254), and out ofci.yml, so an outage fails no PR.- That secret is a PAT, as the docs advise: classic with
read:org, or fine-grained owned by the organization withMembers: read.GITHUB_TOKENcannot draw the gist or org cards.
- Every untracked working file lives in
.claude/scratch/, the rule files excepted.
TypeScript is 7.x, with oxlint-tsgolint on the same major. The strict flags are in
tsconfig.base.json; the lint-enforced style (Array<T>, _-prefixed unused args,
@ts-expect-error <description>, no nullish values in template literals) is in
@marcalexiei/oxlint-config/typescript.
- A relative import names the
.tsfile (#42), throughallowImportingTsExtensionsandrewriteRelativeImportExtensions. Only the.jsemit is rewritten: the.d.tsstill says./x.ts, so don't fix it by hand, and keeplint:publish, which guards consumers against it. The GraphQL generator sets the extension twice:importExtensionand theprependliteral inpackages/core/scripts/generate-graphql-types.ts. - Never widen a signature to keep a test compiling; adapt the test. Type a function to what its
production call sites pass;
?: T | undefinedorPartial<…>only for a real caller forwardingundefined. - Declare only the params you read, and validate nothing you don't use.
- A card's options are
interface XCardOptions extends CommonCardOptionsin itsindex.ts, unexported.cards/options.tsholds only the shared base andCardOptions<T>;ThemeNamelives inthemes/index.ts.CommonCardOptionsextendsColorParams: every card forwards intogetLightDarkColors. - What one card exports and another imports moves to
common/; ask first. - One export style per module: named or default, never both (#39).
- A function that branches gets a block body with an early
returnper outcome, never a ternary expression body, and never a nested ternary. Conditional types are exempt. - An untyped dependency gets a
declare moduleshim (tests/_css-to-object.d.ts); a runtime dependency's lives undersrc/. - A dependency whose body is one literal is inlined (#31): read a small package before adding it, and if copying costs a line and the licence allows it, copy it.
- Emit is opt-in:
tsconfig.base.jsonsetsnoEmit, and only each package's identicaltsconfig.build.jsonemits. Don't repeatnoEmit, and put nooutDiron a config that cannot emit. - The build configs set
noCheck, so a green build proves nothing. - Each
tsconfig.typecheck.jsononly clearscustomConditions, resolving siblings through their.d.tsas a consumer would.apps/docschecks against core's source and has none. - Wrong build output? Check what the editor's
tsconfig.jsonemits first: a strayoutDironce wrote tests intobuild/. - No project references, so no
composite: true. - Strict-mode friction:
noUncheckedIndexedAccessneeds a guard or a?? '';noPropertyAccessFromIndexSignaturereads aRecordby brackets;exactOptionalPropertyTypestakes?: T | undefinedfor a realundefined, elseNonNullable<…>.
The rules oxlint already enforces (the .spec.ts suffix, and the vitest rules switched off) carry
their reasons in oxlint.config.ts.
- A test that can pass without the code it covers is a bug (#287): break the code and watch it
fail.
toHaveTextContentmatches a substring, so give itexactly(text)unless the assertion is about part of a longer text, such as a<desc>; an accepted query assertsupstream, since a rejection is an error too. - Restore what a test changes in an
afterEach(#287): timers, spies,vi.stubEnv, amkdtempdir. Never on its last line, which a failure skips. - Card specs read the card through
tests/_card.ts, fetcher specs their requests throughtests/_fetch-mock.ts(#288); extend them rather than copy them. - A param's validation is tested once, in
apiParams.spec.ts. srcnever imports a test helper or library;no-restricted-importsenforces it (#288).- Assert with jest-dom matchers, never by reading the node.
expect(queryBy…()).toBeDefined()cannot fail; count withtoHaveLengthonqueryAllBy*. - Card tests assert on the DOM, not snapshots, bar
renderWakatimeCard.spec.ts.snap. Snapshots are byte-exact, so diff them after any card edit, and hoist a${cond ? a : b}out of a renderer's template literal, which the formatter may reflow into the SVG. - A test that provokes a diagnostic silences it with
vi.spyOn(console, …), saying why (#79);--disable-console-interceptshows what the suite prints. - A test needing an upstream failure answers one; it never drops the request: an
errorsbody for GitHub, a502for WakaTime. A rejected fetch,networkError()or aFetchMockwith no handler is a transport failure, which the retryer waits out for 4.1s; when the transport failure is the subject, use fake timers, asretryer.spec.tsdoes. packages/corerunspool: 'vmThreads';isolate: falsebreaks thefetchTopLanguageserror tests (#122), andfsModuleCachegained nothing. Re-measure before revisiting either.- The type test is
fetchRepo.types.spec-d.ts, checked bytsc; vitest would needtypecheck.include(#132). - Use
@testing-library/dom'sscreen, caching a repeated query.querySelectorbeatsgetElementsByClassName; type itquerySelector<HTMLElement>when you need.style. Mock withvi.mock(import('…'), factory). jest-dom registers inpackages/core/tests/_setup.ts. - Type every piece of test data (#288), including a mock's reply body (
GraphQLBody<Query>,GraphQLErrorBody,Refused,SearchBody) and avi.fn()'s resolved value, throughvi.fn<typeof fn>()or asatisfies. ARecordread by dot usessatisfies. Never cast a fake throughunknown. - A fixture two specs share lives in an
_module, never a spec (#288): vitest runs an imported spec's tests twice.
A comment states the reason, never the behaviour, usually in one line.
- A
@fileblock comes first, after any shebang (#79). - No
@paramtags;@returnsstays (#39). A doc block is a summary, then@returns, plus@see,@exampleor@deprecatedwhere earned. - A note on one parameter or property is a
/** */on that declaration, shown on hover, not a tag or a//comment; domain interfaces (CardColors, …) document their members that way. - Keep what the type cannot say: a range, ordering, unit, fallback, format or side effect.
- When stripping tags mechanically, check for an orphaned continuation line.
require-returns-typeandrequire-returnsstay off: the signature carries the type, and tags everywhere is a bigger decision than a rule.
oxlint (type-aware, via oxlint-tsgolint) and oxfmt replaced eslint and prettier (#39), extending
@marcalexiei/oxlint-config and @marcalexiei/oxfmt-config from the sibling void-0-configs.
- Fix a wrong shared config there, not here.
- Every local override carries its reason in
oxlint.config.ts; prefer a rule option to switching a rule off. import/no-cyclehas no exemptions: untangle the cycle.- The autofixer is not a review: typecheck, test and read the diff after
oxlint --fix.
Renovate runs self-hosted from .github/workflows/renovate.yml, configured by
.github/renovate.json5 (#114); its customManagers also move the docs' version pins.
- Self-hosted for
postUpgradeTasks: its one allowed command,.github/scripts/renovate-changeset.sh, writes apatchchangeset for adependenciesbump inpackages/*, the only bump that reaches a consumer. - The workflow's cron fires; the config's
schedulegates, so per-manager windows are day-wide. - Dev dependency and Actions minors, patches, pins and digests automerge on a green
CI OK(#137); majors anddependenciesstay manual. A laterpackageRulesentry wins, so the held-back rules sit last. - Check the config with
renovate-config-validatoronly:RENOVATE_PLATFORM=localreverts tracked files and deletes untracked ones, so run it on a copy.
pnpm is 12.x (#110); pnpm-workspace.yaml gives each setting's reason, and rejects an unknown key.
- Two renames fail silently: v11's
onlyBuiltDependencies→allowBuilds, and v12's--frozen-lockfile false→--no-frozen-lockfile. allowBuildsand the overrides list only packages still in the graph: check withpnpm why <pkg>, since nothing polices an override (#80).- A stale lockfile can hold an orphan: delete
node_modulesandpnpm-lock.yaml, then reinstall; deleting the lockfile alone reuses what is installed. minimumReleaseAgeexempts the first-party configs alone, by name (#216).