diff --git a/project/ticket-014/README.md b/project/ticket-014/README.md new file mode 100644 index 0000000..043ebd9 --- /dev/null +++ b/project/ticket-014/README.md @@ -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. diff --git a/project/ticket-014/intent.json b/project/ticket-014/intent.json new file mode 100644 index 0000000..fb9f30f --- /dev/null +++ b/project/ticket-014/intent.json @@ -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."} + ] + } +} diff --git a/src/redup/cli_app/main.py b/src/redup/cli_app/main.py index 32c6611..4605719 100644 --- a/src/redup/cli_app/main.py +++ b/src/redup/cli_app/main.py @@ -36,6 +36,7 @@ def _main_callback() -> None: try: from redup.autoupdate import check_for_updates + check_for_updates("redup") except Exception: pass @@ -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, diff --git a/src/redup/cli_app/scan_commands.py b/src/redup/cli_app/scan_commands.py index c099c12..63b35e5 100644 --- a/src/redup/cli_app/scan_commands.py +++ b/src/redup/cli_app/scan_commands.py @@ -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 @@ -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 @@ -341,14 +360,10 @@ 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" @@ -356,6 +371,16 @@ def check_command( 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."), diff --git a/tests/test_cli_check_budget.py b/tests/test_cli_check_budget.py new file mode 100644 index 0000000..e09d484 --- /dev/null +++ b/tests/test_cli_check_budget.py @@ -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