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
1 change: 1 addition & 0 deletions .ai/stack-profiles/diffrat-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ README.md
```bash
pip install -e ".[dev]"
pytest
ruff format --check src tests
ruff check .
mypy .
python -m diffrat --help
Expand Down
18 changes: 14 additions & 4 deletions .github/workflows/validate-workflow-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,17 @@ jobs:
- name: Validate project workflow contracts
run: python ci/validate-workflow-contracts.py --mode project

- name: Run product tests
run: |
pip install -e ".[dev]"
pytest tests/ -q
- name: Install product with dev tools
run: pip install -e ".[dev]"

- name: Ruff format
run: ruff format --check src tests

- name: Ruff lint
run: ruff check .

- name: Mypy
run: mypy .

- name: Pytest
run: pytest tests/ -q
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `mypy` excludes `build/` (avoids duplicate-module errors after `python -m build`)
- Maintainer setup notes CI Python 3.11 and that workflow validate needs
`./scripts/setup-ai-workflow.sh` first
- CI runs `ruff format --check src tests`, `ruff check .`, and `mypy .` in
addition to pytest (matches README / stack-profile quality gates)
- One-shot `ruff format` on `src/` and `tests/`

## [1.1.1] - 2026-08-09

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,8 +332,11 @@ Regex rules on **added** hunk lines. Shorthand or table form with optional

## Tests and quality

Local (same gates as CI on pull requests and `main`):

```bash
pytest
ruff format --check src tests
ruff check .
mypy .
```
Expand Down
3 changes: 2 additions & 1 deletion docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ version succeeds as a no-op.

## Preconditions

1. `pytest`, `ruff check .`, and `mypy .` pass on the release commit.
1. `pytest`, `ruff format --check src tests`, `ruff check .`, and `mypy .`
pass on the release commit (same gates as CI).
2. `CHANGELOG.md` describes the release.
3. Trusted Publisher (above) is configured for automated tag publishes.

Expand Down
68 changes: 17 additions & 51 deletions src/diffrat/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,7 @@ def _build_hints(
focus_risk_hint(
code="config_or_deps",
message=(
"Config or dependency files changed — "
"review install and runtime impact"
"Config or dependency files changed — review install and runtime impact"
),
)
)
Expand All @@ -395,9 +394,7 @@ def _build_hints(
)

security_paths = [
file_change.path
for file_change in summary.files
if _is_security_sensitive(file_change)
file_change.path for file_change in summary.files if _is_security_sensitive(file_change)
]
if security_paths:
preview = ", ".join(security_paths[:3])
Expand All @@ -420,18 +417,15 @@ def _build_hints(
if len(ci_workflow_paths) > 3:
preview = f"{preview}, +{len(ci_workflow_paths) - 3} more"
has_ci_validator_in_diff = any(
_is_ci_directory_path(file_change.path)
for file_change in summary.files
_is_ci_directory_path(file_change.path) for file_change in summary.files
)
if has_ci_validator_in_diff:
message = (
f"CI/workflow paths changed ({preview}) — "
"review CI/workflow changes carefully"
f"CI/workflow paths changed ({preview}) — review CI/workflow changes carefully"
)
else:
message = (
f"CI/workflow paths changed ({preview}) — "
"confirm workflow contracts are validated"
f"CI/workflow paths changed ({preview}) — confirm workflow contracts are validated"
)
configured_command = None
if config is not None:
Expand All @@ -446,9 +440,7 @@ def _build_hints(
)

rename_paths = [
file_change.path
for file_change in summary.files
if file_change.change_type in {"R", "C"}
file_change.path for file_change in summary.files if file_change.change_type in {"R", "C"}
]
if rename_paths:
preview = ", ".join(rename_paths[:3])
Expand Down Expand Up @@ -476,8 +468,7 @@ def _build_hints(
focus_risk_hint(
code="source_without_tests",
message=(
f"Source changed without tests in diff ({preview}) — "
"confirm test coverage"
f"Source changed without tests in diff ({preview}) — confirm test coverage"
),
)
)
Expand All @@ -491,8 +482,7 @@ def _build_hints(
not has_tests_in_diff
and source_additions >= SOURCE_HEAVY_MIN_ADDITIONS
and summary.total_additions > 0
and source_additions * 100
>= SOURCE_HEAVY_PERCENT_THRESHOLD * summary.total_additions
and source_additions * 100 >= SOURCE_HEAVY_PERCENT_THRESHOLD * summary.total_additions
):
hints.append(
focus_risk_hint(
Expand All @@ -505,9 +495,7 @@ def _build_hints(
)
)

non_binary_files = [
file_change for file_change in summary.files if not file_change.binary
]
non_binary_files = [file_change for file_change in summary.files if not file_change.binary]
if non_binary_files and all(
categorize_path(file_change.path) == "tests" for file_change in non_binary_files
):
Expand Down Expand Up @@ -637,9 +625,7 @@ def _mixed_concerns_hint(
return None

source_ci_segments = sorted(
segment
for segment, cats in segment_categories.items()
if "source" in cats or "ci" in cats
segment for segment, cats in segment_categories.items() if "source" in cats or "ci" in cats
)
if len(source_ci_segments) < MIXED_CONCERNS_MIN_SOURCE_CI_SEGMENTS:
return None
Expand Down Expand Up @@ -720,9 +706,7 @@ def _missing_test_file_hints(
hints.append(
focus_risk_hint(
code="missing_test_file",
message=(
f"Changed {file_change.path} has no {test_rel} on disk"
),
message=(f"Changed {file_change.path} has no {test_rel} on disk"),
path=file_change.path,
)
)
Expand All @@ -737,9 +721,7 @@ def _lockfile_consistency_hints(
) -> list[FocusRiskHint]:
"""Emit hints when lockfile and manifest changes are inconsistent."""
changed_lockfiles = [
file_change.path
for file_change in summary.files
if is_lockfile_path(file_change.path)
file_change.path for file_change in summary.files if is_lockfile_path(file_change.path)
]
changed_manifests = [
file_change.path
Expand All @@ -765,18 +747,14 @@ def _lockfile_consistency_hints(

if changed_manifests and not changed_lockfiles and cwd is not None:
root = Path(cwd)
lockfiles_on_disk = [
name for name in sorted(_LOCKFILE_BASENAMES) if (root / name).exists()
]
lockfiles_on_disk = [name for name in sorted(_LOCKFILE_BASENAMES) if (root / name).exists()]
if lockfiles_on_disk:
preview = ", ".join(changed_manifests[:3])
if len(changed_manifests) > 3:
preview = f"{preview}, +{len(changed_manifests) - 3} more"
lockfile_preview = ", ".join(lockfiles_on_disk[:3])
if len(lockfiles_on_disk) > 3:
lockfile_preview = (
f"{lockfile_preview}, +{len(lockfiles_on_disk) - 3} more"
)
lockfile_preview = f"{lockfile_preview}, +{len(lockfiles_on_disk) - 3} more"
hints.append(
focus_risk_hint(
code="manifest_without_lockfile",
Expand Down Expand Up @@ -825,11 +803,7 @@ def _is_config_path(
def _is_ci_path(parts_lower: tuple[str, ...]) -> bool:
if parts_lower and parts_lower[0] == "ci":
return True
return (
len(parts_lower) >= 2
and parts_lower[0] == ".github"
and parts_lower[1] == "workflows"
)
return len(parts_lower) >= 2 and parts_lower[0] == ".github" and parts_lower[1] == "workflows"


def _is_ci_directory_path(path: str) -> bool:
Expand All @@ -841,11 +815,7 @@ def _is_ci_directory_path(path: str) -> bool:
def _is_github_workflow_path(path: str) -> bool:
posix = PurePosixPath(path.replace("\\", "/"))
parts_lower = tuple(part.lower() for part in posix.parts)
return (
len(parts_lower) >= 2
and parts_lower[0] == ".github"
and parts_lower[1] == "workflows"
)
return len(parts_lower) >= 2 and parts_lower[0] == ".github" and parts_lower[1] == "workflows"


def _is_docs_path(
Expand Down Expand Up @@ -912,11 +882,7 @@ def _is_ci_workflow_validator_path(path: str) -> bool:

if parts_lower and parts_lower[0] == "ci":
return True
if (
len(parts_lower) >= 2
and parts_lower[0] == ".github"
and parts_lower[1] == "workflows"
):
if len(parts_lower) >= 2 and parts_lower[0] == ".github" and parts_lower[1] == "workflows":
return True
return False

Expand Down
1 change: 0 additions & 1 deletion src/diffrat/analysis_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,3 @@ def run_analysis(
elif llm_result.error is not None:
result = replace(result, llm_error=llm_result.error)
return result

6 changes: 1 addition & 5 deletions src/diffrat/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,7 @@ def bandit_targets_for_paths(paths: list[str]) -> list[str]:

def is_pip_audit_dependency_path(path: str) -> bool:
"""Return True when a changed path should trigger pip-audit."""
return (
is_pyproject_path(path)
or is_lockfile_path(path)
or is_dependency_manifest_path(path)
)
return is_pyproject_path(path) or is_lockfile_path(path) or is_dependency_manifest_path(path)


def mypy_targets_for_paths(paths: list[str]) -> list[str]:
Expand Down
16 changes: 4 additions & 12 deletions src/diffrat/content_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@
re.compile(r"BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY", re.IGNORECASE),
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
re.compile(r"\bsk-[a-zA-Z0-9]{20,}\b"),
re.compile(
r"""(?i)(?:api[_-]?key|secret|password|token|auth)\s*=\s*['"][^'"]{8,}['"]"""
),
re.compile(r"""(?i)(?:api[_-]?key|secret|password|token|auth)\s*=\s*['"][^'"]{8,}['"]"""),
)

_DEBUG_LEFTOVER_PATTERNS: tuple[re.Pattern[str], ...] = (
Expand Down Expand Up @@ -265,9 +263,7 @@ def _is_high_entropy_literal(value: str) -> bool:
return False
counts = Counter(value)
length = len(value)
entropy = -sum(
(count / length) * math.log2(count / length) for count in counts.values()
)
entropy = -sum((count / length) * math.log2(count / length) for count in counts.values())
return entropy >= _ENTROPY_THRESHOLD_BITS


Expand All @@ -279,10 +275,7 @@ def _matches_broad_exception(line: str) -> bool:


def _matches_hardcoded_url_or_ip(line: str) -> bool:
return (
_HARDCODED_URL_PATTERN.search(line) is not None
or _IPV4_PATTERN.search(line) is not None
)
return _HARDCODED_URL_PATTERN.search(line) is not None or _IPV4_PATTERN.search(line) is not None


def _long_added_hunk_hints(file_diff: FileDiffContent) -> list[FocusRiskHint]:
Expand Down Expand Up @@ -355,8 +348,7 @@ def _cli_flag_without_help_hints(file_diff: FileDiffContent) -> list[FocusRiskHi
focus_risk_hint(
code="cli_flag_without_help",
message=(
f"CLI flag added without help text in {file_diff.path}: "
f"{text.strip()}"
f"CLI flag added without help text in {file_diff.path}: {text.strip()}"
),
path=file_diff.path,
line=line_no,
Expand Down
4 changes: 1 addition & 3 deletions src/diffrat/diff_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,7 @@ def parse_unified_diff(
file_blocks = _split_patch_into_file_blocks(patch)
if only_paths is not None:
filtered_blocks = [
block
for block in file_blocks
if _extract_path_from_block(block) in only_paths
block for block in file_blocks if _extract_path_from_block(block) in only_paths
]
filtered_files = [
_parse_file_block(
Expand Down
21 changes: 5 additions & 16 deletions src/diffrat/json_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,9 @@ def render_review_json(
brief: bool = False,
) -> str:
"""Render a review report as a JSON document for stdout."""
result = (
analysis
if analysis is not None
else run_analysis(summary, diff_content=diff_content)
)

sorted_entries = sort_file_entries(
summary.files, result.categories, result.risk_scores
)
result = analysis if analysis is not None else run_analysis(summary, diff_content=diff_content)

sorted_entries = sort_file_entries(summary.files, result.categories, result.risk_scores)
sorted_paths = [entry[0].path for entry in sorted_entries]
review_order = [entry[0].path for entry in review_order_entries(sorted_entries)]

Expand Down Expand Up @@ -103,10 +97,7 @@ def render_review_json(
],
"review_order": review_order,
"files_by_category": files_by_category_mapping(sorted_entries),
"focus_risk": [
_serialize_focus_risk_hint(hint)
for hint in sort_hints(list(result.hints))
],
"focus_risk": [_serialize_focus_risk_hint(hint) for hint in sort_hints(list(result.hints))],
"review_quality": {
"pillars": [
{
Expand Down Expand Up @@ -181,9 +172,7 @@ def _serialize_changes(
limits = {
"max_files": MAX_CHANGE_FILES,
"max_lines_per_file": (
max_lines_per_file_limit
if max_lines_per_file_limit is not None
else MAX_LINES_PER_FILE
max_lines_per_file_limit if max_lines_per_file_limit is not None else MAX_LINES_PER_FILE
),
}
if diff_content is None:
Expand Down
5 changes: 1 addition & 4 deletions src/diffrat/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,7 @@ def _http_error_message(exc: urllib.error.HTTPError) -> str:
if exc.code in (401, 403):
if api_message:
return f"LLM authentication failed (HTTP {exc.code}): {api_message}"
return (
f"LLM authentication failed (HTTP {exc.code}) — check "
"DIFFRAT_LLM_API_KEY"
)
return f"LLM authentication failed (HTTP {exc.code}) — check DIFFRAT_LLM_API_KEY"

if exc.code == 404:
if api_message:
Expand Down
Loading
Loading