A commit gate for TypeScript projects.
It owns the invocation of ESLint, react-doctor, fallow and Stryker, and nothing else: which tool runs, in what order, scoped to what, with what ceiling on resources. It does not reimplement them, does not proxy their commands, and never decides what a finding means. The verdict is their exit code, passed through untouched.
$ dharness sync dharness 1.5.0 · sync · ~/projects/your-app js project repository root package manager yarn test runner vitest owned files .dharness/ ■ 11 steps · 5 applied · 3 delegated · 3 satisfied · 0 failed 6.76s ── Applied (5) ── ✓ 1/11 install what this project is missing 6.73s │ ➤ YN0013: │ 33 packages were added to the project. │ installed [email protected] ~ package.json ~ yarn.lock ✓ 2/11 write the files dharness owns 0.03s + .dharness/lefthook.yml + .dharness/fallow.jsonc + .dharness/eslint.config.js + .dharness/rules.json ✓ 3/11 point .fallowrc.json at the file dharness owns 0.00s ✓ 6/11 point eslint.config.js at the file dharness owns 0.00s ✓ 8/11 give the agent fallow’s own tools 0.00s ── Left to you (3) ── ! 9/11 wire the gate into git nothing answers: there is no lefthook config, no .husky/ and no lefthook binary. Choosing a hook manager is a decision this project has not made, and not a default dharness gets to pick. … steps 10, 11 and the satisfied three elided … ✓ 5 applied · 3 delegated · 3 satisfied · 0 failed exit 0 next wire the gate into gitESLint runs first because it was measured cheapest of the four: it resolves from the install your project already has, so it skips the package-manager round trip the other stages pay on every run. react-doctor follows, scoped to the staged change with `--staged`, so its cost tracks the diff rather than the repository. fallow goes last because it builds the repository graph either way, which gives it a floor a median on one project does not capture. Ordering by ascending cost is not a style preference — a failure in an earlier stage skips every stage behind it, and on a gate that runs at every commit that cut is most of the saving.
<staged files>Lint over the exact staged list, resolved locally
It runs from the version your project installed, against the explicit file list rather than a glob. Local resolution is most of why it is the cheapest of the four, and it reads the config dharness owns — which already carries the preset your framework recommends.
--staged --no-dead-codeReact semantics, over the staged change only
Bounded by what is actually being committed, and told not to answer the dead-code question so it never duplicates the stage after it. Its cost follows the size of the diff, which is the only stage here for which that is true.
audit --changed-since HEAD --diff-stdinThe repository graph, judged on what the change introduces
The staged diff is handed to it on stdin, because a staged change is not a commit and a commit range cannot name one. It already exits 1 on a fail verdict and already gates on new findings alone — neither behaviour had to be built here.
dupesThe duplication ceiling, enforced over the whole repository
A percentage over the whole repository is a different question from what a changeset introduces, and `dupes` is the command that asks it. Measured against fallow 3.14.0: a repository at 80% duplication with the threshold at 3 passes `audit` with exit 0 and fails `dupes` with exit 1. The number lives in the config dharness owns, where your project can read it, argue with it and override it.
Medians of three runs each, one reference project, the same explicit staged file list for every stage — 12 August 2026.
First failureexit 1The exit code is the answer, propagated untouched, and a failing stage names every stage it skipped so you know what has not been asked yet. A gate that reports its own status instead of the tool’s turns a red check into a green commit the moment the two disagree — which is exactly what Stryker’s default `break: null` does, documented as “never let your build fail”.
$ dharness checkno staged source files, nothing to check $ echo $?0A command that cannot be named is doing two things. `doctor` had no possible name because it printed a diagnosis and measured the cost of mutation at the same time; split in two, it disappeared — the diagnosis was already `sync`, and the measurement became `mutate --dry-run`. `init` went the same way for the opposite reason: adopting a repository and bringing it up to date are one derivation, and a derivation only needs one name.
$ dharness syncSet the project up, and keep it that way
WritesInstalls what is missing, writes the files it owns into `.dharness/`, points the project’s own configuration at them with one reference line, wires the gate into git, and hands what it cannot run to the agent with the reason. It derives the whole plan from the repository as it is right now, so running it again after months reports drift rather than a stale record of what once happened — a progress file can lie; a derivation cannot. The install belongs to the same transaction as the files: on failure it removes exactly what that run added and restores the manifest and lockfile byte for byte.
--format <human|json> (default human)$ dharness checkThe commit gate itself
Writes nothingESLint on the staged list, then react-doctor on the staged change, then fallow twice — `audit` for what the change introduces and `dupes` for the duplication ceiling. It stops at the first failure, names every stage it skipped, and hands over that tool’s own help in the form that project uses. Both readers — a person, and the model that ran the commit — get the tools’ own text rather than a reinterpretation of it.
$ dharness mutate <path...>Find out whether these files’ tests would notice the code breaking
Writes nothingA path can name lines instead of the whole file, as `src/thing.ts:12-40`, and the verdict then covers exactly those lines. It runs the Stryker your project installed — the only place that can resolve your own TypeScript — and reaches for `@latest` only when the project declares no version at all. Deliberate rather than gated: Stryker aborts when the initial test run fails, so mutation answers a question worth asking only once the tests are green. Concurrency is capped because no tool knows the others exist and every one of them sizes its parallelism against the whole machine.
--dry-run--concurrency <n> (default 2)--upgrade$ dharness versionPrint version
Writes nothingReleases are cut by release-please from the conventional commits on main: it opens a pull request carrying the version and the changelog entry, and merging it tags the release and builds the binaries. Nothing about a release is decided by hand.
Four tools that already exist, each owning a diagnostic the others do not touch. Nothing here is reimplemented, and no two of them answer the same question twice.
Does this file follow the rules this stack recommends?
The owned config layers what each framework’s own guide names — `eslint-config-next/core-web-vitals` and `eslint-config-next/typescript` for Next.js, `eslint-config-expo/flat` for Expo — and the version comes from the framework that ships it rather than from a number dharness would have to keep current. Your `eslint.config.js` gains one reference line, inside a marked region rewritten on every sync.
Visit the toolWhat does this repository’s graph look like?
It prints its resolved entry points and warns when a zone matches nothing, so the heuristic invented to detect a misconfigured graph was never needed. In its boundaries a type-only import still crosses a zone, while an import of an external package does not.
Visit the toolIs this React code semantically sound?
Its rule severity accepts only `error`, `warn` or `off`, so a rule cannot carry a threshold: `context.options` arrives empty. That single constraint is what produced the companion package below.
Visit the toolWould the tests notice the code breaking?
It exposes no way to fail on survivors from the command line — neither `--break` nor `--thresholds.break` exists — so the verdict has to come from reading its report. More workers also made it slower on a small scope, so the cap costs nothing.
Visit the tooldharness-eslint-pluginfallow bans names, not shapes. react-doctor ships 787 rules and none of these. oxlint has neither `no-restricted-syntax` nor a jsdoc rule, so neither the size of a file nor the presence of documentation can be stated as configuration. The package exists because that gap is measured, not because a wrapper wanted rules of its own.
dharness/max-file-linesA file past the ceiling this project set.dharness/require-jsdocA declaration at the top of a file with nothing saying what it is for.dharness/require-variable-jsdocA top-level variable with no JSDoc immediately above it.dharness/pure-index-barrelA barrel that does anything other than re-export.dharness/role-file-shapeA declaration a role file’s own name did not promise.dharness/folder-ownershipA folder that splits a module into role files and publishes no index.{
"schema": "dharness.rules/v1",
"maxFileLines": 500,
"roleSuffixes": [".types.ts", ".constants.ts", ".helpers.ts"]
}The numbers live in `.dharness/rules.json`, not in the rule. They have to: react-doctor accepts only a severity, `["error", 500]` is rejected outright, and `context.options` arrives empty. Reading a file lets one project differ from another without publishing a new version. A missing or unreadable file falls back to the defaults, because a linter that refuses to start over one number it could not read stops every other rule with it.
The same built file loads in both hosts. react-doctor loads plugins with `require`, so the package ships a CommonJS entry alongside the ESM one — declaring only `import` is what stopped its predecessor from loading at all.
None of the twenty-one was written in advance. Each came out of a concrete argument, and several out of having been wrong first — the test for one is direct: if it cannot reject a future proposal, it is not a principle. Two of them are dated amendments, because a principle that survives contact with a bug is worth more than one that was never tested. That is what keeps the tool small enough to predict, and it is why the eight below describe how it behaves rather than what it promises.
You get react-doctor, fallow and Stryker themselves, at the version your project pinned. Every time wrapping one looked tempting, the tool already solved it better: the mutation ratchet turned out to be fallow’s `--gate new-only`, and the configuration check turned out to be the `entry_points` fallow already reports.
Both readers — you and the agent — get the tools’ own text, and a failure hands over that tool’s own help, so there is no translation layer between a finding and what you do about it. A command whose conclusion has to be read out of its prose is broken.
Pass or fail comes from exit codes and JSON, so the same commit gives the same answer every time you run it. The agent edits; it never decides whether something passes. The non-determinism lives in the fix, never in the failure.
It writes into `.dharness/` and adds one reference line to the project’s file, leaving everything you configured yourself intact. If a repository configured a tool, it chose on purpose — and command-line arguments beat a configuration file, so passing one would let a detection error overwrite a deliberate decision.
Detection follows the lockfile, which is the actual signal for where a package manager installs — so a layout with git at the top and the whole JS project in a subdirectory, as every Wails repository has, is read correctly instead of being reported as an npm project with no lockfile in sight.
Staged files are read with `-z`, so a path like `src/café.ts` reaches the gate as itself. `git diff --cached --name-only` quotes anything that is not plain ASCII, and a gate reading that output would drop the file and exit 0 over a change it never read.
Adoption reports what it applied, what it handed back and what was already in place, and finishes either way. Stopping is reserved for the two cases that earn it: no plan is possible, or bytes were written that have to be returned. Everything else lands in the plan, and a pending step reappears on its own next run because the state is derived from the repository rather than remembered.
The client is the agent that ran the commit, so a delegated step hands over the prompt that resolves it and stops there — never an instruction addressed to someone dharness cannot see. Choosing a hook manager, or declaring what the architecture means, is a decision the repository has not made; naming it is worth more than picking a default on its behalf.
`@latest` resolves to the newest tagged release, cut by release-please from the conventional commits on main. What lands is one static binary — built with cgo off for Linux, macOS and Windows on both amd64 and arm64 — so there is nothing to keep up to date beyond the binary itself.
$ go install github.com/Disble/dharness/cmd/dharness@latestThe CLI, its twenty-one design principles and the dated learning log behind them.
The six rules, loadable by ESLint and by react-doctor from one built file.
The Go mutation engine this repository runs its own staged changes through.