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" },