Skip to main content

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


Download files

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

Source Distribution

sniffdiff-0.0.1.tar.gz (62.0 kB view details)

Uploaded Source

Built Distribution

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

sniffdiff-0.0.1-py3-none-macosx_11_0_arm64.whl (593.8 kB view details)

Uploaded Python 3macOS 11.0+ ARM64

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

Hashes for sniffdiff-0.0.1.tar.gz
Algorithm Hash digest
SHA256 4ad50cf0f73a90f13a00598b9e6fdd68f7e0300e672d30a6a39558647c29b6bd
MD5 b8cf062eb8b2a1f3d58822f0ad28650b
BLAKE2b-256 71405dce602c776a4dab6bb7e3dbd058b0df4e414dace8ab6e6b96ea15eb68ad

See more details on using hashes here.

File details

Details for the file sniffdiff-0.0.1-py3-none-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for sniffdiff-0.0.1-py3-none-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 908cd2f6ccb190243a77c93f6c0d6712c1ef06d3aa0ecb31ee833cca5ca57c60
MD5 ecb20cbf86a9d74a72129529196304ae
BLAKE2b-256 20e79f6a44985f7d91130ade95b6b539f214a7d9b153643af84015e9d5150ed8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page