Code standards
Every repository is checked by one toolchain, configured once at its root through the
preset @stealthscale/tool-config provides. What follows is what that configuration
enforces. A package never carries a rule of its own that a root rule already covers.
The toolchain
| Tool | Used for |
|---|---|
| bun 1.4 | Installing, running scripts, the workspace and the catalog |
Vite+ (vp) |
vp check formats, lints and type-checks; vp test; vp pack; vp run for tasks |
| oxfmt | Formatting, through vp fmt |
| oxlint | Linting, type-aware, through vp lint |
| TypeScript 7 | The compiler, native; declarations are emitted by tsgo at pack time |
| Vitest | Specs in Node and jsdom, and every story in Chromium |
| tsdown | Packing a library: per-file ESM, declarations, the exports map |
| changesets | Versioning and changelogs |
vp check --fix before a commit; vp run ci is what CI runs, and it is the same task on a
developer’s machine.
Formatting
100 columns, single quotes, no semicolons, manifests sorted. The formatter does not wrap
comments or prose: a docblock line and a Markdown line are wrapped by hand at 100 columns.
Generated output is neither formatted nor linted: dist/, coverage/, *.gen.*, compiled
catalogues.
Lint
oxlint’s correctness, suspicious, perf and pedantic categories are errors. The
typescript, unicorn, oxc, import and promise plugins run everywhere; react and
jsx-a11y run where things render; Node’s rules run under tools/. Taste is left to the
formatter.
Size is a proxy for whether a thing does one thing:
| Limit | Value |
|---|---|
| lines per file | 300, blank lines and comments not counted |
| lines per function | 60; a spec’s describe and a story’s render are exempt |
| cyclomatic complexity | 10 |
| nesting depth | 4 |
| parameters | 4 |
| lines per story file | 600 |
Beyond oxlint’s own rules, two plugins are load-bearing. eslint-plugin-jsdoc holds every
declaration, exported or not, to a typed multi-line docblock, as Docblocks
describes. eslint-plugin-perfectionist
sorts imports, exports, object keys, interface members, JSX props and union members
alphabetically, with a blank line starting a new block, so a diff shows a change rather than
a reordering; a generator’s manifest literal is the one place sorting is off.
Rules that stand for a security decision, on in every repository:
no-restricted-propertiesoninnerHTML,outerHTML,insertAdjacentHTMLanddocument.cookie: markup is rendered, never assigned, and cookies belong to the package that owns authentication.no-script-url,react/jsx-no-target-blank,react/no-danger: a package that has to render untrusted text sanitises first and argues for its exception in its own override.no-restricted-importswith the ownership table: a wrapped dependency has exactly one importer, and everything else reaches for that package.react/forbid-elementson every intrinsic element in the composing trees: one layer writes markup, the primitives, and everything above it composes that layer, which is what makes a theme, a density setting or a focus ring apply everywhere at once.
Types
strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride,
noImplicitReturns, noFallthroughCasesInSwitch, noUnusedLocals, noUnusedParameters,
erasableSyntaxOnly, verbatimModuleSyntax, isolatedModules, allowImportingTsExtensions,
moduleDetection: force; target and lib es2023, module esnext, resolution bundler,
customConditions: ["stealth-source"]. A package extends the root’s tsconfig.base.json and
adds nothing but its include. A package’s tsconfig never includes its vite.config.ts, or
the declaration build writes files beside the config’s imports.
A package is checked against its dependencies’ source, not their dist: no build sits
between an edit and vp check, a spec or a story. Generated code is still written first, by
vp run -r codegen, and never committed.
Tests
A spec sits beside every source, named after it: thing.ts and thing.spec.ts. Exempt by
what they are: a barrel (index.ts), a declaration, a generated file, a story, a fixtures
file, a bin/ entry that only parses arguments, a Vite or Playwright config. Exempt by path
only with a reason written beside the exemption.
| Spec | Runs in | Because |
|---|---|---|
*.spec.ts |
Node | It renders nothing |
*.spec.tsx |
jsdom | It renders |
*.stories.tsx |
Chromium | Its play function and its accessibility check need a browser |
Never a .spec.ts and a .spec.tsx of the same basename beside each other: the type-aware
linter resolves the second without the workspace condition.
Coverage is on by default and the floor is 100% of statements, branches, functions and lines per file. What sits outside the floor is listed in the root config with the reason beside each entry: a composition root the e2e suite covers, a service’s entry the suite starts, codegen the build runs. A green suite is not a review: coverage says a line ran, not that it behaved, so a spec asserts the outcome a caller sees.
A spec on the real workspace never pins a count or a list of what the tree holds today. It
states the rule and derives the expectation from the tree; a scratch workspace in a temporary
directory may pin, because the spec wrote it. Test data is realistic: names from more than
one locale, amounts with a currency, identifiers that look like identifiers; never foo,
Item 1 or lorem ipsum.
Dependencies
- A version is declared once, in the root catalog; a package writes
catalog:and a siblingworkspace:^. - Nothing published in the last three days is installed;
bunfig.tomlsets the window, and CI installs with a frozen lockfile, so it is unaffected. - A lifecycle script runs only for a package listed in
trustedDependencies. - A third-party dependency is declared by exactly one package, which wraps it; a spec’s shared fixtures are declared once, in the root’s devDependencies.
bun auditruns first in CI and fails on any known advisory.
The CI order
vp run ci is one task, and every repository’s workflow calls the toolchain’s reusable one:
bun install --frozen-lockfileandbun audit: the tree is the one that was reviewed.vp run -r codegen, in a repository that generates: what the build reads is written first, and a protobuf module is held tobuf breakingagainst whatmainserves.vp run -r build: every package packs, in dependency order, and its declarations resolve.vp checkandvp test: the format, the lint findings and the type errors in one pass, then every specification at the coverage floor.- The catalogue build, then every e2e suite against the built artifact, where they exist.
Every step fails closed. A landing is green at every step, which is what the gates say, not a box in a list.
Packing and releasing
A library is packed by vp pack: per-file ESM under dist/, declarations from tsgo, and the
exports map written back into the manifest. Every pack is checked the way a registry and a
consumer read it: publint reads the manifest and arethetypeswrong resolves the declarations,
under the esm-only profile, with a stylesheet export left out of the type check because it
is not a module.
A change to a published package carries a changeset file. On main, changesets turns the
pending files into one version pull request; when it merges, every package whose version is
not on the registry yet is packed with bun pm pack and published with npm publish and
provenance from the job’s identity token, in dependency order, halting at the first failure.
No long-lived token anywhere; trusted publishing is configured per package on npmjs.com,
and the repository is public, which provenance requires. Before a release, a smoke test
publishes the release set to a registry it starts, installs consumers from it and builds
them.