Coding agents are fast. They're also good at making a repo slightly worse in ways a diff review misses: coverage down a point, a new 800-line file, a lint you already banned. The PR looks fine. The floor moved.
Pawl is a language-agnostic quality ratchet for CI. It records the numbers a repository already produces — coverage, lint findings, failing tests, file sizes, bundle size — as a baseline committed to Git, then exits 1 when a change makes one of them worse. Old mess can stay. New mess cannot. There is no server, no account, and no telemetry.
pawl record # record the current baseline
pawl check # exit 1 if a metric regressed
pawl record --only line-coverage # lock in one improvement
pawl guard origin/main # verify that the baseline was not weakenedA dimension can be anything that produces a number: coverage, passing tests, lint findings, long files, bundle size, dependency cycles, or a project-specific measurement. Pawl supplies common adapters and accepts custom commands, so it works with the tools and languages already in the repository.
Install the static binary through npm, Go, or the install script:
npm install -D @pawl-tools/cli
# or: go install github.com/tiangong-dev/pawl/cmd/pawl@latest
# or: curl -fsSL https://raw.githubusercontent.com/tiangong-dev/pawl/main/install.sh | shThe release workflow signs each archive it publishes keylessly with cosign (install.sh verifies it automatically when cosign is on the machine). If you download an archive by hand from the Releases page instead, verify it against the matching .sigstore.json next to it (releases before this signing was added have no .sigstore.json and can't be verified this way):
cosign verify-blob --bundle <archive>.sigstore.json \
--certificate-identity-regexp 'https://github.com/tiangong-dev/pawl/\.github/workflows/release\.yml@.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
<archive>
# <archive> is the file you downloaded, e.g. pawl-<version>-linux-x64.tar.gz or pawl-<version>-win32-x64.zipThen create a config and record the first baseline:
pawl init
pawl record
git add pawl.yaml pawl.snapshot.json
git commit -m "chore: add pawl quality gate"Run pawl check locally and in CI. A regression produces a normal gate failure with enough detail to find the metric that moved:
$ pawl check
metric baseline current Δ status
------------------------------------------------
file-length 3 4 +1 ❌ worse
panics 1 1 ±0 ✅ same
todo-markers 12 12 ±0 ✅ same
❌ regressions:
• file-length (Files over 500 lines)
total 3 → 4That is the whole loop: measure, compare, fix regressions, and record genuine improvements.
Because the repo isn't at 80%. It's at 62%, with three 700-line files, and a deadline. A fixed bar either fails every PR or protects nothing.
Pawl starts at whatever you have today. The first snapshot is the floor. After that, worse is a red CI. When you actually fix something, re-record that one metric so the new floor sticks. Per-file and per-key comparisons mean fixing file A does not pay for a new mess in file B.
The baseline is a JSON file in Git, so pawl trend reads history from commits you already have.
Stopping a number from getting worse is not a new idea. What differs is where the verdict is computed and what the number is compared against.
| where the verdict is computed | where the baseline lives | |
|---|---|---|
| pawl | locally — it is the CLI's exit code | pawl.snapshot.json, committed to the repository |
| SonarQube Server / Cloud | on a server, including in the free self-hosted edition | that server |
| Codecov / Coveralls | on the service, after the uploaded reports are processed | the service |
| betterer | locally | .betterer.results, committed alongside the code |
| git-ratchet | locally | git-notes |
Qlty (formerly Code Climate Quality) does not fit one row. Its CLI fails locally on issue severity (--fail-level, default fmt) and compares against the ref given by --upstream; the stored history and the pull-request gates are in Qlty Cloud.
What each one protects moves with the product line and the plan: Codecov added bundle-size and test analytics on top of coverage, Qlty Cloud can fail a pull request when total coverage drops, and a custom SonarQube gate can carry conditions on overall code. The difference pawl is after is not the length of that list. It is that pawl ships no analyzer of its own — it records whatever number it can extract from a command's output, and then defends that number. git-ratchet takes numbers the same way, reading measure,value pairs on stdin, with one caveat: it fails when a measure rises past the configured slack, so coverage and anything else where higher is better has to be inverted first.
Two things sit close enough to this to be worth separating:
A fixed threshold is not a ratchet. SonarQube's default gate applies fixed conditions to new code, where "new code" is a configurable window — the previous version, the last N days, a specific analysis, or the diff against a reference branch — and a custom gate can add conditions on overall code as well. But a threshold is a line somebody picked: coverage must be at least 80%. A ratchet has no such line. The bar is wherever the number stood when it was last recorded, so every pawl record that accepts an improvement moves the bar up, and nobody has to agree on what "good enough" is first.
A diff filter has no memory. golangci-lint --new-from-rev (empty by default, so you turn it on) and reviewdog -filter-mode=added narrow findings to the lines a change touched — cheap, and worth having. But narrowing to changed lines cannot catch a metric that got worse while the responsible line was never edited: a dependency that pulled in more code, a bundle that grew, a test that started being skipped.
pawl.yaml defines dimensions. Every dimension has an id, a direction, and one source of measurement:
- a zero-dependency primitive such as
file-lengthorpattern-count; - an adapter for a tool or report format such as ESLint, Oxlint, SARIF, JUnit, lcov, or cobertura;
- a custom command that prints a number or a measurement object.
The measuring tool is deliberately separate from the verdict. A team can replace one linter with another without replacing the baseline workflow or the CI gate.
snapshot: "pawl.snapshot.json"
dimensions:
- id: "file-length"
title: "Files over 500 lines"
direction: "lower-is-better"
builtin: "file-length"
options:
threshold: 500
include: ["src/**/*.ts", "src/**/*.go"]
- id: "type-escapes"
title: "TypeScript escape hatches"
direction: "lower-is-better"
gate: "per-file-count"
builtin: "pattern-count"
options:
pattern: 'as\s+any|@ts-(ignore|nocheck)'
include: ["src/**/*.ts", "src/**/*.tsx"]
- id: "line-coverage"
title: "Line coverage %"
direction: "higher-is-better"
tolerance: 0.5
builtin: "coverage"
options:
file: "coverage/lcov.info"
format: "lcov"The recipe cookbook has ready-to-edit examples for Go, TypeScript, Python, Rust, Swift, common linters, report formats, and custom commands.
The scalar total is always compared. gate can add a more precise comparison:
| gate | use it for | behavior |
|---|---|---|
total |
coverage, bundle size, total findings | compares the single value |
per-file-count |
lint findings, suppressions, TODOs | findings in one file cannot be offset by fixes in another |
per-key-value |
per-package coverage, size by bundle | each key already present in the baseline is protected |
lower-is-better is for debt counts and size. higher-is-better is for coverage, passing tests, and similar floors. tolerance adds absolute slack in the worse direction for noisy measurements.
| builtin | reads or runs | typical gate |
|---|---|---|
file-length, file-bytes |
repository files | total + per-key-value |
pattern-count |
regular-expression matches | per-file-count |
eslint, oxlint |
native linter output | per-file-count |
jscpd, swift-complexity |
tool-specific JSON | total / per-file-count |
json-value |
one numeric value from JSON | total / per-key-value |
lines |
line-oriented analyzer output | per-file-count |
sarif |
SARIF findings | per-file-count |
junit |
passing, failing, skipped, or total tests | total |
coverage |
lcov or cobertura coverage | total |
Named analyzers let several dimensions share one scan. Exact options and output semantics are part of the engine contract.
For a tool pawl does not know, use a command dimension. The full adapter contract accepts a JSON object:
{ "value": 42, "unit": "findings", "breakdown": { "src/a.ts:17": 2 } }If the command already prints a number or one finding per line, extract avoids a wrapper script:
- id: "circular-deps"
title: "Import cycles"
direction: "lower-is-better"
command: "npx madge --circular --json src | jq 'length'"
extract: number
- id: "todos"
title: "TODO markers"
direction: "lower-is-better"
gate: "per-file-count"
command: "grep -rn TODO src"
valid_exit_codes: [0, 1]
extract:
regex: '^(?P<path>[^:]+):(?P<line>\d+):'Measurement failures are not clean results. A crashed command, malformed report, timeout, or extraction error makes pawl exit 2; it never turns “could not measure” into “measured zero.” Use valid_exit_codes for tools that legitimately report findings with a non-zero exit instead of hiding every failure behind || true.
File-backed report dimensions may set artifact_max_age: "24h" to turn an old
artifact into exit 2; command-produced reports are considered fresh by
construction. Without that option pawl reports age as provenance only.
| command | purpose |
|---|---|
pawl init |
write a starter pawl.yaml without overwriting an existing one |
pawl record |
measure and write the snapshot |
pawl check |
compare current measurements with the snapshot; the default command |
pawl measure |
print current measurements without reading a baseline or giving a verdict |
pawl guard <ref> |
compare the snapshot with the version at <ref> |
pawl trend [<id>] |
show committed snapshot history |
pawl rank |
rank included files by line or byte size |
pawl agent |
install or print operating instructions for coding agents |
pawl version |
print the installed version |
The important exit-code distinction is:
| code | meaning |
|---|---|
0 |
measurement completed and the gate passed |
1 |
measurement completed and a metric regressed |
2 |
pawl could not produce an honest verdict |
Run pawl help [command] for the complete flag reference. The stable JSON verdict is available through --format json.
Use a partial record after improving a metric:
pawl record --only line-coverageOnly that dimension is measured and updated. The others are copied from the existing snapshot unchanged, so an unrelated broken adapter cannot block the record and an unnoticed regression cannot be locked in along with it.
For repositories with substantial existing debt:
pawl check --since origin/mainLine-addressable per-file-count findings are scoped to changed lines. Metrics that cannot be attributed to a line, such as total coverage, remain fully enforced and are labelled as such. Uncommitted and untracked changes are included.
pawl record refuses worse values by default. When a regression is deliberate, preview it and record it explicitly:
pawl record --dry-run --accept-worse
pawl record --accept-worsePawl prints a Pawl-Accept: <id> <value> commit trailer. pawl guard uses that trailer to distinguish reviewed debt from a weakened snapshot.
When dimensions read generated reports, measure once so the check and record see the same build:
pawl measure > .pawl/current.json
pawl check --current .pawl/current.json
pawl record --only line-coverage --current .pawl/current.jsonpawl trend
pawl trend line-coverage --limit 50History comes from pawl.snapshot.json in Git; no separate database is needed.
Any CI system can install the binary and run pawl check. On pull requests, run guard as a separate step so changing the snapshot cannot lower the bar unnoticed.
permissions:
contents: read
pull-requests: write
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Run tests or analyzers that produce reports before the gate.
- run: npm test -- --coverage
- uses: tiangong-dev/pawl@v0.8.2
with:
command: check
args: --since origin/${{ github.base_ref || 'main' }}
guard-ref: origin/${{ github.base_ref || 'main' }}With command: check, the action enforces the exit code and can keep updating one PR comment with the JSON verdict instead of posting a new comment every run. Set guard-ref after fetching the base ref to make baseline protection part of the same action; if args contains -c/--config, the same config is used for the guard. The guard runs before the optional comment. Set comment: 'false' if logs and annotations are enough — and drop pull-requests: write from the job's permissions: block, since the comment is the only thing that needs it. Without command, the action only installs pawl on PATH.
pawl check --format json is the stable contract; scripts/gitlab-codequality.mjs converts one verdict into a GitLab Code Quality report for the merge-request widget. It is a converter script, not a pawl output format — GitLab is not a pawl target the way GitHub Actions is, so this stays outside the CLI surface.
quality:
script:
- curl -fsSL -o gitlab-codequality.mjs https://raw.githubusercontent.com/tiangong-dev/pawl/main/scripts/gitlab-codequality.mjs
- pawl check --format json > pawl.json || rc=$?
- node gitlab-codequality.mjs pawl.json > gl-code-quality.json
- exit ${rc:-0}
artifacts:
when: always
reports:
codequality: gl-code-quality.jsonmain above always has the current script; pin to a specific tag or commit instead once one has shipped it (the same way guard-ref/the Action version are pinned elsewhere in this doc) — vendoring a copy into the repo works too. Capture rc before converting and exit after — && would short-circuit on the regression/could-not-measure exit codes and skip the report exactly when the widget matters most. A could-not-measure verdict (exit 2) still produces a blocker-severity issue rather than an empty report, so a broken gate cannot read as a clean one.
Pawl reports paths relative to the config file's directory, not the repository root. If pawl check above ran with -c config/pawl.yaml, pass that directory through so GitLab attaches issues to the right file: node gitlab-codequality.mjs pawl.json --config-dir=config. --anchor (used for the could-not-measure/total-only fallback location) is itself config-relative and defaults to pawl.yaml, so it already resolves under --config-dir correctly without repeating the directory — override it only if the config file has a different name, e.g. --anchor=quality.yaml for config/quality.yaml, not --anchor=config/quality.yaml.
Jenkins, CircleCI, Buildkite, Azure Pipelines, Woodpecker — anything that can run a binary needs no plugin. Use the release binary, the npm package, or:
npx -y @pawl-tools/cli@0.8.2 checkNo server-side component is required.
If an agent is writing code in this repo, tell it about the gate:
pawl agent --write agent # AGENTS.md
pawl agent --write claude # CLAUDE.mdCI is still the authority. pawl agent just writes the operating notes so the agent runs pawl check, reads the JSON verdict, and records a fix narrowly instead of rewriting the snapshot. Demo and fixtures: demo/.
Pawl owns measurement orchestration, comparison, and the verdict. It is not a new linter, hosted dashboard, package manager, or auto-fixer. Projects continue to install and configure their own analyzers; pawl gives those measurements one consistent baseline and one enforceable answer.
- Recipes — configurations to copy and adapt
- Engine contract — precise behavior and file formats
- Contributing — development and test workflow
- Changelog — release and migration notes
MIT — see LICENSE.