$ dharness

A commit gate for TypeScript projects.

ESLint, react-doctor and fallow check every commit before it lands, and dharness mutate tells you whether your tests would notice the code breaking.

~/projects/your-appPath redacted
$ git commit -m "feat: add discount helper" 🥊 lefthook v2.1.4  hook: pre-commitdharness: eslint exited with code 1 … the cost note on react-doctor and fallow running at @latest, elided … ── eslint ── src/discount.js  1:8  error  This function is declared at the top of the file with nothing saying what it is for  dharness/require-jsdoc ✖ 1 problem (1 error, 0 warnings) eslint failed, so react-doctor and fallow audit and fallow dupes did not run.… the tool’s own --help hint, elided … exit status 1
Real output from dharness 1.9.2: git commit runs the wired pre-commit hook, and ESLint blocks it on dharness/require-jsdoc before react-doctor or fallow run. The path was shortened to its relative form and two blocks elided, both marked.
The gate

Cheapest first, and the first failure cuts the rest

A failure stops the run and skips every stage behind it, so each commit pays only up to its first problem. Each stage carries the median it was measured at.

  1. 011008 ms
    ESLint--no-warn-ignored <staged files>

    Does this file follow the rules this stack recommends?

    It uses the version your project installed and the preset your framework recommends, only on what you staged.

    Runs as soon as your project installs it

  2. 022959 ms
    react-doctor--staged --no-dead-code --no-score --no-supply-chain -y

    Is this React code semantically sound?

    It checks only the staged files, and its analysis never leaves your machine.

  3. 032102 ms
    fallow auditaudit --changed-since HEAD --diff-stdin

    What does this change add to the repository graph?

    It judges the change you are about to commit and blocks only on new findings.

    Runs as soon as the repository has a commit to compare against

  4. 041398 ms

    How much duplication does the repository carry?

    The duplication ceiling lives in a config your project can read and override.

    Runs as soon as the repository has a commit to compare against

Medians of three runs each, one reference project, the same explicit staged file list for every stage. 12 August 2026.

First failureexit 1
The verdict

The exit code is the answer, passed through unchanged. A failing stage names the ones it skipped, so you always know what did not run.

Mutation

Find out whether your tests would notice the code breaking

With the tests green, Stryker breaks your code one change at a time. Every break no test notices comes back with its line.

  • $ dharness mutate --staged

    Exactly the lines your staged change added

    It judges the commit you are about to make, with the Stryker your project already installed.

    Flags (1)
    --exclude-prefix <p> (repeatable)

    Leaves staged files under that prefix out of scope.

  • $ dharness mutate <path...>

    Named files, or named lines

    A path can name lines, like src/thing.ts:12-40, and the verdict covers exactly those. Results are reused, so the next run is faster.

    Flags (4)
    --dry-run

    Measures how many tests a scoped run executes, without mutating anything.

    --concurrency <n> (default 2)

    Every tool sizes its parallelism against the whole machine, and more workers measured slower on a small scope.

    --upgrade
    --fresh

    Measures only the paths you named, ignoring the results kept from earlier runs.

What decides the exit code
  • exit 0
    Every mutant is caughtThese tests notice this code breaking.
  • exit 1
    A mutant survivesNamed with its line and what it became.
  • exit 1
    A mutant no test ranCounted as missed, never as caught.
  • exit 1
    A staged file no test importsStops at related, before Stryker starts.
~/projects/your-app
$ dharness mutate --stagedoutside Stryker's mutate set: src/price.test.ts… Stryker’s own progress log elided … [Survived] EqualityOperatorsrc/price.ts:11:10-     return price <= 0;+     return price < 0;Tests ran:    isFree… Stryker’s score table elided … 1 file(s), 1 range(s), 8 in-scope mutant(s): 7 killed, 1 survived, 0 no coverage, 0 timeout, 0 errors, 0 ignored 1 mutant(s) survived — a test would not have noticed:   src/price.ts:11 EqualityOperator → price < 0    If equivalent, wrap the statement in `// Stryker disable EqualityOperator: <reason>` … `// Stryker restore EqualityOperator`; next-line does not reach call arguments such as dependency arrays.phases: snapshot 0.0981005s · classify 0.1508243s · discover 1.0291609s · related 0.5316295s · stryker 2.8742501000000003sdharness: 1 mutant(s) survived: a test would not have noticed this code breaking
Real output from dharness 1.9.2, exit 1. The staged isFree returns price <= 0 and its test checks -1 and 5, so nothing notices the boundary moving. Two blocks of Stryker’s own log were elided, both marked.
Set up

One command sets the project up, and keeps it that way

Every run derives its plan from the repository as it is right now, so running it months later reports the drift. A run that fails leaves the repository as it found it.

~/projects/your-appPath redacted
$ dharness sync dharness 1.9.2 · sync · ~/projects/your-app   js project       repository root  package manager  npm  test runner      vitest ■ 11 steps · 5 applied · 3 delegated · 3 satisfied · 0 failed   1.43s ── Applied (5) ── ✓ 1/11 install what this project is missing              1.40s ✓ 2/11 write the files dharness owns                     0.02s ✓ 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… the files each step wrote, elided … ── Left to you (3) ── ! 9/11   wire the gate into git… its reason, and steps 10 and 11, elided … ✓ 5 applied · 3 delegated · 3 satisfied · 0 failed   1.43s  exit 0
Real output from dharness 1.9.2 on a fresh npm and vitest project: it detected both, applied five of its eleven steps and handed three back. The path was replaced and three blocks elided, all marked.
The eleven steps, as it prints them
  1. 1/11
    install what this project is missing

    ✓ Applies it

  2. 2/11
    write the files dharness owns

    ✓ Applies it

  3. 3/11
    point .fallowrc.json at the file dharness owns

    ! Hands it back when your .fallowrc.json already has settings of its own

  4. 4/11
    resolve the keys this project and dharness both declare

    ! Hands it back when you and dharness declare the same fallow key

  5. 5/11
    point lefthook.yml at the file dharness owns

    ! Hands it back when your lefthook.yml already has jobs of its own

  6. 6/11
    point eslint.config.js at the file dharness owns

    ! Hands it back when your ESLint config is TypeScript, legacy, or a shape it cannot read

  7. 7/11
    fix the lint config react-doctor silently drops

    ! Hands it back when a legacy .eslintrc.json is present

  8. 8/11
    give the agent fallow's own tools

    ✓ Applies it

  9. 9/11
    wire the gate into git

    ! Hands it back when the project has not chosen lefthook or husky yet

  10. 10/11
    install react-doctor's agent skill

    ! Hands it back when always, with the exact command to run

  11. 11/11
    decide this project's architecture

    ! Hands it back when always, because the zones are your agent’s analysis to write

What each preset adds (4)
  • Next.js
    • eslint-config-next/core-web-vitals
    • eslint-config-next/typescript
    • eslint-plugin-react-doctor · recommended, next

    The TypeScript layer joins once a tsconfig.json exists.

  • Expo
    • eslint-config-expo/flat.js
    • eslint-plugin-react-doctor · recommended, react-native
  • Wails
    • fallow ignorePatterns · wailsjs/**

    Read from the wailsjsdir your wails.json declares.

  • Every project
    • fallow duplicates · mode semantic
    • minOccurrences 3
    • threshold 3
The companion package

Size, documentation and folder shape, checked

dharness-eslint-plugin

Size, documentation and folder shape need a number or a policy to check against, and this package is where a project states one.

One registration, every file ESLint touches

One `plugins: { dharness: plugin }` registration in the config dharness owns is the whole wiring, and it puts the six rules wherever ESLint already runs in that project. The package ships a CommonJS entry and an ESM one, so it loads in whichever module system a project is written in.

View the source
The six rules it publishes
  1. dharness/max-file-lines

    suggestion

    A file past the ceiling this project set.

    Every file stays short enough to read in one sitting. Blank lines and comments count, so the ceiling measures what a reader actually scrolls.

    Where it looks

    Every file ESLint lints

    Run against a fixture
    ✕ Reportedsrc/large.ts
    .dharness/rules.json
    1{ "maxFileLines": 4 }
    src/large.ts
    1const line0 = 0;2const line1 = 1;3const line2 = 2;4const line3 = 3;5const line4 = 4;6const line5 = 5;7const line6 = 6;8const line7 = 7;9const line8 = 8;
    1. 1:1This file has 9 lines, over the 4 this project allows. Split it.
    Where the numbers live
    {  "schema": "dharness.rules/v1",  "maxFileLines": 500,  "roleSuffixes": [".types.ts", ".constants.ts", ".helpers.ts", ".schema.ts"]}

    The numbers live in `.dharness/rules.json`, so one project can differ from another without publishing a new version. A missing or unreadable file falls back to the defaults, and every other rule keeps running.

dharness/max-file-lines

suggestion

A file past the ceiling this project set.

Every file stays short enough to read in one sitting. Blank lines and comments count, so the ceiling measures what a reader actually scrolls.

Where it looks

Every file ESLint lints

Run against a fixture
✕ Reportedsrc/large.ts
.dharness/rules.json
1{ "maxFileLines": 4 }
src/large.ts
1const line0 = 0;2const line1 = 1;3const line2 = 2;4const line3 = 3;5const line4 = 4;6const line5 = 5;7const line6 = 6;8const line7 = 7;9const line8 = 8;
  1. 1:1This file has 9 lines, over the 4 this project allows. Split it.
Where the numbers live
{  "schema": "dharness.rules/v1",  "maxFileLines": 500,  "roleSuffixes": [".types.ts", ".constants.ts", ".helpers.ts", ".schema.ts"]}

The numbers live in `.dharness/rules.json`, so one project can differ from another without publishing a new version. A missing or unreadable file falls back to the defaults, and every other rule keeps running.

Get started

From install to a checked commit in five steps

Run each step from the root of the repository you want to gate. Builds with Go 1.26 or later; release archives for Linux, macOS and Windows, on amd64 and arm64, skip the build.

What it needsOpen the repository
  • A JavaScript or TypeScript project with its lockfile committed: bun, pnpm, yarn or npm
  • lefthook or husky, to carry the gate into git
  • For mutation: a JSON Stryker config running vitest or jest
  • Yarn Plug’n’Play projects set nodeLinker: node-modules before mutation
  1. 01

    Install the binary

    go install github.com/Disble/dharness/cmd/dharness@latest

    One binary on your PATH. Release archives cover Linux, macOS and Windows if you would rather not build it.

  2. 02

    Set the project up

    dharness sync

    It installs what is missing, writes .dharness/ and points your configs at it. Anything it should not decide for you comes back under Left to you, with the reason.

  3. 03

    Close what it hands back

    dharness sync

    Add lefthook or husky, give your agent the architecture step, and run it again. Each run lists only what is still open and names the next one.

  4. 04

    Commit as usual

    git commit

    The hook runs dharness check over what you staged. A failure blocks the commit and points you at that tool’s own help.

  5. 05

    Test what you added

    dharness mutate --staged

    Once the tests are green, it mutates exactly the lines you staged and names every change your tests would miss.