Command-line tool · Go · one static binary

$ dharness

A commit gate for TypeScript projects.

It owns the invocation of ESLint, react-doctor, fallow and Stryker: which tool runs, in what order, scoped to what, with what ceiling on resources. The verdict is their exit code, passed through untouched.

4
commands
11
steps it derives
4
stages in the gate
~/projects/your-appPath redacted
$ dharness sync dharness 1.7.5 · 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.mjs         + .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 git
Real output. `sync` detected yarn and vitest on its own, derived eleven steps, applied the five it could, and named the three it handed back. The project path and the version string were substituted and one block elided, all three marked; every other character is the tool’s.
The gate

Cheapest first, and the first failure cuts the rest

A failure stops the run and skips every stage behind it, and on a gate that runs at every commit that cut is most of the saving. Each stage below carries the median it was measured at and what puts it there.

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

    Lint over the exact staged list, resolved locally

    It runs from the version your project installed, over the exact staged list, using the config dharness owns with the preset your framework recommends already in it.

    WhyResolved from the install your project already has, with no package-manager round trip.

    Runs as soon as your project installs it

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

    React semantics, over the staged change only

    Bounded by what is actually being committed, and it is the only stage here for which that is true. Its analysis stays on your machine.

    WhyScoped to the staged change, so the cost tracks the diff.
  3. 032102 ms
    fallow auditaudit --changed-since HEAD --diff-stdin

    The repository graph, judged on what the change introduces

    The staged diff is handed to it on stdin, so the graph is judged on the change you are about to commit. It exits 1 on a fail verdict and gates on new findings alone.

    WhyBuilds the repository graph either way, so the median has a floor.

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

  4. 041398 ms
    fallow dupesdupes

    The duplication ceiling, enforced over the whole repository

    The ceiling lives in the config dharness owns, where your project can read it and override it.

    WhyBuilds the same graph, then asks one question of the whole repository.

    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 same repository, two questionsfallow 3.14.0 · one repository at 80% duplication · threshold 3
  • fallow audit

    What does this change introduce?

    exit 0
  • fallow dupes

    How much duplication does the repository carry?

    exit 1
The verdict

The exit code is the answer, propagated untouched. A failing stage names every stage it skipped, so all four are always accounted for.

~/projects/your-app
$ dharness checkno staged source files, nothing to check $ echo $?0
Real output, and the cheapest run there is: with no sources in the index none of the four stages is started at all. The same early exit applies to mutation, where an empty scope never starts the engine.
The surface

Four commands, and each one can be named

The whole surface fits on this screen.

  1. $ dharness sync

    Set the project up, and keep it that way

    Writes

    Every run derives its whole plan from the repository as it is right now, so running it again after months reports the drift.

    Flags
    --format <human|json> (default human)
    What one run does, in order
    1. 01Installs what is missing
    2. 02Writes the files it owns into .dharness/
    3. 03Points your config at them with one reference line
    4. 04Wires the gate into git
    5. 05Hands the agent what it cannot run, with the reason
    Proved before the run may report successeslint --print-config

    One path per source extension, up to eight of them. A green sync is one whose config has already been loaded and read back.

    A failed run leaves the repository as it found it
    What the run doesWhat a failure leaves
    Packages installed, files written into .dharness/Exactly what this run added, removed
    The manifest and the lockfile editedBoth restored byte for byte
  2. $ dharness check

    The commit gate itself

    Writes nothing

    It stops at the first failure, names every stage it skipped, and hands you that tool’s own help in the form your project uses. It also names which kind of failure it got: an ESLint exit 2 is a configuration that never loaded, so no staged file was linted and the answer is in the config.

  3. $ dharness mutate <path...>

    Find out whether these files’ tests would notice the code breaking

    Writes nothing

    A 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 one that can resolve your own TypeScript.

    Flags
    --dry-run
    --concurrency <n> (default 2)

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

    --upgrade
    --fresh

    Results accumulate in `.git/dharness/`, so Stryker’s table can cover more files than you asked about. This measures only the paths you named.

  4. $ dharness version

    Print version

    Writes nothing

    Releases are cut by release-please from the conventional commits on main, so the number this prints names a tagged release with a changelog entry behind it.

Ownership

One question per tool, asked once

Four tools that already exist, each owning one diagnostic of its own.

  • ESLint

    Does this file follow the rules this stack recommends?

    • The preset your framework documents
    • Four presets: Next.js, Expo, Wails, generic
    • Resolved from your own install
    • Run over the explicit staged list
    The preset each framework’s own guide names
    FrameworkPreset
    Next.jseslint-config-next/core-web-vitals
    Next.js, once a tsconfig.json existseslint-config-next/typescript
    Expoeslint-config-expo/flat.js

    The version comes from the framework that ships the preset. Your `eslint.config.js` gains one reference line, inside a marked region rewritten on every sync, pointing at `.dharness/eslint.config.mjs` or `.cjs`, whichever matches your project.

    Visit the tool
  • fallow

    What does this repository’s graph look like?

    • Dead code
    • Dependencies
    • Cycles
    • Architecture boundaries
    • Entry points, reported directly
    What counts as crossing a zone
    ImportIn its boundaries
    A type-only importCrosses the zone
    An import of an external packageNot counted as a crossing

    It prints its resolved entry points and warns when a zone matches nothing.

    Visit the tool
  • react-doctor

    Is this React code semantically sound?

    • React and React Native semantics
    • Hook and effect misuse
    • Design-system drift
    • Scoped to the staged change in the gate
    • Layered into ESLint as a plugin

    It reaches the project twice: the CLI in the gate, and `eslint-plugin-react-doctor` in the config dharness owns, so the same analysis answers while you type and again at the commit. Its rule severity accepts only `error`, `warn` or `off`, and that single constraint produced the companion package below.

    Visit the tool
  • Stryker

    Would the tests notice the code breaking?

    • Mutation testing over a scoped file list
    • Scope intersected with the change
    • Concurrency capped at 2 by default
    • Run deliberately, outside the gate

    The verdict comes from the report it writes, so the number that decides a run is one you can read.

    Visit the tool
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.

The six rules it publishes
  • 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.dharness asks `git ls-files -- */index.ts */index.tsx` and writes this rule as `error` where the tree already publishes a barrel, `off` where it does not.
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.

Where each rule reads its answer
  • dharness/max-file-linesmaxFileLines
  • dharness/folder-ownershiproleSuffixes
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
Install

One command, from the terminal you are already in

One static binary for Linux, macOS and Windows, on amd64 and arm64.

$ go install github.com/Disble/dharness/cmd/dharness@latest
Open the repository
  • Disble/dharness

    The CLI itself: its source, its tagged releases and the dated learning log.

  • dharness-eslint-plugin

    The six rules ESLint loads from the owned config, shipped with a CommonJS entry and an ESM one.

  • ditto

    The Go mutation engine this repository runs its own staged changes through.