Skip to main content

hurdle

A source ↔ test existence mapping tool. A single-file executable that finds source files added without tests (Python 3 standard library only, zero external dependencies).

What it does — decides from filename/path conventions alone whether each source file has a corresponding test, and reports the gaps (untested sources). Text is the default output; --json produces a machine-readable report.

What it does not do

  • Does not run tests — existence only; whether tests pass is out of scope.
  • Does not measure coverage — this is a different (and far cheaper) signal than line/branch coverage: "does this file have any test at all?"
  • Does not judge code quality — a test file that exists but is thin will not be caught.

Installation

# Recommended: isolated install via pipx
pipx install .

# Development: venv + editable install (includes dev extras)
python3 -m venv .venv && source .venv/bin/activate
pip install -e .[dev]

# Environments where pip is unavailable (PEP 668 etc.): runs without installing
PYTHONPATH=src python3 -m hurdle scan .

Quick start

# 1) Full scan — gap report for the whole repository (use this to create a baseline)
hurdle scan ~/study/kopia --json kopia.json

# 2) diff mode — evaluate only sources new/changed vs. a base point (core of the local/CI gate)
hurdle scan . --diff origin/main

# 3) strict mode — fail (non-zero exit code) if any evaluated file has a gap
hurdle scan . --diff origin/main --strict --config .hurdle.json

Reading the output: the report shows a per-file status (matched / gap / unmapped — files with unknown extensions are counted only as unmapped and excluded from language totals), each file's matching test and confidence (exact/heuristic), and per-language totals. Exit codes under --strict: no gaps = 0, gaps present = failure, runtime error (e.g. --diff outside a git repository) = 2. CI decides pass/fail from the strict-mode exit code.

Verdict rules (per language)

Language Test-existence verdict confidence
go Package-level: an exact {stem}_test.go match wins first; otherwise covered if any *_test.go exists in the same package (directory) exact
py Probes 4 pytest-convention candidate paths — beside the file / in a tests/ directory × test_{stem}.py / {stem}_test.py combinations — plus variant names tests/test_{stem}_*.py and tests/test_*_{stem}.py (v0.2.0). __init__.py counts as a package marker and non-test files under test_dirs (default tests, test) count as test-support — both excluded rather than gapped (config: strict_init, test_dirs) exact
ts/js Naming conventions: the *.test.ts(x) / *.spec.ts(x) families exact
c / shell File-to-file mapping is impossible in trees like lustre (centralized lustre/tests/ + kunit/), so coverage is aggregated per module/directory (heuristic). Listing module→test keywords in module_map improves accuracy heuristic

Configuration (.hurdle.json)

Passed via --config. See .hurdle.example.json in this repository for a minimal example and baselines/*.hurdle.json for real-world ones.

{
  "exclude": ["vendor/", "**/*_pb.go"],        // kept out of the scan
  "allowlist": { "cmd/legacy/main.go": "thin wrapper; covered in pkg/" },
  "module_map": { "lustre/osd-zfs": ["osd", "conf-sanity"] }
}
Key Format Description
exclude array of strings Directories are excluded wholesale with a trailing / in the name (vendor/). Everything else is an fnmatch pattern matched against full paths/filenames (**/*_pb.go)
allowlist relpath → reason string Do not count this file as a gap (escape hatch). Checked before exclude, so a file with an explicit reason survives even broad exclude patterns. The reason stays on record in commit history
module_map module relpath → keyword array Feeds the C/shell heuristic. List the test-suite keywords that cover a module (e.g. ["sanity", "conf-sanity"]) and module coverage is looked up in the centralized tests/ directory

Baseline → gate workflow

The core design is a ratchet: existing gaps are frozen, and only new gaps are blocked.

  1. Capture a baseline — run a full scan, commit the resulting JSON gap report (covering all existing code) to the repository. This becomes a de-facto allowlist. The actual results of doing this for the kopia/lustre/ longhorn-manager/velero repositories are in BASELINES.md and baselines/*.json.
  2. Classify with config — filter generated code (*_pb.go etc.), vendor, and test-support code (internal/testutil etc.) via exclude/allowlist so that only files with genuinely no corresponding test remain in the baseline.
  3. diff + strict gate — in CI, --diff origin/<base> --strict judges only the files changed now. Legacy gaps never show up in the diff and are ignored; only new/modified files are forced to carry tests. The gap count decreases monotonically.

CI integration

Copy ci/test-exist-gate.yml to .github/workflows/test-exist-gate.yml in the target repository and follow the 4 steps in the comment block at the top of the file (swap the URL, commit the config, mark the check as required). The flow: fetch-depth: 0 checkout → install hurdle → run scan . --diff origin/base --strict --config .hurdle.json --json → upload the report artifact even on failure (if: always()).

pre-commit local hook

Copy ci/pre-commit-hook.sh to .git/hooks/pre-commit, or use this minimal 3-line form:

#!/bin/sh
[ -n "$(git diff --name-only --diff-filter=ACMR HEAD)" ] || exit 0
exec hurdle scan . --diff HEAD --strict

Limitations (honestly)

  • It only checks file existence. It never sees whether tests run, pass, or assert anything meaningful. It is a minimal gate against "source added with no test" — not a coverage replacement.
  • Heuristic module verdicts can over- and under-count. The module-aggregated C/shell verdict depends on module_map keywords: keywords that are too broad over-report coverage; a missing per-module suite under-reports it. Treat the report's confidence markers as the trust boundary.
  • The only escape hatch is the allowlist. When a rule misjudges a file, allowlisting it with a reason is the sole exception path — hiding it with exclude removes it from the scan entirely and breaks gap tracking.

License

Apache-2.0 — see the LICENSE file for the full text.

Universe coverage (hurdle universe)

Beyond per-file test existence, hurdle can enforce coverage of a defined universe — a complete set of items every one of which must be either used, wildcard-covered, or explicitly allowlisted. The motivating case: CDP protocol commands/events (protocol.json defines the complete universe, so "missing something" is well-defined).

// .hurdle.json
"universes": {
  "cdp-commands": {
    "items_file": "universes/cdp-commands.json",   // bare array or {"items":[], "meta":{}}
    "include": ["src/**/*.py"],
    "literal_shape": "^[A-Z][A-Za-z0-9]*\\.[a-zA-Z][A-Za-z0-9]+$",
    "wildcard_patterns": ["\\.subscribe\\(\\s*\"({domain})\\.\\*\""],
    "extra_usage": ["Page.javascriptDialogOpening"],
    "allowlist": {"Tracing.*": "out of scope — low-level tracing"}
  }
}
hurdle universe cdp-commands . --strict --json report.json
  • Usage extraction collects shape-matched string literals (module constants are picked up at their definition site) and intersects them with the universe — universe membership itself disambiguates commands vs events that share the Domain.name shape.
  • Statuses: covered / wildcard (a Domain.* subscription) / allowed (fnmatch on item names, reason recorded) / gap.
  • --strict exits 1 on any gap that is not allowlisted — newly added universe items cannot appear silently.

Release files for hurdle-gate 0.3.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for hurdle-gate 0.3.1
File Size Uploaded
hurdle_gate-0.3.1.tar.gz 24.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for hurdle-gate 0.3.1
File Interpreter ABI Platform
hurdle_gate-0.3.1-py3-none-any.whl Python 3 none any Details

Total release size: 44.6 kB

Release files / hurdle_gate-0.3.1.tar.gz

Download URL hurdle_gate-0.3.1.tar.gz
Size 24.7 kB
Tags Source
SHA-256 checksum
How to use checksums
a1fa03555bde65ac6c525973c1c3ee28e01c1484b82892fa4cd531090872f941
BLAKE2b-256 checksum
How to use checksums
e6e9ee1e3aec1f9d9ceb0e03cff0c9a3e21edfd27da281ab2491a84d6c4d1f12
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / hurdle_gate-0.3.1-py3-none-any.whl

Download URL hurdle_gate-0.3.1-py3-none-any.whl
Size 19.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e85a2995af868d8a81fd09d5dde17ac12209c5e35dff4172d64426e1e5fefe90
BLAKE2b-256 checksum
How to use checksums
bf10176b2001b8ba9ce9d8e6825bbb90296707b078eed6f30fed422edb54531c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

This release

0.3.1 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page