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
2 changes: 1 addition & 1 deletion src/agentready/cli/align.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def align(repository, dry_run, attributes, interactive):
scanner = Scanner(repo_path, config)

# Create assessors
from agentready.assessors import create_all_assessors
from agentready.cli.main import create_all_assessors

assessors = create_all_assessors()

Expand Down
16 changes: 16 additions & 0 deletions src/agentready/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,19 @@ def get_weight(self, attribute_id: str, default: float) -> float:
def is_excluded(self, attribute_id: str) -> bool:
"""Check if attribute is excluded from assessment."""
return attribute_id in self.excluded_attributes

@classmethod
def load_default(cls) -> "Config":
"""Create a default configuration with no customizations.

Returns:
Config with empty weights, no exclusions, no overrides
"""
return cls(
weights={},
excluded_attributes=[],
language_overrides={},
output_dir=None,
report_theme="default",
custom_theme=None,
)
4 changes: 4 additions & 0 deletions src/agentready/models/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class Repository:

def __post_init__(self):
"""Validate repository data after initialization."""
# Convert string paths to Path objects for runtime type safety
if isinstance(self.path, str):
object.__setattr__(self, "path", Path(self.path))

if not self.path.exists():
raise ValueError(f"Repository path does not exist: {self.path}")

Expand Down
48 changes: 36 additions & 12 deletions src/agentready/utils/privacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def sanitize_path(path: Path | str, relative_to: Path | None = None) -> str:
"secret/data.txt"
"""
path_obj = Path(path) if isinstance(path, str) else path
requested_relative = relative_to is not None

# Try to make relative to specified directory
if relative_to:
Expand All @@ -44,25 +45,48 @@ def sanitize_path(path: Path | str, relative_to: Path | None = None) -> str:
# Convert to string for replacements
path_str = str(path_obj)

# Redact home directory
try:
home = str(Path.home())
if path_str.startswith(home):
path_str = path_str.replace(home, "~")
except (RuntimeError, OSError):
pass

# Redact username
# Redact home directory and username
# Note: Do specific replacements first (home directories) before generic username replacement
try:
username = getpass.getuser()
path_str = path_str.replace(f"/{username}/", "/<user>/")
path_str = path_str.replace(f"\\{username}\\", "\\<user>\\")
# Replace specific home directory patterns first
path_str = path_str.replace(f"/Users/{username}/", "~/")
path_str = path_str.replace(f"/home/{username}/", "~/")
path_str = path_str.replace(f"C:\\Users\\{username}\\", "~\\")
# Then do generic username replacement for other locations
path_str = path_str.replace(f"/{username}/", "/<user>/")
path_str = path_str.replace(f"\\{username}\\", "\\<user>\\")
except Exception:
pass

# Fallback: Redact home directory using Path.home() for current user
try:
home = str(Path.home())
if path_str.startswith(home):
path_str = path_str.replace(home, "~", 1)
except (RuntimeError, OSError):
pass

# Generic home directory pattern sanitization for any username
# Replace common home directory patterns even if they don't match current user
path_str = re.sub(r"/home/[^/]+/", "~/", path_str)
path_str = re.sub(r"/Users/[^/]+/", "~/", path_str)
path_str = re.sub(r"C:\\Users\\[^\\]+\\", r"~\\", path_str)

# Final fallback: Redact any remaining absolute paths to avoid leaking sensitive locations
# This catches paths like /secret, /opt/app, /var/sensitive, etc.
# Only do this if the path hasn't already been sanitized (contains ~ or <user>)
# AND if relative_to wasn't requested (in that case, preserve original for debugging)
if not requested_relative:
if (
"~" not in path_str
and "<user>" not in path_str
and "<path>" not in path_str
):
# If it's an absolute path, redact it
if path_str.startswith("/") or (len(path_str) > 2 and path_str[1] == ":"):
path_str = "<path>"

return path_str


Expand Down Expand Up @@ -91,7 +115,7 @@ def sanitize_command_args(args: List[str]) -> List[str]:
continue

# Redact values after these flags
if arg in ("--config", "-c", "--api-key", "--token", "--password"):
if arg in ("--config", "-c", "--api-key", "--key", "--token", "--password"):
sanitized.append(arg)
skip_next = True
continue
Expand Down
65 changes: 46 additions & 19 deletions tests/unit/cli/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,23 @@ def test_repo(tmp_path):


@pytest.fixture
def mock_assessment():
def mock_assessment(tmp_path):
"""Create a mock assessment for testing."""
from datetime import datetime

from agentready.models.assessment import Assessment
from agentready.models.attribute import Attribute
from agentready.models.finding import Finding
from agentready.models.repository import Repository

# Create a temporary directory with .git for Repository validation
test_repo_path = tmp_path / "test-repo"
test_repo_path.mkdir()
(test_repo_path / ".git").mkdir()

repo = Repository(
name="test-repo",
path=Path("/tmp/test"),
path=test_repo_path,
url=None,
branch="main",
commit_hash="abc123",
Expand All @@ -62,14 +71,40 @@ def mock_assessment():
total_lines=100,
)

# Create 25 dummy findings to match attributes_total requirement
findings = []
for i in range(25):
attr = Attribute(
id=f"attr_{i}",
name=f"Attribute {i}",
category="Testing",
tier=1,
description="Test attribute",
criteria="Test criteria",
default_weight=0.5,
)
finding = Finding(
attribute=attr,
status="pass" if i < 20 else "not_applicable",
score=100.0 if i < 20 else 0.0,
measured_value="present",
threshold="present",
evidence=[f"Test evidence {i}"],
remediation=None,
error_message=None,
)
findings.append(finding)

assessment = Assessment(
repository=repo,
findings=[],
timestamp=datetime.now(),
findings=findings,
overall_score=85.0,
certification_level="Gold",
attributes_assessed=20,
attributes_not_assessed=5,
attributes_total=25,
config=None,
duration_seconds=1.5,
)

Expand Down Expand Up @@ -393,9 +428,7 @@ def test_load_config_sensitive_output_dir(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("output_dir: /etc/passwords")

with pytest.raises(
ValueError, match="cannot be in sensitive system directory"
):
with pytest.raises(ValueError, match="cannot be in sensitive system directory"):
load_config(config_file)

def test_load_config_invalid_report_theme(self, tmp_path):
Expand Down Expand Up @@ -482,9 +515,7 @@ def test_generate_config_creates_file(self, runner):
"""Test generate-config creates config file."""
with runner.isolated_filesystem():
# Create example config
Path(".agentready-config.example.yaml").write_text(
"weights:\n attr1: 1.0"
)
Path(".agentready-config.example.yaml").write_text("weights:\n attr1: 1.0")

result = runner.invoke(generate_config, [])

Expand All @@ -504,9 +535,7 @@ def test_generate_config_overwrite_prompt(self, runner):
"""Test generate-config prompts when file exists."""
with runner.isolated_filesystem():
# Create both example and target
Path(".agentready-config.example.yaml").write_text(
"weights:\n attr1: 1.0"
)
Path(".agentready-config.example.yaml").write_text("weights:\n attr1: 1.0")
Path(".agentready-config.yaml").write_text("existing: content")

# Decline overwrite
Expand All @@ -520,9 +549,7 @@ def test_generate_config_overwrite_confirm(self, runner):
"""Test generate-config overwrites when confirmed."""
with runner.isolated_filesystem():
# Create both example and target
Path(".agentready-config.example.yaml").write_text(
"weights:\n attr1: 2.0"
)
Path(".agentready-config.example.yaml").write_text("weights:\n attr1: 2.0")
Path(".agentready-config.yaml").write_text("existing: content")

# Confirm overwrite
Expand Down Expand Up @@ -598,9 +625,7 @@ def test_assess_large_repo_warning(self, runner, test_repo, mock_assessment):
mock_scanner_class.return_value = mock_scanner

# Mock file count to be large
with patch(
"agentready.cli.main.safe_subprocess_run"
) as mock_subprocess:
with patch("agentready.cli.main.safe_subprocess_run") as mock_subprocess:
# Simulate large repo with 15000 files
mock_subprocess.return_value = MagicMock(
returncode=0, stdout="\n".join(["file.py"] * 15000)
Expand All @@ -624,7 +649,9 @@ def test_run_assessment_function(self, test_repo, mock_assessment):
mock_scanner_class.return_value = mock_scanner

# Call run_assessment directly
run_assessment(str(test_repo), verbose=False, output_dir=None, config_path=None)
run_assessment(
str(test_repo), verbose=False, output_dir=None, config_path=None
)

# Should have created reports
assert (test_repo / ".agentready").exists()
Expand Down
84 changes: 39 additions & 45 deletions tests/unit/learners/test_pattern_extractor.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,50 @@
"""Unit tests for pattern extraction."""

from datetime import datetime
from pathlib import Path

import pytest

from agentready.learners.pattern_extractor import PatternExtractor
from agentready.models import Assessment, Attribute, Finding, Repository


def create_test_repository(tmp_path=None):
"""Create a test repository with valid path."""
if tmp_path is None:
# For inline usage without fixture, create minimal valid repo
import tempfile

temp_dir = Path(tempfile.mkdtemp())
(temp_dir / ".git").mkdir(exist_ok=True)
test_repo = temp_dir
else:
test_repo = tmp_path / "test-repo"
test_repo.mkdir(exist_ok=True)
(test_repo / ".git").mkdir(exist_ok=True)

return Repository(
path=test_repo,
name="test",
url=None,
branch="main",
commit_hash="abc",
languages={},
total_files=0,
total_lines=0,
)


@pytest.fixture
def sample_repository():
def sample_repository(tmp_path):
"""Create test repository."""
# Create temporary directory with .git for Repository validation
test_repo = tmp_path / "test-repo"
test_repo.mkdir()
(test_repo / ".git").mkdir()

return Repository(
path="/tmp/test",
path=test_repo,
name="test-repo",
url=None,
branch="main",
Expand Down Expand Up @@ -188,9 +220,7 @@ def test_filters_low_score_findings(self, sample_assessment_with_findings):
assert len(skills) == 1
assert skills[0].confidence == 95.0

def test_filters_failing_findings(
self, sample_repository, sample_finding_failing
):
def test_filters_failing_findings(self, sample_repository, sample_finding_failing):
"""Test that failing findings are filtered."""
assessment = Assessment(
repository=sample_repository,
Expand Down Expand Up @@ -296,16 +326,7 @@ def test_extract_specific_patterns_filters_correctly(
def test_should_extract_pattern_logic(self, sample_finding_high_score):
"""Test _should_extract_pattern() logic."""
assessment = Assessment(
repository=Repository(
path="/tmp",
name="test",
url=None,
branch="main",
commit_hash="abc",
languages={},
total_files=0,
total_lines=0,
),
repository=create_test_repository(),
timestamp=datetime.now(),
overall_score=95.0,
certification_level="Platinum",
Expand Down Expand Up @@ -367,16 +388,7 @@ def test_should_not_extract_unknown_attribute(self, sample_repository):
def test_create_skill_from_finding(self, sample_finding_high_score):
"""Test _create_skill_from_finding() creates valid skill."""
assessment = Assessment(
repository=Repository(
path="/tmp",
name="test",
url=None,
branch="main",
commit_hash="abc",
languages={},
total_files=0,
total_lines=0,
),
repository=create_test_repository(),
timestamp=datetime.now(),
overall_score=95.0,
certification_level="Platinum",
Expand Down Expand Up @@ -494,16 +506,7 @@ def test_reusability_score_calculation(self, sample_repository):
def test_extract_code_examples_from_evidence(self, sample_finding_high_score):
"""Test extracting code examples from evidence."""
assessment = Assessment(
repository=Repository(
path="/tmp",
name="test",
url=None,
branch="main",
commit_hash="abc",
languages={},
total_files=0,
total_lines=0,
),
repository=create_test_repository(),
timestamp=datetime.now(),
overall_score=95.0,
certification_level="Platinum",
Expand Down Expand Up @@ -564,16 +567,7 @@ def test_extract_code_examples_limits_to_three(self, sample_repository):
def test_create_pattern_summary(self, sample_finding_high_score):
"""Test pattern summary generation."""
assessment = Assessment(
repository=Repository(
path="/tmp",
name="test",
url=None,
branch="main",
commit_hash="abc",
languages={},
total_files=0,
total_lines=0,
),
repository=create_test_repository(),
timestamp=datetime.now(),
overall_score=95.0,
certification_level="Platinum",
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/test_fixer_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ def generate_fix(self, repository: Repository, finding: Finding):
@pytest.fixture
def sample_repository(tmp_path):
"""Create test repository."""
# Create .git directory for Repository validation
(tmp_path / ".git").mkdir()

return Repository(
path=tmp_path,
name="test-repo",
Expand Down
Loading