What a reviewer — human or @claude — should look for in a pull request here.
CI gates build, bundlesize, check, prettier, skills, syncpack, and test (see .github/workflows/ci.yml), runs an informational benchmark job alongside them, and pnpm test enforces 100% coverage. This file deliberately covers only what those jobs cannot catch. Don't ask a reviewer to re-run a machine. If something here becomes mechanically enforced, delete it.
Two facts set the stakes: both packages are published to npm and depended on by other people's builds, and this repo takes outside contributions. A mistake in the public API or the published artifact ships to strangers, and a mistake in CI privileges is exploitable by a pull request.
The highest-stakes surface in the repo. Types are the product here as much as the runtime is.
- A newly-nameable internal type must be exported. Per
CONTRIBUTING.md: a type that appears named in a consumer's generated.d.tsunderdeclaration: truebreaks their build with aTS4023/TS2459-family error if it isn't exported fromsrc/index.ts— whilecva's own build stays green. Thedescribe("exported types", …)anddescribe("CVAVariantShape", …)blocks inpackages/cva/src/index.test.tspin the existing portability exports (CVAComponent,CVAComponentShape,CVAVariantShape), so losing one failspnpm --filter cva check:tsc. Nothing detects a newly required one. If a PR reshapes a public signature, ask for the packed-artifact verification documented inAGENTS.md—pnpm pack(nevernpm pack), extract, compile a real consumer with--module nodenext. - Not everything reachable needs exporting — TypeScript structurally expands some helpers instead of naming them. Export the minimum a real consumer build fails without, mark it with the same "you shouldn't really use it directly" JSDoc the existing three carry, and don't add docs-site coverage for it.
- Inference can widen silently and still compile.
pnpm checkonly provestscis happy. WhetherVariantProps<typeof x>still resolves to a literal union rather thanstring, orgetSchemastill names every variant, is pinned only by theexpectTypeOf(…).toEqualTypeOf<…>()assertions in the test files. Treat a loosened assertion (toEqualTypeOf→toMatchTypeOf), a deleted one, or a deleted@ts-expect-erroras part of the diff to review, not as test housekeeping. - The awkward generics are load-bearing, and each says why in a comment.
ComposedTuplesplits inference across two type parameters so an array literal stays a tuple instead of collapsing to a union (which silently drops structurally-subtyped components).RightMergeis a mapped type rather thanOmit<A, keyof B> & Bbecause the latter stays deferred and breaksany-narrowing downstream.DefaultsOfnormalisesundefinedto{}becausekeyof neverisstring | number | symboland poisons the key union.CVAComponentShapetakesanyarguments on purpose — a shapedconfigrejects every real component via props contravariance. A PR that "simplifies" any of these should demonstrate the documented failure no longer applies.
cva's composition rules are written twice — once in the types, once in defineConfig — and nothing cross-checks them. A change to one side typechecks fine and usually still passes the existing tests.
- Local
defaultVariantsbeat composed ones. Type side: theOmit<MergedDefaultVariants<…>, keyof CVADefaultVariants<Config>> & CVADefaultVariants<Config>in theCVAinterface. Runtime side: the last spread intomergedDefaultVariants. Same rule, two places. - Variants merge one level deep so overlapping keys union their values (
mergeVariantsat runtime,MergedVariants/UnionToIntersectionin the types). Check merge depth and direction on both sides. - Internal
_-prefixed variants are filtered in three places:InternalVariantKeyinVariantProps, the key remap in theGetSchemainterface, and thekey.startsWith("_")guard ingetSchema's implementation. All three, or none. - Prop normalisation is subtle and looks like dead weight:
definedPropsWithoutClassdrops an explicitundefinedso it falls back to the default;falsyToStringmapsfalse→"false"and0→"0";getSchemaround-trips numeric keys throughString(n) === vso"01"and" 1"stay strings. Each has an observable consequence for consumers. emptyClassNamesis a module-level array shared across every non-composed call, as an allocation optimisation. Anything that mutates a class-name array in place would corrupt every later call.
packages/class-variance-authority(0.7.x) is stable and in maintenance mode;packages/cva(1.0.0-beta.x) is where features go. Flag feature work that lands in the stable package, and flag a beta change mirrored into stable by reflex —AGENTS.mdis explicit that the two are intentionally separate and a change to one does not imply a change to the other.cva@betais not covered by semver and may change without warning.class-variance-authorityis, and has the large installed base — a behaviour change there needs to be a bug fix, not a redesign.- The two have independent release lines and dist-tags:
cvaparks npmlatestat0.0.0and publishes tobeta;class-variance-authoritypublishes tolatestand has nobetatag (seePACKAGE_BASELINESintest/bench/scripts/baselines.ts). Anything assuming one repository tag identifies both packages is wrong.
CI's build job runs tsdown's publint/attw/unused gates, so a genuinely broken publish shape fails on its own. What it can't see:
- A hand-edit to
exportsorpublishConfig.exportsthat happens to be valid. Both blocks are regenerated on every build (exports: { devExports: true }in.config/tsdown.base.mts), so the edit is silently reverted by the next build and the intent is lost. A diff to either block with no matching change to that package'stsdown.config.mtsor the shared base is the tell. class-variance-authority'spublishConfig.typesVersions— the node10 fallback for its./typessubpath — is the one hand-maintained field in those manifests. tsdown preserves it but doesn't generate it, so a deletion never comes back.- The published packages deliberately omit
engines.nodeso they don't constrain consumers (CONTRIBUTING.md). syncpack treats the field as optional and only snaps a declared one to the root, so an added"24"sails through — still wrong for a published package.
bundlesizeenforces the numbers, not the choice of numbers. Thesize-limitblocks capcvaat1.6KBandclass-variance-authorityat1.2KB. A PR that raises its own limit to fit gets a green job — that is a review decision, weighed against the "performance & minimal footprint" project goal.clsxis the only runtime dependency of either package. Anything added todependenciesunderpackages/*is a headline change, not a detail.pnpm-workspace.yamlcarries two supply-chain protections that a PR can quietly weaken:minimumReleaseAge, and the explicitallowBuildsallowlist (adding a package there grants it install-time script execution). Removing or loweringminimumReleaseAgeto work around anERR_PNPM_MISSING_TIMEis the specific anti-fix called out inAGENTS.md— the right answer is to re-run the install.- Dependabot is configured for
github-actionsonly (.github/dependabot.yml), so npm bumps arrive by hand and carry no automated provenance.
The split between trusted and untrusted CI here is deliberate and documented, and a well-meaning workflow change can undo it without failing anything.
claude.yml's two guards are load-bearing and nothing tests them. the actor gate (github.actorandgithub.triggering_actor, both== 'joe-bell') is the only thing standing between a public commenter and a job holdingcontents: write;include_comments_by_actoris the only thing keeping attacker-authored comment text out of Claude's input. Both are one line in a file CI will happily keep green after either is deleted. Treat a diff that widens, moves, or removes either as a permissions change, not a config tweak.claude.yml's checkout must stay pinned toref: ${{ github.event.repository.default_branch }}. Deleting that line does not fall back to the base branch — forpull_request_reviewandpull_request_review_commentGitHub sets the triggering ref torefs/pull/<n>/merge, so the checkout would take the contributor's tree and the./.github/actions/installstep below would execute theiraction.ymlin a job holdingcontents: write. The actor gate is no defence here: the attack is a maintainer mentioning@claudein a review on a hostile PR. Any localuses: ./…action in a privileged workflow inherits whatever that checkout produced.- Pinning the checkout is not the whole story:
claude-code-actionputs the PR head in the workspace anyway. It restores only a fixed list of Claude config paths from the base branch —.claude/,.mcp.json,.claude.json,.gitmodules,.ripgreprc,CLAUDE.md,CLAUDE.local.md,.husky/. Everything else, includingscripts/,.github/,package.jsonand the lockfile, is the contributor's. Two consequences this repo has already had to close, and which any new repo tooling can silently reopen:- A trusted hook that execs an untrusted path.
.claude/settings.json'sSessionStarthook is restored from base, but it used to execscripts/setup-worktree.sh, which is not..claude/hooks/session-start.shnow returns early whenGITHUB_ACTIONS=true. Any hook,apiKeyHelper, orstatusLinethat shells out to a repo-relative path, a package-manager script, or amaketarget is the same bug — keep them self-contained, as the action's own security guidance says. core.hooksPath.pnpm irunsprepare:hooks, pointing git at.github/hooks, whosepre-commitsourcesscripts/setup-node.sh. After the PR head lands in the workspace both are contributor-controlled, so any commit Claude makes would execute them.claude.ymlunsetscore.hooksPathfor that job; removing that step re-arms it.
- A trusted hook that execs an untrusted path.
ci.ymlruns contributor code and holdscontents: readonly.pr-comment.ymlholdspull-requests: writeand lives onworkflow_runprecisely so it never checks out or executes PR code — which is what makes benchmark comments work for fork PRs at all. Any new job that combines a write permission with running contributor code breaks that, as does apull_request_targettrigger.- Every
actions/checkoutin the repo setspersist-credentials: false, and every third-party action is SHA-pinned with a version comment — that pinning is what makes the Dependabot config meaningful. An unpinned@v4-style ref, or a checkout that keeps credentials, is a regression. One exception, and it is the only one: the outer checkout inclaude.ymlomits it, becauseclaude-code-actionpushes Claude's branches and is documented to need a working git remote. The inner checkout inside./.github/actions/installstill sets it. If that exception ever stops being needed, close it rather than widening the rule. - Artifacts are hostile input.
test/bench/scripts/compare.tsvalidates schema and escapes strings before anything is rendered as markdown, and.github/scripts/process-pr-comment.mjscross-checks the artifact's claimed PR against bothhead_shaand the head repository. A new PR-comment section that skips either check is exploitable; the header of.github/workflows/pr-comment.ymlspells out the full contract for adding one. - Benchmark numbers are informational and unauthenticated. A regression doesn't fail CI, and the renderer validates the artifact's shape without authenticating its metrics — a PR author can upload fabricated ops/s from their own branch (
CONTRIBUTING.mdsays so explicitly). Reproduce a large swing locally withpnpm bench:previewrather than taking the comment as proof either way. - The
benchmarkjob writes an Actions job summary only onpush. Restoring it for pull requests would let PR code — which ran earlier on the same runner — influence a trusted summary. test/bench/scripts/baselines.tsinstalls published baselines withpnpm add --ignore-scripts, into a directory outside the repo. Both details are load-bearing: the flag stops npm lifecycle scripts running in the untrusted job, and the location stops the workspaceoverridessilently resolving baselines back to local source.pr-comment.ymlcannot be exercised by the PR that changes it — GitHub always runs aworkflow_runworkflow as it exists on the default branch. Changes to it are unreviewable by CI and need the scratch-fork verification described inAGENTS.md. Review it accordingly: this is the file where CI green means least.
100% thresholds cover packages/*/src, test/bench/scripts, and .github/scripts, so missing tests fail on their own. Two ways to pass without being covered:
- A new
v8 ignorecomment. The sanctioned set is theisMainModule()entry guards plus one unreachable fallback inpr-comment.mjs.AGENTS.mdsays to lower that glob'sbranchesthreshold instead of adding ignores, so a new one is always a review item. - Code outside
coverage.include. Vitest only measures matching files, so a new tooling directory that isn't added tocoverage.includein.config/vitest.config.ts, with a matching threshold, is invisible rather than failing. - Bench and CI scripts stay import-safe via
isMainModule()entry guards and injectedexecImpl/fetchImplparameters rather thanvi.mock. Avi.mockin a new script is a smell — it means the script isn't structured the way the rest are.
docs/src/components/stackblitz.astrohardcodesbranch = "main"and builds both the GitHub link and the StackBlitz iframe from itsdir/fileprops. Renaming or moving anything underexamples/**, or changing a filename an.mdxpage points at, silently 404s a live embed on cva.style. Nothing in CI resolves those URLs — grep the docs for the old path.- A green
build:examplesis not evidence the embed works. In-repo,pnpm-workspace.yaml'soverridespincvaandclass-variance-authoritytoworkspace:*, so the examples build against local source no matter what theirpackage.jsondeclares. StackBlitz opens the example directory in isolation and installs the publishedbeta/latestversions instead. The two can diverge, and only the published side is what visitors see. - The docs are versioned, and the split matters. Stable content lives at
docs/src/content/docs/**; beta content lives underdocs/src/content/docs/beta/**with its own sidebar indocs/src/content/versions/beta.json. Apackages/cvachange documented in the stable tree ships beta behaviour to stable users; a new beta page missing frombeta.jsonis unreachable from the sidebar. - Docs build watch paths live in the Cloudflare dashboard, not in the repo (see Deployment). A PR that makes the docs build depend on a new root-level input won't trigger a redeploy until those paths are updated — flag it rather than assuming it's wired.
- Prose:
// =>output comments are claims about real behaviour and should be verified, not assumed. Markdown is never hard-wrapped — Prettier runs withproseWrap: "preserve", soprettier --checkpasses on hard wraps and commits them as noisy diffs. Content underdocs/src/content/docs/**also follows thewriting-guidelineshouse style (US English, no em/en-dash punctuation, preserved author voice in the FAQs and What's New pages).
- Agent-config mirrors drift silently.
.claude/skills/<name>must stay a relative symlink into.agents/skills/;.vscode/mcp.json(schema:servers) and thecontext_serversblock in.zed/settings.jsonare hand-mirrored and must change in the same commit as.mcp.json.AGENTS.mdstates outright that none of this is checked — a real file committed under.claude/skills/looks fine to both git andpnpm lint:skills. - A skill is instructions an agent will follow. Review an added or updated
SKILL.mdand itsreferences/the way you'd review a dependency, and never let a changedsourceinskills-lock.jsonpass silently. Vendored skill files are Prettier-ignored so the committed bytes match the recorded hash — a formatting diff there means something rewrote them. - No version bumps in a PR. The
versionfield inpackages/*/package.jsonchanges only onmain, as the owner's own commit (Releases). Nothing stops one landing on a feature branch, even when a PR is titled as a release. - This repository is public. No private repository names, URLs, or file paths in code, docs, commit messages, or PR text — ported work is described neutrally.
AGENTS.mdmoves with the change. A PR that establishes or changes a convention, or that cost someone a wrong turn worth warning about, should updateAGENTS.mdin the same commit. A guidance entry that the PR makes wrong should be deleted in that same commit — stale guidance is worse than none.- The PR title and body are the squash-merge commit. Commits follow Conventional Commits and changelogs are generated from them, so the title needs the right type and scope, and the body needs to describe the final diff rather than the first push.