Skip to content

Guarantee formatting and linting with a pre-commit hook - #159

Merged
abernier merged 20 commits into
mainfrom
husky
Aug 9, 2026
Merged

Guarantee formatting and linting with a pre-commit hook#159
abernier merged 20 commits into
mainfrom
husky

Conversation

@abernier

@abernier abernier commented Aug 9, 2026

Copy link
Copy Markdown
Member

Adds husky + lint-staged, so formatting and linting are guaranteed at commit time rather than left to scripts nobody runs.

What runs on commit

staged task
every staged file prettier --write --ignore-unknown
pnpm lint
turbo.json, test/**, vitest.config.ts pnpm test:turbo

And on push, pnpm check — the four tasks CI gates on, before CI runs them. It reads the whole repo (format:check alone is ~10s), so it is worth paying once per push and not once per commit; nothing new since the last push is FULL TURBO (~0.1s).

pnpm test deliberately runs in neither: turbo test is 326 playwright tasks that a repo-wide change invalidates wholesale (266s in CI for the reformat commits, against 93s cached), and the snapshots are not time-deterministic — a hook that runs them refuses commits at random, and the habit it teaches is git commit -n.

pnpm lint is now the one entry point for every linter — turbo lint lint:metadata lint:versions. The two root scripts become //# tasks in turbo.json with explicit inputs, so what needs re-running is a question turbo answers from the task graph rather than one the hook guesses with globs of its own: a commit that moved nothing lint-relevant is ~0.5s (FULL TURBO) instead of ~4.5s, and touching one pmndrs.json re-runs the metadata check while syncpack replays from cache.

Verified on the cache: a pmndrs.json edit misses metadata and hits syncpack; a .syncpackrc.json or pnpm-workspace.yaml edit does the opposite; docs-only touches hit everything. Failures are never cached — a broken pmndrs.json re-executes and fails on every run until fixed.

--concurrent false in the hook, plus one ordered task array: pnpm lint reads the files prettier may have just rewritten.

What CI gates on

CI ran lint:metadata and lint:versions directly. It now runs pnpm check — the same four tasks — which brings eslint and a repo-wide prettier --check under CI for the first time. The hook is the fast path in front of that gate, not the only thing enforcing it. CI also runs pnpm test:turbo.

format:check becomes //#format:check, with $TURBO_DEFAULT$ for its inputs rather than an explicit **: ** also hashes what git ignores, so the .turbo/ logs written by the run itself changed the hash every time — a task that never hit its own cache, and took ~26s hashing node_modules on the way.

One config, one style

The examples carried 160 .prettierrc (plus two prettier keys in a package.json) for 23 distinct variants of the same intent — among them a printWidth of 16078 and a fluid option prettier has never had. All of them are gone: prettier.config.mjs is now the only prettier config in the repo, and every file in it is formatted the same way.

That is 805 files reformatted to the repo style. Keeping the sandbox look (160 cols, no semicolons, single quotes) as an examples/** override would have cost 428 files instead — but it only ever bought a look, and once every example had been reformatted anyway, byte-identity with the sandboxes they were copied from was gone regardless.

One file, examples/svg-maps-with-html-annotations/src/index.jsx, needs two prettier passes to converge; it is committed converged.

What prettier must not touch

.prettierignore is new. The compiled payloads in it are picked by a measurement — max line length — because prettier claims a parser for all of them and --ignore-unknown therefore does not help:

**/draco*/            # minified, 200k-character lines
**/realism-effects/   # vendored bundles, 87k-character lines
**/*.gltf             # 3D scenes; prettier parses them as json

Three further entries are there for reasons of their own:

.claude/              # local agent checkouts, plus a vendored skill that has
                      # to stay byte-identical to what its registry emits
pnpm-lock.yaml        # generated
patches/              # `format:check` runs without `--ignore-unknown`, so a
                      # `.patch` would error rather than be skipped

.glb, .blob, .svg and .splinecode need no entry — prettier has no parser for them. Build outputs and .turbo/ need none either: they are gitignored, and prettier reads .gitignore as well. Font atlases are deliberately not ignored: they are json, and 94% of the catch-up diff is two of them being expanded from a single 800k-character line.

A test for the cache

test/turbo-cache.test.ts (vitest, 10 cases, 12s) pins which file makes which task re-run: build for one example, eslint, the metadata and syncpack checks, format:check. It runs turbo with --dry, so asserting on a build costs a hash rather than a vite build. Sabotaging an inputs list in turbo.json fails exactly the cases that name it — including the hash-stability case, which is there because inputs: ["**"] hashed gitignored files (the run's own .turbo/ logs) and the task could never hit its own cache.

Catch-up

Three commits format the repo so the hook starts from zero drift: 349 files to each example's own .prettierrc, then 428 and 805 more as those 160 configs were unified away. prettier --check over every tracked file now passes.

Also here

  • examples/lulaby-city/package.json: drops a vestigial lint-staged block + devDependency inherited from its CodeSandbox template. Nothing ran it, but a lint-staged key makes that directory its own config root — staged files under it would have escaped the root config entirely.

prepare: husky installs the hook on pnpm install. Bypass with git commit -n.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK

abernier and others added 13 commits August 9, 2026 10:14
Runs prettier over staged files and, when a manifest moves, the two
repo-wide checks CI already runs (pmndrs.json metadata + syncpack).

examples/* are left alone: each is a vendored sandbox with its own
prettier config that it isn't formatted to, so reformatting one on
commit would bury the change under a whole-file diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
Rather than calling eslint directly: which workspaces have a linter stays
a property of the task graph, and turbo's cache makes the check ~0.4s
when nothing lint-relevant moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
It came from the CodeSandbox template and nothing ran it (the example has
no hook of its own). With a real lint-staged at the root it stopped being
inert: a `lint-staged` key makes that directory its own config root, so
staged files under it would be formatted by the example's sandbox prettier
settings instead of being left alone like every other example.

Removing the devDependency also undoes the version bump it forced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
They are root scripts, so `//#lint:metadata` and `//#lint:versions` with
explicit inputs -- turbo then knows a docs-only commit needs neither, and
the pre-commit hook drops the manifest glob it was using as a poor man's
cache. `pnpm lint` becomes the one entry point for every linter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
The file is schema'd as plain JSON; the rationale for the `//#` tasks and
their explicit inputs lives in the previous commit message instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
pnpm 11 keeps `overrides` there (this repo pins postprocessing through it)
and syncpack reads them, so a change to that file has to invalidate the
check -- without it turbo replayed a cache hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
The hook was skipping all of `examples/`, including the 544 .ts/.tsx/.md
that `pnpm format` does maintain there (3 of them non-conformant, so they
really are kept in shape). Only the vendored .js/.jsx/.css need the
exemption. `.prettierignore` is the wrong lever for this: it would take
those 544 away from `pnpm format` too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
Same behaviour, named after what it actually is: the format script's glob,
as a predicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
The narrower rule restated the format script's glob in a second place with
nothing keeping the two in sync -- widen `format` and the hook silently
stays behind. What it bought was small: 3 of the 544 .ts/.tsx/.md under
examples/ drift today, and no CI job checks formatting anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
Formatting is the hook's job to guarantee; `pnpm format` was never invoked
by anything. The exemption moves from a filter over `examples/` to
`.prettierignore`, restricted to what a measurement (max line length) shows
to be compiled: minified draco, the vendored realism-effects bundles, and
`.gltf` scenes -- prettier parses those as json/babel, so --ignore-unknown
does not cover them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
One-off catch-up, so the hook starts from zero drift: from here on it keeps
every commit formatted. Each example is formatted to its own `.prettierrc`,
so a sandbox stays in the style it was written in.

94% of the added lines are two three.js font atlases stored as a single
800k-character line; the rest is ~350 sources at a handful of lines each.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
The examples carried 160 `.prettierrc` (plus two `prettier` keys in a
package.json) for 23 distinct variants of the same intent -- and among them
a `printWidth` of 16078 and a `fluid` option prettier has never had. They
are replaced by a single `examples/**` override in `prettier.config.mjs`,
holding the majority values of that set, so the demos keep the style they
were written in and there is one place left to change it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
@abernier abernier changed the title Add a husky + lint-staged pre-commit hook Guarantee formatting and linting with a pre-commit hook Aug 9, 2026
abernier and others added 7 commits August 9, 2026 11:14
The override only ever bought a look -- 160 columns, no semicolons, single
quotes -- and once every example had been reformatted anyway, byte-identity
with the sandboxes they were copied from was gone regardless. One style is
one less thing to know.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
The hook formats what it is given, which leaves `git commit -n` and anything
that wrote outside a commit. `prettier --check .` covers that, but it reads
every file, so any commit would invalidate it -- at ~9s it does not belong in
the hook. It becomes `//#format:check`, and CI runs `pnpm check` (the four
tasks) instead of the two it ran directly.

`$TURBO_DEFAULT$` for the inputs, not `**`: an explicit `**` also hashes what
git ignores, so `.turbo/` logs written by the run itself changed the hash
every time -- a task that never hit its own cache, and took ~26s hashing
node_modules on the way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
A task whose `inputs` are too wide re-runs on every commit; one that is too
narrow replays a stale pass over code that did change. Both are silent, and
the second is the dangerous one. The suite pins, per task, which file must
make it re-run and which must not -- build for one example, eslint, the
metadata and syncpack checks, and format:check -- plus the hash-stability
case that `inputs: ["**"]` used to break.

`turbo --dry` throughout, so asserting on a build costs a hash rather than a
vite build: 10 cases in 12s. Sabotaging an inputs list in turbo.json fails
exactly the cases that name it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
12s, and turbo cannot cache it -- it asserts on cache behaviour -- so it runs
only when `turbo.json`, the suite, or its config is staged, which is the only
way its meaning changes. CI keeps running it against everything else.

`pnpm test` stays out: 326 playwright tasks that a repo-wide change
invalidates wholesale (266s in CI for the reformat commits, against 93s
cached), and snapshots that are not time-deterministic would block commits at
random.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
`pnpm check` reads the whole repo -- `format:check` alone is ~10s -- so it
cannot go in the pre-commit hook, where every commit would invalidate it and
where lint-staged fixes the staged files rather than refusing them. Once per
push is where it costs what it is worth: the same four tasks CI runs, before
CI runs them, and FULL TURBO when nothing moved since the last push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
The comment still gave the reason from before `prettier.config.mjs` became
the only config: that each example carries its own `.prettierrc` and so is
kept in the style it was written in. It is the one comment a reader consults
to learn why `examples/` is not excluded, and it named a rule this branch
deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
`out/` and `.turbo/` are in `.gitignore`, and prettier reads that too, so
neither entry ever fired -- and no tracked file lives under either. Verified
by checking a deliberately unformatted probe in both with the entries
removed: still skipped.

What is left needs its own reason, so it gets one: `pnpm-lock.yaml` is
generated, and `patches/` is there because `format:check` runs without
`--ignore-unknown`, where a `.patch` errors rather than being skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vtf9zptihDq57ThnNMVfUK
@abernier
abernier merged commit 3f913a0 into main Aug 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant