Skip to main content

diffgate

Review the change, not the file.

diffgate reads a unified diff and reports the things that pass CI but should not pass review: a test that just became skipped, an exception handler that just became empty, certificate verification that just got turned off, an auth decorator that quietly disappeared.

Zero dependencies. One command. Python 3.11+.

$ diffgate

diffgate  3 file(s) changed, +12 -7

src/api/client.py
  error  provider API secret key committed in source  [security.hardcoded_secret]
         src/api/client.py:14
         │ self.api_key = api_key or "sk-l********"
         Move the value to an environment variable or a secret store. Anything
         committed to git must be treated as leaked, so rotate it as well as
         removing it.

  error  TLS certificate verification disabled  [security.tls_disabled]
         src/api/client.py:18
         │ return requests.get(path, timeout=10, verify=False)

  error  exception caught and ignored  [error.swallowed]
         src/api/client.py:19
         │ except Exception:

src/api/views.py
  error  `@login_required` was removed and does not appear in the new code  [security.guard_removed]
         src/api/views.py:5
         │ @login_required

tests/test_client.py
  error  pytest skip/xfail marker added  [test.skip_added]
         tests/test_client.py:4
         │ @pytest.mark.skip(reason="flaky")

5 errors

Why a diff-aware tool

Your linter looks at the final state of a file. That makes it blind to a whole category of change:

@pytest.mark.skip(reason="flaky")     # ruff: fine. mypy: fine. diffgate: error.
def test_payment_is_captured():
    ...

Nothing is wrong with that file. Something is wrong with the diff that introduced it. A skip marker that has been there for a year is somebody's known trade-off; a skip marker added in this pull request is a decision being made right now, and it deserves to be a deliberate one.

The same asymmetry runs through the whole rule set. except Exception: pass that already existed is technical debt. except Exception: pass that appeared in this change is a failing test being silenced. diffgate only reports the second kind.

Install

pip install diffgate          # or: pipx install diffgate / uv tool install diffgate

Use

diffgate                      # uncommitted changes against HEAD
diffgate --staged             # what `git commit` would record
diffgate --base main          # this whole branch, the way a PR shows it
git diff | diffgate --stdin   # any diff from anywhere

Exit codes are CI-friendly: 0 clean, 1 findings at or above your threshold, 2 diffgate itself could not run.

As a pre-commit hook

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/KozueMitarai/diffgate
    rev: v0.1.0
    hooks:
      - id: diffgate

In GitHub Actions

Findings appear as inline annotations on the pull request diff.

# .github/workflows/review.yml
name: review
on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  diffgate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0        # diffgate needs the base branch
      - uses: KozueMitarai/diffgate@v0.1.0
        with:
          fail-on: error
          comment: "true"       # also post the report as a PR comment

Or wire it up by hand, in any CI system:

pip install diffgate
diffgate --base "origin/$BASE_BRANCH" --format github     # inline annotations
diffgate --base "origin/$BASE_BRANCH" --format sarif -o diffgate.sarif
diffgate --base "origin/$BASE_BRANCH" --format markdown   # for a PR comment

Available formats: text, json, markdown, github, sarif.

The rules

Run diffgate --rules for the current list with default severities.

Rule Default What it catches
test.skip_added error @pytest.mark.skip, it.skip, t.Skip(), #[ignore], @Disabled
test.focused error .only / fit — leaves the rest of the suite unrun while CI stays green
test.always_true error assert True, expect(true).toBe(true)
test.no_assertion warn a new test whose body asserts nothing
test.removed warn more tests deleted than added
error.swallowed error except: pass, catch {}, .catch(() => {}), _ = err
error.lint_suppressed warn # type: ignore, // @ts-ignore, # noqa, as any
security.tls_disabled error verify=False, rejectUnauthorized: false, InsecureSkipVerify
security.hardcoded_secret error AWS/GitHub/Slack/provider keys, private keys, inline DB passwords
security.guard_removed error an auth decorator or permission check deleted and not replaced
security.permissions_widened error chmod 777, privileged: true, 0.0.0.0/0, "Principal": "*"
security.dangerous_exec warn shell=True, eval, pickle.loads, innerHTML =
security.weak_crypto warn MD5/SHA-1 for hashing, ECB mode, Math.random() for tokens
destructive.force_push error git push --force without --force-with-lease
destructive.sql error DROP TABLE, TRUNCATE, DELETE/UPDATE with no WHERE
destructive.command warn rm -rf, kubectl delete, terraform destroy
ci.check_disabled error continue-on-error: true, || true, --exit-zero, [skip ci]
ci.coverage_lowered warn a coverage or warning threshold moved down
deps.added warn a new third-party dependency
deps.lockfile_stale warn manifest changed, lockfile did not
deps.pin_loosened warn an exact pin replaced with a range
scope.env_file error a real .env committed (.env.example is fine)
scope.migration_edited warn an already-applied migration edited in place
scope.generated_edited warn a hand edit to a generated file
scope.infra_changed info Dockerfiles, Terraform, workflows, k8s manifests
scope.large_diff info more added lines than review can absorb
scope.binary_added info a binary blob whose contents nobody can review
stub.not_implemented warn raise NotImplementedError, todo!(), panic("TODO")
stub.fake_return warn a placeholder value returned as if it were real
stub.todo_added info a new TODO/FIXME
debug.leftover warn console.log, breakpoint(), pdb.set_trace(), debugger
debug.print_statement off bare print / fmt.Println (opt in for libraries)
quality.commented_out_code info code commented out instead of deleted

Configuration

Anything can be re-levelled or switched off. Start with:

diffgate --init
# .diffgate.toml
[diffgate]
fail_on = "error"          # error | warn | info | never
max_added_lines = 800
exclude = ["**/node_modules/**", "**/*.min.js"]

[rules]
"stub.todo_added" = "off"
"debug.print_statement" = "warn"
"deps.added" = "info"

[[exempt]]
paths = ["tests/fixtures/**"]
rules = ["security.hardcoded_secret"]
reason = "fixture credentials, not real"

Per-line, in the code itself:

password = "not-really-a-secret"  # diffgate: ignore[security.hardcoded_secret]
# diffgate: ignore-file

Bare # diffgate: ignore silences every rule on that line, and a marker on the line above a finding works too.

As a library

from diffgate import analyze, load_config

result = analyze(open("change.diff").read(), load_config(None))
for finding in result.report.findings:
    print(finding.severity, finding.rule_id, finding.path, finding.line)

Design notes

False positives are the only thing that matters. A rule that cries wolf gets the whole tool switched off, which is worse than not shipping the rule. Patterns are narrow on purpose, credible-but-noisy rules ship at info, and debug.print_statement ships disabled. If diffgate is wrong about your code, that is a bug — please report it.

Secrets are redacted before they are printed. A finding about a hardcoded key would otherwise copy that key into your CI logs.

No network, no telemetry, no dependencies. It reads a diff and writes a report.

diffgate gates itself. Its own CI runs diffgate --base main on every pull request. Its .diffgate.toml exempts exactly two things — the file that is the pattern list, and the test fixtures that must contain the code the rules detect — each with a written reason.

Contributing

git clone https://github.com/KozueMitarai/diffgate
cd diffgate
pip install -e ".[dev]"
pytest

A new rule needs three things: a narrow pattern, a test that it fires, and a test that it does not fire on the nearest legitimate code. The second test is the important one.

License

MIT.


About this project

diffgate was planned, designed, written and tested autonomously by an AI (Claude) as part of an experiment in AI-run software projects. A human handles account registration, payment setup and the decision to publish; every technical choice in this repository is the AI's.

This content is part of an experimental project planned and produced autonomously by an AI. No fact-checking was performed, and the accuracy of any information is not guaranteed.

That disclosure is about claims, not about code. The code makes no claims you cannot check yourself: every rule ships with tests that demonstrate what it catches and what it deliberately ignores (pytest), and diffgate runs against its own diffs in CI. Nothing in this README asserts a measured improvement to your bug rate, your review time, or anything else — no such measurement has been made. Run it on your repository and judge the output.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

diffgate-0.1.0.tar.gz (43.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

diffgate-0.1.0-py3-none-any.whl (37.8 kB view details)

Uploaded Python 3

File details

Details for the file diffgate-0.1.0.tar.gz.

File metadata

  • Download URL: diffgate-0.1.0.tar.gz
  • Upload date:
  • Size: 43.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for diffgate-0.1.0.tar.gz
Algorithm Hash digest
SHA256 dc1562ab571502dc0fbee706664dff45685f7681979861bc92794bc2439c8308
MD5 185da10680f9f0dafd3b364d80c65d7e
BLAKE2b-256 cb1806899d8eadad3a576daf1d60e898253ed353e9fef9b5368e9f9a95f19643

See more details on using hashes here.

Provenance

The following attestation bundles were made for diffgate-0.1.0.tar.gz:

Publisher: release.yml on KozueMitarai/diffgate

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file diffgate-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: diffgate-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 37.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for diffgate-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a197c63f19e4a7e6ac6c6e5122c88c935f29f63d653d91514c2d1aeda2dabf76
MD5 0c520ee564fd00b0963a7851d729b6d0
BLAKE2b-256 dd4cb3346aa579028794cdc1b267fd90560e86addb38e783ada9646d51994ff8

See more details on using hashes here.

Provenance

The following attestation bundles were made for diffgate-0.1.0-py3-none-any.whl:

Publisher: release.yml on KozueMitarai/diffgate

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 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