diff --git a/.agentready-config.example.yaml b/.agentready-config.example.yaml index f4c4ae1f..082d7fc3 100644 --- a/.agentready-config.example.yaml +++ b/.agentready-config.example.yaml @@ -82,6 +82,12 @@ report_theme: default # border: "#334155" # shadow: "rgba(0, 0, 0, 0.5)" +# Lint suppression density thresholds +# lint_suppression_density: +# pass_per_kloc: 5.0 # density at which score=100 (pass); default 5.0 +# fail_per_kloc: 15.0 # density at which score=0 (fail); default 15.0 +# exclude_tests: false # set true to exclude test files from LOC count + # Example: Increase weight for CLAUDE.md and tests # This increases CLAUDE.md from 7% to 15% and test_execution from 10% to 15% # Other attributes are automatically rescaled to maintain sum of 1.0 diff --git a/docs/attributes.md b/docs/attributes.md index 08a15883..ea8883a6 100644 --- a/docs/attributes.md +++ b/docs/attributes.md @@ -3,7 +3,7 @@ layout: page title: Attributes Reference --- -Complete reference for all 27 agent-ready attributes assessed by AgentReady. +Complete reference for all 30 agent-ready attributes assessed by AgentReady.

🤖 Bootstrap Automation

@@ -26,7 +26,7 @@ Complete reference for all 27 agent-ready attributes assessed by AgentReady. ## Overview -AgentReady evaluates repositories against 27 attributes derived from research by Anthropic, Microsoft, Google, ETH Zurich, and Red Hat. Each attribute has specific pass/fail criteria, a tier-based weight, and concrete remediation steps. +AgentReady evaluates repositories against 30 attributes derived from research by Anthropic, Microsoft, Google, ETH Zurich, and Red Hat. Each attribute has specific pass/fail criteria, a tier-based weight, and concrete remediation steps. Each entry below covers: what the assessor checks, the scoring breakdown, and how to fix a failing result. @@ -39,8 +39,8 @@ Attributes are organized into four weighted tiers: | Tier | Weight | Focus | Attribute Count | |------|--------|-------|-----------------| | **Tier 1: Essential** | 58% | Fundamentals enabling basic AI functionality | 9 attributes | -| **Tier 2: Critical** | 27% | Major quality improvements and safety nets | 9 attributes | -| **Tier 3: Important** | 13% | Significant improvements in specific areas | 7 attributes | +| **Tier 2: Critical** | 27% | Major quality improvements and safety nets | 10 attributes | +| **Tier 3: Important** | 13% | Significant improvements in specific areas | 9 attributes | | **Tier 4: Advanced** | 2% | Refinement and optimization | 2 attributes | Missing a Tier 1 attribute (up to 12% weight) has up to 12x the score impact of missing a Tier 4 attribute (1% weight). @@ -1501,6 +1501,51 @@ EOF --- +### Lint Suppression Density (`lint_suppression_density`, 2%) + +Counts suppression directives (`//nolint`, `# noqa`, `# type: ignore`, `# pylint: disable`, `// eslint-disable`, `// @ts-ignore`, `# rubocop:disable`, ``) across source files and normalizes the count per 1,000 lines of code. A high density means lint passes not because code is clean, but because violations are silenced — degrading lint as a quality signal for AI agents. + +**Supported languages**: Go, Python, JavaScript, TypeScript, Ruby, Java, Terraform, Shell, Dockerfile, YAML (including kube-linter directives), Markdown. + +**File scanning**: Only git-tracked regular files are scanned (`.gitignore` is honored; symlinks and non-regular files are skipped). Generated directories (`vendor/`, `node_modules/`, `.venv/`, etc.) are excluded even if tracked. Each file read is capped at 1 MiB. Returns **skipped** if git inventory is unavailable (missing git, non-git checkout, or timeout) — does not fall back to ignore-unaware directory walks. + +**Scoring**: + +| Condition | Score | +|-----------|-------| +| ≤5 suppressions/1k LOC | 100 (pass) | +| 5–15 suppressions/1k LOC | Linear 100→0 (fail) | +| >15 suppressions/1k LOC | 0 (fail) | + +**Pass threshold**: density ≤5 suppressions per 1,000 source lines. + +**Evidence reported**: total suppression count, total LOC scanned, density ratio, and the top files by suppression concentration. + +**Configuration** (`.agentready-config.yaml`): + +```yaml +lint_suppression_density: + pass_per_kloc: 5.0 # density at which score=100 (pass) + fail_per_kloc: 15.0 # density at which score=0 (fail) + exclude_tests: false # set true to exclude test files from LOC count +``` + +#### Remediation + +Fix the underlying lint violations instead of suppressing them. For unavoidable suppressions, use narrow rule-specific directives with explanatory comments: + +```python +# Python — specific rule + rationale (not blanket noqa) +result = legacy_func() # noqa: ERA001 # legacy API, refactor tracked in #123 + +# Go — rule name + reason +//nolint:errcheck // legacy path, full error handling in #456 +``` + +For generated or vendored code, exclude the directory from lint config rather than adding inline suppressions. + +--- + *Full details for each attribute available in the [research document](https://github.com/ambient-code/agentready/blob/main/RESEARCH_REPORT.md).* --- @@ -1520,12 +1565,12 @@ EOF ## Implementation Status -All 27 assessors are fully implemented across all four tiers. +All 30 assessors are fully implemented across all four tiers. **Current State**: - ✅ **Tier 1 (Essential)**: Fully implemented (9 attributes) -- ✅ **Tier 2 (Critical)**: Fully implemented (9 attributes) -- ✅ **Tier 3 (Important)**: Fully implemented (7 attributes) +- ✅ **Tier 2 (Critical)**: Fully implemented (10 attributes) +- ✅ **Tier 3 (Important)**: Fully implemented (9 attributes) - ✅ **Tier 4 (Advanced)**: Fully implemented (2 attributes) See the [GitHub repository](https://github.com/ambient-code/agentready) for current implementation details. diff --git a/src/agentready/assessors/__init__.py b/src/agentready/assessors/__init__.py index 83572e99..cdc3b38b 100644 --- a/src/agentready/assessors/__init__.py +++ b/src/agentready/assessors/__init__.py @@ -11,6 +11,7 @@ from .code_quality import ( CyclomaticComplexityAssessor, LintConfigCoverageAssessor, + LintSuppressionAssessor, StructuredLoggingAssessor, TypeAnnotationsAssessor, ) @@ -59,6 +60,7 @@ __all__ = [ "create_all_assessors", "BaseAssessor", + "LintSuppressionAssessor", "LockFilesAssessor", "AdrFrontmatterAssessor", ] @@ -99,15 +101,16 @@ def create_all_assessors() -> list[BaseAssessor]: LintConfigCoverageAssessor(), # 2% (issue #511) DbtDataTestsAssessor(), # dbt conditional DbtProjectStructureAssessor(), # dbt conditional - # Tier 3 Important — 13% total (8 attributes) + # Tier 3 Important — 13% total (9 attributes) ArchitectureDecisionsAssessor(), # 1% AdrFrontmatterAssessor(), # 2% (2.4 - ADR Frontmatter Completeness) OpenAPISpecsAssessor(), # 2% - CyclomaticComplexityAssessor(), # 2% + CyclomaticComplexityAssessor(), # 1% StructuredLoggingAssessor(), # 1% ProgressiveDisclosureAssessor(), # 1% (moved from T4) - ArchitecturalBoundaryAssessor(), # 2% (ADR B.1) + ArchitecturalBoundaryAssessor(), # 1% (ADR B.1) ThreatModelAssessor(), # 2% (ADR B.2) + LintSuppressionAssessor(), # 2% # Tier 4 Advanced — 2% total (2 attributes, 1% each) IssuePRTemplatesAssessor(), ContainerSetupAssessor(), diff --git a/src/agentready/assessors/code_quality.py b/src/agentready/assessors/code_quality.py index 898ed335..1db21de8 100644 --- a/src/agentready/assessors/code_quality.py +++ b/src/agentready/assessors/code_quality.py @@ -6,15 +6,18 @@ import logging import os import re +import stat import subprocess import tomllib from pathlib import Path +from typing import Iterator import lizard import radon.complexity import yaml from ..models.attribute import Attribute +from ..models.config import LintSuppressionOptions from ..models.finding import Citation, Finding, Remediation from ..models.repository import Repository from ..services.scanner import MissingToolError @@ -590,7 +593,7 @@ def attribute(self) -> Attribute: tier=self.tier, description="Cyclomatic complexity thresholds enforced", criteria="Average complexity <10, no functions >15", - default_weight=0.02, + default_weight=0.01, ) def is_applicable(self, repository: Repository) -> bool: @@ -1925,3 +1928,342 @@ def _create_remediation(self, missing: list[str], language: str) -> Remediation: ) ], ) + + +# ============================================================================= +# LintSuppressionAssessor — module-level constants +# ============================================================================= + +# Suppression directive patterns per language (applied per line) +_SUPPRESSION_PATTERNS: dict[str, list[re.Pattern]] = { + "Go": [re.compile(r"//\s*nolint")], + "Python": [ + re.compile(r"#\s*noqa"), + re.compile(r"#\s*type:\s*ignore"), + re.compile(r"#\s*pylint:\s*disable"), + re.compile(r"#\s*ruff:\s*noqa"), + re.compile(r"#\s*flake8:\s*noqa"), + ], + "JavaScript": [ + re.compile(r"//\s*eslint-disable"), + re.compile(r"/\*\s*eslint-disable"), + re.compile(r"//\s*@ts-ignore"), + ], + "TypeScript": [ + re.compile(r"//\s*eslint-disable"), + re.compile(r"/\*\s*eslint-disable"), + re.compile(r"//\s*@ts-ignore"), + re.compile(r"//\s*@ts-nocheck"), + re.compile(r"//\s*@ts-expect-error"), + ], + "Ruby": [re.compile(r"#\s*rubocop:disable")], + "Java": [re.compile(r"@SuppressWarnings")], + "Terraform": [re.compile(r"#\s*tflint-ignore:")], + "Shell": [re.compile(r"#\s*shellcheck\s+disable=")], + "Dockerfile": [re.compile(r"#\s*hadolint\s+ignore=")], + "YAML": [ + re.compile(r"#\s*yamllint\s+disable"), + re.compile(r"#\s*kube-linter\s+disable"), + ], + "Markdown": [re.compile(r", etc.) normalized per 1,000 lines " + "of source code" + ), + criteria=f"≤{_SUPPRESSION_DEFAULTS.pass_per_kloc} suppressions per 1,000 lines of source code", + default_weight=0.02, + ) + + def is_applicable(self, repository: Repository) -> bool: + return bool( + set(repository.languages.keys()) & set(_SUPPRESSION_PATTERNS.keys()) + ) + + def assess(self, repository: Repository) -> Finding: + try: + return self._assess_suppression_density(repository) + except Exception as exc: + logger.exception("LintSuppressionAssessor unexpected error") + return Finding.error(self.attribute, str(exc)) + + def _get_options(self, repository: Repository) -> tuple[float, float, bool]: + if repository.config: + opts = repository.config.lint_suppression_density + return opts.pass_per_kloc, opts.fail_per_kloc, opts.exclude_tests + d = _SUPPRESSION_DEFAULTS + return d.pass_per_kloc, d.fail_per_kloc, d.exclude_tests + + def _is_test_file(self, rel_path: str, lang: str) -> bool: + normalized = rel_path.replace("\\", "/") + name = Path(rel_path).name + if any(frag in normalized for frag in _TEST_DIR_FRAGMENTS) or any( + normalized.startswith(pfx) for pfx in _TEST_ROOT_PREFIXES + ): + return True + if lang == "Go": + return name.endswith("_test.go") + if lang == "Python": + return name.startswith("test_") or name.endswith("_test.py") + if lang in ("JavaScript", "TypeScript"): + ext = ".ts" if lang == "TypeScript" else ".js" + return f".test{ext}" in name or f".spec{ext}" in name + if lang == "Ruby": + return name.endswith("_spec.rb") + if lang == "Java": + return name.endswith("Test.java") or name.endswith("Tests.java") + return False + + def _walk_source_files( + self, root: Path, extensions: list[str] + ) -> Iterator[tuple[Path, str]]: + # Separate bare filenames (e.g. "Dockerfile") from dot-extensions (e.g. ".go") + dot_exts = {e for e in extensions if e.startswith(".")} + bare_names = {e for e in extensions if not e.startswith(".")} + + def _matches(filename: str) -> bool: + return filename in bare_names or any( + filename.endswith(ext) for ext in dot_exts + ) + + def _excluded(rel: str) -> bool: + return any(part in _SUPPRESSION_EXCLUDED_DIRS for part in Path(rel).parts) + + # git ls-files only — never fall back to ignore-unaware traversal. + try: + result = safe_subprocess_run( + ["git", "ls-files"], + cwd=root, + capture_output=True, + text=True, + timeout=30, + check=True, + ) + except Exception as exc: + raise _GitInventoryUnavailable( + "Git file inventory unavailable (missing git, not a git repo, " + "or timeout); lint suppression density requires ignore-aware discovery" + ) from exc + + for rel_path in result.stdout.splitlines(): + if not rel_path: + continue + if not (_matches(Path(rel_path).name) and not _excluded(rel_path)): + continue + abs_path = root / rel_path + try: + mode = abs_path.lstat().st_mode + except OSError: + continue + # Reject symlinks and non-regular files without following links. + if not stat.S_ISREG(mode): + continue + yield abs_path, rel_path + + def _count_file_suppressions( + self, + file_path: Path, + patterns: list[re.Pattern], + ) -> tuple[int, int]: + try: + with open(file_path, "rb") as handle: + raw = handle.read(_MAX_SUPPRESSION_FILE_BYTES) + except OSError: + return 0, 0 + text = raw.decode("utf-8", errors="ignore") + lines = text.splitlines() + sup_count = sum(1 for line in lines if any(p.search(line) for p in patterns)) + return sup_count, len(lines) + + def _assess_suppression_density(self, repository: Repository) -> Finding: + pass_per_kloc, fail_per_kloc, exclude_tests = self._get_options(repository) + detected_langs = set(repository.languages.keys()) & set( + _SUPPRESSION_PATTERNS.keys() + ) + total_suppressions = 0 + total_lines = 0 + file_stats: list[tuple[int, str]] = [] + + try: + for lang in sorted(detected_langs): + extensions = _LANG_EXTENSIONS[lang] + patterns = _SUPPRESSION_PATTERNS[lang] + for src_file, rel in self._walk_source_files( + repository.path, extensions + ): + if exclude_tests and self._is_test_file(rel, lang): + continue + sup_count, line_count = self._count_file_suppressions( + src_file, patterns + ) + total_suppressions += sup_count + total_lines += line_count + if sup_count > 0: + file_stats.append((sup_count, rel)) + except _GitInventoryUnavailable as exc: + return Finding.skipped( + self.attribute, + reason=str(exc), + remediation=( + "Ensure git is installed and the target is a valid git repository." + ), + ) + + if total_lines == 0: + return Finding.not_applicable( + self.attribute, + reason="No source files found for supported languages", + ) + + density = (total_suppressions / total_lines) * 1000.0 + + if density <= pass_per_kloc: + score = 100.0 + elif density >= fail_per_kloc: + score = 0.0 + else: + score = 100.0 * (fail_per_kloc - density) / (fail_per_kloc - pass_per_kloc) + + status = "pass" if density <= pass_per_kloc else "fail" + + evidence = [ + f"Total suppressions: {total_suppressions} across {total_lines:,} LOC " + f"({density:.1f}/1k lines)", + f"Threshold: pass ≤{pass_per_kloc}/1k, fail >{fail_per_kloc}/1k", + f"Languages scanned: {', '.join(sorted(detected_langs))}", + ] + if exclude_tests: + evidence.append("Test files excluded from analysis") + if file_stats: + top_files = sorted(file_stats, reverse=True)[:_TOP_FILES_SHOWN] + tops = ", ".join(f"{path} ({count})" for count, path in top_files) + evidence.append(f"Top files by suppression count: {tops}") + + remediation = None + if status == "fail": + top3_paths = [path for _, path in sorted(file_stats, reverse=True)[:3]] + steps = [ + f"Current density is {density:.1f}/1k; target ≤{pass_per_kloc}/1k", + "Fix the underlying lint violations rather than suppressing them", + "Replace broad suppressions with narrowly-scoped, rule-specific ones and add explanatory comments", + "Isolate generated or vendored code in a subdirectory and exclude it from lint config instead", + ] + if top3_paths: + steps.append( + f"Prioritize high-suppression files: {', '.join(top3_paths)}" + ) + remediation = Remediation( + summary=( + f"Reduce suppression density from {density:.1f}/1k to " + f"≤{pass_per_kloc}/1k lines" + ), + steps=steps, + tools=[], + commands=[], + examples=[ + ( + "# Python — prefer specific rule over blanket noqa:\n" + "result = legacy_func() # noqa: ERA001 " + "# legacy API, tracked in #123\n\n" + "# Go — include rule name and rationale:\n" + "//nolint:errcheck // legacy path, refactor in #456" + ), + ], + citations=[], + ) + + return Finding( + attribute=self.attribute, + status=status, + score=round(score, 1), + measured_value=( + f"{total_suppressions} suppressions / {total_lines:,} LOC " + f"({density:.1f}/1k)" + ), + threshold=f"≤{pass_per_kloc}/1k lines", + evidence=evidence, + remediation=remediation, + error_message=None, + ) diff --git a/src/agentready/assessors/structure.py b/src/agentready/assessors/structure.py index 7e6c4b95..619f82c2 100644 --- a/src/agentready/assessors/structure.py +++ b/src/agentready/assessors/structure.py @@ -1400,7 +1400,7 @@ def attribute(self) -> Attribute: tier=self.tier, description="Import restriction rules configured in linter to enforce module boundaries", criteria="Linter config with import boundary rules (ESLint no-restricted-imports, Go depguard, Python import-linter, or similar)", - default_weight=0.02, + default_weight=0.01, ) def _has_supported_language(self, repository: Repository) -> bool: diff --git a/src/agentready/data/.agentready-config.example.yaml b/src/agentready/data/.agentready-config.example.yaml index 1cd91bd0..34744354 100644 --- a/src/agentready/data/.agentready-config.example.yaml +++ b/src/agentready/data/.agentready-config.example.yaml @@ -79,6 +79,12 @@ report_theme: default # border: "#334155" # shadow: "rgba(0, 0, 0, 0.5)" +# Lint suppression density thresholds +# lint_suppression_density: +# pass_per_kloc: 5.0 # density at which score=100 (pass); default 5.0 +# fail_per_kloc: 15.0 # density at which score=0 (fail); default 15.0 +# exclude_tests: false # set true to exclude test files from LOC count + # Example: Increase weight for CLAUDE.md and tests # This increases CLAUDE.md from 7% to 15% and test_execution from 12% to 15% # Other attributes are automatically rescaled to maintain sum of 1.0 diff --git a/src/agentready/data/default-weights.yaml b/src/agentready/data/default-weights.yaml index d5df4420..a4071ed3 100644 --- a/src/agentready/data/default-weights.yaml +++ b/src/agentready/data/default-weights.yaml @@ -1,6 +1,6 @@ # Default Tier-Based Weight Distribution # -# This file defines the default weights for all 29 attributes. +# This file defines the default weights for all 30 attributes. # Weights are based on evidence from ETH Zurich (Feb 2026), Anthropic, # Red Hat best practices (April 2026), and Cursor agent guidelines. # @@ -40,6 +40,11 @@ # - Added lint_config_coverage (T2, 2%): lint depth across correctness/standards/security # - Reduced gitignore_completeness (3% -> 2%) to partially fund new weight # - Reduced pattern_references (3% -> 2%) to partially fund new weight +# +# Changes in v2.5.0 (feat/lint-suppression-density, issue #510): +# - Added lint_suppression_density (T3, 2%): detects //nolint/# noqa/eslint-disable overuse +# - Reduced cyclomatic_complexity (2% -> 1%) to fund new weight +# - Reduced architectural_boundaries (2% -> 1%) to fund new weight # Tier 1 (Essential) - 58% total weight test_execution: 0.11 # 5.1 - Test Execution & Coverage @@ -68,11 +73,12 @@ lint_config_coverage: 0.02 # 5.4 - Lint Config Coverage (issue #511, ne architecture_decisions: 0.01 # 2.3 - Architecture Decision Records adr_frontmatter_completeness: 0.02 # 2.4 - ADR Frontmatter Completeness openapi_specs: 0.02 # 10.1 - OpenAPI/Swagger Specifications -cyclomatic_complexity: 0.02 # 3.1 - Cyclomatic Complexity Thresholds +cyclomatic_complexity: 0.01 # 3.1 - Cyclomatic Complexity Thresholds structured_logging: 0.01 # 9.2 - Structured Logging progressive_disclosure: 0.01 # 17.3 - Progressive Disclosure (moved from T4) -architectural_boundaries: 0.02 # B.1 - Architectural Boundary Lint Rules (ADR) +architectural_boundaries: 0.01 # B.1 - Architectural Boundary Lint Rules (ADR) threat_model: 0.02 # B.2 - Threat Model Documentation (ADR) +lint_suppression_density: 0.02 # B.3 - Lint Suppression Density # Tier 4 (Advanced) - 2% total weight issue_pr_templates: 0.01 # 7.3 - Issue & Pull Request Templates diff --git a/src/agentready/models/config.py b/src/agentready/models/config.py index 53c2b805..ab00487b 100644 --- a/src/agentready/models/config.py +++ b/src/agentready/models/config.py @@ -1,13 +1,53 @@ """Config model for user customization of assessment behavior.""" from pathlib import Path -from typing import Annotated +from typing import Annotated, Any -from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from ..utils.security import validate_path +class LintSuppressionOptions(BaseModel): + """Typed options for the lint_suppression_density assessor. + + Attributes: + pass_per_kloc: Suppressions per 1,000 LOC at or below which score is 100 (pass). + fail_per_kloc: Suppressions per 1,000 LOC at or above which score is 0 (fail). + exclude_tests: When True, test files are excluded from suppression scanning. + """ + + pass_per_kloc: Annotated[ + float, + Field( + default=5.0, gt=0, description="Pass threshold (suppressions per 1k LOC)" + ), + ] + fail_per_kloc: Annotated[ + float, + Field( + default=15.0, gt=0, description="Fail threshold (suppressions per 1k LOC)" + ), + ] + exclude_tests: Annotated[ + bool, + Field( + default=False, description="Exclude test files from suppression scanning" + ), + ] + + model_config = ConfigDict(extra="forbid") + + @model_validator(mode="after") + def validate_thresholds(self) -> "LintSuppressionOptions": + if self.fail_per_kloc <= self.pass_per_kloc: + raise ValueError( + f"fail_per_kloc ({self.fail_per_kloc}) must exceed " + f"pass_per_kloc ({self.pass_per_kloc})" + ) + return self + + class AdrSourceConfig(BaseModel): """Typed configuration for a central ADR repository. @@ -109,6 +149,20 @@ class Config(BaseModel): description="Central ADR repository config (repo path + relative ADR subdir)", ), ] + lint_suppression_density: Annotated[ + LintSuppressionOptions, + Field( + default_factory=LintSuppressionOptions, + description="Options for the lint_suppression_density assessor", + ), + ] + assessor_options: Annotated[ + dict[str, dict[str, Any]], + Field( + default_factory=dict, + description="Per-assessor configuration keyed by attribute_id (generic fallback)", + ), + ] model_config = ConfigDict( arbitrary_types_allowed=True, # Allow Path objects diff --git a/tests/unit/test_assessors_code_quality.py b/tests/unit/test_assessors_code_quality.py index 407950ea..e46b4525 100644 --- a/tests/unit/test_assessors_code_quality.py +++ b/tests/unit/test_assessors_code_quality.py @@ -4,11 +4,15 @@ import subprocess from unittest.mock import patch +import pytest + from agentready.assessors.code_quality import ( CyclomaticComplexityAssessor, LintConfigCoverageAssessor, + LintSuppressionAssessor, TypeAnnotationsAssessor, ) +from agentready.models.config import Config, LintSuppressionOptions from agentready.models.repository import Repository @@ -838,3 +842,625 @@ def test_circleci_run_command_field_detected(self, tmp_path): assert finding.status == "pass" assert finding.score == 100.0 + + +# ============================================================================= +# LintSuppressionAssessor +# ============================================================================= + + +def _make_suppression_repo( + tmp_path, languages: dict | None = None, config: Config | None = None +): + """Create a minimal test repository for suppression tests. + + Uses a real git checkout and stages existing files so git ls-files works. + """ + if not (tmp_path / ".git" / "HEAD").exists(): + result = subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) + if result.returncode != 0: + pytest.skip("git init unavailable in this environment") + # Stage whatever source files tests already wrote (honors .gitignore). + add = subprocess.run(["git", "add", "-A"], cwd=tmp_path, capture_output=True) + if add.returncode != 0: + pytest.skip("git add unavailable in this environment") + return Repository( + path=tmp_path, + name="test-repo", + url=None, + branch="main", + commit_hash="abc123", + languages=languages or {"Python": 10}, + total_files=10, + total_lines=200, + config=config, + ) + + +class TestLintSuppressionAssessorApplicability: + def test_applicable_for_python(self, tmp_path): + repo = _make_suppression_repo(tmp_path, languages={"Python": 10}) + assert LintSuppressionAssessor().is_applicable(repo) + + def test_applicable_for_go(self, tmp_path): + repo = _make_suppression_repo(tmp_path, languages={"Go": 10}) + assert LintSuppressionAssessor().is_applicable(repo) + + def test_applicable_for_typescript(self, tmp_path): + repo = _make_suppression_repo(tmp_path, languages={"TypeScript": 10}) + assert LintSuppressionAssessor().is_applicable(repo) + + def test_applicable_for_ruby(self, tmp_path): + repo = _make_suppression_repo(tmp_path, languages={"Ruby": 10}) + assert LintSuppressionAssessor().is_applicable(repo) + + def test_not_applicable_for_unsupported_language(self, tmp_path): + repo = _make_suppression_repo(tmp_path, languages={"Haskell": 10}) + assert not LintSuppressionAssessor().is_applicable(repo) + + def test_applicable_mixed_languages(self, tmp_path): + repo = _make_suppression_repo(tmp_path, languages={"Haskell": 5, "Python": 5}) + assert LintSuppressionAssessor().is_applicable(repo) + + +class TestLintSuppressionAssessorNoFiles: + def test_no_source_files_returns_not_applicable(self, tmp_path): + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + +class TestLintSuppressionPythonPass: + def test_clean_python_file_passes(self, tmp_path): + src = tmp_path / "main.py" + src.write_text(("def add(a: int, b: int) -> int:\n return a + b\n") * 50) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "pass" + assert finding.score == 100.0 + assert finding.remediation is None + + def test_noqa_below_threshold_passes(self, tmp_path): + lines = ["x = 1\n"] * 999 + ["x = bad_call() # noqa\n"] + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "pass" + assert finding.score == 100.0 + + +class TestLintSuppressionPythonFail: + def test_heavy_noqa_usage_fails(self, tmp_path): + lines = ["x = 1\n"] * 180 + ["x = bad() # noqa\n"] * 20 + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + assert finding.score == 0.0 + assert finding.remediation is not None + + def test_type_ignore_counted(self, tmp_path): + lines = ["x = 1\n"] * 190 + ["x = f() # type: ignore\n"] * 10 + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + assert finding.score == 0.0 + + def test_pylint_disable_counted(self, tmp_path): + lines = ["x = 1\n"] * 190 + [ + "x = f() # pylint: disable=unused-variable\n" + ] * 10 + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + +class TestLintSuppressionPartialScore: + def test_density_in_warning_range(self, tmp_path): + lines = ["x = 1\n"] * 990 + ["x = bad() # noqa\n"] * 10 + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + assert 40.0 <= finding.score <= 60.0 + + def test_density_just_above_pass_threshold(self, tmp_path): + lines = ["x = 1\n"] * 994 + ["x = bad() # noqa\n"] * 6 + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + assert finding.score > 80.0 + + +class TestLintSuppressionPythonWholeFilePatterns: + def test_ruff_noqa_detected(self, tmp_path): + lines = ["x = bad() # ruff: noqa\n"] * 20 + ["x = 1\n"] * 80 + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_flake8_noqa_detected(self, tmp_path): + lines = ["x = bad() # flake8: noqa\n"] * 20 + ["x = 1\n"] * 80 + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + +class TestLintSuppressionGoPatterns: + def test_go_nolint_detected(self, tmp_path): + lines = ( + ["package main\n"] + ["x := bad() //nolint\n"] * 20 + ["var y = 1\n"] * 80 + ) + (tmp_path / "main.go").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Go": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_go_nolint_with_space_detected(self, tmp_path): + lines = ( + ["package main\n"] + ["x := bad() // nolint\n"] * 20 + ["var y = 1\n"] * 80 + ) + (tmp_path / "main.go").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Go": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + +class TestLintSuppressionTypeScriptPatterns: + def test_eslint_disable_detected(self, tmp_path): + lines = ["const x = 1;\n"] * 80 + ["// eslint-disable-next-line\n"] * 20 + (tmp_path / "index.ts").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"TypeScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_ts_ignore_detected(self, tmp_path): + lines = ["const x = 1;\n"] * 80 + ["// @ts-ignore\n"] * 20 + (tmp_path / "index.ts").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"TypeScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_ts_nocheck_detected(self, tmp_path): + lines = ["const x = 1;\n"] * 80 + ["// @ts-nocheck\n"] * 20 + (tmp_path / "index.ts").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"TypeScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_ts_expect_error_detected(self, tmp_path): + lines = ["const x = 1;\n"] * 80 + ["// @ts-expect-error\n"] * 20 + (tmp_path / "index.ts").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"TypeScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + +class TestLintSuppressionJavaScriptPatterns: + def test_eslint_disable_in_js_detected(self, tmp_path): + lines = ["const x = 1;\n"] * 80 + ["// eslint-disable-next-line\n"] * 20 + (tmp_path / "app.js").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"JavaScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_jsx_file_scanned(self, tmp_path): + lines = ["const x = 1;\n"] * 80 + ["// eslint-disable\n"] * 20 + (tmp_path / "App.jsx").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"JavaScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_block_eslint_disable_detected_js(self, tmp_path): + """Block-form /* eslint-disable */ is counted as a suppression.""" + lines = ["const x = 1;\n"] * 80 + ["/* eslint-disable no-console */\n"] * 20 + (tmp_path / "app.js").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"JavaScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_block_eslint_disable_detected_ts(self, tmp_path): + """Block-form /* eslint-disable */ is counted in TypeScript files too.""" + lines = ["const x = 1;\n"] * 80 + [ + "/* eslint-disable @typescript-eslint/no-explicit-any */\n" + ] * 20 + (tmp_path / "index.ts").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"TypeScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + +class TestLintSuppressionRubyPatterns: + def test_rubocop_disable_detected(self, tmp_path): + lines = ["x = 1\n"] * 80 + ["x = bad # rubocop:disable Style/Foo\n"] * 20 + (tmp_path / "app.rb").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Ruby": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + +class TestLintSuppressionTestFileDetection: + def test_contest_utils_not_treated_as_test(self, tmp_path): + contest_dir = tmp_path / "contest_utils" + contest_dir.mkdir() + lines = ["x = bad() # noqa\n"] * 20 + ["x = 1\n"] * 80 + (contest_dir / "module.py").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_test_foo_py_excluded_when_configured(self, tmp_path): + lines = ["x = bad() # noqa\n"] * 20 + ["x = 1\n"] * 80 + (tmp_path / "test_foo.py").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + def test_go_test_file_excluded_when_configured(self, tmp_path): + lines = ["x := bad() // nolint\n"] * 20 + ["var y = 1\n"] * 80 + (tmp_path / "foo_test.go").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo(tmp_path, languages={"Go": 1}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + def test_js_test_file_excluded_when_configured(self, tmp_path): + lines = ["const x = 1;\n"] * 80 + ["// eslint-disable\n"] * 20 + (tmp_path / "foo.test.js").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo( + tmp_path, languages={"JavaScript": 1}, config=config + ) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + def test_ts_spec_file_excluded_when_configured(self, tmp_path): + lines = ["const x = 1;\n"] * 80 + ["// @ts-ignore\n"] * 20 + (tmp_path / "foo.spec.ts").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo( + tmp_path, languages={"TypeScript": 1}, config=config + ) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + def test_ruby_spec_file_excluded_when_configured(self, tmp_path): + lines = ["x = 1\n"] * 80 + ["x = bad # rubocop:disable Style/Foo\n"] * 20 + (tmp_path / "foo_spec.rb").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo(tmp_path, languages={"Ruby": 1}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + def test_java_test_file_excluded_when_configured(self, tmp_path): + lines = ["int x = 1;\n"] * 80 + ['@SuppressWarnings("unchecked")\n'] * 20 + (tmp_path / "FooTest.java").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo(tmp_path, languages={"Java": 1}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + def test_java_file_under_test_dir_excluded_when_configured(self, tmp_path): + """Java files in src/test/java are excluded via shared dir fragments, + even when the filename itself doesn't follow *Test.java naming.""" + test_dir = tmp_path / "src" / "test" / "java" + test_dir.mkdir(parents=True) + lines = ["int x = 1;\n"] * 80 + ['@SuppressWarnings("unchecked")\n'] * 20 + (test_dir / "Helper.java").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo(tmp_path, languages={"Java": 1}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + +class TestLintSuppressionExcludedDirs: + def test_vendor_dir_excluded(self, tmp_path): + vendor = tmp_path / "vendor" + vendor.mkdir() + lines = ["x = 1\n"] * 80 + ["x = bad() # noqa\n"] * 20 + (vendor / "lib.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + def test_node_modules_excluded(self, tmp_path): + nm = tmp_path / "node_modules" + nm.mkdir() + lines = ["const x = 1;\n"] * 80 + ["// eslint-disable\n"] * 20 + (nm / "dep.ts").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"TypeScript": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "not_applicable" + + def test_clean_src_with_dirty_vendor_passes(self, tmp_path): + src = tmp_path / "src" + src.mkdir() + vendor = tmp_path / "vendor" + vendor.mkdir() + (src / "main.py").write_text("def f(x: int) -> int:\n return x\n" * 50) + dirty = ["x = bad() # noqa\n"] * 20 + ["x = 1\n"] * 80 + (vendor / "lib.py").write_text("".join(dirty)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 2}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "pass" + + +class TestLintSuppressionExcludeTests: + def test_test_files_excluded_when_configured(self, tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + dirty = ["x = bad() # noqa\n"] * 30 + ["x = 1\n"] * 70 + (tests_dir / "test_foo.py").write_text("".join(dirty)) + (tmp_path / "app.py").write_text("def f(x: int) -> int:\n return x\n" * 50) + config = Config( + lint_suppression_density=LintSuppressionOptions(exclude_tests=True) + ) + repo = _make_suppression_repo(tmp_path, languages={"Python": 2}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "pass" + assert "Test files excluded" in " ".join(finding.evidence) + + def test_test_files_counted_by_default(self, tmp_path): + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + dirty = ["x = bad() # noqa\n"] * 30 + ["x = 1\n"] * 70 + (tests_dir / "test_foo.py").write_text("".join(dirty)) + (tmp_path / "app.py").write_text("def f(x: int) -> int:\n return x\n" * 50) + repo = _make_suppression_repo(tmp_path, languages={"Python": 2}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + +class TestLintSuppressionCustomThresholds: + def test_custom_strict_threshold(self, tmp_path): + lines = ["x = 1\n"] * 997 + ["x = bad() # noqa\n"] * 3 + (tmp_path / "code.py").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions( + pass_per_kloc=1.0, fail_per_kloc=10.0 + ) + ) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_custom_lenient_threshold(self, tmp_path): + lines = ["x = 1\n"] * 990 + ["x = bad() # noqa\n"] * 10 + (tmp_path / "code.py").write_text("".join(lines)) + config = Config( + lint_suppression_density=LintSuppressionOptions( + pass_per_kloc=20.0, fail_per_kloc=40.0 + ) + ) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}, config=config) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "pass" + + def test_invalid_thresholds_rejected_at_config_construction(self, tmp_path): + """Pydantic rejects fail_per_kloc <= pass_per_kloc at construction time.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError): + LintSuppressionOptions(pass_per_kloc=20.0, fail_per_kloc=5.0) + + +class TestLintSuppressionEvidenceContent: + def test_evidence_includes_suppression_count_and_density(self, tmp_path): + lines = ["x = 1\n"] * 990 + ["x = bad() # noqa\n"] * 10 + (tmp_path / "code.py").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + evidence_text = " ".join(finding.evidence) + assert "10" in evidence_text + assert "1,000" in evidence_text or "1000" in evidence_text + assert "/1k" in evidence_text + + def test_evidence_reports_top_files(self, tmp_path): + dirty = ["x = bad() # noqa\n"] * 20 + ["x = 1\n"] * 80 + (tmp_path / "dirty.py").write_text("".join(dirty)) + (tmp_path / "clean.py").write_text("x = 1\n" * 100) + repo = _make_suppression_repo(tmp_path, languages={"Python": 2}) + finding = LintSuppressionAssessor().assess(repo) + assert "dirty.py" in " ".join(finding.evidence) + + def test_measured_value_format(self, tmp_path): + (tmp_path / "code.py").write_text("x = 1\n" * 100) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.measured_value is not None + assert "suppressions" in finding.measured_value + assert "LOC" in finding.measured_value + + def test_threshold_field_contains_pass_value(self, tmp_path): + (tmp_path / "code.py").write_text("x = 1\n" * 100) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.threshold is not None + assert "5.0" in finding.threshold + + +class TestLintSuppressionAdditionalLanguages: + def test_java_suppress_warnings_detected(self, tmp_path): + lines = ( + ["public class Foo {\n"] + + [' @SuppressWarnings("unchecked")\n'] * 20 + + [" void m() {}\n"] * 80 + ) + (tmp_path / "Foo.java").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Java": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_terraform_tflint_ignore_detected(self, tmp_path): + lines = ( + ['resource "aws_instance" "x" {\n'] + + [" # tflint-ignore: terraform_naming_convention\n"] * 20 + + [' ami = "ami-123"\n'] * 80 + ) + (tmp_path / "main.tf").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Terraform": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_shell_shellcheck_disable_detected(self, tmp_path): + lines = ( + ["#!/bin/bash\n"] + ["# shellcheck disable=SC2034\n"] * 20 + ["x=1\n"] * 80 + ) + (tmp_path / "script.sh").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Shell": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_dockerfile_hadolint_ignore_detected(self, tmp_path): + lines = ( + ["FROM ubuntu\n"] + + ["# hadolint ignore=DL3008\n"] * 20 + + ["RUN apt-get update\n"] * 80 + ) + (tmp_path / "Dockerfile").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Dockerfile": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_yaml_yamllint_disable_detected(self, tmp_path): + lines = ( + ["---\n"] + + ["# yamllint disable-line rule:line-length\n"] * 20 + + ["key: value\n"] * 80 + ) + (tmp_path / "config.yaml").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"YAML": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_yaml_kube_linter_disable_detected(self, tmp_path): + lines = ( + ["---\n"] + + ["# kube-linter disable no-read-only-root-fs\n"] * 20 + + ["key: value\n"] * 80 + ) + (tmp_path / "deploy.yml").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"YAML": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + def test_markdown_markdownlint_disable_detected(self, tmp_path): + lines = ( + ["# Title\n"] + + ["\n"] * 20 + + ["some text\n"] * 80 + ) + (tmp_path / "README.md").write_text("".join(lines)) + repo = _make_suppression_repo(tmp_path, languages={"Markdown": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "fail" + + +class TestLintSuppressionGitInventory: + """Verify git ls-files path: gitignored files don't affect the score.""" + + def test_gitignored_files_not_scanned(self, tmp_path): + result = subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) + if result.returncode != 0: + pytest.skip("git init unavailable in this environment") + + # Tracked file: clean (git add'd → ls-files sees it) + (tmp_path / "app.py").write_text("x = 1\n" * 100) + subprocess.run(["git", "add", "app.py"], cwd=tmp_path, capture_output=True) + + # generated/ is NOT in _SUPPRESSION_EXCLUDED_DIRS, so old os.walk would scan it. + # gitignore it — ls-files won't see it, os.walk would. + generated = tmp_path / "generated" + generated.mkdir() + (generated / "lib.py").write_text("x = bad() # noqa\n" * 100) + (tmp_path / ".gitignore").write_text("generated/\n") + + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + # ls-files skips generated/ → density stays 0 → pass + # os.walk would scan generated/ → 1000/kloc density → fail + assert ( + finding.status == "pass" + ), f"Gitignored suppressions leaked into score: {finding.evidence}" + + def test_git_inventory_unavailable_skips(self, tmp_path): + (tmp_path / "app.py").write_text("x = 1\n" * 100) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + with patch( + "agentready.assessors.code_quality.safe_subprocess_run", + side_effect=TimeoutError("git ls-files timed out"), + ): + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "skipped" + assert "inventory unavailable" in " ".join(finding.evidence).lower() + + def test_symlinks_not_scanned(self, tmp_path): + (tmp_path / "app.py").write_text("x = 1\n" * 100) + # Dirty target is gitignored so only the symlink is tracked. + target = tmp_path / "hidden_target.py" + target.write_text("x = bad() # noqa\n" * 100) + (tmp_path / ".gitignore").write_text("hidden_target.py\n") + try: + (tmp_path / "link.py").symlink_to(target.name) + except OSError: + pytest.skip("symlinks unavailable in this environment") + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert ( + finding.status == "pass" + ), f"Symlink suppressions leaked into score: {finding.evidence}" + + def test_file_read_is_byte_bounded(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "agentready.assessors.code_quality._MAX_SUPPRESSION_FILE_BYTES", 40 + ) + # noqa only appears after the byte cap — must not be counted. + (tmp_path / "app.py").write_text("x = 1\n" * 20 + "x = bad() # noqa\n" * 20) + repo = _make_suppression_repo(tmp_path, languages={"Python": 1}) + finding = LintSuppressionAssessor().assess(repo) + assert finding.status == "pass" + assert "Total suppressions: 0" in " ".join(finding.evidence) + + +class TestLintSuppressionAttributeMetadata: + def test_attribute_id(self): + assert LintSuppressionAssessor().attribute_id == "lint_suppression_density" + + def test_attribute_tier(self): + assert LintSuppressionAssessor().tier == 3 + + def test_attribute_default_weight(self): + assert LintSuppressionAssessor().attribute.default_weight == 0.02 + + def test_attribute_name(self): + assert "Suppression" in LintSuppressionAssessor().attribute.name + + def test_registered_in_create_all_assessors(self): + from agentready.assessors import create_all_assessors + + assessors = create_all_assessors() + ids = [a.attribute_id for a in assessors] + assert "lint_suppression_density" in ids diff --git a/uv.lock b/uv.lock index d0dd6cf3..7b48a5e3 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "agentready" -version = "2.49.0" +version = "2.50.0" source = { editable = "." } dependencies = [ { name = "anthropic" },