$ 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. There may be more to fix behind it.… the tool’s own --help hint, elided … exit status 1
Real output from dharness 1.13.1: 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, and a failure hands you the commands that read it back.

    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 is free below zero and not above it… Stryker’s score table elided … 1 file(s), 1 range(s), 5 in-scope mutant(s): 4 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.1081839s · classify 0.1465392s · discover 0.9919626s · related 0.4707219s · stryker 2.5960918sdharness: 1 mutant(s) survived: a test would not have noticed this code breaking
Real output from dharness 1.13.1, 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.
Coverage tiers

Demand the most from the code your business runs on

Declare in coverage-tiers.json which code is core, one feature at a time. The commit hook measures every core file against 100% with your own Vitest.

What each tier is held to
  • CORE100%
    The decisions your business depends on: every statement, branch, function and line tested.src/features/checkout/
  • IMPORTANT80%
    The code around them, like presentation.src/features/checkout/ui/
  • INFRASTRUCTURE0%
    Declarations and glue, left out of coverage and of mutation runs.src/features/checkout/types.ts
~/projects/your-appPath redacted
$ cat .dharness/lefthook.ymlpre-commit:  commands:    dharness:      run: dharness check    coverage:      run: bunx --no-install vitest run --coverage --config .dharness/vitest.coverage.mjs $ git commit -m "feat: add checkout total"╭──────────────────────────────────────╮│ 🥊 lefthook v2.1.4  hook: pre-commit │╰──────────────────────────────────────╯ERROR: Coverage for statements (85.71%) does not meet "src/features/checkout/total.ts" threshold (100%) for src/features/checkout/total.tsERROR: Coverage for branches (75%) does not meet "src/features/checkout/total.ts" threshold (100%) for src/features/checkout/total.tsERROR: Coverage for lines (0%) does not meet "src/features/checkout/ui/format.ts" threshold (80%) for src/features/checkout/ui/format.tsERROR: Coverage for functions (0%) does not meet "src/features/checkout/ui/format.ts" threshold (80%) for src/features/checkout/ui/format.tsERROR: Coverage for statements (0%) does not meet "src/features/checkout/ui/format.ts" threshold (80%) for src/features/checkout/ui/format.ts┃  coverage ❯   RUN  v5.0.3 ~/projects/your-app      Coverage enabled with v8  ✓ src/features/checkout/total.test.ts (2 tests) 3ms  Test Files  1 passed (1)      Tests  2 passed (2)  % Coverage report from v8-------------|---------|----------|---------|---------|-------------------File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s -------------|---------|----------|---------|---------|-------------------All files    |      75 |       75 |   66.66 |      80 |                    checkout    |   85.71 |       75 |     100 |     100 |                     total.ts   |   85.71 |       75 |     100 |     100 | 4                  checkout/ui |       0 |      100 |       0 |       0 |                     format.ts  |       0 |      100 |       0 |       0 | 2                 -------------|---------|----------|---------|---------|-------------------… coverage summary elided …exit status 1┃  dharness ❯ … dharness check output elided … summary: (done in 31.21 seconds)✔️ dharness (26.23 seconds)🥊 coverage (4.94 seconds)
Real output of a commit, dharness 1.13.1 with lefthook 2.1.4 and Vitest 5.0.3, exit 1 and no commit made. The coverage job runs beside the gate, and total.ts, a core file at 75% of branches, stops it. The path was replaced; two blocks 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.13.1 · sync · ~/projects/your-app   js project       repository root  package manager  npm  test runner      vitest  presets          none matched  owned files      .dharness/ ■ 14 steps · 6 applied · 4 delegated · 4 satisfied · 0 failed   0.99s ── Applied (6) ── ✓ 1/14 install what this project is missing                        0.92s ✓ 2/14 write the files dharness owns                               0.04s ✓ 3/14 keep the thresholds the rules read in .dharness/rules.json  0.00s ✓ 4/14 point .fallowrc.json at the file dharness owns              0.00s ✓ 7/14 point eslint.config.js at the file dharness owns            0.00s ✓ 9/14 give the agent fallow's own tools                           0.00s… the files each step wrote, elided … ── Left to you (4) ── ! 10/14   wire the gate into git… its reason, and steps 11 to 13, elided … … the four steps already in place, elided … ──────────────────────────────────────────────────────────────────────✓ 6 applied · 4 delegated · 4 satisfied · 0 failed   0.99s  exit 0   next  wire the gate into git
Real output from dharness 1.13.1 on a fresh npm and vitest project: it detected both, applied six of its fourteen steps and handed four back. The path was replaced and three blocks elided, all marked.
The fourteen steps, as it prints them
  1. 1/14
    install what this project is missing

    ✓ Applies it

  2. 2/14
    write the files dharness owns

    ✓ Applies it

  3. 3/14
    keep the thresholds the rules read in .dharness/rules.json

    ! Hands it back when your rules.json exists but is not a JSON object

  4. 4/14
    point .fallowrc.json at the file dharness owns

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

  5. 5/14
    resolve the keys this project and dharness both declare

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

  6. 6/14
    point lefthook.yml at the file dharness owns

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

  7. 7/14
    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

  8. 8/14
    fix the lint config react-doctor silently drops

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

  9. 9/14
    give the agent fallow's own tools

    ✓ Applies it

  10. 10/14
    wire the gate into git

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

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

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

  12. 12/14
    decide this project's architecture

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

  13. 13/14
    enforce the coverage tiers in the native test run

    ! Hands it back when the repository declares no coverage tiers yet, as an offer to adopt them

  14. 14/14
    distribute the coverage-tiers skill and its agent references

    ! Hands it back when an instruction file holds a partial or duplicated managed region

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. The ceiling counts code, so documenting a file never pushes it over; a project that wants comments or blank lines counted turns them on.

    Where it looks

    Every file ESLint lints

    Run against a fixture
    ✕ Reportedsrc/price.ts
    .dharness/rules.json
    1{ "maxFileLines": 4 }
    src/price.ts
    1/**2 * Whether an item costs the buyer nothing.3 */4export function isFree(price: number) {5  return price <= 0;6}78export const FREE = 0;9export const LIMIT = 100;
    1. 4:1This file has 5 lines, over the 4 this project allows. Split it.
    Where the numbers live
    {  "schema": "dharness.rules/v1",  "maxFileLines": 500,  "countComments": false,  "countBlankLines": false,  "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. The ceiling counts code, so documenting a file never pushes it over; a project that wants comments or blank lines counted turns them on.

Where it looks

Every file ESLint lints

Run against a fixture
✕ Reportedsrc/price.ts
.dharness/rules.json
1{ "maxFileLines": 4 }
src/price.ts
1/**2 * Whether an item costs the buyer nothing.3 */4export function isFree(price: number) {5  return price <= 0;6}78export const FREE = 0;9export const LIMIT = 100;
  1. 4:1This file has 5 lines, over the 4 this project allows. Split it.
Where the numbers live
{  "schema": "dharness.rules/v1",  "maxFileLines": 500,  "countComments": false,  "countBlankLines": false,  "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
  • For coverage tiers: Vitest and its coverage provider, installed in the project
  • 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.