Skip to content
Open
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
88 changes: 65 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,53 @@ jobs:
- name: Pre-commit (repo policy)
uses: pre-commit/[email protected]

# === PASS B: CI BARRIERS TO PREVENT REGRESSION ===

- name: "BARRIER: File size limit (max 500 LOC)"
run: |
echo "πŸ›‘οΈ Enforcing file size limits to prevent regression..."
chmod +x scripts/check-file-size.sh
scripts/check-file-size.sh

- name: "BARRIER: Ruff complexity and quality rules (strict)"
run: |
echo "πŸ›‘οΈ Enforcing strict complexity and quality rules..."
# Complexity rules (C901) and pylint refactor rules (PLR)
python -m ruff check . --select PLR0913,PLR0912,PLR0911,PLR0915,C901 --no-cache
# All configured quality rules as errors
python -m ruff check . --no-cache

- name: "BARRIER: No magic numbers in tests"
run: |
echo "πŸ›‘οΈ Checking for magic numbers in tests..."
# Allow some magic numbers but fail on excessive use
VIOLATIONS=$(python -m ruff check . --select PLR2004 --format=json | python -c "import sys, json; print(len(json.load(sys.stdin)))")
echo "Magic number violations: $VIOLATIONS"
if [ "$VIOLATIONS" -gt 350 ]; then
echo "::error::Too many magic numbers ($VIOLATIONS > 350). Refactor to use constants."
exit 1
fi

- name: "BARRIER: Code duplication limit (max 1%)"
run: |
echo "πŸ›‘οΈ Enforcing code duplication limits..."
npx --yes [email protected] --min-lines 9 --pattern "autorepro/**/*.py" --reporters json --output /tmp/jscpd || true
RATE=$(jq -r '.statistics.total.duplicatedRate // 0' /tmp/jscpd/jscpd-report.json 2>/dev/null || echo 0)
echo "Duplicated rate: $RATE"
RATE="$RATE" python - <<'PY'
import os, sys
try:
rate = float(os.environ.get("RATE", "0") or 0)
except Exception:
rate = 0.0
if rate > 0.01:
print(f"::error::Code duplication {rate:.4f} > 0.01 (1%) - refactor duplicated code")
sys.exit(1)
PY

# === PASS C: COMPREHENSIVE TESTING ===

- name: Ruff (lint)
run: python -m ruff check .
# === PASS C: COMPREHENSIVE TESTING ===

- name: Mypy (type check)
run: mypy autorepro tests || true
Expand All @@ -66,46 +109,45 @@ jobs:
echo "---- Coverage summary ----"
grep -E "^TOTAL" /tmp/cov.txt || true

- name: Duplication budget (jscpd <= 1%)
- name: Pytest quiet + goldens validation
run: |
npx --yes [email protected] --min-lines 9 --pattern "autorepro/**/*.py" --reporters json --output /tmp/jscpd || true
RATE=$(jq -r '.statistics.total.duplicatedRate // 0' /tmp/jscpd/jscpd-report.json 2>/dev/null || echo 0)
echo "Duplicated rate: $RATE"
RATE="$RATE" python - <<'PY'
import os, sys
try:
rate = float(os.environ.get("RATE", "0") or 0)
except Exception:
rate = 0.0
if rate > 0.01:
print(f"::error::Duplicated rate {rate:.4f} > 0.01 (1%)")
sys.exit(1)
PY
pytest -q
python scripts/regold.py --write
git diff --exit-code || (echo "::error::Golden tests have drifted. Run 'python scripts/regold.py --write' to update." && exit 1)

- name: Coverage floor (>= 50%)
- name: "BARRIER: Coverage floor (>= 50%)"
run: |
echo "πŸ›‘οΈ Enforcing minimum coverage requirements..."
PCT=$(grep -E "^TOTAL" /tmp/cov.txt | awk '{print $4}' | tr -d '%')
echo "Coverage: ${PCT:-0}%"
if [ "${PCT:-0}" -lt "50" ]; then
echo "::error::Coverage ${PCT:-0}% < 50%"; exit 1; fi
echo "::error::Coverage ${PCT:-0}% < 50% - add more tests"; exit 1; fi

- name: Enforce module boundaries (import-linter)
run: lint-imports

- name: LOC per PR (<= 300 added)
- name: "BARRIER: LOC per PR (<= 300 added)"
if: ${{ github.event_name == 'pull_request' }}
run: |
echo "πŸ›‘οΈ Enforcing PR size limits to prevent large changes..."
git fetch --no-tags --prune --depth=1 origin +refs/heads/${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}
# Calculate added lines, excluding deletions and modifications
ADDED=$(git diff --diff-filter=A --numstat origin/${{ github.base_ref }}... | awk '{s+=$1} END {print s+0}')
echo "Added lines: $ADDED"
if [ "${ADDED:-0}" -gt "300" ]; then
echo "::error::PR adds $ADDED LOC (>300). Split it."; exit 1; fi
echo "::error::PR adds $ADDED LOC (>300). Split into smaller PRs to maintain reviewability."; exit 1; fi

- name: Golden drift check
- name: "BARRIER: Critical function complexity check"
run: |
python scripts/regold.py --write
git diff --exit-code
echo "πŸ›‘οΈ Checking for overly complex functions that need refactoring..."
# Find functions with very high complexity using radon
COMPLEX_FUNCS=$(python -m radon cc autorepro/ -a -nc -s | grep -E "^F|^C" | wc -l || echo 0)
echo "Functions with F/C complexity: $COMPLEX_FUNCS"
if [ "$COMPLEX_FUNCS" -gt "3" ]; then
echo "::error::Too many complex functions ($COMPLEX_FUNCS > 3). Refactor high complexity code."
python -m radon cc autorepro/ -a -nc -s | grep -E "^F|^C" || true
exit 1
fi

- name: Verify CLI functionality
run: |
Expand Down
139 changes: 65 additions & 74 deletions autorepro/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,51 +721,48 @@ def cmd_scan( # noqa: PLR0913

print(json.dumps(json_result, indent=2))
return 0
else:
# Use enhanced evidence collection for text output too
try:
evidence = collect_evidence(
Path("."),
depth=depth,
ignore_patterns=ignore_patterns,
respect_gitignore=respect_gitignore,
)
except (OSError, PermissionError):
print("No known languages detected.")
return 0
# Use enhanced evidence collection for text output too
try:
evidence = collect_evidence(
Path("."),
depth=depth,
ignore_patterns=ignore_patterns,
respect_gitignore=respect_gitignore,
)
except (OSError, PermissionError):
print("No known languages detected.")
return 0

if not evidence:
print("No known languages detected.")
return 0
if not evidence:
print("No known languages detected.")
return 0

# Extract language names for header (sorted)
languages = sorted(evidence.keys())
print(f"Detected: {', '.join(languages)}")

# Extract language names for header (sorted)
languages = sorted(evidence.keys())
print(f"Detected: {', '.join(languages)}")

# Print details for each language
for lang in languages:
lang_data = evidence[lang]
reasons = lang_data.get("reasons", [])

# Extract unique patterns for display (with type check)
if isinstance(reasons, list):
patterns = list(
dict.fromkeys(
reason["pattern"]
for reason in reasons
if isinstance(reason, dict)
)
# Print details for each language
for lang in languages:
lang_data = evidence[lang]
reasons = lang_data.get("reasons", [])

# Extract unique patterns for display (with type check)
if isinstance(reasons, list):
patterns = list(
dict.fromkeys(
reason["pattern"] for reason in reasons if isinstance(reason, dict)
)
reasons_str = ", ".join(patterns)
else:
reasons_str = "unknown"
print(f"- {lang} -> {reasons_str}")
)
reasons_str = ", ".join(patterns)
else:
reasons_str = "unknown"
print(f"- {lang} -> {reasons_str}")

# Add score if --show-scores is enabled
if show_scores:
print(f" Score: {lang_data['score']}")
# Add score if --show-scores is enabled
if show_scores:
print(f" Score: {lang_data['score']}")

return 0
return 0


@dataclass
Expand Down Expand Up @@ -1173,7 +1170,7 @@ def _read_plan_input_text(config: PlanConfig) -> str:
try:
if config.desc is not None:
return config.desc
elif config.file is not None:
if config.file is not None:
# File path resolution: try CWD first, then repo-relative as fallback
file_path = Path(config.file)

Expand Down Expand Up @@ -1430,17 +1427,16 @@ def _output_plan_result(plan_data: PlanData, config: PlanConfig) -> int:
if config.print_to_stdout:
print(content, end="")
return 0
else:
# Write output file
try:
out_path = Path(config.out).resolve()
FileOperations.atomic_write(out_path, content)
print(f"Wrote repro to {out_path}")
return 0
except OSError as e:
log = logging.getLogger("autorepro")
log.error(f"Error writing file {config.out}: {e}")
return 1
# Write output file
try:
out_path = Path(config.out).resolve()
FileOperations.atomic_write(out_path, content)
print(f"Wrote repro to {out_path}")
return 0
except OSError as e:
log = logging.getLogger("autorepro")
log.error(f"Error writing file {config.out}: {e}")
return 1


def cmd_plan(config: PlanConfig | None = None, **kwargs) -> int:
Expand Down Expand Up @@ -1668,7 +1664,7 @@ def _read_exec_input_text(
try:
if config.desc is not None:
return config.desc, None
elif config.file is not None:
if config.file is not None:
file_path = Path(config.file)
if file_path.is_absolute():
with open(file_path, encoding="utf-8") as f:
Expand Down Expand Up @@ -2003,11 +1999,8 @@ def _execute_exec_pipeline(config: ExecConfig) -> int: # noqa: PLR0911
# Single command execution (backward compatible)
command_str, score, rationale = suggestions[selected_indices[0]]
return _execute_exec_command_real(command_str, repo_path, config)
else:
# Multi-command execution with JSONL support
return _execute_multiple_commands(
suggestions, selected_indices, repo_path, config
)
# Multi-command execution with JSONL support
return _execute_multiple_commands(suggestions, selected_indices, repo_path, config)


def _execute_exec_command_real(
Expand Down Expand Up @@ -2945,21 +2938,19 @@ def _setup_logging(args, project_verbosity: str | None = None) -> None:
level = logging.DEBUG
elif args.verbose == 1:
level = logging.INFO
else:
# Use project-level verbosity if provided
if project_verbosity == "quiet":
level = logging.ERROR
elif project_verbosity == "verbose":
level = logging.INFO
else:
level = logging.WARNING
else:
if project_verbosity == "quiet":
# Use project-level verbosity if provided
elif project_verbosity == "quiet":
level = logging.ERROR
elif project_verbosity == "verbose":
level = logging.INFO
else:
level = logging.WARNING
elif project_verbosity == "quiet":
level = logging.ERROR
elif project_verbosity == "verbose":
level = logging.INFO
else:
level = logging.WARNING

# Use centralized logging configuration (JSON/text), defaults to key=value text.
# Users can set AUTOREPRO_LOG_FORMAT=json for structured logs.
Expand Down Expand Up @@ -2989,17 +2980,17 @@ def _dispatch_command(args, parser) -> int: # noqa: PLR0911
"""Dispatch command based on parsed arguments."""
if args.command == "scan":
return _dispatch_scan_command(args)
elif args.command == "init":
if args.command == "init":
return _dispatch_init_command(args)
elif args.command == "plan":
if args.command == "plan":
return _dispatch_plan_command(args)
elif args.command == "exec":
if args.command == "exec":
return _dispatch_exec_command(args)
elif args.command == "pr":
if args.command == "pr":
return _dispatch_pr_command(args)
elif args.command == "report":
if args.command == "report":
return _dispatch_report_command(args)
elif args.command == "replay":
if args.command == "replay":
return _dispatch_replay_command(args)

return _dispatch_help_command(parser)
Expand Down
9 changes: 4 additions & 5 deletions autorepro/config/argument_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,11 +314,10 @@ def create_config_from_args(command: str, **kwargs: Any) -> Any:
"""Factory function to create appropriate config based on command."""
if command == "plan":
return EnhancedPlanConfig.from_args(**kwargs)
elif command == "exec":
if command == "exec":
return EnhancedExecConfig.from_args(**kwargs)
elif command == "pr":
if command == "pr":
return EnhancedPrConfig.from_args(**kwargs)
elif command == "init":
if command == "init":
return EnhancedInitConfig.from_args(**kwargs)
else:
raise ValueError(f"Unknown command: {command}")
raise ValueError(f"Unknown command: {command}")
6 changes: 2 additions & 4 deletions autorepro/core/plan_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,8 +328,7 @@ def _generate_content(plan_data: PlanData, config: PlanConfig) -> str:
"""Generate content in the requested format."""
if config.format_type == "json":
return PlanOutputHandler._generate_json_content(plan_data)
else:
return PlanOutputHandler._generate_markdown_content(plan_data)
return PlanOutputHandler._generate_markdown_content(plan_data)

@staticmethod
def _generate_json_content(plan_data: PlanData) -> str:
Expand Down Expand Up @@ -408,8 +407,7 @@ def generate_plan(self) -> int:
except ValueError as e:
if "min-score" in str(e):
return 1 # Strict mode failure
else:
return 2 # Configuration error
return 2 # Configuration error
except OSError:
return 1 # File I/O error

Expand Down
5 changes: 2 additions & 3 deletions autorepro/core/planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,8 @@ def _extract_plugin_keywords(text: str, plugin_keywords: set[str]) -> set[str]:
if " " in keyword:
if keyword.lower() in text.lower():
matched_keywords.add(keyword)
else:
if keyword.lower() in text_words:
matched_keywords.add(keyword)
elif keyword.lower() in text_words:
matched_keywords.add(keyword)

return matched_keywords

Expand Down
Loading
Loading