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
26 changes: 26 additions & 0 deletions project/ticket-014/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Ticket 014: Enforce reDUP CLI duplicate budgets

- **ID**: ticket-014
- **Owner**: agent:codex
- **Status**: IN_PROGRESS
- **Workflow state**: PUBLICATION
- **Created**: 2026-10-11

## Goal and scope

SESSION_EXECUTION_AUTHORIZATION: user requests continuation of standard refactoring, verification and Issue/PR maintenance. Parent wellmanifest/PLF-002; bounded follow-up [reDUP #20](https://github.com/semcod/redup/issues/20), Planfile PLF-022. Application preflight admits a disjoint scope; Docs229 remains frozen.

Fix the CLI check command's successful exit despite exceeded duplication budgets. Preserve scanner behavior and MCP contracts. Canonical cross-repository result remains [harmonization v13](https://github.com/subactor/docs/blob/b80c27e6d4c8caa0c6db389eddb4377d9f86cd8a/architecture/analysis/wellmanifest-standards-harmonization.md); new evidence awaits protected report recovery.

## Acceptance criteria

- [x] AC-01: CLI enforces both configured/default and explicit budgets, with equality passing and invalid budgets rejected.
- [x] AC-02: Actual CLI duplicate and empty fixtures plus configuration/override regression tests pass; managed governance passes.
- [ ] AC-03: Material change published and independently reviewed through the declared protected route.

## Tracking boundary

Verification: 16 new CLI cases; full suite 256 passed / 13 existing skips, zero failures. Three negative CLI cases fail against the unchanged baseline. Exact Metafile source canary exits1 with groups94/lines1152 instead of the baseline's misleading exit0. Ruff lint/format and scoped governance pass. No protected approval or merge is inferred from these local checks.

This directory contains the minimal reviewed intent. Optional participant prose
and raw command logs are not required delivery output.
48 changes: 48 additions & 0 deletions project/ticket-014/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-014",
"summary": "Enforce reDUP CLI duplicate budgets",
"workstream": "application",
"classification": {
"kind": "BUG",
"priority": "P1",
"origin": "health"
},
"allowedPaths": [
"project/ticket-014/**",
"src/redup/cli_app/main.py",
"src/redup/cli_app/scan_commands.py",
"tests/test_cli_check_budget.py"
],
"forbiddenPaths": [
"project/ticket-*/user-*.md"
],
"stacks": [],
"dependsOn": [],
"conflictsWith": [],
"integrationTicket": null,
"delivery": {
"acceptedBaseSha": "827418a798d9a5d4d455ffc7e4228e7d063c106f",
"targetBranch": "main",
"outcome": "CLI check rejects exceeded duplicate budgets and invalid limits, preserves inclusive boundaries and configuration precedence, with actual CLI regression coverage for Issue20.",
"nonGoals": ["No scanner or MCP changes", "No dependency or runtime activation", "No original frozen refactor changes", "No author approval or merge"],
"complexity": "S",
"estimatedMinutes": 20,
"budgets": {"maxImplementationFiles": 3, "maxAffectedComponents": 1, "maxPublicInterfaceChanges": 1, "maxRuntimeDependencies": 0},
"architecture": {
"status": "accepted",
"decision": "Resolve check budgets from existing canonical configuration loader; enforce raw DuplicationMap totals after one existing scan; keep display independent of limits. Existing command flags and scanner/MCP contracts remain stable.",
"components": [{"name": "cli-check", "paths": ["src/redup/cli_app/main.py", "src/redup/cli_app/scan_commands.py", "tests/test_cli_check_budget.py"]}],
"responsibilityChanges": false,
"interfaceChanges": ["The declared CI check now returns1 when either configured budget is exceeded."],
"dataChanges": [],
"ui": {"impact": "none", "states": [], "evidence": []},
"rollback": "Revert the bounded CLI fix through independent delivery; no persisted source or runtime data is migrated."
},
"runtimeDependencies": [],
"validation": [
{"criterion": "AC-01", "commands": ["python -m pytest tests/test_cli_check_budget.py"], "evidence": "16 actual CLI cases; three negative cases fail against the unchanged baseline."},
{"criterion": "AC-02", "commands": ["python -m pytest tests", "project/governance-check.sh", "ruff check", "ruff format --check"], "evidence": "256passed/13existing skips, zero failures; governance and lint/format pass."}
]
}
}
3 changes: 3 additions & 0 deletions src/redup/cli_app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
def _main_callback() -> None:
try:
from redup.autoupdate import check_for_updates

check_for_updates("redup")
except Exception:
pass
Expand Down Expand Up @@ -303,11 +304,13 @@ def check(
None,
"--max-groups",
help="Maximum allowed duplicate groups. Overrides config.",
min=0,
),
max_saved_lines: int | None = typer.Option(
None,
"--max-lines",
help="Maximum allowed recoverable lines. Overrides config.",
min=0,
),
extensions: str | None = typer.Option(
None,
Expand Down
43 changes: 34 additions & 9 deletions src/redup/cli_app/scan_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from redup.cli_app.config_builder import build_config, build_config_with_file_support
from redup.cli_app.scan_helpers import print_scan_header, print_scan_summary
from redup.core.config import create_sample_redup_toml
from redup.core.config import create_sample_redup_toml, load_config
from redup.core.models import DEFAULT_SEMANTIC_THRESHOLD
from redup.core.pipeline import analyze, analyze_optimized

Expand Down Expand Up @@ -330,7 +330,26 @@ def check_command(
),
include_tests: bool | None = typer.Option(False, "--include-tests", help="Include test files."),
) -> None:
"""Quick check for duplicates with summary report."""
"""Check raw duplicate counts against inclusive CI budgets."""

path = _validate_scan_path(path)
settings = load_config(path)
check_settings = settings.get("check", {})
if not isinstance(check_settings, dict):
raise typer.BadParameter("check configuration must be a table")
group_limit = (
max_groups
if max_groups is not None
else settings.get("max_groups", check_settings.get("max_groups", 10))
)
line_limit = (
max_saved_lines
if max_saved_lines is not None
else settings.get("max_lines", check_settings.get("max_lines", 100))
)
for name, limit in (("max-groups", group_limit), ("max-lines", line_limit)):
if type(limit) is not int or limit < 0:
raise typer.BadParameter(f"{name} must be a nonnegative integer")

config = build_config_with_file_support(
path, extensions, min_lines, min_similarity, include_tests
Expand All @@ -341,21 +360,27 @@ def check_command(
dup_map = analyze(config)
print_scan_summary(dup_map)

# Show top duplicate groups
# Diagnostic output is bounded independently of the pass/fail budgets.
if dup_map.groups:
_max_groups = max_groups if max_groups is not None else 10
_max_saved = max_saved_lines if max_saved_lines is not None else 100
typer.echo(f"\n🎯 Top {min(_max_groups, len(dup_map.groups))} duplicate groups:")
for i, group in enumerate(dup_map.sorted_by_impact()[:_max_groups]):
if group.saved_lines_potential <= _max_saved:
break
typer.echo(f"\n🎯 Top {min(10, len(dup_map.groups))} duplicate groups:")
for i, group in enumerate(dup_map.sorted_by_impact()[:10]):
typer.echo(
f" {i + 1}. {group.id}: {group.occurrences} occurrences, "
f"{group.saved_lines_potential} lines recoverable"
)
else:
typer.echo("✅ No duplicates found!")

violations = []
if dup_map.total_groups > group_limit:
violations.append(f"groups {dup_map.total_groups} > {group_limit}")
if dup_map.total_saved_lines > line_limit:
violations.append(f"recoverable lines {dup_map.total_saved_lines} > {line_limit}")
if violations:
typer.echo("❌ Duplicate budget exceeded: " + "; ".join(violations))
raise typer.Exit(1)
typer.echo(f"✅ Duplicate budgets passed: groups <= {group_limit}, lines <= {line_limit}")


def config_command(
init: bool = typer.Option(False, "--init", help="Create sample redup.toml configuration."),
Expand Down
130 changes: 130 additions & 0 deletions tests/test_cli_check_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Exercise the CLI gate with real duplicate files and configured budgets."""

from pathlib import Path

import pytest
from typer.testing import CliRunner

from redup.cli_app.config_builder import build_config_with_file_support
from redup.cli_app.main import app
from redup.core.pipeline import analyze

runner = CliRunner()


@pytest.fixture(autouse=True)
def clean_environment(monkeypatch):
monkeypatch.setenv("NO_AUTOUPDATE", "1")
for key in ("REDUP_MAX_GROUPS", "REDUP_MAX_LINES"):
monkeypatch.delenv(key, raising=False)


@pytest.fixture
def duplicates(tmp_path: Path) -> Path:
code = (
"def calculate(value):\n"
+ "".join(f" value += {index}\n" for index in range(12))
+ " return value\n"
)
(tmp_path / "first.py").write_text(code)
(tmp_path / "second.py").write_text(code)
return tmp_path


def invoke(path, *options):
return runner.invoke(app, ["check", str(path), "--ext", ".py", "--min-lines", "10", *options])


@pytest.mark.parametrize(
"options, message",
[
(["--max-groups", "0", "--max-lines", "10000"], "groups"),
(["--max-groups", "10000", "--max-lines", "0"], "recoverable lines"),
(["--max-groups", "0", "--max-lines", "0"], "Duplicate budget exceeded"),
],
)
def test_actual_duplicates_fail_each_budget(duplicates, options, message):
result = invoke(duplicates, *options)
assert result.exit_code == 1, result.output
assert message in result.output
assert "Top " in result.output # Zero budgets must not hide the diagnostics.


def test_exact_boundary_passes(duplicates):
config = build_config_with_file_support(duplicates, ".py", 10, None, False)
result_map = analyze(config)
assert result_map.total_groups > 0 and result_map.total_saved_lines > 0
result = invoke(
duplicates,
"--max-groups",
str(result_map.total_groups),
"--max-lines",
str(result_map.total_saved_lines),
)
assert result.exit_code == 0, result.output
assert "Duplicate budgets passed" in result.output


def test_zero_budget_passes_without_duplicates(tmp_path):
(tmp_path / "unique.py").write_text("print('unique')\n")
result = invoke(tmp_path, "--max-groups", "0", "--max-lines", "0")
assert result.exit_code == 0, result.output
assert "Scanned 1 files" in result.output
assert "No duplicates found" in result.output


def test_default_budgets_pass_small_duplicate_fixture(duplicates):
result = invoke(duplicates)
assert result.exit_code == 0, result.output
assert "groups <= 10, lines <= 100" in result.output


def test_default_budgets_reject_large_duplicate_fixture(duplicates):
code = (duplicates / "first.py").read_text()
for index in range(20):
(duplicates / f"copy_{index}.py").write_text(code)
result = invoke(duplicates)
assert result.exit_code == 1, result.output
assert "Duplicate budget exceeded" in result.output


def test_configured_budget_and_cli_override(duplicates):
(duplicates / "redup.toml").write_text("[check]\nmax_groups = 0\nmax_lines = 0\n")
assert invoke(duplicates).exit_code == 1
result = invoke(duplicates, "--max-groups", "10000", "--max-lines", "10000")
assert result.exit_code == 0, result.output


def test_environment_overrides_file_budget(duplicates, monkeypatch):
(duplicates / "redup.toml").write_text("[check]\nmax_groups = 10000\nmax_lines = 10000\n")
monkeypatch.setenv("REDUP_MAX_GROUPS", "0")
assert invoke(duplicates).exit_code == 1
assert invoke(duplicates, "--max-groups", "10000").exit_code == 0


@pytest.mark.parametrize("setting", ["max_groups = -1", "max_lines = -1", 'max_groups = "zero"'])
def test_invalid_configured_budget_is_rejected(duplicates, setting):
(duplicates / "redup.toml").write_text("[check]\n" + setting + "\n")
result = invoke(duplicates)
assert result.exit_code == 2, result.output
assert "must be a nonnegative integer" in result.output


@pytest.mark.parametrize("flag", ["--max-groups", "--max-lines"])
def test_negative_cli_budget_is_rejected(duplicates, flag):
result = invoke(duplicates, flag, "-1")
assert result.exit_code == 2, result.output


def test_missing_root_cannot_pass_as_zero_files(tmp_path):
result = invoke(tmp_path / "missing", "--max-groups", "0", "--max-lines", "0")
assert result.exit_code == 2, result.output
assert "scan root does not exist" in result.output


def test_file_root_cannot_pass_as_zero_files(tmp_path):
source = tmp_path / "module.py"
source.write_text("print('source')\n")
result = invoke(source, "--max-groups", "0", "--max-lines", "0")
assert result.exit_code == 2, result.output
assert "scan root is not a directory" in result.output
Loading