Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.1.1] - 2026-08-09

### Fixed

- `--check` bandit invocation uses a single `-r` with multiple paths (repeated
`-r` flags broke bandit CLI)
- `--check` no longer maps package `__init__.py` to a non-existent
`tests/test___init__.py`
- `possible_secret` high-entropy detection ignores code/prose string fragments
(e.g. f-string message pieces with braces and operators)

### Changed

- `docs/feedback-checklist.md` updated for 1.1.1 (`--brief`, Review quality)

## [1.1.0] - 2026-08-08

### Added
Expand Down Expand Up @@ -91,6 +106,7 @@ project name, CLI command, and import package match (`diffrat`). PyPI held a
`.diffrat.toml`, and `DIFFRAT_LLM_*` (old `NUMBAT_*` / `[tool.numbat]` no
longer read)

[Unreleased]: https://github.com/szymoniwacz/diffrat/compare/v1.1.0...HEAD
[Unreleased]: https://github.com/szymoniwacz/diffrat/compare/v1.1.1...HEAD
[1.1.1]: https://github.com/szymoniwacz/diffrat/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/szymoniwacz/diffrat/compare/v1.0.0...v1.1.0
[1.0.0]: https://github.com/szymoniwacz/diffrat/releases/tag/v1.0.0
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ is empty while `changes.limits` remains. `--brief` works with `--staged`,

## Status

**1.1.0** is the current release on PyPI as
**1.1.1** is the current release on PyPI as
[`diffrat`](https://pypi.org/project/diffrat/) (formerly developed as Numbat;
see D-008). **1.0.0** was the first product release:

Expand Down
13 changes: 10 additions & 3 deletions docs/feedback-checklist.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Feedback checklist (1.0.0)
# Feedback checklist (1.1.1)

Short session for external testers and dogfood. Goal: feedback on the **CLI
product**, not on private AI workflow tooling.
Expand All @@ -14,6 +14,8 @@ pip install diffrat
diffrat --version
```

Expect `diffrat 1.1.1` (or newer).

From source:

```bash
Expand Down Expand Up @@ -46,8 +48,12 @@ diffrat review --staged
# Branch vs base
diffrat review --base main

# Triage-first (omit Changes hunks); note Review quality pillars after Summary
diffrat review --base main --brief

# JSON for scripting
diffrat review --base main --json
diffrat review --base main --brief --json

# Optional gates / deep view
diffrat review --base main --fail-on=possible_secret,docs_touched
Expand All @@ -72,8 +78,9 @@ diffrat review --base main
1. What was confusing or unexpected in Setup or the first successful report?
2. Which Focus/Risk hints felt useful? Which felt like false positives or noise?
3. Was Review order / risk scoring helpful for deciding what to look at first?
4. Did `--json` fit how you would script or gate a review?
5. What is the one thing you most wanted that is missing?
4. Did the Review quality pillars (understand / one thing well / maintainable) help?
5. Did `--json` fit how you would script or gate a review?
6. What is the one thing you most wanted that is missing?

## What not to evaluate

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "diffrat"
version = "1.1.0"
version = "1.1.1"
description = "Local CLI for diff and PR review assistance"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
2 changes: 1 addition & 1 deletion src/diffrat/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Diffrat — local CLI for diff and PR review assistance."""

__version__ = "1.1.0"
__version__ = "1.1.1"
10 changes: 4 additions & 6 deletions src/diffrat/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ def _map_path_to_pytest_target(path: str) -> str | None:
parts = posix.parts

if is_src_package_path(path):
if posix.name == "__init__.py":
return None
if posix.suffix == ".py":
return f"tests/test_{posix.stem}.py"
return "tests"
Expand Down Expand Up @@ -281,12 +283,8 @@ def _format_subprocess_output(stdout: str, stderr: str) -> str:


def _bandit_display_command(targets: list[str]) -> str:
flags = " ".join(f"-r {target}" for target in targets)
return f"{_BANDIT_COMMAND} {flags}"
return f"{_BANDIT_COMMAND} -r {' '.join(targets)}"


def _bandit_argv(executable: str, targets: list[str]) -> tuple[str, ...]:
argv: list[str] = [executable]
for target in targets:
argv.extend(["-r", target])
return tuple(argv)
return (executable, "-r", *targets)
3 changes: 3 additions & 0 deletions src/diffrat/content_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@ def _is_high_entropy_literal(value: str) -> bool:
return False
if "://" in value or "|" in value or value.startswith("^") or value.endswith("$"):
return False
# Reject code/prose fragments (f-strings, expressions), keep token-like secrets.
if re.search(r"[\s{}()*/\\]", value):
return False
if not re.search(r"[A-Za-z]", value) or not re.search(r"\d", value):
return False
counts = Counter(value)
Expand Down
48 changes: 48 additions & 0 deletions tests/test_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
)
from diffrat.checks import (
CheckSpec,
_bandit_argv,
_bandit_display_command,
bandit_targets_for_paths,
is_pip_audit_dependency_path,
mypy_targets_for_paths,
Expand Down Expand Up @@ -131,6 +133,15 @@ def test_pytest_targets_maps_conftest_to_tests_directory() -> None:
assert pytest_targets_for_paths(["tests/conftest.py"]) == ["tests"]


def test_pytest_targets_skips_package_init() -> None:
assert pytest_targets_for_paths(["src/diffrat/__init__.py"]) == []


def test_pytest_targets_skips_init_among_other_modules() -> None:
paths = ["src/diffrat/__init__.py", "src/diffrat/review.py"]
assert pytest_targets_for_paths(paths) == ["tests/test_review.py"]


def test_mypy_targets_maps_source_modules() -> None:
assert mypy_targets_for_paths(["src/diffrat/review.py"]) == ["src/diffrat/review.py"]

Expand Down Expand Up @@ -385,6 +396,43 @@ def test_plan_checks_selects_bandit_when_on_path() -> None:
assert specs[2].skip_reason is None


def test_bandit_command_uses_single_r_flag_for_multiple_targets() -> None:
targets = ["src/diffrat/__init__.py", "src/diffrat/analysis.py", "src/diffrat/scoring.py"]
assert _bandit_display_command(targets) == (
"bandit -r src/diffrat/__init__.py src/diffrat/analysis.py src/diffrat/scoring.py"
)
assert _bandit_argv("/usr/bin/bandit", targets) == (
"/usr/bin/bandit",
"-r",
"src/diffrat/__init__.py",
"src/diffrat/analysis.py",
"src/diffrat/scoring.py",
)


def test_plan_checks_bandit_multi_file_single_r_flag() -> None:
summary = DiffSummary(
files=(
FileChange(path="src/diffrat/review.py", additions=1, deletions=0, binary=False),
FileChange(path="src/diffrat/scoring.py", additions=1, deletions=0, binary=False),
)
)

with patch("diffrat.checks.shutil.which", return_value="/usr/bin/bandit"):
specs = plan_checks(summary)

bandit_spec = next(spec for spec in specs if spec.code == "bandit")
assert bandit_spec.display_command == (
"bandit -r src/diffrat/review.py src/diffrat/scoring.py"
)
assert bandit_spec.argv == (
"/usr/bin/bandit",
"-r",
"src/diffrat/review.py",
"src/diffrat/scoring.py",
)


def test_plan_checks_skips_bandit_when_missing() -> None:
summary = DiffSummary(
files=(
Expand Down
18 changes: 18 additions & 0 deletions tests/test_content_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,24 @@ def test_content_hints_possible_secret_negative() -> None:
assert content_focus_risk_hints(content) == []


def test_content_hints_possible_secret_rejects_fstring_code_fragment() -> None:
content = _single_file_content(
"src/diffrat/analysis.py",
'f"additions ({source_additions * 100 // summary.total_additions}% "',
)

assert content_focus_risk_hints(content) == []


def test_content_hints_possible_secret_high_entropy_token() -> None:
token = "a7Kx9Qm2Vp4Ln8Rw0Yz3Bc6Hd1Jf5Tg"
content = _single_file_content("src/diffrat/auth.py", f'token_blob = "{token}"')

hints = content_focus_risk_hints(content)
assert len(hints) == 1
assert hints[0].code == "possible_secret"


def test_content_hints_debug_leftover_positive() -> None:
content = _single_file_content("src/diffrat/review.py", "print(result)")

Expand Down
Loading