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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dc1562ab571502dc0fbee706664dff45685f7681979861bc92794bc2439c8308
|
|
| MD5 |
185da10680f9f0dafd3b364d80c65d7e
|
|
| BLAKE2b-256 |
cb1806899d8eadad3a576daf1d60e898253ed353e9fef9b5368e9f9a95f19643
|
Provenance
The following attestation bundles were made for diffgate-0.1.0.tar.gz:
Publisher:
release.yml on KozueMitarai/diffgate
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
diffgate-0.1.0.tar.gz -
Subject digest:
dc1562ab571502dc0fbee706664dff45685f7681979861bc92794bc2439c8308 - Sigstore transparency entry: 2361846789
- Sigstore integration time:
-
Permalink:
KozueMitarai/diffgate@83827cb59619e5e65f820d15fe0050ecd5c6c3ac -
Branch / Tag:
refs/heads/main - Owner: https://github.com/KozueMitarai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@83827cb59619e5e65f820d15fe0050ecd5c6c3ac -
Trigger Event:
workflow_dispatch
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a197c63f19e4a7e6ac6c6e5122c88c935f29f63d653d91514c2d1aeda2dabf76
|
|
| MD5 |
0c520ee564fd00b0963a7851d729b6d0
|
|
| BLAKE2b-256 |
dd4cb3346aa579028794cdc1b267fd90560e86addb38e783ada9646d51994ff8
|
Provenance
The following attestation bundles were made for diffgate-0.1.0-py3-none-any.whl:
Publisher:
release.yml on KozueMitarai/diffgate
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
diffgate-0.1.0-py3-none-any.whl -
Subject digest:
a197c63f19e4a7e6ac6c6e5122c88c935f29f63d653d91514c2d1aeda2dabf76 - Sigstore transparency entry: 2361846793
- Sigstore integration time:
-
Permalink:
KozueMitarai/diffgate@83827cb59619e5e65f820d15fe0050ecd5c6c3ac -
Branch / Tag:
refs/heads/main - Owner: https://github.com/KozueMitarai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@83827cb59619e5e65f820d15fe0050ecd5c6c3ac -
Trigger Event:
workflow_dispatch
-
Statement type: