Semantic commit analysis for Python: blast radius, symbol diffs and complexity changes.
Project description
sniffdiff
sniffdiff is a Rust CLI for reviewing Python diffs by symbol instead of by
line count.
It compares two local Git refs, parses the Python code before and after the range, and prints a compact report of review facts:
- changed functions, methods, and classes;
- body versus signature changes;
- structural complexity movement;
- changed and unchanged callers;
- changed and unchanged tests that reference changed production symbols;
- implementation changes with no direct test references.
The goal is not to replace git diff. The goal is to answer:
Which changed symbols deserve attention first, and why?
sniffdiff is intentionally not a repo knowledge graph, hosted service,
dashboard, AI reviewer, or broad multi-language analyzer.
Status
Early MVP. Python only. Local Git only.
The crate is versioned as 0.0.1 while the fact model and output format settle.
Support Expectations
sniffdiff does not run Python code or depend on the Python interpreter in the
target repository. It parses Python source statically with tree-sitter-python,
so syntax support follows the bundled parser grammar rather than a local
python executable version. The current lockfile resolves tree-sitter-python
to 0.23.6, whose grammar includes Python 3-era constructs such as structural
pattern matching (match/case), exception groups (except*), positional-only
and keyword-only parameter separators, f-string interpolation, and Python
3.12-style type alias statements and generic type parameters. It also retains
some legacy Python grammar support, but sniffdiff is tested and positioned as
a Python 3 source analyzer. Newer Python syntax should be treated as supported
only after it is accepted by the bundled parser and covered by fixtures.
Runtime is currently proportional to the amount of Python source in the compared
refs, not only to the number of changed files. The analyzer parses non-test
Python files at both base and head, then parses test files at head to
attach test-reference facts. It is intended to be fast enough for local review
on typical Python packages, but large-repo performance is not yet benchmarked or
optimized.
Monorepos are supported when the repository is local and the refs are available,
but the tool is monorepo-compatible today rather than monorepo-optimized. Future
monorepo work should add repeatable --path scopes, --exclude and config
support, smarter changed-file scoping, parallel parsing, batched Git object
reads, optional blob-based caching, and timing/file-count fields in JSON output.
Install
From a local checkout:
cargo install --path .
Then run from any Git repo:
sniffdiff main..HEAD
From crates.io:
cargo install sniffdiff
From PyPI, once the Python package is published:
uv tool install sniffdiff
pipx install sniffdiff
Or install directly from the repository:
cargo install --git https://github.com/wlamnorman/sniffdiff
Usage
Compare your working tree against a base ref:
sniffdiff main
Compare two committed refs:
sniffdiff main..HEAD
Analyze another repository:
sniffdiff --repo ../some-python-repo main..HEAD
Use explicit refs instead of base..head:
sniffdiff --repo ../some-python-repo --base main --head HEAD
Show more report items:
sniffdiff main..HEAD --limit 10
sniffdiff main..HEAD --limit all
Show more caller/test references inside each report item:
sniffdiff main..HEAD --caller-preview-limit 8
Choose report detail:
sniffdiff main..HEAD --verbose
sniffdiff main..HEAD --detail verbose
sniffdiff main..HEAD --detail full
The default output format is YAML. Emit the same report model as JSON:
sniffdiff main..HEAD --json
sniffdiff main..HEAD --format json --detail full
When installed as a Python package, python -m sniffdiff main..HEAD delegates
to the same Rust binary. For local wrapper testing, set SNIFFDIFF_BIN to an
explicit binary path.
Example Output
schema_version: 1
detail: normal
scope:
changed_files: 11
changed_symbols: 16
changed_test_files: 2
inspect:
- symbol: src/features.py::build_features
changes:
- public signature
- implementation
signature:
before: build_features(rows)
after: build_features(rows, *, strict=False, source="unknown")
complexity:
status: increased
metrics:
- name: branches
before: 0
after: 2
changed_tests:
- tests/test_features.py::test_build_features (1 callsite)
- tests/test_features.py::test_build_features_skips_missing_names (1 callsite)
unchanged_callers:
- src/api.py::preview (1 callsite)
- src/batch.py::build_batch (1 callsite)
- src/pipeline.py::run_pipeline (1 callsite)
- src/predict.py::predict (1 callsite)
changed_callers:
- src/reporting.py::summarize (1 callsite)
- src/train.py::train (1 callsite)
- symbol: src/validators.py::validate_row
changes:
- public signature
- implementation
signature:
before: validate_row(row)
after: validate_row(row, *, strict=False)
complexity:
status: increased
metrics:
- name: branches
before: 1
after: 4
tests: no direct test references found
changed_callers:
- src/validators.py::is_ready (1 callsite)
- symbol: src/scoring.py::score_features
changes:
- implementation
complexity:
status: increased
metrics:
- name: branches
before: 0
after: 2
- name: nesting
before: 0
after: 2
tests: no direct test references found
changed_callers:
- src/train.py::train (1 callsite)
- symbol: src/features.py::Formatter.format_many
changes:
- public signature
signature:
before: format_many(self, names)
after: format_many(self, names, *, uppercase=False)
- symbol: src/features.py::Formatter.format_name
changes:
- public signature
signature:
before: format_name(self, name)
after: format_name(self, name, *, uppercase=False, fallback="unknown")
changed_callers:
- src/features.py::Formatter.format_many (1 callsite)
omitted:
symbol_changes: 11
high_signal: 9
low_signal: 2
hint: use --limit 14 to show all high-signal items, --detail full for 2 low-signal facts
Examples
Generate and analyze a throwaway Python repo:
make example
make demo is kept as an alias. The built-in example is deterministic and
offline. It includes signature changes, body changes, alias imports,
module-alias callers, changed tests, missing test movement, added/deleted
symbols, and a Git-detected file rename.
Run against real commits from well-known Python packages:
make example-requests
make example-click
make examples # all real-world examples
Real-world examples live under examples/real-world/. They clone upstream
repositories into target/ at run time instead of vendoring package source into
this repo, so they require network access the first time they run.
GitHub Action
This repository includes .github/workflows/sniff.yml, which runs sniffdiff
against pull requests and writes the report to the GitHub Actions step summary.
It is intentionally log-first for now: no bot comments, no tokens beyond
read-only repository access, and no hosted service dependency.
Parse Errors
By default, sniffdiff fails if either side of the range contains Python syntax
errors. That keeps the review facts honest.
For partial output while debugging a broken branch:
sniffdiff main..HEAD --allow-parse-errors
The report includes a parse_errors: line when partial facts were produced.
Current Limits
- Python only.
- Local Git refs only.
- Working-tree comparison is supported with
sniffdiff <base-ref>. - No hosted forge APIs.
- No persistent index.
- No full Python call graph.
- Import and call matching are static heuristics.
- Tests are parsed only to support production-symbol facts, not as primary review targets.
Development
Run the normal checks:
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
Check package contents before publishing:
cargo package --list
cargo publish --dry-run
See docs/releasing.md for the crates.io and PyPI release checklists.
Build an optimized release binary:
cargo build --release
Check the Python package wrapper:
PYTHONPATH=python SNIFFDIFF_BIN=target/debug/sniffdiff python -m sniffdiff --help
Build a Python wheel locally when maturin is available:
maturin build
Run sniffdiff --help for the complete CLI surface.
Project details
Release history Release notifications | RSS feed
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 sniffdiff-0.0.1.tar.gz.
File metadata
- Download URL: sniffdiff-0.0.1.tar.gz
- Upload date:
- Size: 62.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: maturin/1.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4ad50cf0f73a90f13a00598b9e6fdd68f7e0300e672d30a6a39558647c29b6bd
|
|
| MD5 |
b8cf062eb8b2a1f3d58822f0ad28650b
|
|
| BLAKE2b-256 |
71405dce602c776a4dab6bb7e3dbd058b0df4e414dace8ab6e6b96ea15eb68ad
|
File details
Details for the file sniffdiff-0.0.1-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: sniffdiff-0.0.1-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 593.8 kB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: maturin/1.13.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
908cd2f6ccb190243a77c93f6c0d6712c1ef06d3aa0ecb31ee833cca5ca57c60
|
|
| MD5 |
ecb20cbf86a9d74a72129529196304ae
|
|
| BLAKE2b-256 |
20e79f6a44985f7d91130ade95b6b539f214a7d9b153643af84015e9d5150ed8
|