Foundational conventions for the Graft monorepo. Keep this short and current.
- All packages live under
packages/*and are named@usegraft/<name>. - Each package:
package.json,tsconfig.json(extends../../tsconfig.base.json), and asrc/directory withsrc/index.tsas the entry point. - Libraries build with
tsuptodist/(ESM +.d.ts). Apps (CLI, Studio) may differ. - Cross-package imports use the package name (
@usegraft/contracts), never relative paths across package boundaries.
- Git is authoritative for authored content. Postgres is a derived index; if they disagree, git wins and the compiler rebuilds.
- Operational data (orders, accounts, progress) is owned by Postgres, accessed only through typed functions.
- Validation is one Zod layer shared across schema, compiler, and functions.
- Destructive operations are always human-gated, regardless of approval policy.
- TypeScript 7 (native Go
tsc) for typechecking. Dual install until tooling catches up:@typescript/native→typescript@^7(CLItscforpnpm typecheck)typescript→@typescript/typescript6(JS API for tsup DTS / programmatic consumers)- Drop the dual install when tsup (and peers) support the TS 7 API (expected ~7.1 era).
stricteverywhere (seetsconfig.base.json). Target ES2022, ESM,moduleResolution: Bundler.- Prefer explicit return types on exported functions.
- Errors thrown across boundaries are
GraftError(from@usegraft/contracts) and must carry afix.
| Command | What it does |
|---|---|
pnpm build |
Build all packages (Turborepo) |
pnpm dev |
Watch-build all packages |
pnpm test |
Run all package tests (Vitest) |
pnpm lint |
oxlint across packages |
pnpm typecheck |
tsc --noEmit across packages |
pnpm format / pnpm format:check |
oxfmt write / check |
pnpm changeset |
Describe a change for the next release |
pnpm check:canary-snapshot |
Guard: refuse a canary that is not a snapshot |
pnpm lint runs oxlint once from the repo root, so .oxlintrc.json is the only
lint config that applies. (Running oxlint . inside a package silently uses oxlint's
defaults and ignores this file — do not add per-package lint scripts.)
On top of oxlint's defaults we vendor the anti-slop
plugin at tools/oxlint/anti-slop/. Its 15 rules are calibrated rather than switched on
wholesale, because three of them contradict what this codebase does for a living:
| Rule | Setting | Why |
|---|---|---|
no-unsafe-dictionary-type |
off | Record<string, unknown> is the correct type for a JSONB column and for authored frontmatter. The shape is per-collection and enforced by Zod at the boundary, not by the column type. |
no-runtime-typeof |
off | The rule says "parse at the I/O boundary and branch on the domain value". Our typeof checks are the boundary parse — reading a JWT claim that may be a string or an array, or sniffing untyped frontmatter. |
no-unknown-parameters |
off | catch (e: unknown) is correct TypeScript, and our error helpers take unknown by design. |
require-safety-comment-for-type-assertion |
warn | 378 assertions is a real signal worth watching, but a SAFETY: comment on each is churn, not safety. Ratchet the count down; do not bulk-annotate. |
no-conditional-empty-object-spread, no-known-value-widening, no-shape-in-symbol-names |
warn | Style, not correctness. |
| everything else | error | These catch real defects: chained assertions that lie to the type checker, and module mocking that stubs out the thing under test. |
The plugin is TypeScript, and oxlint loads it through Node's ESM loader. Node
strips types by default from 22.18; the repo's floor is 22.16 (node:sqlite
bundles FTS5 from there), so on 22.16–22.17 pnpm lint needs
NODE_OPTIONS=--experimental-strip-types. CI sets it. The symptom without it is
ERR_UNKNOWN_FILE_EXTENSION ".ts", which reads like a config error and is not.
Never silence a rule to get a green run. Either fix the finding, or change the setting here and write down why.
- Unit tests (pure logic, no network/DB) run in
pnpm testand CI — keep them deterministic and fast. - Integration tests (live DB / R2) are opt-in and skipped by default. Name them
*.integration.test.ts, gate them behindRUN_INTEGRATION=1, and load.envin-file. Run locally with PowerShell:$env:RUN_INTEGRATION='1'; pnpm --filter <pkg> test.
mainis the default branch and the public face of the repository. It is merged fromfeat/coreat each release, and nothing lands on it directly.feat/coreis the working mainline. Every change lands there, and it is the only branch that publishes to npm.- Feature branches are cut from
feat/coreand merge back into it. - Branch naming:
preview/<short-topic>for content/feature branches (these map to copy-on-write DB preview branches from Phase 4). - Commits: Conventional Commits (
feat:,fix:,chore:,docs:,refactor:,test:). - Do not commit
.env,dist/, ornode_modules/.
Two channels. Both publish from .github/workflows/release.yml, because npm
trusted publishing registers one workflow filename per package and a second
workflow would authenticate against nothing.
changesets/action runs on push to feat/core. It opens a
"chore: version packages" PR, and merging that PR publishes. Both gates
belong to the repository owner.
All packages bump together: .changeset/config.json sets
fixed: [["@usegraft/*"]], so every package versions whether it changed or not.
pnpm changeset # describe a change
pnpm changeset status # what the next release would bumpCI on the version PR shows action_required and does not run, because GitHub
holds workflows on PRs the changesets bot opens. Approve it, or rely on CI
having passed on feat/core for the identical code.
Snapshot releases: 0.0.0-canary-<timestamp>, published under the canary
dist-tag. latest never moves and real version numbers never change. There is
no canary branch and none is needed.
- Branch off
feat/core, build the change. pnpm changeset. Required — with nothing pending there is nothing to snapshot and the run is refused.- Push the branch.
- Actions → Release → Run workflow → select your branch → tick "Publish a snapshot to the canary dist-tag".
npm i @usegraft/cli@canary
The tick resets every dispatch. Unticked is the safe default: a normal release, which is an idempotent no-op when nothing is pending.
Never commit a snapshot bump. changeset version --snapshot consumes the
changeset files as it runs, so committing it would drop the pending changesets
for the real release.
scripts/assert-canary-snapshot.mjs runs between versioning and publishing
because changeset version --snapshot does not fail when nothing is pending —
it warns and exits 0, and the publish would then push a stable version to
the canary tag. Run it locally with pnpm check:canary-snapshot.
-
Create
packages/<name>/withpackage.json(@usegraft/<name>),tsconfig.json,src/index.ts. -
Copy the build/test/typecheck/lint scripts from an existing package.
-
pnpm installto register it in the workspace. -
Publish it once by hand, before CI ever tries. OIDC trusted publishing needs the package to already exist on npm — there is nothing to hold the trusted-publisher config on until then, so the first release fails with E404 while everything that depends on it publishes fine.
npm login pnpm --filter @usegraft/<name> publish --access public --no-git-checks
pnpm, nevernpm:npm publishleavesworkspace:*in the manifest, which npm cannot resolve. pnpm rewrites it to the real version. -
Add its trusted publisher on npmjs.com (GitHub Actions,
AndersonDesign1/graft,release.yml) so it releases with everything else from then on.
Skipping 4 and 5 is what left npm i @usegraft/cli broken after the 0.2.0
release: 15 packages published, @usegraft/mdx-safety did not, and five of the
15 depended on it.