Development
The build, the gates, the conventions the tooling enforces, and why the tests never need an API key.
git clone <this repo> && cd handbooks
pnpm install
pnpm build
pnpm testRequires Node ≥ 20.11 and pnpm ≥ 9. No native compilation.
Everyday commands
pnpm build # tsc -b (composite project references)
pnpm build:watch
pnpm test # build + vitest
pnpm test:watch
pnpm check # the everyday gate — run this before committing
pnpm check:all # check + packaging + install + CLI smoke — what CI runs
pnpm check:cli # every subcommand and config layer, end to end, offlinepnpm check runs, in order:
typecheck— sources, then the tests againsttsconfig.tests.jsoncheck:workspace— the monorepo's structural invariantslint— eslint over the whole repo, zero warnings toleratedformat:check— prettiertest:coverage— vitest with per-package coverage floors
It is deliberately the fast one. pnpm check:all adds three heavier gates —
check:packaging (publint + are-the-types-wrong), check:install (pack eleven tarballs,
install them with plain npm, drive the CLI) and check:cli (below) — which belong in CI
and before a release rather than in every local loop.
What check:cli covers
scripts/smoke-cli.sh drives the real binary end to end against the bundled mock
LLM, asserting on exit codes and artifacts across every subcommand, every configuration
layer and — most importantly — the refusals.
- Every
--helpsurface, and an unknown subcommand exiting1 configprovenance,--json, and--checkexiting2on a missing required value- Invalid enum / integer / phase values exiting
1rather than falling through to a default - The generation matrix: phase subsets,
--resume,--detail deep,--synth-mode doctor,--llm-cache,--narrate-lang zh - Every render format, and
renderon an empty work dir failing skillrefusing an--outthat would eat its own input;validateexiting2applyrefusing an ambiguous anchor and a path escape; a realrollbackrestoring byte for byteresyncwith and without an LLM, and an empty diff skipping cleanly- Precedence: shell env over config file,
.env.<name>overhandbook.config.<name>.yaml, scoped over flat, empty-as-unset, and the API key masked inconfigoutput - Artifact sanity: every expected file present, card coverage complete, no unassigned files, token usage recorded
The unit tests mock generateHandbook and its neighbours, so they cannot catch a flag
that resolves correctly and is then never passed on, a wrong exit code, or an artifact
contract that broke at the seam. This can — and it is entirely offline, so it is safe in
CI.
pnpm check:cli
SMOKE_PORT=9123 pnpm check:cli # if port 8123 is takenA pre-commit hook runs the formatter and linter over staged files only, and
commit-msg enforces Conventional Commits.
Testing philosophy
Everything runs offline. No test ever needs an API key.
- LLM-dependent flows are tested against
MockChatClient— a list of rules, first match wins — and against a bundled mock HTTP endpoint for the real client. - Deterministic packages are tested directly. Analyzer tests build real mini-repos in temp directories and assert on real nodes and edges; a mocked parse tree would prove nothing about a grammar.
- Failure paths get the same attention as happy paths: unparseable replies, partial batches, degradation tiers, mid-run aborts, sandbox escapes, ambiguous anchors.
pnpm test # everything
pnpm exec vitest run packages/analyzer # one package
pnpm exec vitest run -t "dropped calls" # one test by name
pnpm test:coverageFour conventions the tooling enforces
Versions live in one place
Every third-party version is declared in pnpm-workspace.yaml's catalog; packages depend
on "catalog:" and never restate a range. A literal range in a manifest fails
pnpm check:workspace, and so does an unused catalog entry.
{ "dependencies": { "zod": "catalog:" } }pnpm rewrites catalog: to the resolved range when packing, so consumers never see the
protocol.
dist/ is the published surface
Build projects exclude *.test.ts and *.test-helper.ts; tsconfig.tests.json
type-checks tests with noEmit. Source maps are excluded from the tarball because they
name sources that are never published. A test artifact under dist/ fails the check.
Coverage floors are per package
A single repo-wide number hides what matters: at 86% overall, @handbooks/cli sits at 23%.
Each package has its own floor in vitest.config.ts, set just under what it measures, so
it ratchets.
If your change raises coverage, raise the floor with it. Do not widen the gap to make a red run pass.
Tests resolve @handbooks/* to source, not dist
Otherwise coverage of anything consumed across a package boundary is attributed nowhere —
core/src/util/hash.ts measured 0% while the pipeline called it on every run.
The real dist is verified by tsc -b and by pnpm check:install, which installs the
packed tarballs with plain npm and drives the CLI against them. That is a stronger check
on dist than a unit test was.
The structural invariants
scripts/check-workspace.mjs enforces seven rules, each of which the repo violated at
least once:
- TypeScript project references mirror workspace dependencies exactly.
- Workspace dependencies use the
workspace:protocol and actually exist. - The root solution file references every package.
- Build projects exclude tests, and
dist/contains none. - Manifest shape is uniform —
type,description,license,files,engines,exports,scripts,publishConfig. - A publishable package never depends on a private one.
- Third-party versions live in the catalog and nowhere else.
Generated files
Three files are generated from the settings registry and compared byte for byte by a drift test:
pnpm run config:docs
# writes .env.example
# docs/content/docs/reference/configuration.md
# handbook.config.example.yamlHand-editing any of them fails the build. Change the registry instead
(packages/core/src/config/registry.ts) and regenerate.
The same drift test also checks that both READMEs name every registered language and reference no nonexistent pnpm script, and that every relative link in them points at a git-tracked file.
The documentation site
cd docs
pnpm install
pnpm dev # → http://localhost:3000Next.js + Fumadocs, MDX content under docs/content/docs/. It is not part of the pnpm
workspace, so a root pnpm install ignores it entirely.
Diagrams live in assets/ at the repo root — both READMEs reference them from there — and
are copied into docs/public/diagrams/ at build time by docs/scripts/sync-generated.mjs.
Do not hand-copy them; the copy is gitignored for exactly that reason.
Commit conventions
Conventional Commits, enforced by commitlint:
feat(analyzer): add a Kotlin generic-tier spec
fix(patcher): refuse an anchor that matches zero times
docs(cli): document the --env cascade
chore(deps): bump vitestChanges that affect a published package need a changeset:
pnpm changesetCommit that file with the code. See Releasing.
Where things live
packages/<name>/src/ source
packages/<name>/src/*.test.ts tests, colocated
scripts/ repo tooling (workspace checks, doc generation, smoke tests)
examples/ the offline demo, the mock LLM server, the fixture project
assets/ diagrams referenced by both READMEs
docs/ the documentation site (a standalone Next.js app)
docs/internal/ the engineering journal — LOCAL ONLY, gitignored