@turbobase/code-standards
v0.1.0
Published
One code standard for TypeScript, React and Svelte projects: oxlint profiles with type-aware rules, the oxfmt house format, a strict tsconfig, knip, jscpd, actionlint and shellcheck, behind one check that only lets a baseline shrink.
Maintainers
Readme
@turbobase/code-standards
One code standard for a TypeScript project — React, Svelte or Node — and one command that enforces it:
npx code-standards checkIt catches the mistakes that compile and still break: a promise nobody waited
for, a React hook called after an early return, a duplicated object key, a
switch that forgot a case, dead exports, copied blocks. Formatting is done by
a tool, so nobody spends time on style.
Code that already exists is recorded in a baseline that can only shrink, so you can adopt the standard on a large project today and the check passes on the day it lands.
What it runs
| Check | Tool | What fails |
|---|---|---|
| Format | oxfmt | any JS, TS or Svelte file that is not in the house format |
| Lint | oxlint, with type-aware rules | a finding over the baseline |
| Disable comments | built in | an oxlint-disable… or eslint-disable… comment without -- reason |
| Unused code | knip | a new unused file, dependency or export |
| Duplicates | jscpd | a new copied block inside one unit |
| Workflows | actionlint | a mistake in .github/workflows/*.yml |
| Shell | shellcheck | a mistake in a *.sh file |
Markdown, YAML and JSON files are never reformatted.
Set it up
Install it as a dev dependency:
npm install --save-dev @turbobase/code-standardsAdd these files at the root of your project.
code-standards.json — what the check covers:
{
"ignore": ["src/types.ts"],
"units": []
}| Key | Default | Meaning |
|---|---|---|
| ignore | [] | globs no check reads (generated code, vendored files) |
| units | [] | globs naming independent parts of the project; duplicates are counted inside each one and never between two, e.g. ["apps/*"] |
| baseline | code-standards.baseline.json | where the baseline lives |
| typeAware | true | run the type-aware lint rules (needs a tsconfig.json) |
| knip | true | look for unused files, dependencies and exports |
| duplicates | true | look for copied blocks |
| workflows | true | run actionlint over .github/workflows/ |
| shell | true | run shellcheck over *.sh |
A file whose first line contains GENERATED or @generated is skipped by
every check.
.oxlintrc.json — the lint profiles, base first:
{
"extends": [
"./node_modules/@turbobase/code-standards/oxlint/react.json",
"./node_modules/@turbobase/code-standards/oxlint/test.json"
]
}| Profile | For |
|---|---|
| base.json | every project; every other profile includes it |
| node.json | scripts and servers |
| react.json | React apps — rules-of-hooks, exhaustive-deps, jsx-key and more |
| svelte.json | Svelte apps |
| test.json | test files: no .skip, no .only, no commented-out tests |
Your own rules and ignorePatterns go in the same file.
oxfmt.config.ts — the house format, in one line:
export { default } from '@turbobase/code-standards/oxfmt';Tabs, semicolons, single quotes, 120 columns, trailing commas everywhere.
Formatting a .svelte file needs svelte installed in your project.
tsconfig.json — optional, the strict base. The type-aware rules run on
TypeScript's next compiler, which refuses a tsconfig that sets baseUrl:
remove it (paths resolve relative to the tsconfig without it). A tsconfig
the type-aware rules cannot use fails check with exit 2, naming the file
and the reason, rather than passing with those rules off.
{
"extends": "@turbobase/code-standards/tsconfig/strict.json",
"include": ["src"]
}knip reads your own knip.json if you have one; otherwise it uses the
package's defaults. Either way oxfmt.config.ts is never reported as an unused
file: the formatter reads it and nothing imports it.
package.json scripts:
{
"scripts": {
"check": "code-standards check",
"fix": "code-standards fix"
}
}Adopt it on an existing project
npx code-standards fix --format-only # 1. format everything, nothing else
git commit -am "Format with code-standards"
git rev-parse HEAD >> .git-blame-ignore-revs
npx code-standards fix # 2. apply the safe lint fixes
git commit -am "Apply code-standards lint fixes"
npx code-standards baseline # 3. record what is left
git add code-standards.baseline.json .git-blame-ignore-revs
git commit -m "Record the code-standards baseline"
npx code-standards check # 4. passesKeep the format commit on its own and list it in .git-blame-ignore-revs, so
git blame skips it. The lint fixes change code, so they stay out of it and
git blame keeps them.
The commands
code-standards check [--root <dir>] [--baseline <file>] [--base-ref <ref>]
code-standards fix [--root <dir>] [--files <path…>] [--format-only]
code-standards baseline [--root <dir>] [--output <file>]check runs everything and exits:
| Exit | Meaning |
|---|---|
| 0 | nothing beyond the baseline |
| 1 | a new finding, an unformatted file, or a baseline that grew |
| 2 | the check could not run: no code-standards.json, no baseline, no files matched, a tool failed, or the type-aware rules could not use a tsconfig.json |
A check that could not run never passes.
Output names the file, the line, the rule and the offending line of code.
fix formats every file and applies every safe lint fix, and repeats
both until a pass changes nothing — one fix can expose the next — so a second
fix never finds work the first one left. If files still change after three
passes it stops with exit 1 and names them. Run check afterwards for what
is left.
fix --files <path…> does the same for the named files only, which is what
an editor hook wants after each save. Paths are relative to the root. A file the
check would not read — ignored, generated, git-ignored, not a JS, TS or Svelte
file, or outside the root — is skipped, and a call that names only such files
exits 0.
fix --format-only runs the formatter and applies no lint fix. Use it for
the reformat commit you list in .git-blame-ignore-revs, so that commit holds
whitespace and quotes only and the lint fixes land in a commit of their own.
It combines with --files.
baseline writes today's findings to the baseline. It refuses while any
file is unformatted (formatting is never baselined) and while any tool fails.
The baseline only shrinks
The baseline counts findings per file and rule. check fails when a file has
more findings of a rule than its baseline allows, and tells you when the
baseline can shrink.
In a pull request, check also compares the committed baseline with the base
branch's copy (--base-ref, or origin/$GITHUB_BASE_REF automatically on
GitHub Actions) and fails if any rule's total grew. Fix the new finding instead
of re-running baseline. Renaming a file does not count as growth.
The base branch must be fetched: use actions/checkout with fetch-depth: 0.
In CI
standards:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx code-standards checkDo not put a paths: filter or an if: on the job: a skipped required check
blocks pull requests.
actionlint and shellcheck
Both are single binaries downloaded from their upstream releases on first use,
checked against SHA-256 sums pinned in this package before they are unpacked,
and cached in ~/.cache/turbobase-code-standards (or $CODE_STANDARDS_CACHE).
A checksum that does not match deletes the download and fails the check.
Pinned builds exist for Linux and macOS on x64 and arm64. If your network
cannot reach the upstream release host, set CODE_STANDARDS_TOOL_MIRROR to a
host serving the same files; the pinned checksums still decide.
Rules kept off on purpose
Non-null assertions, explicit return types, magic numbers, file length and
complexity caps, no-await-in-loop, no-console, sorted keys, required JSDoc
and no-invalid-fetch-options. They are either too noisy to be worth a gate or
wrong for code that runs steps one after another on purpose.
unicorn/no-useless-spread is off because its automatic fix is wrong for a
string: it turns [...text.slice(0, n)].length (characters) into
text.slice(0, n).length (UTF-16 units) and [...hex.slice(1)].map(f) into
hex.slice(1).map(f), which throws. fix applies
it without asking, so the rule cannot be on.
Versions
A new blocking rule, or any change to the house format, is a major version.
See CHANGELOG.md.
License
MIT
